From 909627130775fb5be8efa62f7eec62932596993d Mon Sep 17 00:00:00 2001 From: erio Date: Fri, 13 Mar 2026 19:51:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A6=81=E6=AD=A2=E6=89=80=E6=9C=89?= =?UTF-8?q?=E6=A8=A1=E6=80=81=E6=A1=86=E7=82=B9=E5=87=BB=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E5=85=B3=E9=97=AD=EF=BC=8C=E9=98=B2=E6=AD=A2=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 channels 编辑/同步、TopUpModal、OrderDetail、RefundDialog 四处模态框 backdrop 上的 onClick 关闭行为及 stopPropagation, 用户只能通过显式关闭按钮或取消按钮关闭弹窗。 同时新增支付宝直连和微信支付直连接入文档。 --- docs/payment-alipay.md | 129 ++++++++++++++++++ docs/payment-wxpay.md | 186 ++++++++++++++++++++++++++ src/app/admin/channels/page.tsx | 6 +- src/components/TopUpModal.tsx | 3 +- src/components/admin/OrderDetail.tsx | 3 +- src/components/admin/RefundDialog.tsx | 3 +- 6 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 docs/payment-alipay.md create mode 100644 docs/payment-wxpay.md diff --git a/docs/payment-alipay.md b/docs/payment-alipay.md new file mode 100644 index 0000000..b50b3ec --- /dev/null +++ b/docs/payment-alipay.md @@ -0,0 +1,129 @@ +# 支付宝直连支付接入指南 + +## 概述 + +本项目通过直接对接 **支付宝开放平台** 实现收款,不依赖任何三方聚合支付平台。支持以下产品: + +| 产品 | API 方法 | 场景 | +|------|---------|------| +| 电脑网站支付 | `alipay.trade.page.pay` | PC 浏览器扫码 | +| 手机网站支付 | `alipay.trade.wap.pay` | 移动端 H5 拉起支付宝 | + +签名算法:**RSA2 (SHA256withRSA)**,密钥格式 **PKCS8**。 + +## 前置条件 + +1. 注册 [支付宝开放平台](https://open.alipay.com/) 企业/个人账号 +2. 创建网页/移动应用,获取 **APPID** +3. 在应用中签约 **电脑网站支付** 和 **手机网站支付** 产品 +4. 配置 **接口加签方式** → 选择 **公钥模式 (RSA2)**,生成密钥对 + +## 密钥说明 + +支付宝公钥模式涉及 **三把密钥**,务必区分: + +| 密钥 | 来源 | 用途 | 对应环境变量 | +|------|------|------|-------------| +| **应用私钥** | 你自己生成 | 对请求参数签名 | `ALIPAY_PRIVATE_KEY` | +| **支付宝公钥** | 上传应用公钥后,支付宝返回 | 验证回调通知签名 | `ALIPAY_PUBLIC_KEY` | +| 应用公钥 | 你自己生成 | 上传到支付宝后台 | (不配置到项目中) | + +> **常见错误**:把「应用公钥」填到 `ALIPAY_PUBLIC_KEY`。必须使用「支付宝公钥」,否则回调验签永远失败。 + +## 环境变量 + +```env +# ── 必需 ── +ALIPAY_APP_ID=2021000000000000 # 支付宝开放平台 APPID +ALIPAY_PRIVATE_KEY=MIIEvQIBADANB... # 应用私钥(PKCS8 格式,Base64 / PEM 均可) +ALIPAY_PUBLIC_KEY=MIIBIjANBgkqh... # 支付宝公钥(非应用公钥!) +ALIPAY_NOTIFY_URL=https://pay.example.com/api/alipay/notify # 异步通知地址 + +# ── 可选 ── +ALIPAY_RETURN_URL=https://pay.example.com/pay/result # 同步跳转地址(默认自动生成) + +# ── 启用渠道 ── +PAYMENT_PROVIDERS=alipay # 逗号分隔,可同时含 easypay,alipay,wxpay,stripe +ENABLED_PAYMENT_TYPES=alipay # 前端展示哪些支付方式 +``` + +### 密钥格式 + +`ALIPAY_PRIVATE_KEY` 和 `ALIPAY_PUBLIC_KEY` 支持两种写法: + +```env +# 方式 1:裸 Base64(推荐,适合 Docker 环境) +ALIPAY_PRIVATE_KEY=MIIEvQIBADANBgkqh...一行到底... + +# 方式 2:完整 PEM(换行用 \n) +ALIPAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA...\n-----END PRIVATE KEY----- +``` + +项目会自动补全 PEM header/footer 并按 64 字符折行(兼容 OpenSSL 3.x 严格模式)。 + +## 架构 + +``` +用户浏览器 + │ + ├── PC:扫码页面 (/pay/{orderId}) → 生成支付宝跳转 URL → 扫码/登录付款 + │ ↓ + │ alipay.trade.page.pay (GET 跳转) + │ + └── Mobile:直接拉起 → alipay.trade.wap.pay (GET 跳转) + +支付宝服务器 + │ + └── POST /api/alipay/notify ← 异步通知(trade_status=TRADE_SUCCESS) + │ + ├── 验签(RSA2 + 支付宝公钥) + ├── 校验 app_id 一致 + ├── 确认订单金额匹配 + └── 调用 handlePaymentNotify() → 订单状态流转 → 充值/订阅履约 +``` + +### PC 支付流程(短链中转) + +PC 端不直接返回支付宝 URL,而是生成一个 **项目内部短链** `/pay/{orderId}`: + +1. 用户扫描短链二维码 +2. 服务端根据 User-Agent 判断设备类型 +3. 如果在支付宝客户端内打开 → 直接跳转 `alipay.trade.wap.pay` +4. 如果在普通浏览器打开 → 跳转 `alipay.trade.page.pay` +5. 订单已支付/已过期 → 显示状态页 + +这种设计避免了支付宝 URL 过长无法生成二维码的问题。 + +## 文件结构 + +``` +src/lib/alipay/ +├── provider.ts # AlipayProvider 实现 PaymentProvider 接口 +├── client.ts # pageExecute (跳转URL) + execute (服务端API调用) +├── sign.ts # RSA2 签名生成 + 验签 +├── codec.ts # 编码处理(GBK/UTF-8 自动检测、回调参数解析) +└── types.ts # TypeScript 类型定义 + +src/app/api/alipay/ +└── notify/route.ts # 异步通知接收端点 + +src/app/pay/ +└── [orderId]/route.ts # PC 扫码中转页(短链) +``` + +## 支持的 API 能力 + +| 能力 | API | 说明 | +|------|-----|------| +| 创建支付 | `alipay.trade.page.pay` / `wap.pay` | GET 跳转方式 | +| 查询订单 | `alipay.trade.query` | 主动查询交易状态 | +| 关闭订单 | `alipay.trade.close` | 超时关单 | +| 退款 | `alipay.trade.refund` | 全额退款 | +| 异步通知 | POST 回调 | RSA2 验签 | + +## 注意事项 + +- **异步通知编码**:支付宝可能使用 GBK 编码发送通知。`codec.ts` 自动检测 Content-Type 和 body 中的 charset 参数,按 `UTF-8 → GBK → GB18030` 优先级尝试解码。 +- **签名空格问题**:支付宝通知中的 `sign` 参数可能包含空格(URL 解码 `+` 导致),`codec.ts` 会自动将空格还原为 `+`。 +- **默认限额**:单笔 ¥1000,单日 ¥10000(可通过环境变量 `MAX_DAILY_AMOUNT_ALIPAY_DIRECT` 调整)。 +- **验签调试**:非生产环境自动输出验签失败的详细信息;生产环境可设置 `DEBUG_ALIPAY_SIGN=1` 开启。 diff --git a/docs/payment-wxpay.md b/docs/payment-wxpay.md new file mode 100644 index 0000000..30300b6 --- /dev/null +++ b/docs/payment-wxpay.md @@ -0,0 +1,186 @@ +# 微信支付直连接入指南 + +## 概述 + +本项目通过直接对接 **微信支付 APIv3** 实现收款。使用 **公钥模式** 验签(非平台证书模式),支持以下产品: + +| 产品 | API | 场景 | +|------|-----|------| +| Native 支付 | `/v3/pay/transactions/native` | PC 扫码支付(生成 `weixin://` 二维码) | +| H5 支付 | `/v3/pay/transactions/h5` | 移动端浏览器拉起微信 | + +> H5 支付需要在微信支付商户后台单独签约开通。如果未开通,移动端会自动降级到 Native 扫码。 + +## 前置条件 + +1. 注册 [微信支付商户平台](https://pay.weixin.qq.com/),获取 **商户号 (mchid)** +2. 在 [微信开放平台](https://open.weixin.qq.com/) 创建应用,获取 **APPID** +3. 在商户后台 → API 安全 → 配置以下内容: + - **APIv3 密钥**(32 字节随机字符串) + - **商户 API 私钥**(RSA 2048,下载 PEM 文件) + - **微信支付公钥**(用于验签通知,注意区别于平台证书) + - **微信支付公钥 ID**(与公钥配套的 serial/key ID) + - **商户证书序列号**(用于签名请求的 Authorization header) + +## 密钥说明 + +微信支付 APIv3 公钥模式涉及 **多组密钥**: + +| 密钥 | 来源 | 用途 | 对应环境变量 | +|------|------|------|-------------| +| **商户 API 私钥** | 商户后台生成/下载 | 对 API 请求签名 | `WXPAY_PRIVATE_KEY` | +| **微信支付公钥** | 商户后台获取 | 验证异步通知签名 | `WXPAY_PUBLIC_KEY` | +| **微信支付公钥 ID** | 与公钥配套 | 匹配通知中的 serial | `WXPAY_PUBLIC_KEY_ID` | +| **商户证书序列号** | 商户后台查看 | 放入 Authorization header | `WXPAY_CERT_SERIAL` | +| **APIv3 密钥** | 商户后台设置 | AES-GCM 解密通知内容 | `WXPAY_API_V3_KEY` | + +> **公钥模式 vs 平台证书模式**:本项目使用公钥模式,直接用微信支付公钥验签,不需要定期拉取/更新平台证书,部署更简单。 + +## 环境变量 + +```env +# ── 必需 ── +WXPAY_APP_ID=wx1234567890abcdef # 微信开放平台 APPID +WXPAY_MCH_ID=1234567890 # 微信支付商户号 +WXPAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n... # 商户 API 私钥 (RSA PEM) +WXPAY_API_V3_KEY=your32bytesrandomstring # APIv3 密钥 (32字节) +WXPAY_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\n... # 微信支付公钥 (PEM) +WXPAY_PUBLIC_KEY_ID=PUB_KEY_ID_xxxxxx # 微信支付公钥 ID +WXPAY_CERT_SERIAL=SERIAL_NUMBER_xxxxxx # 商户证书序列号 +WXPAY_NOTIFY_URL=https://pay.example.com/api/wxpay/notify # 异步通知地址 + +# ── 启用渠道 ── +PAYMENT_PROVIDERS=wxpay # 逗号分隔,可同时含 easypay,alipay,wxpay,stripe +ENABLED_PAYMENT_TYPES=wxpay # 前端展示哪些支付方式 +``` + +### 私钥格式 + +`WXPAY_PRIVATE_KEY` 需要完整的 PEM 格式。在 Docker Compose 中推荐使用 `|-` 多行写法: + +```yaml +# docker-compose.yml +services: + app: + environment: + WXPAY_PRIVATE_KEY: |- + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASC... + ... + -----END PRIVATE KEY----- +``` + +或者在 `.env` 中用 `\n` 表示换行: + +```env +WXPAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvQI...\n-----END PRIVATE KEY----- +``` + +`WXPAY_PUBLIC_KEY` 同理,支持裸 Base64 或完整 PEM(裸 Base64 会自动补全 header/footer)。 + +## 架构 + +``` +用户浏览器 + │ + ├── PC:前端显示 code_url 二维码 → 用户微信扫码 → 完成付款 + │ ↓ + │ POST /v3/pay/transactions/native → 返回 code_url (weixin://wxpay/...) + │ + └── Mobile:跳转 h5_url → 拉起微信客户端付款 + ↓ + POST /v3/pay/transactions/h5 → 返回 h5_url + (如果 H5 未签约,自动 fallback 到 Native) + +微信支付服务器 + │ + └── POST /api/wxpay/notify ← 异步通知(event_type=TRANSACTION.SUCCESS) + │ + ├── 验签(RSA-SHA256 + 微信支付公钥) + ├── 校验 serial 匹配 WXPAY_PUBLIC_KEY_ID + ├── 校验 timestamp 不超过 5 分钟 + ├── AES-256-GCM 解密 resource(使用 APIv3 密钥) + └── 调用 handlePaymentNotify() → 订单状态流转 → 充值/订阅履约 +``` + +### 签名机制 + +**请求签名** (Authorization header): + +``` +签名串 = HTTP方法\n请求URL\n时间戳\n随机串\n请求体\n +签名 = RSA-SHA256(签名串, 商户私钥) +Authorization: WECHATPAY2-SHA256-RSA2048 mchid="...",serial_no="...",nonce_str="...",timestamp="...",signature="..." +``` + +**通知验签**: + +``` +验签串 = 时间戳\n随机串\nJSON body\n +验证 = RSA-SHA256.verify(验签串, 微信支付公钥, Wechatpay-Signature header) +``` + +**通知解密**: + +``` +明文 = AES-256-GCM.decrypt( + ciphertext, + key = APIv3密钥, + nonce = resource.nonce, + aad = resource.associated_data +) +``` + +## 文件结构 + +``` +src/lib/wxpay/ +├── provider.ts # WxpayProvider 实现 PaymentProvider 接口 +├── client.ts # Native/H5 下单、查询、关闭、退款、解密通知、验签 +├── types.ts # TypeScript 类型定义 +└── index.ts # 导出入口 + +src/app/api/wxpay/ +└── notify/route.ts # 异步通知接收端点 +``` + +## 支持的 API 能力 + +| 能力 | API | 说明 | +|------|-----|------| +| Native 下单 | `POST /v3/pay/transactions/native` | 返回 `code_url` 用于生成二维码 | +| H5 下单 | `POST /v3/pay/transactions/h5` | 返回 `h5_url` 拉起微信 | +| 查询订单 | `GET /v3/pay/transactions/out-trade-no/{id}` | 主动查询交易状态 | +| 关闭订单 | `POST /v3/pay/.../close` | 超时关单 | +| 退款 | `POST /v3/refund/domestic/refunds` | 原路退款 | +| 异步通知 | POST 回调 | RSA-SHA256 验签 + AES-GCM 解密 | + +## 与 wechatpay-node-v3 的关系 + +项目使用 [`wechatpay-node-v3`](https://www.npmjs.com/package/wechatpay-node-v3) 库来生成请求签名 (`getSignature`) 和构建 Authorization header (`getAuthorization`)。实际的 HTTP 请求和通知验签/解密逻辑由项目自己实现(使用原生 `fetch` 和 Node.js `crypto`)。 + +## 注意事项 + +- **H5 支付降级**:如果 H5 支付返回 `NO_AUTH` 错误(未签约),自动 fallback 到 Native 扫码模式。 +- **金额单位**:微信 API 使用 **分** 为单位,项目内部使用 **元**。`client.ts` 中 `yuanToFen()` 自动转换。 +- **通知时效**:通知中的 `timestamp` 与服务器时间差超过 5 分钟将被拒绝。 +- **默认限额**:单笔 ¥1000,单日 ¥10000(可通过环境变量 `MAX_DAILY_AMOUNT_WXPAY_DIRECT` 调整)。 +- **WxPay 实例缓存**:`getPayInstance()` 使用模块级单例,避免重复解析密钥。 +- **通知响应格式**:微信要求成功返回 `{"code":"SUCCESS","message":"成功"}`,失败返回 `{"code":"FAIL","message":"处理失败"}`。 + +## 常见问题 + +### Q: 通知验签失败 + +检查以下几点: +1. `WXPAY_PUBLIC_KEY` 是否是 **微信支付公钥**(不是商户公钥或平台证书) +2. `WXPAY_PUBLIC_KEY_ID` 是否与通知 header 中的 `Wechatpay-Serial` 匹配 +3. 服务器时间是否准确(NTP 同步) + +### Q: H5 支付报 NO_AUTH + +需要在微信支付商户后台 → 产品中心 → H5 支付 → 申请开通,并配置 H5 支付域名。未开通前项目会自动降级为 Native 扫码。 + +### Q: 如何获取微信支付公钥? + +微信支付商户后台 → API 安全 → 微信支付公钥。注意这是 2024 年后推出的公钥模式,区别于之前的平台证书模式。如果你的商户号不支持公钥模式,需要联系微信支付升级。 diff --git a/src/app/admin/channels/page.tsx b/src/app/admin/channels/page.tsx index 275d5b6..d511cf1 100644 --- a/src/app/admin/channels/page.tsx +++ b/src/app/admin/channels/page.tsx @@ -660,14 +660,13 @@ function ChannelsContent() { {/* ── Edit / Create Modal ── */} {editModalOpen && ( -
+
e.stopPropagation()} >

{editingChannel ? t.editChannel : t.newChannel} @@ -825,14 +824,13 @@ function ChannelsContent() { {/* ── Sync from Sub2API Modal ── */} {syncModalOpen && ( -
+
e.stopPropagation()} >

{t.syncTitle} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 75fd4d7..2f1cbfe 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -28,13 +28,12 @@ export default function TopUpModal({ open, onClose, onConfirm, amounts, isDark, }; return ( -
+
e.stopPropagation()} > {/* Header */}
diff --git a/src/components/admin/OrderDetail.tsx b/src/components/admin/OrderDetail.tsx index 317d482..dffc7b0 100644 --- a/src/components/admin/OrderDetail.tsx +++ b/src/components/admin/OrderDetail.tsx @@ -167,10 +167,9 @@ export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: Ord } return ( -
+
e.stopPropagation()} >

{text.title}

diff --git a/src/components/admin/RefundDialog.tsx b/src/components/admin/RefundDialog.tsx index 25dd66a..e5732cf 100644 --- a/src/components/admin/RefundDialog.tsx +++ b/src/components/admin/RefundDialog.tsx @@ -72,10 +72,9 @@ export default function RefundDialog({ }; return ( -
+
e.stopPropagation()} >

{text.title}