feat: add local auth billing and usage management

This commit is contained in:
inman committed 2026-08-12 12:13:06 +08:00
1 parent f642b5e71f
commit 196fdde83f
119 files changed
+15695 -2650

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api";
import { requireSuperAdminUser } from "@/lib/server/auth/current-user";
import { getBillingAccountConfig } from "@/lib/server/billing-service";
import { saveApiSettings } from "@/lib/server/app-settings";
export const runtime = "nodejs";
export async function PATCH(request: Request) {
try {
await requireSuperAdminUser();
const body = await readJsonBody<Record<string, unknown>>(request);
const values = {
ZHINIAN_BILLING_ACCOUNT_NAME: normalize(body.accountName),
ZHINIAN_BILLING_ACCOUNT_BANK: normalize(body.bankName),
ZHINIAN_BILLING_ACCOUNT_NUMBER: normalize(body.accountNumber),
ZHINIAN_BILLING_CONTACT: normalize(body.contact)
};
await saveApiSettings(values);
return jsonOk({ billingAccount: getBillingAccountConfig() });
} catch (error) {
return jsonError(error, 500, { request, source: "api.admin.billing.account" });
}
}
function normalize(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
@@ -0,0 +1,75 @@
import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api";
import { requireSuperAdminUser } from "@/lib/server/auth/current-user";
import { getPlatformOrganization } from "@/lib/server/account-store";
import { postOrganizationTopUp } from "@/lib/server/billing-service";
import { postWalletEntry } from "@/lib/server/billing-store";
import { createId } from "@/lib/server/ids";
export const runtime = "nodejs";
export async function POST(request: Request) {
try {
const operator = await requireSuperAdminUser();
const body = await readJsonBody<Record<string, unknown>>(request);
const organizationId = requiredString(body.organizationId, "组织");
const organization = await getPlatformOrganization(organizationId);
if (!organization) throw badRequest("组织不存在。");
const amountFen = parseAmountFen(body.amountFen, body.amountYuan);
if (amountFen <= 0) throw badRequest("请输入大于 0 的金额。");
const direction = body.direction === "debit" ? "debit" : body.direction === "credit" ? "credit" : null;
if (!direction) throw badRequest("余额变动方向无效。");
const note = requiredString(body.note, "备注");
const amountLabel = (amountFen / 100).toFixed(2);
const idempotencyKey = `manual-adjustment:${createId("entry")}`;
const metadata = {
operation: direction === "credit" ? "admin_top_up" : "manual_adjustment",
direction,
note,
operatorId: operator.id,
amountYuan: amountLabel
};
const result = direction === "credit"
? await postOrganizationTopUp({
organizationId,
amountFen,
idempotencyKey,
description: `管理员上账 · ${note}`,
metadata
})
: await postWalletEntry({
organizationId,
kind: "adjustment",
deltaFen: -amountFen,
idempotencyKey,
description: `管理员扣减 · ${note}`,
metadata
});
return jsonOk({ wallet: result.wallet, entry: result.entry });
} catch (error) {
return jsonError(error, 500, { request, source: "api.admin.billing.adjustments" });
}
}
function parseAmountFen(amountFen: unknown, amountYuan: unknown): number {
const fen = Number(amountFen);
if (Number.isFinite(fen) && fen > 0) return Math.round(fen);
const yuan = Number(amountYuan);
if (!Number.isFinite(yuan) || yuan <= 0) return 0;
return Math.round(yuan * 100);
}
function requiredString(value: unknown, label: string): string {
const normalized = optionalString(value);
if (!normalized) throw badRequest(`${label}不能为空。`);
return normalized;
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function badRequest(message: string): Error & { status: number } {
return Object.assign(new Error(message), { status: 400 });
}
@@ -0,0 +1,45 @@
import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api";
import { requireSuperAdminSession } from "@/lib/server/auth/current-user";
import { getBillingPriceRule, updateBillingPriceRule, updateBillingPriceTierMultiplier } from "@/lib/server/billing-store";
export const runtime = "nodejs";
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
try {
await requireSuperAdminSession();
const { id } = await context.params;
const body = await readJsonBody<Record<string, unknown>>(request);
const unsupportedFields = Object.keys(body).filter((key) => !["markupMultiplier", "dimensionKey", "tierValue"].includes(key));
if (unsupportedFields.length) throw badRequest("平台标准价格与参数由系统维护,超管仅可调整上浮倍率。");
const multiplier = Number(body.markupMultiplier);
if (!Number.isFinite(multiplier) || multiplier < 1 || multiplier > 1000) {
throw badRequest("上浮倍率必须在 1.00 至 1000.00 之间。");
}
const dimensionKey = typeof body.dimensionKey === "string" ? body.dimensionKey.trim() : "";
const tierValue = typeof body.tierValue === "string" ? body.tierValue : "";
if ((dimensionKey && !tierValue) || (!dimensionKey && tierValue)) {
throw badRequest("参数档位倍率更新必须同时提供参数维度和档位。");
}
const existing = await getBillingPriceRule(id);
if (!existing) return jsonError("计费规则不存在", 404);
if (!dimensionKey && existing.parameterDimensions?.length) {
throw badRequest("当前服务包含参数档位,请指定要调整的参数档位倍率。");
}
const rule = dimensionKey || tierValue
? await updateBillingPriceTierMultiplier({
ruleId: id,
dimensionKey,
tierValue,
markupMultiplier: Number(multiplier.toFixed(4))
})
: await updateBillingPriceRule(id, { markupMultiplier: Number(multiplier.toFixed(4)) });
if (!rule) return jsonError("计费规则不存在", 404);
return jsonOk({ rule });
} catch (error) {
return jsonError(error, 500, { request, source: "api.admin.billing.prices.updateMultiplier" });
}
}
function badRequest(message: string): Error & { status: number } {
return Object.assign(new Error(message), { status: 400 });
}
+16
View File
@@ -0,0 +1,16 @@
import { jsonError, jsonOk } from "@/lib/server/api";
import { requireSuperAdminSession } from "@/lib/server/auth/current-user";
import { listBillingPriceRules } from "@/lib/server/billing-store";
import { ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog";
export const runtime = "nodejs";
export async function GET() {
try {
await requireSuperAdminSession();
await ensureDefaultBillingPriceRules();
return jsonOk({ priceRules: await listBillingPriceRules({ includeDisabled: true }) });
} catch (error) {
return jsonError(error, 500, { source: "api.admin.billing.prices" });
}
}
+49
View File
@@ -0,0 +1,49 @@
import { jsonError, jsonOk } from "@/lib/server/api";
import { requireSuperAdminSession } from "@/lib/server/auth/current-user";
import { listPlatformOrganizations, listPlatformUsers } from "@/lib/server/account-store";
import { listBillingLedgerEntries, listBillingPriceRules, listOrganizationWallets } from "@/lib/server/billing-store";
import { ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog";
import { getBillingAccountConfig } from "@/lib/server/billing-service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
await requireSuperAdminSession();
await ensureDefaultBillingPriceRules();
const [organizations, wallets, members, ledger, priceRules] = await Promise.all([
listPlatformOrganizations({ includeDisabled: true }),
listOrganizationWallets(),
listPlatformUsers({ includeDisabled: true }),
listBillingLedgerEntries({ limit: 500 }),
listBillingPriceRules({ includeDisabled: true })
]);
const walletByOrganization = new Map(wallets.map((wallet) => [wallet.organizationId, wallet]));
return jsonOk({
billingAccount: getBillingAccountConfig(),
organizations: organizations.map((organization) => ({
...organization,
wallet: walletByOrganization.get(organization.id) || {
organizationId: organization.id,
balanceFen: 0,
totalRechargedFen: 0,
totalChargedFen: 0,
updatedAt: organization.updatedAt
}
})),
members: members.map((member) => ({
id: member.id,
displayName: member.displayName,
phone: member.phone,
role: member.role,
organizationId: member.organizationId || null,
status: member.status
})),
ledger,
priceRules
});
} catch (error) {
return jsonError(error, 500, { source: "api.admin.billing" });
}
}