Expose gateway billing flows from the synced server baseline

The preserved local work adds PopiNewAPI billing checkout, gateway account binding, and matching web console/pricing surfaces on top of the current test-server baseline. During the merge, the start-end video helpers from the deployed baseline were kept alongside the actionGenerate prompt handling from the WIP.

Constraint: Current usable baseline is 7054436, already deployed on the test server.

Rejected: Commit the WIP before syncing the baseline | would have hidden conflicts with the deployed start-end-frame changes.

Confidence: medium

Scope-risk: broad

Directive: Do not deploy this commit to the test server without rechecking gateway credentials and billing checkout behavior in that environment.

Tested: go test ./...

Tested: make build

Tested: cd web && npm run build
This commit is contained in:
wtgoku
2026-05-21 16:53:07 +08:00
parent 7054436a99
commit baba209519
31 changed files with 6035 additions and 86 deletions
+337 -3
View File
@@ -1,6 +1,22 @@
import Link from "next/link";
import { BillingRefreshListener } from "@/components/billing-refresh-listener";
import { CopyButton } from "@/components/copy-button";
import { PopiartApiError, getBudgetSummary, getBudgetUsage, getPopiartEndpoint, getViewerSession } from "@/lib/popiart-api";
import { GatewayBindForm } from "@/components/gateway-bind-form";
import {
type BillingCredits,
type BillingInvoices,
type BillingSubscription,
PopiartApiError,
getBillingCredits,
getBillingInvoices,
getBillingSubscription,
getBudgetSummary,
getBudgetUsage,
getPopiartEndpoint,
getProjects,
getSkillsCatalog,
getViewerSession,
} from "@/lib/popiart-api";
import { getLiveCopy } from "@/lib/live-copy";
import { type Locale } from "@/lib/site-content";
@@ -30,6 +46,26 @@ function formatError(error: unknown) {
return String(error);
}
function coerceEpoch(value: unknown) {
const num = Number(value);
return Number.isFinite(num) && num > 0 ? num : 0;
}
function extractRecentOrder(invoices: BillingInvoices | null) {
type BillingOrderItem = Record<string, unknown> & { __kind: "subscription" | "points" };
const items: BillingOrderItem[] = [
...((invoices?.subscription_orders?.items || []).map((item) => ({ ...item, __kind: "subscription" as const }))),
...((invoices?.point_orders?.items || []).map((item) => ({ ...item, __kind: "points" as const }))),
];
items.sort((a, b) => {
const aTime = coerceEpoch(a.complete_time) || coerceEpoch(a.create_time) || Number(a.id || 0);
const bTime = coerceEpoch(b.complete_time) || coerceEpoch(b.create_time) || Number(b.id || 0);
return bTime - aTime;
});
return items[0] ?? null;
}
export default async function ConsolePage({
params,
}: {
@@ -51,17 +87,64 @@ export default async function ConsolePage({
const usedHint = isZh ? "本月累计消耗" : "Consumed this month";
const callsLabel = isZh ? "调用次数" : "API calls";
const callsHint = isZh ? "本月 CLI / API 调用" : "CLI / API calls this month";
const projectsLabel = isZh ? "活跃项目" : "Projects";
const projectsHint = isZh ? "当前账号下可见项目" : "Projects visible to this account";
const keysTitle = isZh ? "API 密钥" : "API keys";
const quickTitle = isZh ? "快速安装指令" : "Quick install commands";
const quickBody = isZh
? "把下面这段命令复制到终端,完成 CLI 安装、登录和验证。"
: "Copy these commands into your terminal to install the CLI, sign in, and verify the workflow.";
const usageTitle = isZh ? "近期用量" : "Recent usage";
const usageEmpty = isZh ? "当前周期还没有技能用量。" : "No usage has been recorded for the current period.";
const skillsTitle = isZh ? "官方技能" : "Official skills";
const skillsEmpty = isZh ? "当前账号下没有可见技能。" : "No skills are visible for this account yet.";
const projectsTitle = isZh ? "项目" : "Projects";
const projectsEmpty = isZh ? "当前账号下没有可见项目。" : "No projects are visible for this account yet.";
const billingTitle = isZh ? "网关账单" : "Gateway billing";
const billingStatusTitle = isZh ? "订阅状态" : "Subscription";
const billingCreditsTitle = isZh ? "积分余额" : "Credits";
const recentOrderTitle = isZh ? "最近订单" : "Recent order";
const recentOrderEmpty = isZh ? "当前还没有账单订单记录。" : "No billing orders yet.";
const billingUnbound = isZh
? "当前 session 还没有绑定网关用户态,先绑定后才能读取真实订阅与积分。"
: "This session is not bound to a gateway user yet. Bind it first to read live subscription and credit data.";
const billingEmpty = isZh ? "当前没有有效订阅。" : "No active subscription was returned.";
const walletEmpty = isZh ? "当前没有积分钱包记录。" : "No point wallets were returned.";
const boundLabel = isZh ? "已绑定网关用户" : "Gateway user bound";
const preferenceLabel = isZh ? "扣费偏好" : "Billing preference";
const activeSubscriptionLabel = isZh ? "有效订阅" : "Active subscriptions";
const creditBalanceLabel = isZh ? "当前余额" : "Balance";
const walletCountLabel = isZh ? "钱包数量" : "Wallets";
const availablePointsLabel = isZh ? "可用积分" : "Available points";
const totalPointsLabel = isZh ? "总积分" : "Total points";
const walletSourceLabel = isZh ? "来源" : "Source";
const routeKeyLabel = isZh ? "路由键" : "Route key";
const usageJobsLabel = isZh ? "调用次数" : "Jobs";
const usageTokensLabel = isZh ? "Tokens" : "Tokens";
const usageCostLabel = isZh ? "费用" : "Cost";
const sessionKeyLabel = "POPIART_SESSION_KEY";
const endpointLabel = "POPIART_ENDPOINT";
const copyLabel = isZh ? "复制" : "Copy";
const copiedLabel = isZh ? "已复制" : "Copied";
const loginCta = isZh ? "去登录" : "Sign in";
const docsCta = isZh ? "查看文档" : "Open docs";
const bindLabels = {
title: isZh ? "绑定网关用户" : "Bind gateway user",
body: isZh
? "输入网关真实用户 ID 和 user access_token,当前 session 才能读取订阅、积分包和订单。"
: "Enter the real gateway user ID and user access token so this session can read subscriptions, point packs, and orders.",
userIdLabel: isZh ? "网关用户 ID" : "Gateway user ID",
userIdHint: isZh ? "值必须等于网关当前登录用户的真实 ID。" : "This must match the real ID of the gateway user.",
tokenLabel: isZh ? "网关 access_token" : "Gateway access token",
tokenHint: isZh
? "使用网关 `/api/user/token` 生成的普通用户 access_token,不是渠道 key。"
: "Use the user access token generated by gateway `/api/user/token`, not a channel key.",
submit: isZh ? "绑定网关账单" : "Bind gateway billing",
submitting: isZh ? "绑定中..." : "Binding...",
invalidUserId: isZh ? "请输入有效的网关用户 ID。" : "Enter a valid gateway user ID.",
invalidToken: isZh ? "请输入有效的网关 access_token。" : "Enter a valid gateway access token.",
success: isZh ? "绑定成功,正在刷新账单数据。" : "Binding succeeded. Refreshing billing data.",
};
if (!session) {
return (
@@ -91,12 +174,47 @@ export default async function ConsolePage({
);
}
const [budgetResult, usageResult] = await Promise.allSettled([getBudgetSummary(), getBudgetUsage()]);
const [budgetResult, usageResult, skillsResult, projectsResult] = await Promise.allSettled([
getBudgetSummary(),
getBudgetUsage(),
getSkillsCatalog(),
getProjects(),
]);
const budget = budgetResult.status === "fulfilled" ? budgetResult.value : null;
const usage = usageResult.status === "fulfilled" ? usageResult.value : null;
const loadErrors = [budgetResult, usageResult]
const skills = skillsResult.status === "fulfilled" ? skillsResult.value : null;
const projects = projectsResult.status === "fulfilled" ? projectsResult.value : null;
const loadErrors = [budgetResult, usageResult, skillsResult, projectsResult]
.filter((result) => result.status === "rejected")
.map((result) => formatError((result as PromiseRejectedResult).reason));
let billingSubscription: BillingSubscription | null = null;
let billingCredits: BillingCredits | null = null;
let billingInvoices: BillingInvoices | null = null;
const billingErrors: string[] = [];
if (session.gateway_bound) {
const [subscriptionResult, creditsResult, invoicesResult] = await Promise.allSettled([
getBillingSubscription(),
getBillingCredits(),
getBillingInvoices({ page: 1, pageSize: 1 }),
]);
if (subscriptionResult.status === "fulfilled") {
billingSubscription = subscriptionResult.value;
} else {
billingErrors.push(formatError(subscriptionResult.reason));
}
if (creditsResult.status === "fulfilled") {
billingCredits = creditsResult.value;
} else {
billingErrors.push(formatError(creditsResult.reason));
}
if (invoicesResult.status === "fulfilled") {
billingInvoices = invoicesResult.value;
} else {
billingErrors.push(formatError(invoicesResult.reason));
}
}
const recentOrder = extractRecentOrder(billingInvoices);
const metrics = [
{
@@ -114,6 +232,11 @@ export default async function ConsolePage({
value: usage ? formatNumber(typedLocale, usage.total.job_count) : "--",
hint: callsHint,
},
{
label: projectsLabel,
value: projects ? formatNumber(typedLocale, projects.total) : "--",
hint: projectsHint,
},
];
const quickStart = [
@@ -126,6 +249,8 @@ export default async function ConsolePage({
return (
<div className="page-stack">
<BillingRefreshListener />
<section className="section-panel console-hero">
<div className="section-heading">
<h1>{pageTitle}</h1>
@@ -187,6 +312,137 @@ export default async function ConsolePage({
</div>
</section>
<section className="console-surface-card">
{!session.gateway_bound ? (
<GatewayBindForm labels={bindLabels} />
) : (
<>
<div className="section-heading compact">
<h2>{billingTitle}</h2>
<p>
{boundLabel}: {session.gateway_user_id}
</p>
</div>
<div className="hero-actions">
<Link className="button button-light button-small" href={`/${locale}/billing`}>
{isZh ? "查看账单中心" : "Open billing"}
</Link>
</div>
{billingErrors.length > 0 ? (
<div className="status-banner status-banner-error">
<span>{billingErrors.join(" | ")}</span>
</div>
) : null}
<div className="catalog-grid">
<article className="console-surface-card billing-nested-card">
<div className="section-heading compact">
<h2>{billingStatusTitle}</h2>
</div>
{billingSubscription && billingSubscription.subscriptions.length > 0 ? (
<div className="table-list">
<div className="table-row">
<div>
<strong>{preferenceLabel}</strong>
<span>{billingSubscription.billing_preference || "-"}</span>
</div>
<div>
<strong>{activeSubscriptionLabel}</strong>
<span>{formatNumber(typedLocale, billingSubscription.subscriptions.length)}</span>
</div>
</div>
{billingSubscription.subscriptions.map((item, index) => (
<div className="table-row" key={item.subscription?.id ?? index}>
<div>
<strong>{item.subscription?.member_level || "subscription"}</strong>
<span>{item.subscription?.status || "-"}</span>
</div>
<div>
<strong>{availablePointsLabel}</strong>
<span>
{formatNumber(
typedLocale,
billingSubscription.subscription_points[String(item.subscription?.id)]?.available_points || 0,
)}
</span>
</div>
</div>
))}
</div>
) : (
<p className="billing-copy">{billingEmpty}</p>
)}
</article>
<article className="console-surface-card billing-nested-card">
<div className="section-heading compact">
<h2>{billingCreditsTitle}</h2>
</div>
{billingCredits ? (
<div className="table-list">
<div className="table-row">
<div>
<strong>{creditBalanceLabel}</strong>
<span>{formatNumber(typedLocale, billingCredits.balance)}</span>
</div>
<div>
<strong>{walletCountLabel}</strong>
<span>{formatNumber(typedLocale, billingCredits.wallets.length)}</span>
</div>
</div>
{billingCredits.wallets.slice(0, 4).map((wallet) => (
<div className="table-row" key={wallet.id}>
<div>
<strong>{walletSourceLabel}: {wallet.source_type}</strong>
<span>{availablePointsLabel}: {formatNumber(typedLocale, wallet.points)}</span>
</div>
<div>
<strong>{totalPointsLabel}</strong>
<span>{formatNumber(typedLocale, wallet.points_total)}</span>
</div>
</div>
))}
</div>
) : (
<p className="billing-copy">{walletEmpty}</p>
)}
</article>
<article className="console-surface-card billing-nested-card">
<div className="section-heading compact">
<h2>{recentOrderTitle}</h2>
</div>
{recentOrder ? (
<div className="table-list">
<div className="table-row">
<div>
<strong>{String(recentOrder.plan_title || recentOrder.package_name || recentOrder.trade_no || "order")}</strong>
<span>{String(recentOrder.status || "-")}</span>
</div>
<div>
<strong>{String(recentOrder.money || "-")} {String(recentOrder.currency || "")}</strong>
<span>{String(recentOrder.payment_method || "-")}</span>
</div>
</div>
<div className="table-row">
<div>
<strong>{isZh ? "交易号" : "Trade no"}</strong>
<span>{String(recentOrder.trade_no || "-")}</span>
</div>
<div>
<strong>{isZh ? "订单类型" : "Order kind"}</strong>
<span>{recentOrder.__kind === "subscription" ? (isZh ? "订阅订单" : "Subscription") : (isZh ? "积分包订单" : "Points pack")}</span>
</div>
</div>
</div>
) : (
<p className="billing-copy">{recentOrderEmpty}</p>
)}
</article>
</div>
</>
)}
</section>
<section className="console-surface-card">
<div className="section-heading compact">
<h2>{quickTitle}</h2>
@@ -198,6 +454,84 @@ export default async function ConsolePage({
</pre>
</div>
</section>
<section className="console-surface-card">
<div className="section-heading compact">
<h2>{usageTitle}</h2>
</div>
{usage && usage.rows.length > 0 ? (
<div className="table-list">
{usage.rows.map((row) => (
<div className="table-row" key={row.dimension}>
<div>
<strong>{row.dimension}</strong>
<span>
{usageJobsLabel}: {formatNumber(typedLocale, row.job_count)}
</span>
</div>
<div>
<strong>
{usageTokensLabel}: {formatNumber(typedLocale, row.tokens_used)}
</strong>
<span>
{usageCostLabel}: ${row.cost_usd.toFixed(2)}
</span>
</div>
</div>
))}
</div>
) : (
<p className="billing-copy">{usageEmpty}</p>
)}
</section>
<section className="catalog-grid">
<article className="console-surface-card">
<div className="section-heading compact">
<h2>{skillsTitle}</h2>
</div>
{skills && skills.items.length > 0 ? (
<div className="table-list">
{skills.items.slice(0, 6).map((skill) => (
<div className="table-row" key={skill.id}>
<div>
<strong>{skill.name}</strong>
<span>{skill.description}</span>
</div>
<div>
<strong>{skill.version}</strong>
<span>
{routeKeyLabel}: {skill.route_key || skill.id}
</span>
</div>
</div>
))}
</div>
) : (
<p className="billing-copy">{skillsEmpty}</p>
)}
</article>
<article className="console-surface-card">
<div className="section-heading compact">
<h2>{projectsTitle}</h2>
</div>
{projects && projects.items.length > 0 ? (
<div className="table-list">
{projects.items.map((project) => (
<div className="table-row" key={project.id}>
<div>
<strong>{project.name}</strong>
<span>{project.id}</span>
</div>
</div>
))}
</div>
) : (
<p className="billing-copy">{projectsEmpty}</p>
)}
</article>
</section>
</div>
);
}