feat: 支付宝当面付

This commit is contained in:
ggyy
2026-06-16 15:33:45 +08:00
parent 8de61e9af8
commit c5849018b0
17 changed files with 466 additions and 13 deletions

View File

@@ -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

View File

@@ -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 封邮件,支持自定义域名发信,免信用卡与手机号验证,开箱即用。

View 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)

View File

@@ -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
**发起支付(请求签名)**

View File

@@ -47,6 +47,8 @@ export function getPaymentProviderLabel(provider: string) {
return "易支付";
case "ALIPAY":
return "支付宝";
case "ALIPAY_FACE":
return "支付宝当面付";
case "STRIPE":
return "Stripe";
case "BEPUSDT":

View File

@@ -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,

View 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",
};
},
};
}

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ?? '';

View 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>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);

View 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);
}
});
}