mirror of
https://github.com/34892002/edgeKey.git
synced 2026-09-03 06:47:41 +08:00
feat: 支付宝当面付
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 封邮件,支持自定义域名发信,免信用卡与手机号验证,开箱即用。
|
||||
|
||||
85
docs/pay/alipay-face/start.md
Normal file
85
docs/pay/alipay-face/start.md
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
**发起支付(请求签名)**:
|
||||
|
||||
@@ -47,6 +47,8 @@ export function getPaymentProviderLabel(provider: string) {
|
||||
return "易支付";
|
||||
case "ALIPAY":
|
||||
return "支付宝";
|
||||
case "ALIPAY_FACE":
|
||||
return "支付宝当面付";
|
||||
case "STRIPE":
|
||||
return "Stripe";
|
||||
case "BEPUSDT":
|
||||
|
||||
@@ -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,
|
||||
|
||||
143
modules/payment/alipay-face.ts
Normal file
143
modules/payment/alipay-face.ts
Normal file
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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<string, string>) {
|
||||
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<string, string> = {};
|
||||
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",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<PaymentProvider, PaymentConfigValue> = {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<option value="">全部支付方式</option>
|
||||
<option value="EPAY">易支付</option>
|
||||
<option value="ALIPAY">支付宝</option>
|
||||
<option value="ALIPAY_FACE">支付宝当面付</option>
|
||||
<option value="STRIPE">Stripe</option>
|
||||
<option value="BEPUSDT">BEpusdt</option>
|
||||
</select>
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
支付宝
|
||||
<span v-if="localConfigs.ALIPAY?.isEnabled" class="ml-1.5 inline-block w-2 h-2 rounded-full bg-success"></span>
|
||||
</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'ALIPAY_FACE' }" @click="activeTab = 'ALIPAY_FACE'">
|
||||
当面付
|
||||
<span v-if="localConfigs.ALIPAY_FACE?.isEnabled" class="ml-1.5 inline-block w-2 h-2 rounded-full bg-success"></span>
|
||||
</a>
|
||||
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'STRIPE' }" @click="activeTab = 'STRIPE'">
|
||||
Stripe
|
||||
<span v-if="localConfigs.STRIPE?.isEnabled" class="ml-1.5 inline-block w-2 h-2 rounded-full bg-success"></span>
|
||||
@@ -24,6 +28,7 @@
|
||||
<PaymentConfigCard v-if="activeTab === 'BEPUSDT'" provider="BEPUSDT" title="Usdt/Usdc(数字货币)" :initial-value="localConfigs.BEPUSDT" @saved="localConfigs.BEPUSDT = $event" />
|
||||
<PaymentConfigCard v-if="activeTab === 'EPAY'" provider="EPAY" title="易支付(聚合支付)" :initial-value="localConfigs.EPAY" @saved="localConfigs.EPAY = $event" />
|
||||
<PaymentConfigCard v-if="activeTab === 'ALIPAY'" provider="ALIPAY" title="支付宝(官方)" :initial-value="localConfigs.ALIPAY" @saved="localConfigs.ALIPAY = $event" />
|
||||
<PaymentConfigCard v-if="activeTab === 'ALIPAY_FACE'" provider="ALIPAY_FACE" title="支付宝当面付" :initial-value="localConfigs.ALIPAY_FACE" @saved="localConfigs.ALIPAY_FACE = $event" />
|
||||
<PaymentConfigCard v-if="activeTab === 'STRIPE'" provider="STRIPE" title="Stripe(信用卡)" :initial-value="localConfigs.STRIPE" @saved="localConfigs.STRIPE = $event" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
24
pages/admin/payments/forms/AlipayFaceForm.vue
Normal file
24
pages/admin/payments/forms/AlipayFaceForm.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">App ID</span>
|
||||
<input v-model="modelValue.alipayAppId" class="input input-bordered w-full" placeholder="支付宝开放平台应用ID" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">应用私钥(PKCS#8,去掉头尾和换行)</span>
|
||||
<SecretInput v-model="modelValue.alipayPrivateKey" placeholder="MIIEvgIBADANBgkq..." />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">支付宝公钥(用于验签,去掉头尾和换行)</span>
|
||||
<SecretInput v-model="modelValue.alipayPublicKey" placeholder="MIIBIjANBgkq..." />
|
||||
</label>
|
||||
<p class="text-xs text-base-content/60">密钥生成工具:<a href="https://opendocs.alipay.com/common/02kipl" target="_blank" class="link">支付宝密钥工具</a></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SecretInput from "../../../../components/SecretInput.vue";
|
||||
defineProps<{ modelValue: Record<string, any> }>();
|
||||
</script>
|
||||
@@ -40,7 +40,24 @@
|
||||
<div class="flex justify-between"><span>支付方式</span><span>{{ getPaymentProviderLabel(order.paymentProvider) }}</span></div>
|
||||
</div>
|
||||
<div v-if="order.paymentStatus === 'UNPAID'" class="mt-4">
|
||||
<AppButton size="sm" variant="primary" :loading="paying" @click="handleContinuePay">继续支付</AppButton>
|
||||
<AppButton v-if="order.paymentProvider !== 'ALIPAY_FACE'" size="sm" variant="primary" :loading="paying" @click="handleContinuePay">继续支付</AppButton>
|
||||
<div v-if="order.paymentProvider === 'ALIPAY_FACE'" class="space-y-3">
|
||||
<div v-if="qrCodeUrl">
|
||||
<p class="text-sm text-base-content/70">请使用支付宝扫描下方二维码完成支付:</p>
|
||||
<div class="flex justify-center my-3">
|
||||
<img :src="qrCodeUrl" alt="支付宝当面付二维码" class="w-48 h-48 rounded-box border border-base-300" />
|
||||
</div>
|
||||
<p class="text-xs text-center" :class="qrExpired ? 'text-error' : 'text-base-content/50'">
|
||||
{{ qrExpired ? '二维码已过期' : `二维码有效期:${qrCountdown}` }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="paying && !qrCodeUrl" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
<AppButton size="sm" variant="outline" :loading="paying" @click="handleRefreshQrCode">
|
||||
{{ qrExpired ? '重新生成二维码' : '刷新二维码' }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<p v-if="paymentError" class="mt-2 text-sm text-error">{{ paymentError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,7 +78,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeTelefuncError } from "../../../lib/app-error";
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import AppButton from "../../../components/AppButton.vue";
|
||||
import { useData } from "vike-vue/useData";
|
||||
import { formatCents } from "../../../lib/utils/money";
|
||||
@@ -75,6 +92,62 @@ import type { Data } from "./+data";
|
||||
const { order } = useData<Data>();
|
||||
const paying = ref(false);
|
||||
const paymentError = ref("");
|
||||
const qrCodeUrl = ref("");
|
||||
const qrCountdown = ref("");
|
||||
const qrExpired = ref(false);
|
||||
|
||||
const QR_EXPIRE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
const POLL_INTERVAL = 5000; // 5 seconds
|
||||
|
||||
let qrGeneratedAt = 0;
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startCountdown() {
|
||||
stopCountdown();
|
||||
qrExpired.value = false;
|
||||
qrGeneratedAt = Date.now();
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
const remaining = QR_EXPIRE_MS - (Date.now() - qrGeneratedAt);
|
||||
if (remaining <= 0) {
|
||||
qrExpired.value = true;
|
||||
qrCountdown.value = "00:00";
|
||||
stopCountdown();
|
||||
return;
|
||||
}
|
||||
const minutes = Math.floor(remaining / 60000);
|
||||
const seconds = Math.floor((remaining % 60000) / 1000);
|
||||
qrCountdown.value = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!order || order.paymentStatus !== "UNPAID") return;
|
||||
try {
|
||||
const result = await onQueryAlipayPayment({ orderNo: order.orderNo });
|
||||
if (result.isPaid || result.alreadyPaid) {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {}
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!order) return;
|
||||
@@ -90,6 +163,12 @@ onMounted(async () => {
|
||||
deliveryStatus: order.deliveryStatus,
|
||||
});
|
||||
|
||||
if (order.paymentStatus === 'UNPAID' && order.paymentProvider === 'ALIPAY_FACE') {
|
||||
await generateQrCode();
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (order.paymentStatus !== "UNPAID" || order.paymentProvider !== "ALIPAY") return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.get("out_trade_no")) return;
|
||||
@@ -99,6 +178,37 @@ onMounted(async () => {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCountdown();
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
async function generateQrCode() {
|
||||
if (!order) return;
|
||||
|
||||
paying.value = true;
|
||||
paymentError.value = "";
|
||||
|
||||
try {
|
||||
const result = await onCreatePayment({ orderId: order.id });
|
||||
if (result.payUrl) {
|
||||
const text = encodeURIComponent(result.payUrl);
|
||||
qrCodeUrl.value = `https://quickchart.io/qr?text=${text}&size=300&ecLevel=M`;
|
||||
startCountdown();
|
||||
return;
|
||||
}
|
||||
paymentError.value = "未获取到支付二维码";
|
||||
} catch (error) {
|
||||
paymentError.value = normalizeTelefuncError(error, "生成二维码失败");
|
||||
} finally {
|
||||
paying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshQrCode() {
|
||||
await generateQrCode();
|
||||
}
|
||||
|
||||
async function handleContinuePay() {
|
||||
if (!order) return;
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ let mobile = false;
|
||||
function getDefaultPaymentChannel(provider: PaymentProvider | "") {
|
||||
if (provider === "EPAY") return "alipay";
|
||||
if (provider === "ALIPAY") return mobile ? "alipay_h5" : "alipay_pc";
|
||||
if (provider === "ALIPAY_FACE") return "";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -278,7 +279,7 @@ async function handleCreateOrder() {
|
||||
paymentStatus: result.paymentStatus ?? 'UNPAID',
|
||||
});
|
||||
|
||||
if (result.payUrl) {
|
||||
if (result.payUrl && form.paymentProvider !== 'ALIPAY_FACE') {
|
||||
window.location.href = result.payUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { registerHealthRoutes } from "./health";
|
||||
import { registerBepusdtRoutes } from "./payment-bepusdt";
|
||||
import { registerEpayRoutes } from "./payment-epay";
|
||||
import { registerAlipayRoutes } from "./payment-alipay";
|
||||
import { registerAlipayFaceRoutes } from "./payment-alipay-face";
|
||||
import { registerStripeRoutes } from "./payment-stripe";
|
||||
import { registerRobotsRoutes } from "./robots";
|
||||
import { registerSitemapRoutes } from "./sitemap";
|
||||
@@ -16,6 +17,7 @@ export function registerApiRoutes(app: Hono) {
|
||||
registerBepusdtRoutes(app);
|
||||
registerEpayRoutes(app);
|
||||
registerAlipayRoutes(app);
|
||||
registerAlipayFaceRoutes(app);
|
||||
registerStripeRoutes(app);
|
||||
registerRobotsRoutes(app);
|
||||
registerSitemapRoutes(app);
|
||||
|
||||
44
server/routes/payment-alipay-face.ts
Normal file
44
server/routes/payment-alipay-face.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Hono } from "hono";
|
||||
import type { PrismaClient } from "../../generated/prisma/client";
|
||||
import { handlePaymentNotify } from "../../modules/payment/service";
|
||||
import { logger } from "../../lib/logger";
|
||||
|
||||
function normalizePayload(input: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => [key, Array.isArray(value) ? String(value[0]) : String(value)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerAlipayFaceRoutes(app: Hono) {
|
||||
app.on(["GET", "POST"], "/api/payments/alipay-face/notify", async (c) => {
|
||||
let payload: Record<string, string> = {};
|
||||
try {
|
||||
payload =
|
||||
c.req.method === "GET"
|
||||
? normalizePayload(Object.fromEntries(new URL(c.req.url).searchParams.entries()))
|
||||
: normalizePayload(await c.req.parseBody());
|
||||
const universalContext = (c as any).get("universalContext") as { prisma: PrismaClient };
|
||||
if (!universalContext?.prisma) {
|
||||
logger.error("Missing prisma for alipay-face notify", {
|
||||
event: "payment.notify.context_missing",
|
||||
provider: "ALIPAY_FACE",
|
||||
source: "notify",
|
||||
payload,
|
||||
});
|
||||
return c.text("fail", 500);
|
||||
}
|
||||
const result = await handlePaymentNotify("ALIPAY_FACE", payload, universalContext.prisma, "notify");
|
||||
return c.text(result.ok ? "success" : "fail");
|
||||
} catch (error) {
|
||||
logger.error(error instanceof Error ? error : new Error(String(error)), {
|
||||
event: "payment.notify.route_exception",
|
||||
provider: "ALIPAY_FACE",
|
||||
source: "notify",
|
||||
payload,
|
||||
});
|
||||
return c.text("fail", 400);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user