diff --git a/CHANGELOG.md b/CHANGELOG.md index 7340ded..bc8275a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## v1.4.5 (2026-06-16) + +### Features + +- **payment:** 新增支付宝当面付(订单码)支付方式 +- **order:** 新增 0 元订单支持,使用 100% 折扣码时自动跳过支付流程并直接发货 + +### Security + +- **order:** 0 元订单金额使用 Math.max(0,...) 防止负数篡改 +- **order:** 折扣码验证和金额计算均在服务端完成,不信任前端数据 +- **order:** 记录免费订单支付日志(FREE_ORDER 事件)用于审计追踪 + ## v1.4.4 (2026-06-01) ### Bug Fixes 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..07d5b5f 100644 --- a/lib/utils/order-status.ts +++ b/lib/utils/order-status.ts @@ -47,10 +47,14 @@ export function getPaymentProviderLabel(provider: string) { return "易支付"; case "ALIPAY": return "支付宝"; + case "ALIPAY_FACE": + return "支付宝当面付"; case "STRIPE": return "Stripe"; case "BEPUSDT": return "BEpusdt"; + case "FREE_PAY": + return "免费"; default: return provider; } 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/order/service.ts b/modules/order/service.ts index 5832cb7..14626f8 100644 --- a/modules/order/service.ts +++ b/modules/order/service.ts @@ -6,6 +6,7 @@ import { validateOrderInput } from "../../lib/validators/order"; import { getAdminContext, logAdminOperation } from "../auth/service"; import { getAdminProductById } from "../catalog/service"; import { createPaymentForOrder, handlePaymentNotify } from "../payment/service"; +import { deliverOrder } from "../delivery/service"; import { closeOrderRecord, createOrderRecord, findOrderById, findOrderWithProduct, listOrderRecords } from "./repository"; import { generateOrderNo, generateQueryToken } from "./number"; import { logger } from "../../lib/logger"; @@ -96,7 +97,81 @@ export async function createOrder(input: { discountCodeStr = discountCode.code; } - const amount = originalAmount - discountAmount; + const amount = Math.max(0, originalAmount - discountAmount); + + // 0 元订单直接标记为已支付并发货,跳过支付流程 + if (amount === 0) { + const order = await createOrderRecord(prisma, { + orderNo, + queryToken, + productId: product.id, + productNameSnapshot: product.name, + unitPrice: product.price, + quantity, + amount: 0, + contactType: input.contactType, + contactValue, + buyerNote: input.buyerNote?.trim() || null, + paymentProvider: input.paymentProvider, + paymentChannel, + discountCodeId, + discountCodeStr, + originalAmount: discountAmount > 0 ? originalAmount : null, + discountAmount: discountAmount > 0 ? discountAmount : null, + }); + + // 标记为已支付 + await prisma.order.update({ + where: { id: order.id }, + data: { + status: "PAID", + paymentStatus: "PAID", + paidAt: new Date(), + }, + }); + + // 记录 0 元订单支付日志 + await prisma.paymentLog.create({ + data: { + orderId: order.id, + provider: input.paymentProvider, + orderNo, + eventType: "FREE_ORDER", + rawPayload: JSON.stringify({ amount: 0, discountCode: discountCodeStr, discountAmount }), + verifyStatus: "VERIFIED", + message: "0 元订单,自动完成", + }, + }); + + // 递增折扣码使用次数 + if (discountCodeId) { + await applyDiscountCode(discountCodeId, prisma); + } + + // 执行发货 + try { + await deliverOrder(prisma, order.orderNo); + } catch (error) { + logger.error(error instanceof Error ? error : new Error(String(error)), { + event: "order.free_delivery_failed", + orderNo, + }); + } + + return { + id: order.id, + orderNo: order.orderNo, + queryToken: order.queryToken, + amount: 0, + originalAmount: order.originalAmount, + discountAmount: order.discountAmount, + discountCodeStr: order.discountCodeStr, + paymentProvider: order.paymentProvider, + paymentChannel: order.paymentChannel, + paymentStatus: "PAID" as const, + payUrl: undefined, + }; + } const order = await createOrderRecord(prisma, { orderNo, 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..d9f6c4a 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" | "FREE_PAY"; export interface PaymentMethodItem { provider: PaymentProvider; diff --git a/package.json b/package.json index a9fac6a..fdc947e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.4.4", + "version": "1.4.5", "scripts": { "dev": "vike dev", "build": "bun run db:generate && vike build", diff --git a/pages/admin/orders/+Page.vue b/pages/admin/orders/+Page.vue index 133e3e4..421da57 100644 --- a/pages/admin/orders/+Page.vue +++ b/pages/admin/orders/+Page.vue @@ -11,8 +11,10 @@ + +