Files
Jyotisha/frontend/src/app/api/payment/epay/create/route.ts
T

221 lines
7.2 KiB
TypeScript

import crypto from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { epaySign } from "@/lib/epay/sign";
import { readEpayAvailability } from "@/lib/epay/availability";
import {
epaySubmitUrl,
readEpayConfig,
EpayConfigurationError,
} from "@/lib/epay/config";
import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
import { loadRuntimeFeatureFlags } from "@/lib/feature-flags";
export const runtime = "nodejs";
const schema = z
.object({
productId: z.string().uuid().optional(),
packageId: z.string().uuid().optional(),
})
.refine(
(value) => Boolean(value.productId || value.packageId),
"product_required",
);
type ProductRow = {
id: string;
code: string;
version: number;
name: string;
description: string;
product_type: "credit_pack" | "trial" | "subscription";
billing_period: "none" | "day" | "month" | "year";
interval_count: number;
price_cents: number;
currency: string;
enabled: boolean;
status: string;
one_time_per_user: boolean;
effective_from: string | null;
effective_to: string | null;
};
type EntitlementRow = {
feature_key: string;
allowance_type: string;
allowance_count: number | null;
reset_period: string;
model_tier: string | null;
fair_use_policy_id: string | null;
metadata: unknown;
};
export async function POST(request: Request) {
try {
const client = await createServerSupabaseClient();
const {
data: { user },
} = await client.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success)
return NextResponse.json({ error: "请选择有效套餐" }, { status: 400 });
const availability = await readEpayAvailability();
if (!availability.enabled)
return NextResponse.json(
{ error: "在线支付暂未开放", code: "EPAY_DISABLED" },
{ status: 403 },
);
const config = await readEpayConfig();
const submitUrl = epaySubmitUrl(config.gatewayUrl);
await assertPublicGatewayUrl(submitUrl);
const productId = parsed.data.productId ?? parsed.data.packageId!;
const admin = createAdminSupabaseClient();
const { data, error: productError } = await admin
.from("billing_products")
.select(
"id,code,version,name,description,product_type,billing_period,interval_count,price_cents,currency,enabled,status,one_time_per_user,effective_from,effective_to",
)
.eq("id", productId)
.maybeSingle();
const product = data as ProductRow | null;
const now = Date.now();
if (
productError ||
!product ||
!product.enabled ||
product.status !== "published" ||
(product.effective_from && Date.parse(product.effective_from) > now) ||
(product.effective_to && Date.parse(product.effective_to) <= now)
) {
return NextResponse.json(
{ error: "套餐不存在或已下架" },
{ status: 404 },
);
}
if (product.product_type !== "credit_pack") {
const flags = await loadRuntimeFeatureFlags(["billing.subscriptions"]);
if (!flags.get("billing.subscriptions")?.enabled) {
return NextResponse.json(
{ error: "订阅套餐暂未开放", code: "BILLING_SUBSCRIPTIONS_DISABLED" },
{ status: 403 },
);
}
}
if (product.one_time_per_user) {
const { data: redemption, error: redemptionError } = await admin
.from("user_product_redemptions")
.select("id")
.eq("user_id", user.id)
.eq("product_code", product.code)
.maybeSingle();
if (redemptionError)
return NextResponse.json(
{ error: "暂时无法核对体验资格" },
{ status: 500 },
);
if (redemption)
return NextResponse.json(
{ error: "该体验套餐每位用户仅限一次" },
{ status: 409 },
);
}
const { data: entitlementRows, error: entitlementError } = await admin
.from("product_entitlements")
.select(
"feature_key,allowance_type,allowance_count,reset_period,model_tier,fair_use_policy_id,metadata",
)
.eq("product_id", product.id);
if (entitlementError)
return NextResponse.json(
{ error: "暂时无法读取套餐权益" },
{ status: 500 },
);
const entitlements = ((entitlementRows ?? []) as EntitlementRow[]).map(
(item) => ({
featureKey: item.feature_key,
allowanceType: item.allowance_type,
allowanceCount: item.allowance_count,
resetPeriod: item.reset_period,
modelTier: item.model_tier,
fairUsePolicyId: item.fair_use_policy_id,
metadata: item.metadata,
}),
);
const credits =
entitlements.find((item) => item.allowanceType === "credits")
?.allowanceCount ?? 0;
const productSnapshot = {
id: product.id,
code: product.code,
version: product.version,
name: product.name,
description: product.description,
productType: product.product_type,
billingPeriod: product.billing_period,
intervalCount: product.interval_count,
priceCents: product.price_cents,
currency: product.currency,
oneTimePerUser: product.one_time_per_user,
};
const orderNo = `JY${Date.now().toString(36)}${crypto.randomBytes(10).toString("hex")}`;
const { error: orderError } = await admin.from("payment_orders").insert({
order_no: orderNo,
user_id: user.id,
package_id: product.product_type === "credit_pack" ? product.id : null,
product_id: product.id,
product_code: product.code,
product_version: product.version,
product_snapshot: productSnapshot,
entitlement_snapshot: entitlements,
money_cents: product.price_cents,
currency: product.currency,
credits,
grant_type:
product.product_type === "credit_pack"
? "credits"
: product.product_type,
grant_status: "pending",
});
if (orderError)
return NextResponse.json({ error: "创建订单失败" }, { status: 500 });
const params = {
money: (product.price_cents / 100).toFixed(2),
name: product.name,
notify_url: config.notifyUrl,
out_trade_no: orderNo,
pid: config.pid,
return_url: config.returnUrl,
sitename: config.siteName,
type: "alipay",
};
const signedParams = {
...params,
sign: epaySign(params, config.key),
sign_type: "MD5",
};
const payUrl = new URL(submitUrl);
for (const [name, value] of Object.entries(signedParams))
payUrl.searchParams.set(name, value);
return NextResponse.json({
orderNo,
payUrl: payUrl.toString(),
qrCode: null,
product: productSnapshot,
});
} catch (error) {
if (error instanceof EpayConfigurationError)
return NextResponse.json(
{ error: "易支付尚未配置", code: "EPAY_NOT_CONFIGURED" },
{ status: 503 },
);
return NextResponse.json({ error: "创建支付失败" }, { status: 500 });
}
}