From c5849018b0faa993821289f815540647594e11c8 Mon Sep 17 00:00:00 2001 From: ggyy <34892002@qq.com> Date: Tue, 16 Jun 2026 15:33:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E4=BB=98=E5=AE=9D=E5=BD=93?= =?UTF-8?q?=E9=9D=A2=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-en.md | 2 +- README.md | 2 +- docs/pay/alipay-face/start.md | 85 +++++++++++ docs/payment-gateway-guide.md | 3 + lib/utils/order-status.ts | 2 + lib/validators/payment.ts | 15 ++ modules/payment/alipay-face.ts | 143 ++++++++++++++++++ modules/payment/service.ts | 25 ++- modules/payment/types.ts | 2 +- pages/admin/orders/+Page.vue | 1 + pages/admin/payments/+Page.vue | 5 + pages/admin/payments/PaymentConfigCard.vue | 7 +- pages/admin/payments/forms/AlipayFaceForm.vue | 24 +++ pages/order/@orderNo/+Page.vue | 114 +++++++++++++- pages/product/@slug/+Page.vue | 3 +- server/routes/index.ts | 2 + server/routes/payment-alipay-face.ts | 44 ++++++ 17 files changed, 466 insertions(+), 13 deletions(-) create mode 100644 docs/pay/alipay-face/start.md create mode 100644 modules/payment/alipay-face.ts create mode 100644 pages/admin/payments/forms/AlipayFaceForm.vue create mode 100644 server/routes/payment-alipay-face.ts diff --git a/README-en.md b/README-en.md index c633ee3..5e6c1da 100644 --- a/README-en.md +++ b/README-en.md @@ -22,7 +22,7 @@ EdgeKey is a full-stack card key shop system built with the Vike framework, depl ## Documentation - [Quick Deploy Guide](./docs/fast_deploy/start.md) -- Payment: [BEpusdt](./docs/pay/bepusdt/start.md), [Epay](./docs/pay/epay/start.md), [Alipay](./docs/pay/alipay/start.md), [Stripe](./docs/pay/stripe/start.md) +- Payment: [BEpusdt](./docs/pay/bepusdt/start.md), [Epay](./docs/pay/epay/start.md), [Alipay](./docs/pay/alipay/start.md), [Alipay Face Pay](./docs/pay/alipay-face/start.md), [Stripe](./docs/pay/stripe/start.md) - [Changelog](./CHANGELOG.md) ## Screenshots diff --git a/README.md b/README.md index 459abf3..349d6ef 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ EdgeKey 是一套有vike框架开发,可直接部署到 Cloudflare 的一体 ## 技术文档 & 资源推荐 - [一键部署教程](./docs/fast_deploy/start.md) - [CDN加速配置](./docs/cdn/start.md) -- 支付:[BEpusdt](./docs/pay/bepusdt/start.md)、 [易支付](./docs/pay/epay/start.md) 、[支付宝](./docs/pay/alipay/start.md) 、[Stripe](./docs/pay/stripe/start.md) +- 支付:[BEpusdt](./docs/pay/bepusdt/start.md)、 [易支付](./docs/pay/epay/start.md) 、[支付宝](./docs/pay/alipay/start.md) 、[支付宝当面付](./docs/pay/alipay-face/start.md) 、[Stripe](./docs/pay/stripe/start.md) - S3存储:[backblaze](https://www.backblaze.com/) 免费提供 10GB 空间免绑卡/手机号验证。本项目已完美适配 Cloudflare(小黄云),接入后流量费全免;未接入 CF 的用户请选用其他 S3 或图床 - 图床: [91星空图床](https://img.91starry.com/) 免费1G、邮箱注册即用 - 邮件: [resend](https://resend.com/) 免费用户每日可发 100 封邮件,支持自定义域名发信,免信用卡与手机号验证,开箱即用。 diff --git a/docs/pay/alipay-face/start.md b/docs/pay/alipay-face/start.md new file mode 100644 index 0000000..052b47d --- /dev/null +++ b/docs/pay/alipay-face/start.md @@ -0,0 +1,85 @@ +# EdgeKey 对接支付宝当面付教程 + +**当前教程对接支付宝当面付(订单码)通过测试验收** + +## 配置步骤 + +**配置说明** + +- **网关地址**:填写支付宝开放平台生产网关地址 `https://openapi.alipay.com`,系统会自动拼接 API 路径(`/gateway.do`)。 +- **支付宝应用 ID**:在支付宝开放平台获取的应用 App ID。 +- **应用私钥**:支付宝开放平台生成的【应用私钥】(注意,去掉头尾的换行)。 +- **支付宝公钥**:支付宝开放平台提供的【支付宝公钥】(注意,去掉头尾的换行)。 + +[支付宝官方文档-生成及配置密钥](https://opendocs.alipay.com/support/01raut) + +**配置步骤示例**: +1. 登录 EdgeKey 管理后台 +2. 进入「支付配置」页面 +3. 选择「当面付」标签页 +4. 填写具体配置 +5. 启用支付方式:点击右上角的启用开关 +6. 保存配置:点击「保存配置」按钮 + +### 重要提示 +填写完配置信息后,点击右上角的 **启用** 开关,然后点击 **保存配置** 按钮。 +⚠️ **重要提示**:启用支付前,请先前往「站点设置」配置网站地址,否则无法获取支付结果。 + +## 配置字段说明 + +**通用字段说明**: +- **显示名称**:支付方式在前台显示的名称,用户可自定义 +- **网关地址**:支付宝网关服务域名,系统会自动处理接口路径 +- **Notify URL**:异步通知地址,支付完成后系统会回调此地址 + +**支付宝当面付专用字段**: +- **支付宝应用 ID**:支付宝开放平台获取的应用 App ID +- **应用私钥**:支付宝开放平台生成的应用私钥(PKCS#8 格式) +- **支付宝公钥**:支付宝开放平台提供的公钥(用于验签) + +## 异步通知地址 + +这个地址的路由部分 **严格按要求填写**,只需将域名部分替换为您实际部署的 EdgeKey 服务器地址: + +- **异步通知地址**:`/api/payments/alipay-face/notify` + - 路由固定为 `/api/payments/alipay-face/notify` + - 示例:若部署地址为 `https://example.com`,则填写 `/api/payments/alipay-face/notify` + +## 配置验证 + +配置完成后,请按照以下步骤进行测试: + +1. 进入 EdgeKey 前台,选择一个商品进行购买。 +2. 在结算页面选择「支付宝当面付」支付方式。 +3. 提交订单后,页面会显示支付宝二维码。 +4. 使用支付宝 App 扫描二维码完成支付。 + +## 故障排查 + +若出现网关错误提示,请按照以下步骤排查: + +- **检查网络连通性**:确认 EdgeKey 服务器能够正常访问支付宝网关地址。 +- **检查应用 ID 和密钥**:确认所配置的支付宝应用 ID 和密钥与支付宝开放平台中的一致。 +- **检查回调地址**:确保异步通知地址可以从外部访问,且格式正确。 +- **检查站点设置**:确保在「站点设置」中配置了正确的网站地址。 +- **检查当面付权限**:确认支付宝应用已开通「当面付」功能。 +- **查看日志**:检查 EdgeKey 和支付宝的日志,获取更详细的错误信息。 + +## 支付宝当面付工作模式 + +本项目使用 **订单码模式**(`alipay.trade.precreate`): + +1. 用户在前台选择商品并提交订单 +2. 系统调用 `alipay.trade.precreate` 接口 +3. 支付宝返回二维码 URL +4. 系统在订单页面展示二维码 +5. 用户使用支付宝 App 扫描二维码完成支付 +6. 支付宝通过异步通知接口通知系统支付结果 +7. 系统自动发货 + +## 相关链接 + +- [支付宝开放平台](https://open.alipay.com/) +- [支付宝当面付文档](https://opendocs.alipay.com/open/028r8t) +- [支付宝密钥工具](https://opendocs.alipay.com/common/02kipl) +- [EdgeKey 项目主页](https://github.com/34892002/edgeKey) diff --git a/docs/payment-gateway-guide.md b/docs/payment-gateway-guide.md index 3c4a16d..93fc612 100644 --- a/docs/payment-gateway-guide.md +++ b/docs/payment-gateway-guide.md @@ -91,9 +91,12 @@ BEpusdt 是一款个人加密货币收款网关,支持 USDT、USDC、TRX 等 |------|--------|----------| | PC 网站 | `alipay.trade.page.pay` | 表单 POST 跳转 | | H5 手机 | `alipay.trade.wap.pay` | 表单 POST 跳转(唤起 App) | +| 当面付 | `alipay.trade.precreate` | 返回二维码 URL,用户扫码支付 | 通过订单的 `paymentChannel` 字段区分:`pc` → `alipay.trade.page.pay`,其他 → `alipay.trade.wap.pay`。 +当面付(`ALIPAY_FACE`)作为独立支付方式,使用 `alipay.trade.precreate` 接口生成二维码。 + ### 签名流程(RSA2-SHA256) **发起支付(请求签名)**: diff --git a/lib/utils/order-status.ts b/lib/utils/order-status.ts index 7d6c69a..f10f700 100644 --- a/lib/utils/order-status.ts +++ b/lib/utils/order-status.ts @@ -47,6 +47,8 @@ export function getPaymentProviderLabel(provider: string) { return "易支付"; case "ALIPAY": return "支付宝"; + case "ALIPAY_FACE": + return "支付宝当面付"; case "STRIPE": return "Stripe"; case "BEPUSDT": diff --git a/lib/validators/payment.ts b/lib/validators/payment.ts index 2f01c13..cd653fc 100644 --- a/lib/validators/payment.ts +++ b/lib/validators/payment.ts @@ -8,6 +8,9 @@ export function validatePaymentConfigInput(input: { appSecret?: string; pid?: string; key?: string; + alipayAppId?: string; + alipayPrivateKey?: string; + alipayPublicKey?: string; }) { const name = input.name?.trim() || ""; if (!name) { @@ -32,6 +35,18 @@ export function validatePaymentConfigInput(input: { } } + if ((input.provider === "ALIPAY" || input.provider === "ALIPAY_FACE") && input.isEnabled !== false) { + if (!(input.alipayAppId?.trim())) { + throw badRequestError("启用支付宝时必须填写 App ID", "ALIPAY_APP_ID_REQUIRED"); + } + if (!(input.alipayPrivateKey?.trim())) { + throw badRequestError("启用支付宝时必须填写应用私钥", "ALIPAY_PRIVATE_KEY_REQUIRED"); + } + if (!(input.alipayPublicKey?.trim())) { + throw badRequestError("启用支付宝时必须填写支付宝公钥", "ALIPAY_PUBLIC_KEY_REQUIRED"); + } + } + return { name, baseUrl, diff --git a/modules/payment/alipay-face.ts b/modules/payment/alipay-face.ts new file mode 100644 index 0000000..d7f2492 --- /dev/null +++ b/modules/payment/alipay-face.ts @@ -0,0 +1,143 @@ +import { badRequestError } from "../../lib/app-error"; +import type { PaymentProviderAdapter } from "./provider"; + +export interface AlipayFaceConfig { + baseUrl?: string; + alipayAppId?: string; + alipayPrivateKey?: string; + alipayPublicKey?: string; +} + +function pemToBase64(pem: string) { + return pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); +} + +async function rsaSign(content: string, privateKeyBase64: string): Promise { + const keyData = Uint8Array.from(atob(pemToBase64(privateKeyBase64)), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey( + "pkcs8", + keyData, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + const encoder = new TextEncoder(); + const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, encoder.encode(content)); + return btoa(String.fromCharCode(...new Uint8Array(signature))); +} + +async function rsaVerify(content: string, signBase64: string, publicKeyBase64: string): Promise { + try { + const keyData = Uint8Array.from(atob(pemToBase64(publicKeyBase64)), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey( + "spki", + keyData, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + const encoder = new TextEncoder(); + const sigData = Uint8Array.from(atob(signBase64), (c) => c.charCodeAt(0)); + return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, sigData, encoder.encode(content)); + } catch { + return false; + } +} + +function buildSignString(params: Record) { + return Object.entries(params) + .filter(([, v]) => v !== "" && v !== undefined && v !== null) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join("&"); +} + +export function createAlipayFaceAdapter(config: AlipayFaceConfig): PaymentProviderAdapter { + return { + async createPayment(input) { + const gateway = `${config.baseUrl?.trim().replace(/\/+$/, "")}/gateway.do`; + + if (!config.alipayAppId || !config.alipayPrivateKey) { + throw badRequestError("支付宝当面付配置不完整", "ALIPAY_FACE_CONFIG_INCOMPLETE"); + } + + const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19); + + const bizContent = JSON.stringify({ + out_trade_no: input.orderNo, + total_amount: (input.amount / 100).toFixed(2), + subject: input.productName, + product_code: "QR_CODE_OFFLINE", + }); + + const params: Record = {}; + params.app_id = config.alipayAppId; + params.method = "alipay.trade.precreate"; + params.charset = "utf-8"; + params.sign_type = "RSA2"; + params.timestamp = timestamp; + params.version = "1.0"; + if (input.notifyUrl) params.notify_url = input.notifyUrl; + params.biz_content = bizContent; + + const signStr = buildSignString(params); + const sign = await rsaSign(signStr, config.alipayPrivateKey); + + const query = new URLSearchParams({ ...params, sign }).toString(); + const response = await fetch(`${gateway}?${query}`); + const json = await response.json() as { + alipay_trade_precreate_response?: { + code?: string; + msg?: string; + qr_code?: string; + out_trade_no?: string; + }; + sign?: string; + }; + + const res = json.alipay_trade_precreate_response; + if (!res || res.code !== "10000") { + throw badRequestError( + `支付宝当面付下单失败: ${res?.msg || res?.code || "未知错误"}`, + "ALIPAY_FACE_PRECREATE_FAILED", + ); + } + + if (!res.qr_code) { + throw badRequestError("支付宝未返回二维码", "ALIPAY_FACE_NO_QR_CODE"); + } + + return { + payUrl: res.qr_code, + paymentOrderNo: input.orderNo, + raw: json, + }; + }, + + async verifyNotify(payload) { + if (!config.alipayPublicKey) { + return { isValid: false, raw: payload, message: "missing alipay public key" }; + } + + const sign = payload.sign ?? ""; + const unsigned = { ...payload }; + delete unsigned.sign; + delete unsigned.sign_type; + + const signStr = buildSignString(unsigned); + const isValid = await rsaVerify(signStr, sign, config.alipayPublicKey); + const tradeStatus = payload.trade_status ?? ""; + const isPaid = tradeStatus === "TRADE_SUCCESS" || tradeStatus === "TRADE_FINISHED"; + + return { + isValid, + orderNo: payload.out_trade_no, + paymentOrderNo: payload.trade_no, + amount: payload.total_amount ? Math.round(Number(payload.total_amount) * 100) : undefined, + status: isPaid ? "PAID" : tradeStatus ? "FAILED" : "PENDING", + raw: payload, + message: isValid ? "ok" : "invalid signature", + }; + }, + }; +} diff --git a/modules/payment/service.ts b/modules/payment/service.ts index 3a321fc..5ec16fa 100644 --- a/modules/payment/service.ts +++ b/modules/payment/service.ts @@ -12,6 +12,7 @@ import type { PaymentConfigValue } from "./types"; import { createBepusdtAdapter } from "./bepusdt"; import { createEpayAdapter } from "./epay"; import { createAlipayAdapter, queryAlipayTrade } from "./alipay"; +import { createAlipayFaceAdapter } from "./alipay-face"; import { createStripeAdapter } from "./stripe"; import { deliverOrder } from "../delivery/service"; import { findOrderRecord, updateOrderPayment } from "../order/repository"; @@ -48,6 +49,17 @@ const defaultPaymentConfigs: Record = { notifyUrl: "/api/payments/alipay/notify", returnUrl: "/order/{orderNo}?token={token}", }, + ALIPAY_FACE: { + provider: "ALIPAY_FACE", + name: "支付宝当面付", + isEnabled: false, + baseUrl: "https://openapi.alipay.com", + alipayAppId: "", + alipayPrivateKey: "", + alipayPublicKey: "", + notifyUrl: "/api/payments/alipay-face/notify", + returnUrl: "", + }, STRIPE: { provider: "STRIPE", name: "Stripe", @@ -166,6 +178,9 @@ function createProviderAdapter(config: PaymentConfigValue) { if (config.provider === "ALIPAY") { return createAlipayAdapter(config); } + if (config.provider === "ALIPAY_FACE") { + return createAlipayFaceAdapter(config); + } if (config.provider === "STRIPE") { return createStripeAdapter(config); } @@ -362,8 +377,9 @@ export async function queryAlipayPayment(orderNo: string, prisma?: PrismaClient) if (!order) throw notFoundError("订单不存在", "ORDER_NOT_FOUND"); if (order.paymentStatus === "PAID") return { alreadyPaid: true }; + const provider = order.paymentProvider === "ALIPAY_FACE" ? "ALIPAY_FACE" : "ALIPAY"; const configs = await getPaymentConfigs(client); - const config = configs["ALIPAY"]; + const config = configs[provider]; const result = await queryAlipayTrade(config, orderNo); if (result.isPaid) { @@ -376,17 +392,18 @@ export async function queryAlipayPayment(orderNo: string, prisma?: PrismaClient) if (updated) { await createPaymentLogRecord(client, { orderId: order.id, - provider: "ALIPAY", + provider, orderNo, paymentOrderNo: result.tradeNo, eventType: "QUERY_PAID", - rawPayload: JSON.stringify(result),verifyStatus: "VERIFIED", + rawPayload: JSON.stringify(result), + verifyStatus: "VERIFIED", message: "paid via query", }); try { await deliverOrder(client, orderNo); } catch (error) { - logger.error(error instanceof Error ? error : String(error), { + logger.error(error instanceof Error ? error : new Error(String(error)), { event: "payment.alipay_query.delivery_failed", orderNo, }); diff --git a/modules/payment/types.ts b/modules/payment/types.ts index e7c2267..a22bebd 100644 --- a/modules/payment/types.ts +++ b/modules/payment/types.ts @@ -1,4 +1,4 @@ -export type PaymentProvider = "BEPUSDT" | "EPAY" | "ALIPAY" | "STRIPE"; +export type PaymentProvider = "BEPUSDT" | "EPAY" | "ALIPAY" | "ALIPAY_FACE" | "STRIPE"; export interface PaymentMethodItem { provider: PaymentProvider; diff --git a/pages/admin/orders/+Page.vue b/pages/admin/orders/+Page.vue index 133e3e4..923ad43 100644 --- a/pages/admin/orders/+Page.vue +++ b/pages/admin/orders/+Page.vue @@ -11,6 +11,7 @@ + diff --git a/pages/admin/payments/+Page.vue b/pages/admin/payments/+Page.vue index 033a55a..4fb924e 100644 --- a/pages/admin/payments/+Page.vue +++ b/pages/admin/payments/+Page.vue @@ -16,6 +16,10 @@ 支付宝 + + 当面付 + + Stripe @@ -24,6 +28,7 @@ + diff --git a/pages/admin/payments/PaymentConfigCard.vue b/pages/admin/payments/PaymentConfigCard.vue index 38c69f1..f5c4de1 100644 --- a/pages/admin/payments/PaymentConfigCard.vue +++ b/pages/admin/payments/PaymentConfigCard.vue @@ -53,10 +53,11 @@ import { onSavePaymentConfig } from "./savePaymentConfig.telefunc"; import BEpusdtForm from "./forms/BEpusdtForm.vue"; import EpayForm from "./forms/EpayForm.vue"; import AlipayForm from "./forms/AlipayForm.vue"; +import AlipayFaceForm from "./forms/AlipayFaceForm.vue"; import StripeForm from "./forms/StripeForm.vue"; import type { PaymentProvider, PaymentConfigValue } from "../../../modules/payment/types"; -const formMap = { BEPUSDT: BEpusdtForm, EPAY: EpayForm, ALIPAY: AlipayForm, STRIPE: StripeForm }; +const formMap = { BEPUSDT: BEpusdtForm, EPAY: EpayForm, ALIPAY: AlipayForm, ALIPAY_FACE: AlipayFaceForm, STRIPE: StripeForm }; const emit = defineEmits<{ saved: [value: typeof props.initialValue] }>(); @@ -77,7 +78,7 @@ const form = reactive({ const extraFields = reactive( props.provider === 'BEPUSDT' ? { appId: props.initialValue?.appId ?? '', appSecret: props.initialValue?.appSecret ?? '' } - : props.provider === 'ALIPAY' + : props.provider === 'ALIPAY' || props.provider === 'ALIPAY_FACE' ? { alipayAppId: props.initialValue?.alipayAppId ?? '', alipayPrivateKey: props.initialValue?.alipayPrivateKey ?? '', alipayPublicKey: props.initialValue?.alipayPublicKey ?? '' } : props.provider === 'STRIPE' ? { stripeSecretKey: props.initialValue?.stripeSecretKey ?? '', stripeWebhookSecret: props.initialValue?.stripeWebhookSecret ?? '', stripeCurrency: props.initialValue?.stripeCurrency ?? 'cny' } @@ -106,7 +107,7 @@ async function handleSave() { (extraFields as any).stripeSecretKey = (result as any).stripeSecretKey ?? ''; (extraFields as any).stripeWebhookSecret = (result as any).stripeWebhookSecret ?? ''; (extraFields as any).stripeCurrency = (result as any).stripeCurrency ?? 'cny'; - } else if (props.provider === 'ALIPAY') { + } else if (props.provider === 'ALIPAY' || props.provider === 'ALIPAY_FACE') { (extraFields as any).alipayAppId = (result as any).alipayAppId ?? ''; (extraFields as any).alipayPrivateKey = (result as any).alipayPrivateKey ?? ''; (extraFields as any).alipayPublicKey = (result as any).alipayPublicKey ?? ''; diff --git a/pages/admin/payments/forms/AlipayFaceForm.vue b/pages/admin/payments/forms/AlipayFaceForm.vue new file mode 100644 index 0000000..7f819ce --- /dev/null +++ b/pages/admin/payments/forms/AlipayFaceForm.vue @@ -0,0 +1,24 @@ + + + diff --git a/pages/order/@orderNo/+Page.vue b/pages/order/@orderNo/+Page.vue index 9ce38c2..1def86d 100644 --- a/pages/order/@orderNo/+Page.vue +++ b/pages/order/@orderNo/+Page.vue @@ -40,7 +40,24 @@
支付方式{{ getPaymentProviderLabel(order.paymentProvider) }}
- 继续支付 + 继续支付 +
+
+

请使用支付宝扫描下方二维码完成支付:

+
+ 支付宝当面付二维码 +
+

+ {{ qrExpired ? '二维码已过期' : `二维码有效期:${qrCountdown}` }} +

+
+
+ +
+ + {{ qrExpired ? '重新生成二维码' : '刷新二维码' }} + +

{{ paymentError }}

@@ -61,7 +78,7 @@