819 lines
31 KiB
TypeScript
819 lines
31 KiB
TypeScript
// 运维控制台 → 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 ?? [];
|
||
}
|
||
|
||
// savePack:id 空 = 新建。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 ?? [];
|
||
}
|
||
|
||
// ---- 微信支付配置(DB 存储、热生效;APIv3 密钥密文入库、不回显)----
|
||
export interface WechatPayConfig {
|
||
mchid: string;
|
||
cert_serial: string;
|
||
private_key_path: string;
|
||
public_key_path: string; // 微信支付公钥(2024 起新商户体系;本商户 2025-09 开户)
|
||
public_key_id: string; // PUB_KEY_ID_ 开头
|
||
appid: string;
|
||
notify_url: string;
|
||
has_apiv3_key: boolean;
|
||
}
|
||
|
||
export async function getWechatPay(): Promise<{ config: WechatPayConfig; enabled: boolean; reason: string }> {
|
||
const res = guard(await fetch(`${ADMIN}/payment/wechat`, { headers: authHeaders() }));
|
||
const d = (await res.json().catch(() => ({}))) as { config?: WechatPayConfig; enabled?: boolean; reason?: string; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `load failed: ${res.status}`);
|
||
return { config: d.config!, enabled: !!d.enabled, reason: d.reason ?? "" };
|
||
}
|
||
|
||
// saveWechatPay:apiv3_key 传空串 = 沿用已保存的密钥。返回热重载后的渠道状态。
|
||
export async function saveWechatPay(body: {
|
||
mchid: string;
|
||
cert_serial: string;
|
||
private_key_path: string;
|
||
public_key_path: string;
|
||
public_key_id: string;
|
||
apiv3_key: string;
|
||
appid: string;
|
||
notify_url: string;
|
||
}): Promise<{ enabled: boolean; reason: string }> {
|
||
const res = guard(await fetch(`${ADMIN}/payment/wechat`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
|
||
const d = (await res.json().catch(() => ({}))) as { enabled?: boolean; reason?: string; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
||
return { enabled: !!d.enabled, reason: d.reason ?? "" };
|
||
}
|
||
|
||
// ---- 充值订单流 + 对账(P5.3)----
|
||
export interface PayOrder {
|
||
id: string;
|
||
tenant_id: string;
|
||
tenant_name: string;
|
||
amount_fen: number;
|
||
credits_micro: number;
|
||
channel: string;
|
||
status: string;
|
||
created_at: string;
|
||
}
|
||
export interface OrderStats {
|
||
pending: number;
|
||
paid: number;
|
||
expired: number;
|
||
paid_fen_total: number;
|
||
}
|
||
export interface ReconcileDiff {
|
||
order_id: string;
|
||
tenant_id: string;
|
||
credits_micro: number;
|
||
issue: string;
|
||
}
|
||
|
||
export async function adminOrders(status = ""): Promise<{ orders: PayOrder[]; stats: OrderStats }> {
|
||
const q = status ? `?status=${status}` : "";
|
||
const res = guard(await fetch(`${ADMIN}/orders${q}`, { headers: authHeaders() }));
|
||
const d = (await res.json().catch(() => ({}))) as { orders?: PayOrder[]; stats?: OrderStats; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `orders failed: ${res.status}`);
|
||
return { orders: d.orders ?? [], stats: d.stats ?? { pending: 0, paid: 0, expired: 0, paid_fen_total: 0 } };
|
||
}
|
||
|
||
export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: boolean }> {
|
||
const res = guard(await fetch(`${ADMIN}/orders/reconcile`, { headers: authHeaders() }));
|
||
const d = (await res.json().catch(() => ({}))) as { diffs?: ReconcileDiff[]; ok?: boolean; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `reconcile failed: ${res.status}`);
|
||
return { diffs: d.diffs ?? [], ok: !!d.ok };
|
||
}
|
||
|
||
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
|
||
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
|
||
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
|
||
const res = guard(await fetch(`${ADMIN}/orders/${id}/refund`, {
|
||
method: "POST",
|
||
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
||
body: JSON.stringify({ memo }),
|
||
}));
|
||
const d = (await res.json().catch(() => ({}))) as { status?: string; detail?: string; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `refund failed: ${res.status}`);
|
||
return { status: d.status ?? "", detail: d.detail };
|
||
}
|
||
|
||
// ---- 全平台任务/运行观测(管理端,来自 sundynix_task 跨租户)----
|
||
export interface AdminTask {
|
||
task_id: string;
|
||
tenant_id: string;
|
||
tenant_name: string;
|
||
owner: string;
|
||
owner_email: string;
|
||
status: string;
|
||
detail: string;
|
||
topic: string;
|
||
at: string; // RFC3339
|
||
eval_level: string;
|
||
eval_overall: number;
|
||
}
|
||
|
||
// adminTasks 全平台任务流 + 状态计数。status 空=全部;tenant 空=全租户;含 HITL 待审批(status=waiting)。
|
||
export async function adminTasks(
|
||
status = "",
|
||
tenant = "",
|
||
limit = 50,
|
||
): Promise<{ tasks: AdminTask[]; counts: Record<string, number> }> {
|
||
const q = new URLSearchParams();
|
||
if (status) q.set("status", status);
|
||
if (tenant) q.set("tenant", tenant);
|
||
q.set("limit", String(limit));
|
||
const res = guard(await fetch(`${ADMIN}/tasks?${q.toString()}`, { headers: authHeaders() }));
|
||
const d = (await res.json().catch(() => ({}))) as { tasks?: AdminTask[]; counts?: Record<string, number>; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `tasks failed: ${res.status}`);
|
||
return { tasks: d.tasks ?? [], counts: d.counts ?? {} };
|
||
}
|
||
|
||
// ---- 全平台空间(Space)观测(管理端,跨租户 sundynix_space)----
|
||
export interface AdminSpace {
|
||
id: string;
|
||
tenant_id: string;
|
||
tenant_name: string;
|
||
name: string;
|
||
kind: string; // personal / project / tenant
|
||
creator: string;
|
||
creator_email: string;
|
||
archived: boolean;
|
||
members: number;
|
||
created_at: string;
|
||
}
|
||
|
||
export async function adminSpaces(limit = 200): Promise<AdminSpace[]> {
|
||
const res = guard(await fetch(`${ADMIN}/spaces?limit=${limit}`, { headers: authHeaders() }));
|
||
const d = (await res.json().catch(() => ({}))) as { spaces?: AdminSpace[]; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `spaces failed: ${res.status}`);
|
||
return d.spaces ?? [];
|
||
}
|
||
|
||
// ---- 自动评测观测(真数据,来自 sundynix_eval)----
|
||
export interface EvalDay {
|
||
day: string; // YYYYMMDD
|
||
avg_overall: number;
|
||
avg_faithful: number;
|
||
count: number;
|
||
poor_count: number;
|
||
}
|
||
export interface EvalSummary {
|
||
total: number;
|
||
ok: number;
|
||
warn: number;
|
||
poor: number;
|
||
corrected: number;
|
||
avg_overall: number;
|
||
}
|
||
export interface PoorEval {
|
||
task_id: string;
|
||
tenant_name: string;
|
||
owner: string;
|
||
overall: number;
|
||
rule: number;
|
||
llm: number;
|
||
faithful: number;
|
||
level: string;
|
||
reason: string;
|
||
sources: number;
|
||
corrected: boolean;
|
||
created_at: string;
|
||
}
|
||
|
||
export async function adminEvals(days = 14): Promise<{ from: string; to: string; trend: EvalDay[]; summary: EvalSummary; poor: PoorEval[] }> {
|
||
const res = guard(await fetch(`${ADMIN}/evals?days=${days}`, { headers: authHeaders() }));
|
||
const d = (await res.json().catch(() => ({}))) as { from?: string; to?: string; trend?: EvalDay[]; summary?: EvalSummary; poor?: PoorEval[]; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `evals failed: ${res.status}`);
|
||
return {
|
||
from: d.from ?? "",
|
||
to: d.to ?? "",
|
||
trend: d.trend ?? [],
|
||
summary: d.summary ?? { total: 0, ok: 0, warn: 0, poor: 0, corrected: 0, avg_overall: 0 },
|
||
poor: d.poor ?? [],
|
||
};
|
||
}
|
||
|
||
// ---- 输入护栏安全事件(真数据,来自 guardrail_event)----
|
||
export interface GuardrailEvent {
|
||
id: string;
|
||
actor: string;
|
||
kind: string; // blocked(硬拦)/ suspect(灰区放行)
|
||
reason: string;
|
||
signals: string; // 命中软信号 JSON 数组字符串
|
||
method: string;
|
||
path: string;
|
||
ip: string;
|
||
at: string;
|
||
}
|
||
|
||
export async function guardrailEvents(limit = 100): Promise<GuardrailEvent[]> {
|
||
const res = guard(await fetch(`${ADMIN}/guardrail-events?limit=${limit}`, { headers: authHeaders() }));
|
||
const d = (await res.json().catch(() => ({}))) as { events?: GuardrailEvent[]; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `guardrail failed: ${res.status}`);
|
||
return d.events ?? [];
|
||
}
|
||
|
||
// ---- 数据源清单(真数据,全平台知识库)----
|
||
export interface DatasourceKB {
|
||
id: string;
|
||
name: string;
|
||
kind: string;
|
||
tenant_id: string;
|
||
tenant_name: string;
|
||
owner: string;
|
||
doc_count: number;
|
||
total_words: number;
|
||
}
|
||
|
||
export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> {
|
||
const res = guard(await fetch(`${ADMIN}/datasources`, { headers: authHeaders() }));
|
||
const d = (await res.json().catch(() => ({}))) as { counts?: { users: number; kbs: number; docs: number }; datasources?: DatasourceKB[]; error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `datasources failed: ${res.status}`);
|
||
return { counts: d.counts ?? { users: 0, kbs: 0, docs: 0 }, datasources: d.datasources ?? [] };
|
||
}
|
||
|
||
// 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, plan?: string): Promise<void> {
|
||
const res = guard(await fetch(`${ADMIN}/tenants`, {
|
||
method: "POST", headers: authHeaders(true),
|
||
body: JSON.stringify({ name, slug, owner_email: ownerEmail ?? "", plan: plan ?? "free" }),
|
||
}));
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
// —— KB 存储迁移(管理员一次性运维操作:owner/kb 作用域 → space_id/kb 作用域)——
|
||
export interface MigrateKbResult {
|
||
total: number; // 扫描文档总数
|
||
enqueued: number; // 成功入队重灌的文档数
|
||
skipped: number; // 已是新作用域跳过数
|
||
}
|
||
export async function migrateKbStorage(): Promise<MigrateKbResult> {
|
||
const res = guard(await fetch(`${ADMIN}/migrate-kb-storage`, { method: "POST", headers: authHeaders(true) }));
|
||
const d = (await res.json().catch(() => ({}))) as Partial<MigrateKbResult> & { error?: string };
|
||
if (!res.ok) throw new Error(d.error ?? `migrate kb failed: ${res.status}`);
|
||
return { total: d.total ?? 0, enqueued: d.enqueued ?? 0, skipped: d.skipped ?? 0 };
|
||
}
|
||
|
||
export async function setTenantPlan(tenantId: string, plan: string): Promise<void> {
|
||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/plan`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ plan }) }));
|
||
if (!res.ok) {
|
||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||
throw new Error(d.error ?? `set tenant plan failed: ${res.status}`);
|
||
}
|
||
}
|
||
|
||
export async function setTenantStatus(tenantId: string, status: string): Promise<void> {
|
||
const res = guard(await fetch(`${ADMIN}/tenants/${tenantId}/status`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify({ status }) }));
|
||
if (!res.ok) {
|
||
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||
throw new Error(d.error ?? `set tenant status failed: ${res.status}`);
|
||
}
|
||
}
|
||
|