feat: 自动部署

1. 支付网关组件化
2. 支持cf绑定git自动化部署
This commit is contained in:
ggyy
2026-04-24 02:14:05 +08:00
parent b86b21da19
commit f89221b249
13 changed files with 115 additions and 82 deletions

View File

@@ -3,7 +3,7 @@ import { badRequestError } from "../app-error";
export function validatePaymentConfigInput(input: {
name?: string;
baseUrl?: string;
provider: "BEPUSDT" | "EPAY";
provider: string;
isEnabled?: boolean;
appSecret?: string;
pid?: string;

View File

@@ -6,7 +6,7 @@ export function listPaymentConfigRecords(prisma: PrismaClient) {
});
}
export function getPaymentConfigRecord(prisma: PrismaClient, provider: "BEPUSDT" | "EPAY") {
export function getPaymentConfigRecord(prisma: PrismaClient, provider: string) {
return prisma.paymentConfig.findUnique({
where: { provider },
});
@@ -14,7 +14,7 @@ export function getPaymentConfigRecord(prisma: PrismaClient, provider: "BEPUSDT"
export function upsertPaymentConfigRecord(
prisma: PrismaClient,
provider: "BEPUSDT" | "EPAY",
provider: string,
input: {
name: string;
isEnabled: boolean;
@@ -41,7 +41,7 @@ export function createPaymentLogRecord(
prisma: PrismaClient,
input: {
orderId?: number;
provider: "BEPUSDT" | "EPAY";
provider: string;
orderNo?: string;
paymentOrderNo?: string;
eventType: string;

View File

@@ -7,14 +7,14 @@ import { getAdminContext, logAdminOperation } from "../auth/service";
import { notifyOrderPaid } from "../email/service";
import { getSiteSetting } from "../site/service";
import { createPaymentLogRecord, getPaymentConfigRecord, listPaymentConfigRecords, upsertPaymentConfigRecord } from "./repository";
import type { PaymentMethodItem } from "./types";
import type { PaymentMethodItem, PaymentProvider } from "./types";
import type { PaymentConfigValue } from "./types";
import { createBepusdtAdapter } from "./bepusdt";
import { createEpayAdapter } from "./epay";
import { deliverOrder } from "../delivery/service";
import { findOrderRecord, updateOrderPayment } from "../order/repository";
const defaultPaymentConfigs: Record<"BEPUSDT" | "EPAY", PaymentConfigValue> = {
const defaultPaymentConfigs: Record<PaymentProvider, PaymentConfigValue> = {
BEPUSDT: {
provider: "BEPUSDT",
name: "USDT",
@@ -41,7 +41,7 @@ function getPaymentContext() {
return getContext<{ prisma: PrismaClient }>();
}
function normalizePaymentConfig(record: Awaited<ReturnType<typeof getPaymentConfigRecord>>, provider: "BEPUSDT" | "EPAY"): PaymentConfigValue {
function normalizePaymentConfig(record: Awaited<ReturnType<typeof getPaymentConfigRecord>>, provider: PaymentProvider): PaymentConfigValue {
const defaults = defaultPaymentConfigs[provider];
if (!record) {
return defaults;
@@ -69,7 +69,7 @@ export async function listEnabledPaymentMethods(prisma?: PrismaClient): Promise<
const client = prisma ?? getPaymentContext().prisma;
const records = await listPaymentConfigRecords(client);
return (["BEPUSDT", "EPAY"] as const).map((provider) => {
return (Object.keys(defaultPaymentConfigs) as PaymentProvider[]).map((provider) => {
const record = records.find((item) => item.provider === provider);
const value = normalizePaymentConfig(record ?? null, provider);
return {
@@ -81,17 +81,15 @@ export async function listEnabledPaymentMethods(prisma?: PrismaClient): Promise<
});
}
export async function getPaymentConfigs(prisma?: PrismaClient) {
export async function getPaymentConfigs(prisma?: PrismaClient): Promise<Record<string, PaymentConfigValue>> {
const client = prisma ?? getPaymentContext().prisma;
const [bepusdt, epay] = await Promise.all([
getPaymentConfigRecord(client, "BEPUSDT"),
getPaymentConfigRecord(client, "EPAY"),
]);
return {
BEPUSDT: normalizePaymentConfig(bepusdt, "BEPUSDT"),
EPAY: normalizePaymentConfig(epay, "EPAY"),
};
const records = await listPaymentConfigRecords(client);
const result: Record<string, PaymentConfigValue> = {};
for (const provider of Object.keys(defaultPaymentConfigs) as PaymentProvider[]) {
const record = records.find((r) => r.provider === provider) ?? null;
result[provider] = normalizePaymentConfig(record, provider);
}
return result;
}
export async function savePaymentConfig(input: PaymentConfigValue) {
@@ -200,7 +198,7 @@ export async function createPaymentForOrder(orderNo: string, prisma?: PrismaClie
const notifyUrl = resolveCallbackUrl(
baseOrigin,
config.notifyUrl,
order.paymentProvider === "BEPUSDT" ? "/api/payments/bepusdt/notify" : "/api/payments/epay/notify",
defaultPaymentConfigs[order.paymentProvider as PaymentProvider]?.notifyUrl ?? "/api/payments/notify",
);
const returnUrl = resolveCallbackUrl(
baseOrigin,
@@ -272,7 +270,7 @@ function sanitizePaymentPayload(payload?: Record<string, unknown>) {
async function createNotifyLog(prisma: PrismaClient, input: {
orderId?: number;
provider: "BEPUSDT" | "EPAY";
provider: PaymentProvider;
orderNo?: string;
paymentOrderNo?: string;
eventType?: string;
@@ -294,7 +292,7 @@ async function createNotifyLog(prisma: PrismaClient, input: {
}
function writePaymentNotifyDiagnostic(input: {
provider: "BEPUSDT" | "EPAY";
provider: PaymentProvider;
source: string;
reason: string;
payload?: Record<string, unknown>;
@@ -322,7 +320,7 @@ function writePaymentNotifyDiagnostic(input: {
}
export async function handlePaymentNotify(
provider: "BEPUSDT" | "EPAY",
provider: PaymentProvider,
payload: Record<string, string>,
prisma: PrismaClient,
source: string,

View File

@@ -1,12 +1,14 @@
export type PaymentProvider = "BEPUSDT" | "EPAY";
export interface PaymentMethodItem {
provider: "BEPUSDT" | "EPAY";
provider: PaymentProvider;
label: string;
enabled: boolean;
baseUrl?: string;
}
export interface PaymentConfigValue {
provider: "BEPUSDT" | "EPAY";
provider: PaymentProvider;
name: string;
isEnabled: boolean;
baseUrl: string;
@@ -16,4 +18,4 @@ export interface PaymentConfigValue {
key?: string;
notifyUrl?: string;
returnUrl?: string;
}
}

View File

@@ -7,9 +7,9 @@
"db:seed": "bunx wrangler d1 execute DB --local --file=./scripts/seed.sql -y",
"db:generate": "prisma generate",
"db:studio": "prisma studio",
"db:migrations:remote": "wrangler d1 migrations apply DB --remote",
"db:migrations:remote": "wrangler d1 migrations apply DB --remote ${D1_ID:+--database-id $D1_ID}",
"db:migrations:local": "wrangler d1 migrations apply DB --local",
"db:seed:remote": "wrangler d1 execute DB --remote --file=./scripts/seed.sql -y",
"db:seed:remote": "wrangler d1 execute DB --remote --file=./scripts/seed.sql -y ${D1_ID:+--database-id $D1_ID}",
"verify:payments": "bun run scripts/verify-payment-adapters.ts",
"verify:payment-notify": "bun run scripts/verify-payment-notify.ts",
"deploy": "bun run db:migrations:remote && bun run db:seed:remote && wrangler deploy",

View File

@@ -392,7 +392,7 @@
<th>#</th>
<th>时间</th>
<th>分类</th>
<th>API服务商</th>
<th>邮箱名称</th>
<th>场景</th>
<th>状态</th>
<th>收件人</th>
@@ -407,7 +407,7 @@
<th>{{ index + 1 }}</th>
<td class="whitespace-nowrap">{{ formatDate(log.createdAt) }}</td>
<td class="whitespace-nowrap">{{ getChannelLabel(log.provider) }}</td>
<td class="whitespace-nowrap">{{ log.apiProvider || '-' }}</td>
<td class="whitespace-nowrap">{{ configs.find(c => c.provider === log.provider)?.name || '-' }}</td>
<td class="whitespace-nowrap">{{ getSceneLabel(log.scene) }}</td>
<td>
<span class="badge whitespace-nowrap" :class="log.status === 'SUCCESS' ? 'badge-success' : 'badge-error'">

View File

@@ -1,17 +1,30 @@
<template>
<section class="space-y-6">
<div class="alert alert-info">
<span>启用支付前请先前往站点设置配置网站地址否则无法获取支付结果</span>
<span>启用支付前请先前往"站点设置"配置网站地址否则无法获取支付结果</span>
</div>
<PaymentConfigCard provider="BEPUSDT" title="BEpusdt / USDT" :initial-value="configs.BEPUSDT" />
<PaymentConfigCard provider="EPAY" title="Epay / 聚合支付" :initial-value="configs.EPAY" />
<div role="tablist" class="tabs tabs-border">
<a role="tab" class="tab" :class="{ 'tab-active': activeTab === 'BEPUSDT' }" @click="activeTab = 'BEPUSDT'">
BEpusdt / USDT
<span v-if="localConfigs.BEPUSDT?.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 === 'EPAY' }" @click="activeTab = 'EPAY'">
Epay / 聚合支付
<span v-if="localConfigs.EPAY?.isEnabled" class="ml-1.5 inline-block w-2 h-2 rounded-full bg-success"></span>
</a>
</div>
<PaymentConfigCard v-if="activeTab === 'BEPUSDT'" provider="BEPUSDT" title="BEpusdt / USDT" :initial-value="localConfigs.BEPUSDT" @saved="localConfigs.BEPUSDT = $event" />
<PaymentConfigCard v-if="activeTab === 'EPAY'" provider="EPAY" title="Epay / 聚合支付" :initial-value="localConfigs.EPAY" @saved="localConfigs.EPAY = $event" />
</section>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue";
import { useData } from "vike-vue/useData";
import PaymentConfigCard from "./PaymentConfigCard.vue";
import type { Data } from "./+data";
const { configs } = useData<Data>();
</script>
const activeTab = ref("BEPUSDT");
const localConfigs = reactive({ ...configs });
</script>

View File

@@ -1,6 +1,6 @@
import { getPaymentConfigs } from "../../../modules/payment/service";
export type Data = ReturnType<typeof data>;
export type Data = Awaited<ReturnType<typeof data>>;
export async function data(pageContext: {
prisma: import("../../../generated/prisma/client").PrismaClient;

View File

@@ -23,27 +23,7 @@
</label>
</div>
<div v-if="provider === 'BEPUSDT'" 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="form.appId" class="input input-bordered w-full" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">App Secret</span>
<input v-model="form.appSecret" class="input input-bordered w-full" />
</label>
</div>
<div v-else class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">PID</span>
<input v-model="form.pid" class="input input-bordered w-full" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">Key</span>
<input v-model="form.key" class="input input-bordered w-full" />
</label>
</div>
<component :is="formMap[provider]" v-model="extraFields" />
<div class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-1.5">
@@ -56,10 +36,6 @@
</label>
</div>
<p v-if="provider === 'EPAY'" class="text-xs text-base-content/60">
`Notify URL` `Return URL` 支持填写相对路径或完整 URL`Return URL` 支持 `{orderNo}``{token}` 占位符
</p>
<div class="flex items-center gap-3">
<button class="btn btn-primary" :disabled="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存配置' }}
@@ -72,15 +48,22 @@
</template>
<script setup lang="ts">
import { normalizeTelefuncError } from "../../../lib/app-error";
import { reactive, ref } from "vue";
import { normalizeTelefuncError } from "../../../lib/app-error";
import { onSavePaymentConfig } from "./savePaymentConfig.telefunc";
import BEpusdtForm from "./forms/BEpusdtForm.vue";
import EpayForm from "./forms/EpayForm.vue";
import type { PaymentProvider } from "../../../modules/payment/types";
const formMap = { BEPUSDT: BEpusdtForm, EPAY: EpayForm };
const emit = defineEmits<{ saved: [value: typeof props.initialValue] }>();
const props = defineProps<{
provider: "BEPUSDT" | "EPAY";
provider: PaymentProvider;
title: string;
initialValue: {
provider: "BEPUSDT" | "EPAY";
provider: PaymentProvider;
name: string;
isEnabled: boolean;
baseUrl: string;
@@ -94,18 +77,19 @@ const props = defineProps<{
}>();
const form = reactive({
provider: props.provider,
name: props.initialValue?.name ?? (props.provider === 'BEPUSDT' ? 'USDT' : '聚合支付'),
isEnabled: props.initialValue?.isEnabled ?? false,
baseUrl: props.initialValue?.baseUrl ?? '',
appId: props.initialValue?.appId ?? '',
appSecret: props.initialValue?.appSecret ?? '',
pid: props.initialValue?.pid ?? '',
key: props.initialValue?.key ?? '',
notifyUrl: props.initialValue?.notifyUrl ?? '',
returnUrl: props.initialValue?.returnUrl ?? '',
});
const extraFields = reactive(
props.provider === 'BEPUSDT'
? { appId: props.initialValue?.appId ?? '', appSecret: props.initialValue?.appSecret ?? '' }
: { pid: props.initialValue?.pid ?? '', key: props.initialValue?.key ?? '' }
);
const saving = ref(false);
const saved = ref(false);
const errorMessage = ref('');
@@ -114,23 +98,26 @@ async function handleSave() {
saving.value = true;
saved.value = false;
errorMessage.value = '';
try {
const result = await onSavePaymentConfig({ ...form });
const result = await onSavePaymentConfig({ provider: props.provider, ...form, ...extraFields });
form.name = result.name;
form.isEnabled = result.isEnabled;
form.baseUrl = result.baseUrl;
form.appId = result.appId ?? '';
form.appSecret = result.appSecret ?? '';
form.pid = result.pid ?? '';
form.key = result.key ?? '';
form.notifyUrl = result.notifyUrl ?? '';
form.returnUrl = result.returnUrl ?? '';
if (props.provider === 'BEPUSDT') {
(extraFields as any).appId = result.appId ?? '';
(extraFields as any).appSecret = result.appSecret ?? '';
} else {
(extraFields as any).pid = result.pid ?? '';
(extraFields as any).key = result.key ?? '';
}
saved.value = true;
emit('saved', { provider: props.provider, ...form, ...extraFields });
} catch (error) {
errorMessage.value = normalizeTelefuncError(error, '保存失败');
} finally {
saving.value = false;
}
}
</script>
</script>

View File

@@ -0,0 +1,16 @@
<template>
<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.appId" class="input input-bordered w-full" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">App Secret</span>
<input v-model="modelValue.appSecret" class="input input-bordered w-full" />
</label>
</div>
</template>
<script setup lang="ts">
defineProps<{ modelValue: Record<string, string> }>();
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">PID</span>
<input v-model="modelValue.pid" class="input input-bordered w-full" />
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">Key</span>
<input v-model="modelValue.key" class="input input-bordered w-full" />
</label>
</div>
<p class="text-xs text-base-content/60">
`Notify URL` `Return URL` 支持填写相对路径或完整 URL`Return URL` 支持 `{orderNo}``{token}` 占位符
</p>
</template>
<script setup lang="ts">
defineProps<{ modelValue: Record<string, string> }>();
</script>

View File

@@ -1,8 +1,9 @@
import { assertAdminAccess } from "../../../modules/auth/service";
import { savePaymentConfig } from "../../../modules/payment/service";
import type { PaymentProvider } from "../../../modules/payment/types";
export async function onSavePaymentConfig(input: {
provider: "BEPUSDT" | "EPAY";
provider: PaymentProvider;
name: string;
isEnabled: boolean;
baseUrl: string;

View File

@@ -48,10 +48,7 @@ enum ContactType {
OTHER
}
enum PaymentProvider {
BEPUSDT
EPAY
}
enum EmailChannel {
API
@@ -208,7 +205,7 @@ model Order {
contactType ContactType @default(EMAIL)
contactValue String?
buyerNote String?
paymentProvider PaymentProvider
paymentProvider String
paymentChannel String?
paymentOrderNo String?
status OrderStatus @default(PENDING)
@@ -245,7 +242,7 @@ model OrderDelivery {
model PaymentConfig {
id Int @id @default(autoincrement())
provider PaymentProvider @unique
provider String @unique
name String
isEnabled Boolean @default(false)
configJson String
@@ -256,7 +253,7 @@ model PaymentConfig {
model PaymentLog {
id Int @id @default(autoincrement())
orderId Int?
provider PaymentProvider
provider String
orderNo String?
paymentOrderNo String?
eventType String