mirror of
https://github.com/34892002/edgeKey.git
synced 2026-09-03 06:47:41 +08:00
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,5 +1,18 @@
|
||||
# Change Log
|
||||
|
||||
## v1.4.5 (2026-06-16)
|
||||
|
||||
### Features
|
||||
|
||||
- **payment:** 新增支付宝当面付(订单码)支付方式
|
||||
- **order:** 新增 0 元订单支持,使用 100% 折扣码时自动跳过支付流程并直接发货
|
||||
|
||||
### Security
|
||||
|
||||
- **order:** 0 元订单金额使用 Math.max(0,...) 防止负数篡改
|
||||
- **order:** 折扣码验证和金额计算均在服务端完成,不信任前端数据
|
||||
- **order:** 记录免费订单支付日志(FREE_ORDER 事件)用于审计追踪
|
||||
|
||||
## v1.4.4 (2026-06-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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" | "FREE_PAY";
|
||||
|
||||
export interface PaymentMethodItem {
|
||||
provider: PaymentProvider;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.5",
|
||||
"scripts": {
|
||||
"dev": "vike dev",
|
||||
"build": "bun run db:generate && vike build",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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