Merge pull request #10 from 34892002/dev

v1.4.5
This commit is contained in:
ggyy
2026-06-16 16:46:31 +08:00
committed by GitHub
21 changed files with 645 additions and 89 deletions

View File

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

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

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

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

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" | "FREE_PAY";
export interface PaymentMethodItem {
provider: PaymentProvider;

View File

@@ -1,5 +1,5 @@
{
"version": "1.4.4",
"version": "1.4.5",
"scripts": {
"dev": "vike dev",
"build": "bun run db:generate && vike build",

View File

@@ -11,8 +11,10 @@
<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>
<option value="FREE_PAY">免费</option>
</select>
<select v-model="filter.status" class="select select-sm select-bordered w-32">
<option value="">全部订单状态</option>

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

@@ -1,46 +1,38 @@
<template>
<div class="space-y-8">
<div class="space-y-10">
<section class="relative pt-12">
<!-- Hero 区域 -->
<section class="relative pt-8">
<img class="hero-img pointer-events-none" src="../../assets/home-n.png" alt="hero-img">
<div class="relative rounded-box bg-base-100 shadow-sm overflow-hidden">
<!-- 背景装饰 -->
<div class="pointer-events-none absolute inset-0 opacity-[0.03]" aria-hidden="true">
<div class="absolute -right-20 -top-20 h-96 w-96 rounded-full bg-primary"></div>
<div class="absolute -bottom-20 -left-20 h-72 w-72 rounded-full bg-secondary"></div>
<div class="relative rounded-2xl bg-base-100 shadow-md overflow-hidden border border-base-200/60">
<!-- 背景渐变装饰 -->
<div class="pointer-events-none absolute inset-0" aria-hidden="true">
<div class="absolute -right-24 -top-24 h-80 w-80 rounded-full bg-gradient-to-br from-primary/5 to-transparent blur-2xl"></div>
<div class="absolute -bottom-16 -left-16 h-64 w-64 rounded-full bg-gradient-to-tr from-secondary/5 to-transparent blur-2xl"></div>
</div>
<div class="relative flex flex-col gap-6 px-8 py-8 lg:flex-row lg:items-center lg:justify-between">
<div class="relative flex flex-col gap-6 px-8 py-10 lg:flex-row lg:items-center lg:justify-between">
<!-- 左侧文字 -->
<div class="space-y-4 max-w-lg">
<div class="badge badge-primary badge-outline font-semibold tracking-widest uppercase">
<div class="inline-flex items-center gap-2 badge badge-primary badge-outline font-semibold tracking-widest uppercase">
{{ site.siteSubtitle || 'Welcome' }}
</div>
<p v-if="site.notice" class="text-base-content/60 text-sm leading-relaxed border-l-2 border-primary pl-3">
<p v-if="site.notice" class="text-base-content/50 text-sm leading-relaxed border-l-2 border-primary/40 pl-4">
{{ site.notice }}
</p>
</div>
<!-- 右侧统计 -->
<div class="stats mr-32 bg-base-200 shadow shrink-0 max-lg:w-full">
<!-- <div class="stat">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 10h16M4 14h8" />
</svg>
<div class="flex items-center gap-4 shrink-0 max-lg:w-full">
<div class="flex items-center gap-3 rounded-xl bg-gradient-to-br from-secondary/10 to-base-200 px-6 py-4 shadow-sm">
<div class="flex flex-col items-end">
<span class="text-xs text-base-content/50 font-medium">在售商品</span>
<span class="text-3xl font-bold text-secondary leading-none mt-1">{{ catalog.products.length }}</span>
</div>
<div class="stat-title">商品分类</div>
<div class="stat-value text-primary">{{ catalog.categories.length }}</div>
</div> -->
<div class="stat">
<div class="stat-title text-center">在售商品</div>
<div class="flex text-secondary">
<div class="stat-value mr-2 text-secondary">{{ catalog.products.length }}</div>
<div class="stat-figure flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="size-8" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" />
</svg>
</div>
<div class="flex items-center justify-center size-12 rounded-xl bg-secondary/15 text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="size-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z" />
</svg>
</div>
</div>
</div>
@@ -48,15 +40,18 @@
</div>
</section>
<section class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold">商品列表</h2>
<!-- 商品列表 -->
<section class="space-y-5">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-base-content">商品列表</h2>
<span v-if="filteredProducts.length" class="text-xs text-base-content/40 font-medium"> {{ filteredProducts.length }} </span>
</div>
<div v-if="catalog.categories.length" class="flex flex-wrap gap-2 mb-6">
<!-- 分类筛选 -->
<div v-if="catalog.categories.length" class="flex flex-wrap gap-2">
<button
class="btn btn-sm"
:class="activeCategoryId === null ? 'btn-primary' : 'btn-outline'"
class="btn btn-sm rounded-full px-4 transition-all duration-200"
:class="activeCategoryId === null ? 'btn-primary shadow-sm shadow-primary/20' : 'btn-ghost border border-base-300 hover:border-primary/40 hover:text-primary'"
@click="activeCategoryId = null"
>
全部商品
@@ -64,76 +59,84 @@
<button
v-for="category in catalog.categories"
:key="category.id"
class="btn btn-sm"
:class="activeCategoryId === category.id ? 'btn-primary' : 'btn-outline'"
class="btn btn-sm rounded-full px-4 transition-all duration-200"
:class="activeCategoryId === category.id ? 'btn-primary shadow-sm shadow-primary/20' : 'btn-ghost border border-base-300 hover:border-primary/40 hover:text-primary'"
@click="activeCategoryId = category.id"
>
{{ category.name }}
</button>
</div>
<div v-if="filteredProducts.length" class="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<article v-for="product in filteredProducts" :key="product.id" class="card bg-base-100 shadow-sm hover:shadow-md transition-all duration-300 group rounded-xl overflow-hidden border border-base-200 cursor-pointer" @click="navigateToProduct(product.slug)">
<!-- 商品网格 -->
<div v-if="filteredProducts.length" class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
<article
v-for="product in filteredProducts"
:key="product.id"
class="card bg-base-100 rounded-xl overflow-hidden border border-base-300/50 shadow-sm cursor-pointer transition-all duration-300 hover:shadow-xl hover:shadow-base-content/8 group"
@click="navigateToProduct(product.slug)"
>
<!-- 商品图片 -->
<figure class="relative pt-[100%] overflow-hidden">
<figure class="relative aspect-square overflow-hidden bg-base-200">
<img
:src="product.coverImage || emptyCoverUrl"
:alt="product.name"
class="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
class="absolute inset-0 w-full h-full object-cover transition-transform duration-500 ease-out group-hover:scale-110"
/>
<!-- 分类标签 -->
<div class="absolute top-2.5 left-2.5">
<span class="badge badge-primary badge-sm font-semibold rounded">
<div class="absolute top-3 left-3">
<span class="badge badge-sm font-medium rounded-md bg-base-100/85 backdrop-blur-sm border-0 text-base-content shadow-sm">
{{ product.categoryName || '默认' }}
</span>
</div>
</figure>
<!-- 商品信息 -->
<div class="card-body p-3">
<!-- 库存状态标签 + 商品名称 -->
<div class="flex items-start gap-1.5">
<div class="card-body p-4 gap-2.5">
<!-- 商品名称 -->
<h3 class="text-sm font-semibold line-clamp-2 text-base-content leading-snug">
{{ product.name }}
</h3>
<!-- 底部状态 + 价格 -->
<div class="flex items-end justify-between mt-auto">
<span
v-if="product.deliveryType === 'CARD_AUTO'"
class="text-xs font-medium px-1.5 py-0.5 rounded shrink-0 mt-0.5"
class="text-xs font-semibold px-2 py-0.5 rounded"
:class="{
'bg-amber-500/10 text-amber-600': lowStock(product),
'bg-emerald-500/10 text-emerald-600': !lowStock(product) && product.availableStock > 0,
'bg-red-500/10 text-red-600': product.availableStock === 0
'bg-red-50 text-red-500': product.availableStock === 0
}"
>
{{ product.availableStock === 0 ? '已售罄' : lowStock(product) ? `紧张(${product.availableStock})` : '有货' }}
</span>
<span
v-else-if="product.deliveryType === 'FIXED_CARD'"
class="text-xs font-medium px-1.5 py-0.5 rounded shrink-0 mt-0.5 bg-emerald-500/10 text-emerald-600"
class="text-xs font-semibold px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-600"
>
有货
</span>
<span
v-else-if="product.deliveryType === 'MANUAL'"
class="text-xs font-medium px-1.5 py-0.5 rounded shrink-0 mt-0.5 bg-sky-500/10 text-sky-600"
class="text-xs font-semibold px-2 py-0.5 rounded bg-sky-500/10 text-sky-600"
>
人工发货
</span>
<h3 class="card-title text-sm font-semibold line-clamp-2 text-base-content flex-1 min-w-0">
{{ product.name }}
</h3>
</div>
<!-- 价格 -->
<div class="flex items-baseline gap-0.5 mt-2">
<span class="text-xs font-bold text-red-500">¥</span>
<span class="text-xl font-bold text-red-500 leading-none">{{ formatCents(product.price) }}</span>
<div class="flex items-baseline gap-0.5">
<span class="text-[11px] font-bold text-red-500/60">¥</span>
<span class="text-xl font-bold text-red-500 leading-none">{{ formatCents(product.price) }}</span>
</div>
</div>
</div>
</article>
</div>
<div v-else class="rounded-box border border-dashed border-base-300 bg-base-100 p-8 text-center text-base-content/60">
当前还没有上架商品请先在后台录入分类商品和库存
<div v-else class="rounded-2xl border border-dashed border-base-300 bg-base-100/50 p-12 text-center text-base-content/40">
<svg xmlns="http://www.w3.org/2000/svg" class="size-10 mx-auto mb-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
<p class="text-sm">当前还没有上架商品</p>
<p class="text-xs mt-1 opacity-60">请先在后台录入分类商品和库存</p>
</div>
</section>
</div>
@@ -170,11 +173,12 @@ const navigateToProduct = (slug: string) => {
<style>
.hero-img {
width: 230px;
width: 200px;
height: auto;
position: absolute;
top: -35px;
right: -22px;
top: -28px;
right: 12px;
z-index: 2;
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.08));
}
</style>

View File

@@ -40,7 +40,22 @@
<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 text-base-content/50">二维码有效期 2 小时</p>
</div>
<div v-if="paying && !qrCodeUrl" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- <div v-if="qrCodeUrl" class="text-center">
<AppButton size="sm" variant="outline" :loading="paying" @click="renderQrImage">刷新二维码</AppButton>
</div> -->
</div>
<p v-if="paymentError" class="mt-2 text-sm text-error">{{ paymentError }}</p>
</div>
</div>
@@ -61,7 +76,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 +90,54 @@ import type { Data } from "./+data";
const { order } = useData<Data>();
const paying = ref(false);
const paymentError = ref("");
const qrCodeUrl = ref("");
const POLL_INTERVAL = 5000; // 5 seconds
let pollTimer: ReturnType<typeof setInterval> | null = null;
let alipayQrCode = ""; // 保存支付宝返回的原始二维码链接
function getQrCacheKey() {
return order ? `alipay_face_qr_${order.orderNo}` : "";
}
function loadCachedQrCode() {
const key = getQrCacheKey();
if (!key) return "";
try {
return localStorage.getItem(key) || "";
} catch {
return "";
}
}
function saveQrCode(qrCode: string) {
const key = getQrCacheKey();
if (!key) return;
try {
localStorage.setItem(key, qrCode);
} catch {}
}
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) {
try { localStorage.removeItem(getQrCacheKey()); } catch {}
window.location.reload();
}
} catch {}
}, POLL_INTERVAL);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
onMounted(async () => {
if (!order) return;
@@ -90,6 +153,19 @@ onMounted(async () => {
deliveryStatus: order.deliveryStatus,
});
if (order.paymentStatus === 'UNPAID' && order.paymentProvider === 'ALIPAY_FACE') {
// 先尝试从缓存加载,避免重复调用 precreate
const cached = loadCachedQrCode();
if (cached) {
alipayQrCode = cached;
renderQrImage();
} else {
await fetchQrCode();
}
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 +175,40 @@ onMounted(async () => {
} catch {}
});
onUnmounted(() => {
stopPolling();
});
// 从支付宝获取二维码(只调用一次)
async function fetchQrCode() {
if (!order) return;
paying.value = true;
paymentError.value = "";
try {
const result = await onCreatePayment({ orderId: order.id });
if (result.payUrl) {
alipayQrCode = result.payUrl;
saveQrCode(alipayQrCode);
renderQrImage();
return;
}
paymentError.value = "未获取到支付二维码";
} catch (error) {
paymentError.value = normalizeTelefuncError(error, "生成二维码失败");
} finally {
paying.value = false;
}
}
// 用已有链接重新生成二维码图片
function renderQrImage() {
if (!alipayQrCode) return;
const text = encodeURIComponent(alipayQrCode);
qrCodeUrl.value = `https://quickchart.io/qr?text=${text}&size=300&ecLevel=M`;
}
async function handleContinuePay() {
if (!order) return;

View File

@@ -70,7 +70,7 @@
<textarea v-model="form.buyerNote" class="textarea textarea-bordered w-full" rows="3" placeholder="可以留下QQ号、微信等联系方式"></textarea>
</label>
<div class="space-y-2">
<div v-if="!isFreeOrder" class="space-y-2">
<div class="text-sm font-medium">支付方式</div>
<div class="grid gap-3">
<label v-for="method in paymentMethods" :key="method.provider" class="rounded-box border border-base-300 p-4">
@@ -82,7 +82,7 @@
</div>
</div>
<div v-if="form.paymentProvider === 'EPAY'" class="space-y-2">
<div v-if="!isFreeOrder && form.paymentProvider === 'EPAY'" class="space-y-2">
<div class="text-sm font-medium">易支付渠道</div>
<div class="grid gap-3 md:grid-cols-2">
<label v-for="channel in epayChannels" :key="channel.value" class="rounded-box border border-base-300 p-4">
@@ -129,10 +129,10 @@
<p v-else-if="product.deliveryType === 'FIXED_CARD'" class="text-sm text-success">自动发货库存充足</p>
<p v-else-if="product.deliveryType === 'MANUAL'" class="text-sm text-success">{{ product.manualDeliveryHint || '支付后客服将尽快为您处理订单请耐心等待' }}</p>
<AppButton variant="primary" :loading="submitting" :disabled="!paymentMethods.length || (product.deliveryType === 'CARD_AUTO' && product.availableStock === 0)" @click="handleCreateOrder">
{{ product.deliveryType === 'CARD_AUTO' && product.availableStock === 0 ? '已售罄' : '提交订单' }}
<AppButton variant="primary" :loading="submitting" :disabled="(!isFreeOrder && !paymentMethods.length) || (product.deliveryType === 'CARD_AUTO' && product.availableStock === 0)" @click="handleCreateOrder">
{{ product.deliveryType === 'CARD_AUTO' && product.availableStock === 0 ? '已售罄' : isFreeOrder ? '免费获取' : '提交订单' }}
</AppButton>
<p v-if="!paymentMethods.length" class="text-sm text-warning">当前没有可用支付方式请联系管理员启用支付配置</p>
<p v-if="!isFreeOrder && !paymentMethods.length" class="text-sm text-warning">当前没有可用支付方式请联系管理员启用支付配置</p>
<p v-if="errorMessage" class="text-sm text-error">{{ errorMessage }}</p>
</div>
</div>
@@ -143,7 +143,7 @@
<script setup lang="ts">
import AppButton from "../../../components/AppButton.vue";
import { normalizeTelefuncError } from "../../../lib/app-error";
import { reactive, ref } from "vue";
import { reactive, ref, computed } from "vue";
import { useData } from "vike-vue/useData";
import { isEmail } from "../../../lib/validators/email";
import { formatCents } from "../../../lib/utils/money";
@@ -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 "";
}
@@ -212,6 +213,10 @@ watch(() => form.discountCode, () => {
const descriptionHtml = formatDescriptionHtml(product?.description || "");
const isFreeOrder = computed(() => {
return discountPreview.valid && discountPreview.finalAmount === 0;
});
async function handlePreviewDiscount() {
if (!product || !form.discountCode.trim()) return;
@@ -238,7 +243,8 @@ async function handlePreviewDiscount() {
async function handleCreateOrder() {
if (!product || submitting.value) return;
if (!form.paymentProvider) {
// 免费订单不需要支付方式
if (!isFreeOrder.value && !form.paymentProvider) {
errorMessage.value = "当前没有可用支付方式,请联系管理员启用支付配置。";
return;
}
@@ -258,11 +264,14 @@ async function handleCreateOrder() {
errorMessage.value = "";
try {
// 免费订单使用 FREE_PAY 作为占位符(服务端会跳过支付)
const paymentProvider = isFreeOrder.value ? "FREE_PAY" : form.paymentProvider;
const result = await onCreateOrder({
productId: product.id,
quantity: form.quantity,
paymentProvider: form.paymentProvider,
paymentChannel: form.paymentProvider === "EPAY" || form.paymentProvider === "ALIPAY" ? form.paymentChannel : undefined,
paymentProvider,
paymentChannel: paymentProvider === "EPAY" || paymentProvider === "ALIPAY" ? form.paymentChannel : undefined,
contactType: "EMAIL",
contactValue: contactEmail,
buyerNote: form.buyerNote,
@@ -278,7 +287,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);
}
});
}