feat: 支付宝官方支付

This commit is contained in:
ggyy
2026-04-24 14:46:03 +08:00
parent 10d406d607
commit bafd97795a
12 changed files with 159 additions and 46 deletions

4
lib/utils/device.ts Normal file
View File

@@ -0,0 +1,4 @@
export function isMobile(): boolean {
if (typeof navigator === "undefined") return false;
return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
}

View File

@@ -56,12 +56,12 @@ export async function createOrder(input: {
const quantity = Math.max(product.minBuy, Math.min(product.maxBuy, Math.floor(input.quantity)));
const orderNo = generateOrderNo();
const queryToken = generateQueryToken();
const paymentChannel =
input.paymentProvider === "EPAY"
? input.paymentChannel === "wxpay"
? "wxpay"
: "alipay"
: null;
let paymentChannel: string | null = null;
if (input.paymentProvider === "EPAY") {
paymentChannel = input.paymentChannel === "wxpay" ? "wxpay" : "alipay";
} else if (input.paymentProvider === "ALIPAY") {
paymentChannel = input.paymentChannel ?? "alipay_h5";
}
const order = await createOrderRecord(prisma, {
orderNo,

View File

@@ -1,7 +1,14 @@
import { badRequestError } from "../../lib/app-error";
import { badRequestError, externalServiceError } from "../../lib/app-error";
import type { PaymentProviderAdapter } from "./provider";
interface AlipayConfig {
export interface AlipayTradeQueryResult {
isPaid: boolean;
tradeNo?: string;
amount?: number;
}
export interface AlipayConfig {
baseUrl?: string;
alipayAppId?: string;
alipayPrivateKey?: string;
alipayPublicKey?: string;
@@ -9,7 +16,7 @@ interface AlipayConfig {
returnUrl?: string;
}
const GATEWAY = "https://openapi.alipay.com/gateway.do";
const DEFAULT_GATEWAY = "https://openapi.alipay.com/gateway.do";
function pemToBase64(pem: string) {
return pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
@@ -58,38 +65,39 @@ function buildSignString(params: Record<string, string>) {
export function createAlipayAdapter(config: AlipayConfig): PaymentProviderAdapter {
return {
async createPayment(input) {
const gateway = config.baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_GATEWAY;
if (!config.alipayAppId || !config.alipayPrivateKey) {
throw badRequestError("支付宝配置不完整", "ALIPAY_CONFIG_INCOMPLETE");
}
const method = input.paymentChannel === "pc" ? "alipay.trade.page.pay" : "alipay.trade.wap.pay";
const method = input.paymentChannel === "alipay_pc" ? "alipay.trade.page.pay" : "alipay.trade.wap.pay";
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: input.paymentChannel === "pc" ? "FAST_INSTANT_TRADE_PAY" : "QUICK_WAP_WAY",
product_code: input.paymentChannel === "alipay_pc" ? "FAST_INSTANT_TRADE_PAY" : "QUICK_WAP_WAY",
});
const params: Record<string, string> = {
app_id: config.alipayAppId,
method,
charset: "utf-8",
sign_type: "RSA2",
timestamp,
version: "1.0",
notify_url: input.notifyUrl,
return_url: input.returnUrl,
biz_content: bizContent,
};
const params: Record<string, string> = {};
params.app_id = config.alipayAppId;
params.method = method;
params.charset = "utf-8";
params.sign_type = "RSA2";
params.timestamp = timestamp;
params.version = "1.0";
if (input.notifyUrl) params.notify_url = input.notifyUrl;
if (input.returnUrl) params.return_url = input.returnUrl;
params.biz_content = bizContent;
const signStr = buildSignString(params);
const sign = await rsaSign(signStr, config.alipayPrivateKey);
const query = new URLSearchParams({ ...params, sign }).toString();
return {
payUrl: `${GATEWAY}?${query}`,
payUrl: `${gateway}?${query}`,
paymentOrderNo: input.orderNo,
raw: params,
};
@@ -121,4 +129,45 @@ export function createAlipayAdapter(config: AlipayConfig): PaymentProviderAdapte
};
},
};
}
export async function queryAlipayTrade(config: AlipayConfig, outTradeNo: string): Promise<AlipayTradeQueryResult> {
if (!config.alipayAppId || !config.alipayPrivateKey) {
throw badRequestError("支付宝配置不完整", "ALIPAY_CONFIG_INCOMPLETE");
}
const gateway = config.baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_GATEWAY;
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
const params: Record<string, string> = {
app_id: config.alipayAppId,
method: "alipay.trade.query",
charset: "utf-8",
sign_type: "RSA2",
timestamp,
version: "1.0",
biz_content: JSON.stringify({ out_trade_no: outTradeNo }),
};
const sign = await rsaSign(buildSignString(params), config.alipayPrivateKey);
const response = await fetch(`${gateway}?${new URLSearchParams({ ...params, sign }).toString()}`);
const json = await response.json() as {
alipay_trade_query_response?: {
code?: string;
trade_status?: string;
trade_no?: string;
total_amount?: string;
};
};
const res = json.alipay_trade_query_response;
if (!res || res.code !== "10000") {
throw externalServiceError(`支付宝查询失败: ${res?.code}`, "ALIPAY_QUERY_FAILED");
}
const isPaid = res.trade_status === "TRADE_SUCCESS" || res.trade_status === "TRADE_FINISHED";
return {
isPaid,
tradeNo: res.trade_no,
amount: res.total_amount ? Math.round(Number(res.total_amount) * 100) : undefined,
};
}

View File

@@ -11,9 +11,10 @@ import type { PaymentMethodItem, PaymentProvider } from "./types";
import type { PaymentConfigValue } from "./types";
import { createBepusdtAdapter } from "./bepusdt";
import { createEpayAdapter } from "./epay";
import { createAlipayAdapter } from "./alipay";
import { createAlipayAdapter, queryAlipayTrade } from "./alipay";
import { deliverOrder } from "../delivery/service";
import { findOrderRecord, updateOrderPayment } from "../order/repository";
import { notifyOrderPaid as _notifyOrderPaid } from "../email/service";
const defaultPaymentConfigs: Record<PaymentProvider, PaymentConfigValue> = {
BEPUSDT: {
@@ -336,6 +337,42 @@ function writePaymentNotifyDiagnostic(input: {
});
}
export async function queryAlipayPayment(orderNo: string, prisma?: PrismaClient) {
const client = prisma ?? getPaymentContext().prisma;
const order = await findOrderRecord(client, orderNo);
if (!order) throw notFoundError("订单不存在", "ORDER_NOT_FOUND");
if (order.paymentStatus === "PAID") return { alreadyPaid: true };
const configs = await getPaymentConfigs(client);
const config = configs["ALIPAY"];
const result = await queryAlipayTrade(config, orderNo);
if (result.isPaid) {
const updated = await updateOrderPayment(client, orderNo, {
paymentOrderNo: result.tradeNo,
status: "PAID",
paymentStatus: "PAID",
paidAt: new Date(),
});
if (updated) {
await createPaymentLogRecord(client, {
orderId: order.id,
provider: "ALIPAY",
orderNo,
paymentOrderNo: result.tradeNo,
eventType: "QUERY_PAID",
rawPayload: JSON.stringify(result),verifyStatus: "VERIFIED",
message: "paid via query",
});
try {
await deliverOrder(client, orderNo);
} catch {}
}
}
return { alreadyPaid: false, isPaid: result.isPaid };
}
export async function handlePaymentNotify(
provider: PaymentProvider,
payload: Record<string, string>,

View File

@@ -23,7 +23,7 @@
</label>
</div>
<component :is="formMap[provider]" v-model="(extraFields as unknown as Record<string, string>)" />
<component :is="formMap[provider]" v-model="extraFields" />
<div class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-1.5">
@@ -54,7 +54,7 @@ import { onSavePaymentConfig } from "./savePaymentConfig.telefunc";
import BEpusdtForm from "./forms/BEpusdtForm.vue";
import EpayForm from "./forms/EpayForm.vue";
import AlipayForm from "./forms/AlipayForm.vue";
import type { PaymentProvider } from "../../../modules/payment/types";
import type { PaymentProvider, PaymentConfigValue } from "../../../modules/payment/types";
const formMap = { BEPUSDT: BEpusdtForm, EPAY: EpayForm, ALIPAY: AlipayForm };
@@ -63,18 +63,7 @@ const emit = defineEmits<{ saved: [value: typeof props.initialValue] }>();
const props = defineProps<{
provider: PaymentProvider;
title: string;
initialValue: {
provider: PaymentProvider;
name: string;
isEnabled: boolean;
baseUrl: string;
appId?: string;
appSecret?: string;
pid?: string;
key?: string;
notifyUrl?: string;
returnUrl?: string;
} | null;
initialValue: PaymentConfigValue | null;
}>();
const form = reactive({
@@ -89,7 +78,7 @@ const extraFields = reactive(
props.provider === 'BEPUSDT'
? { appId: props.initialValue?.appId ?? '', appSecret: props.initialValue?.appSecret ?? '' }
: props.provider === 'ALIPAY'
? { alipayAppId: (props.initialValue as any)?.alipayAppId ?? '', alipayPrivateKey: (props.initialValue as any)?.alipayPrivateKey ?? '', alipayPublicKey: (props.initialValue as any)?.alipayPublicKey ?? '' }
? { alipayAppId: props.initialValue?.alipayAppId ?? '', alipayPrivateKey: props.initialValue?.alipayPrivateKey ?? '', alipayPublicKey: props.initialValue?.alipayPublicKey ?? '' }
: { pid: props.initialValue?.pid ?? '', key: props.initialValue?.key ?? '' }
);

View File

@@ -19,5 +19,5 @@
</template>
<script setup lang="ts">
defineProps<{ modelValue: Record<string, string> }>();
defineProps<{ modelValue: Record<string, any> }>();
</script>

View File

@@ -12,5 +12,5 @@
</template>
<script setup lang="ts">
defineProps<{ modelValue: Record<string, string> }>();
defineProps<{ modelValue: Record<string, any> }>();
</script>

View File

@@ -15,5 +15,5 @@
</template>
<script setup lang="ts">
defineProps<{ modelValue: Record<string, string> }>();
defineProps<{ modelValue: Record<string, any> }>();
</script>

View File

@@ -51,17 +51,28 @@
<script setup lang="ts">
import { normalizeTelefuncError } from "../../../lib/app-error";
import { ref } from "vue";
import { ref, onMounted } from "vue";
import { useData } from "vike-vue/useData";
import { formatCents } from "../../../lib/utils/money";
import { getDeliveryStatusLabel, getOrderStatusLabel, getPaymentProviderLabel, getPaymentStatusLabel } from "../../../lib/utils/order-status";
import { onCreatePayment } from "./createPayment.telefunc";
import { onQueryAlipayPayment } from "./queryAlipayPayment.telefunc";
import type { Data } from "./+data";
const { order } = useData<Data>();
const paying = ref(false);
const paymentError = ref("");
onMounted(async () => {
if (!order || order.paymentStatus !== "UNPAID" || order.paymentProvider !== "ALIPAY") return;
const params = new URLSearchParams(window.location.search);
if (!params.get("out_trade_no")) return;
try {
const result = await onQueryAlipayPayment({ orderNo: order.orderNo });
if (result.isPaid || result.alreadyPaid) window.location.reload();
} catch {}
});
async function handleContinuePay() {
if (!order) return;

View File

@@ -1,6 +1,6 @@
import { getOrderForQuery } from "../../../modules/order/service";
export type Data = ReturnType<typeof data>;
export type Data = Awaited<ReturnType<typeof data>>;
export async function data(pageContext: {
routeParams: { orderNo: string };

View File

@@ -0,0 +1,5 @@
import { queryAlipayPayment } from "../../../modules/payment/service";
export async function onQueryAlipayPayment(input: { orderNo: string }) {
return queryAlipayPayment(input.orderNo);
}

View File

@@ -69,6 +69,8 @@
</div>
</div>
<button class="btn btn-primary" :disabled="submitting || !paymentMethods.length" @click="handleCreateOrder">
{{ submitting ? '提交中...' : '提交订单' }}
</button>
@@ -88,6 +90,8 @@ import { isEmail } from "../../../lib/validators/email";
import { formatCents } from "../../../lib/utils/money";
import { onCreateOrder } from "./createOrder.telefunc";
import type { PaymentProvider } from "../../../modules/payment/types";
import { isMobile } from "../../../lib/utils/device";
import { onMounted, watch } from "vue";
import { saveLocalOrder } from "../../../lib/local-orders";
import type { Data } from "./+data";
@@ -101,12 +105,26 @@ const epayChannels = [
{ value: "wxpay", label: "微信支付" },
] as const;
const form = reactive({
quantity: product?.minBuy ?? 1,
contactValue: "",
buyerNote: "",
paymentProvider: (paymentMethods[0]?.provider ?? "BEPUSDT") as PaymentProvider,
paymentChannel: "alipay",
paymentChannel: "alipay_h5",
});
let mobile = false;
onMounted(() => {
mobile = isMobile();
form.paymentChannel = mobile ? "alipay_h5" : "alipay_pc";
});
watch(() => form.paymentProvider, (provider) => {
if (provider === "EPAY") form.paymentChannel = "alipay";
else if (provider === "ALIPAY") form.paymentChannel = mobile ? "alipay_h5" : "alipay_pc";
else form.paymentChannel = "";
});
const descriptionHtml = formatDescriptionHtml(product?.description || "");
@@ -133,7 +151,7 @@ async function handleCreateOrder() {
productId: product.id,
quantity: form.quantity,
paymentProvider: form.paymentProvider,
paymentChannel: form.paymentProvider === "EPAY" ? form.paymentChannel : form.paymentProvider === "ALIPAY" ? "pc" : undefined,
paymentChannel: form.paymentProvider === "EPAY" || form.paymentProvider === "ALIPAY" ? form.paymentChannel : undefined,
contactType: "EMAIL",
contactValue: contactEmail,
buyerNote: form.buyerNote,