221 lines
7.2 KiB
TypeScript
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 });
|
|
}
|
|
}
|