Files
sundynix-agentix/sundynix-admin/src/api.ts
T
Blizzard 929bbf334b feat(admin): 计费页长出「充值渠道」块 —— 兑换码生成/台账 + 积分包定价
P5.1 收尾:此前生成码只有 API。计费页现在从上到下 = 计费规则(积分→token
汇率) → 充值渠道(钱→积分:兑换码 + 微信定价用的积分包) → 用量观测,
两层汇率在同一页可见、各管各的。

- 兑换码:面额/张数/备注生成;**明文码只在生成响应显示一次**(等同现金,
  台账 GET /admin/redeem-codes 服务端脱敏只露首尾,丢码重生成、不提供找回
  ——顺手把接口这个第二明文出口堵了);台账含核销状态。
- 积分包:新增/上下架(微信 P5.2 上线前把定价面备好);admin api.ts 补
  packs/redeem-codes 四个函数。
- launch.json 加 admin-console-alt(:5176)——5174 被用户自己的 sundynix-site
  占着,不动别人端口。
live:5176 登录→生成 5 张(绿色一次性面板+复制全部)→配「入门包 1000分/¥9.9」
在售可下架→台账脱敏 curl 复核;tsc+41 vitest 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:28:03 +08:00

563 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 运维控制台 → Gateway 控制面 API(带 JWT 鉴权;/admin 受 RequireAdmin 保护)。
export const GATEWAY: string =
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
const ADMIN = `${GATEWAY}/api/v1/admin`;
// ---- 鉴权(JWT,存 localStorage----
const TOKEN_KEY = "sdx_admin_token";
let token = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : "";
export function setToken(t: string): void {
token = t;
try {
localStorage.setItem(TOKEN_KEY, t);
} catch {
/* ignore */
}
}
export function clearToken(): void {
token = "";
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
export function getToken(): string {
return token;
}
function authHeaders(json = false): Record<string, string> {
const h: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
if (json) h["Content-Type"] = "application/json";
return h;
}
// guard 在 401(未登录) 时清令牌并广播登出(403=已登录但非管理员,照常抛错)。
function guard(res: Response): Response {
if (res.status === 401) {
clearToken();
if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout"));
}
return res;
}
export interface AuthUser {
id: string;
email: string;
name?: string;
}
export async function login(email: string, password: string): Promise<AuthUser> {
const res = await fetch(`${GATEWAY}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string };
if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `登录失败: ${res.status}`);
setToken(data.token);
return data.user;
}
export async function me(): Promise<AuthUser | null> {
if (!token) return null;
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: authHeaders() });
if (!res.ok) {
clearToken();
return null;
}
return ((await res.json()) as { user?: AuthUser }).user ?? null;
}
// ---- 模型配置(id 为雪花字符串)----
export type Kind = "chat" | "embedding";
export interface Model {
id: string;
kind: Kind;
provider: string;
base_url: string;
api_key: string; // 列表里是脱敏值
model: string;
active: boolean;
}
export interface ModelInput {
id?: string;
kind: Kind;
provider: string;
base_url: string;
api_key: string;
model: string;
}
export async function listModels(kind: Kind): Promise<Model[]> {
const res = guard(await fetch(`${ADMIN}/models?kind=${kind}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list failed: ${res.status}`);
return ((await res.json()) as { models: Model[] }).models;
}
export async function saveModel(m: ModelInput): Promise<string> {
const res = guard(await fetch(`${ADMIN}/models`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) }));
const data = (await res.json()) as { id?: string; error?: string };
if (!res.ok) throw new Error(data.error ?? `save failed: ${res.status}`);
return data.id ?? "";
}
export async function setActive(id: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/models/${id}/active`, { method: "POST", headers: authHeaders() }));
if (!res.ok) throw new Error(`activate failed: ${res.status}`);
}
export async function deleteModel(id: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/models/${id}`, { method: "DELETE", headers: authHeaders() }));
if (!res.ok) throw new Error(`delete failed: ${res.status}`);
}
export async function testModel(m: ModelInput): Promise<{ ok: boolean; message: string }> {
const res = guard(await fetch(`${ADMIN}/models/test`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) }));
return (await res.json()) as { ok: boolean; message: string };
}
// ---- 计价(token↔真钱,按模型分输入/输出)----
export interface Pricing {
model_id: string;
input_per_1k: number;
output_per_1k: number;
credit_weight: number; // 每模型积分权重(0/缺省=1.0,不加权)
currency: string;
}
export async function listPricing(): Promise<Pricing[]> {
const res = guard(await fetch(`${ADMIN}/pricing`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list pricing failed: ${res.status}`);
return ((await res.json()) as { pricing: Pricing[] }).pricing ?? [];
}
export async function savePricing(p: Pricing): Promise<void> {
const res = guard(await fetch(`${ADMIN}/pricing`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `save pricing failed: ${res.status}`);
}
}
// —— 全局计费规则(token→积分汇率 + 硬拦截开关)——
export async function getBillingConfig(): Promise<{ tokens_per_credit: number; credit_enforce: boolean }> {
const res = guard(await fetch(`${ADMIN}/billing-config`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`billing config failed: ${res.status}`);
const d = (await res.json()) as { tokens_per_credit?: string; credit_enforce?: boolean };
return { tokens_per_credit: Number(d.tokens_per_credit) || 1000, credit_enforce: !!d.credit_enforce }; // 空/未设 → 回退默认
}
export async function saveBillingConfig(tokensPerCredit: number, creditEnforce: boolean): Promise<void> {
const res = guard(
await fetch(`${ADMIN}/billing-config`, {
method: "PUT",
headers: authHeaders(true),
body: JSON.stringify({ tokens_per_credit: tokensPerCredit, credit_enforce: creditEnforce }),
}),
);
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `save billing config failed: ${res.status}`);
}
}
// —— 充值/发放积分(正=充值,负=校正)——
export async function grantCredits(tenantId: string, credits: number, memo: string): Promise<number> {
const res = guard(
await fetch(`${ADMIN}/credits/grant`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ tenant_id: tenantId, credits, memo }) }),
);
const d = (await res.json().catch(() => ({}))) as { balance_micro?: number; error?: string };
if (!res.ok) throw new Error(d.error ?? `grant failed: ${res.status}`);
return d.balance_micro ?? 0;
}
// ---- 充值渠道(P5.1):积分包配置 + 兑换码 ----
export interface CreditPack {
id: string;
name: string;
credits_micro: number;
price_fen: number;
active: boolean;
sort: number;
}
export async function adminPacks(): Promise<CreditPack[]> {
const res = guard(await fetch(`${ADMIN}/packs`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { packs?: CreditPack[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `packs failed: ${res.status}`);
return d.packs ?? [];
}
// savePackid 空 = 新建。credits 单位为「积分」(面向人,服务端转 micro)。
export async function savePack(p: { id?: string; name: string; credits: number; price_fen: number; active: boolean; sort: number }): Promise<void> {
const res = guard(await fetch(`${ADMIN}/packs`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) }));
const d = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
}
export interface RedeemCodeRow {
id: string;
code: string;
credits_micro: number;
status: string; // unused / used
used_tenant: string;
used_at: string | null;
memo: string;
created_at: string;
}
export async function genRedeemCodes(credits: number, count: number, memo: string): Promise<string[]> {
const res = guard(
await fetch(`${ADMIN}/redeem-codes`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ credits, count, memo }) }),
);
const d = (await res.json().catch(() => ({}))) as { codes?: string[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `生成失败: ${res.status}`);
return d.codes ?? [];
}
export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
const res = guard(await fetch(`${ADMIN}/redeem-codes`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { codes?: RedeemCodeRow[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `codes failed: ${res.status}`);
return d.codes ?? [];
}
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
export async function gatewayOnline(): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY}/healthz`);
return res.ok;
} catch {
return false;
}
}
// —— 服务状态:基建 / 应用服务探活 + MCP 工具注册 ——
export interface StatusItem {
name: string;
up: boolean;
detail?: string;
latency_ms?: number;
}
export interface ToolInfo {
name: string;
cn: string;
desc: string;
}
export interface ToolGroup {
server: string;
up: boolean;
tools: ToolInfo[] | null;
}
export interface SystemStatus {
checked_at: string;
infra: StatusItem[];
services: StatusItem[];
tools: ToolGroup[];
}
export async function getStatus(): Promise<SystemStatus> {
const res = guard(await fetch(`${ADMIN}/status`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`status failed: ${res.status}`);
return (await res.json()) as SystemStatus;
}
// —— 工作台概览(仪表盘聚合)——
// /api/v1/stats/overview 在 RequireAuth 组下;Task/Eval 表无 owner,故任务/评测口径为全局。
export interface DayCount {
key: string;
count: number;
}
export interface RecentRun {
task_id: string;
status: string;
detail: string;
at: string;
}
export interface Overview {
tasks_today: number;
tasks_total: number;
status_count: DayCount[]; // 近 7 天终态分布
task_trend: DayCount[]; // 近 7 天每日任务数(仅含有任务的天)
eval_avg: number;
faithful_avg: number; // 仅有来源的评测;无来源时为 0
eval_count: number;
kb_docs: number;
kb_count: number;
tokens_today: number;
daily_budget: number; // 0 = 不限额
token_trend: DayCount[];
recent_runs: RecentRun[];
services: Record<string, boolean>;
}
export async function statsOverview(): Promise<Overview> {
const res = guard(await fetch(`${GATEWAY}/api/v1/stats/overview`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`overview failed: ${res.status}`);
return (await res.json()) as Overview;
}
// —— 管理端系统级聚合(控制塔口径,RequireAdmin)——
// 区别于 statsOverview(桌面端个人工作台):这里一律全平台口径——全部用户/任务/评测/模型态/提示词态/健康。
// 单模型运行时健康态(failover 链上主/备各一条)。
export interface ModelHealthItem {
provider: string;
model: string;
role: string; // primary / fallback
state: string; // closed(在线) / open(熔断中) / half-open(半开探测) / single(无备用链)
fails: number;
}
export interface AdminOverview {
users: number;
kb_count: number; // 全平台知识库数
kb_docs: number; // 全平台文档数
tasks_today: number;
tasks_total: number;
status_count: DayCount[];
task_trend: DayCount[];
eval_avg: number;
faithful_avg: number;
eval_count: number;
models: {
chat_count: number;
embedding_count: number;
active_chat: string;
active_embedding: string;
fallbacks: number;
health: ModelHealthItem[]; // 运行时每模型 failover/熔断态(来自 dispatcher
};
prompts: { managed: number; overrides: number };
services: Record<string, boolean>;
checked_at: string;
}
export async function adminOverview(): Promise<AdminOverview> {
const res = guard(await fetch(`${ADMIN}/overview`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`admin overview failed: ${res.status}`);
return (await res.json()) as AdminOverview;
}
// —— 审计 / 安全事件(敏感操作留痕 + 护栏命中)——
export interface AuditEntry {
id: string;
actor: string; // 操作者 uid
action: string; // POST / PUT / DELETE / PATCH
route: string;
path: string;
status: number;
ip: string;
detail: string;
at: string;
}
export interface GuardrailEventItem {
id: string;
actor: string;
kind: string; // blocked / suspect
reason: string;
signals: string; // JSON 数组字符串
method: string;
path: string;
ip: string;
at: string;
}
export async function listAudit(limit = 50, offset = 0): Promise<AuditEntry[]> {
const res = guard(await fetch(`${ADMIN}/audit?limit=${limit}&offset=${offset}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`audit failed: ${res.status}`);
return ((await res.json()) as { logs?: AuditEntry[] }).logs ?? [];
}
export async function listGuardrailEvents(limit = 50, offset = 0): Promise<GuardrailEventItem[]> {
const res = guard(await fetch(`${ADMIN}/guardrail-events?limit=${limit}&offset=${offset}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`guardrail events failed: ${res.status}`);
return ((await res.json()) as { events?: GuardrailEventItem[] }).events ?? [];
}
// —— Prompt 控制面(建版本 → 激活 → 控制面热下发各服务,不重启即生效)——
// 注意:prompt 路由在 RequireAuth 组下(/api/v1/prompts),不在 /admin 前缀内。
const PROMPTS = `${GATEWAY}/api/v1/prompts`;
export interface PromptVersion {
key: string;
version: number;
active: boolean;
note: string;
content: string;
}
export interface PromptListResp {
keys: string[]; // 平台受管的全部可配 key(即便还没建过版本)
versions: PromptVersion[];
}
// 某 key 的聚合视图:版本按号倒序、当前激活版(无则 null = 回退代码内置默认)。
export interface PromptGroup {
key: string;
versions: PromptVersion[];
active: PromptVersion | null;
}
// groupPrompts 把扁平的 {keys, versions} 归并成按 key 的分组(控制面页面的事实源)。
// 纯函数:保持 keys 的注册顺序,每组版本号倒序,挑出激活版。
export function groupPrompts(resp: PromptListResp): PromptGroup[] {
const byKey = new Map<string, PromptVersion[]>();
for (const k of resp.keys) byKey.set(k, []);
for (const v of resp.versions) {
if (!byKey.has(v.key)) byKey.set(v.key, []); // 容错:DB 有但 Known 未列的 key 也展示
byKey.get(v.key)!.push(v);
}
return Array.from(byKey.entries()).map(([key, vs]) => {
const versions = [...vs].sort((a, b) => b.version - a.version);
return { key, versions, active: versions.find((v) => v.active) ?? null };
});
}
export async function listPrompts(): Promise<PromptListResp> {
const res = guard(await fetch(PROMPTS, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list prompts failed: ${res.status}`);
const data = (await res.json()) as Partial<PromptListResp>;
return { keys: data.keys ?? [], versions: data.versions ?? [] };
}
export async function createPromptVersion(key: string, content: string, note: string): Promise<number> {
const res = guard(
await fetch(`${PROMPTS}/version`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ key, content, note }) }),
);
const data = (await res.json()) as { version?: number; error?: string };
if (!res.ok) throw new Error(data.error ?? `create version failed: ${res.status}`);
return data.version ?? 0;
}
export async function activatePrompt(key: string, version: number): Promise<void> {
const res = guard(
await fetch(`${PROMPTS}/activate`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ key, version }) }),
);
const data = (await res.json()) as { error?: string; warn?: string };
if (!res.ok) throw new Error(data.error ?? `activate failed: ${res.status}`);
if (data.warn) throw new Error(data.warn); // 已激活但广播失败 → 当作错误提示运维
}
export async function deactivatePrompt(key: string): Promise<void> {
const res = guard(await fetch(`${PROMPTS}/deactivate`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ key }) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `deactivate failed: ${res.status}`);
}
}
// —— 用量 / 计费(SaaS P2 计量:token / 积分 / 成本,按租户)——
// 金额与积分均为「微单位」(×10⁻⁶):展示时 ÷1e6。走 /admin/usage(系统级,跨租户)。
export interface UsageDay {
day: string; // YYYYMMDD
total_tok: number;
credits_micro: number;
cost_micros: number;
task_count: number;
}
export interface UsageTenantSum {
tenant_id: string;
name: string;
total_tok: number;
credits_micro: number;
cost_micros: number;
task_count: number;
balance_micro: number;
}
export interface UsageReport {
from: string;
to: string;
tenant: string; // 空 = 全平台口径
trend: UsageDay[];
totals: { total_tok: number; credits_micro: number; cost_micros: number; task_count: number };
balance_micro?: number; // 仅单租户口径
tenants?: UsageTenantSum[]; // 仅全平台口径:各租户排行
}
export async function adminUsage(params?: { tenant?: string; from?: string; to?: string }): Promise<UsageReport> {
const q = new URLSearchParams();
if (params?.tenant) q.set("tenant", params.tenant);
if (params?.from) q.set("from", params.from);
if (params?.to) q.set("to", params.to);
const qs = q.toString();
const res = guard(await fetch(`${ADMIN}/usage${qs ? "?" + qs : ""}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`usage failed: ${res.status}`);
const d = (await res.json()) as UsageReport;
return { ...d, trend: d.trend ?? [], tenants: d.tenants ?? [] }; // Go 空切片可能序列化为 null
}
// —— 多成员租户:租户目录 + 成员管理 ——
export interface TenantRow {
id: string;
name: string;
slug: string;
plan: string;
status: string;
credit_balance_micro: number;
shared_billing: boolean;
members: number;
}
export interface Member {
user_id: string;
email: string;
name: string;
role: string; // owner / admin / member / viewer / billing_admin
status: string;
joined_at: string;
}
export async function listTenants(): Promise<TenantRow[]> {
const res = guard(await fetch(`${ADMIN}/tenants`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list tenants failed: ${res.status}`);
return ((await res.json()) as { tenants?: TenantRow[] }).tenants ?? [];
}
export async function createTenant(name: string, slug: string, ownerEmail?: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "" }) }));
const d = (await res.json().catch(() => ({}))) as { error?: string; warn?: string };
if (!res.ok) throw new Error(d.error ?? `create tenant failed: ${res.status}`);
if (d.warn) throw new Error(d.warn); // 租户已建但指定 owner 失败 → 当提示
}
export async function listMembers(tenantId: string): Promise<Member[]> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list members failed: ${res.status}`);
return ((await res.json()) as { members?: Member[] }).members ?? [];
}
export async function addMember(tenantId: string, email: string, role: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ email, role }) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `add member failed: ${res.status}`);
}
}
export async function setMemberRole(tenantId: string, uid: string, role: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members/${uid}`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ role }) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `set role failed: ${res.status}`);
}
}
export async function removeMember(tenantId: string, uid: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/members/${uid}`, { method: "DELETE", headers: authHeaders() }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `remove member failed: ${res.status}`);
}
}
export async function setSharedBilling(tenantId: string, on: boolean): Promise<void> {
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/shared-billing`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ shared_billing: on }) }));
if (!res.ok) {
const d = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(d.error ?? `set shared billing failed: ${res.status}`);
}
}