Files
sundynix-agentix/sundynix-admin/src/api.ts
T
Blizzard f8f7359723 feat(admin): 订阅管理页 + 修「余额列为 NULL 导致充值永不到账」
管理端「支付 → 订阅」:套餐配置 + 全平台订阅观测。配置时直接算出「一个周期
发几次、合计多少积分」,时长不能被间隔整除时橙字提示到期前会有空档 —— 让人在
配的时候就看见后果,而不是上线后才发现只发了一次。

顺带修了个真 bug,是拿真库验订阅时撞出来的(本地 42 个租户里 11 个中招):

  credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL。而入账语句是
  「余额 + N」—— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**:
  分录照写、余额不动、不报错。这条路径是充值/兑换码/退款/扣费/订阅发放共用的,
  不是订阅引入的问题。

三处修:
  - 5 处余额增减一律改 coalesce(credit_balance_micro, 0),新写入自愈;
  - 启动迁移回填存量 NULL(按账本求和,让「余额 = SUM(ledger)」重新成立);
  - 模型只加 default:0,**刻意不加 not null** —— 存量库有 NULL 行,AutoMigrate
    尝试 SET NOT NULL 会直接失败,而且它在回填之前跑,等于把部署搞挂。

回归测试先证明能失败(去掉 coalesce → 余额 0)再确认修复。第一版测试因为我给
模型加了 not null 而无法造出 NULL,恰好暴露了上面那个部署风险。

真库验证:回填后 42 个租户 0 个 NULL;那个"有分录但余额 NULL"的租户余额
2981 = 账本合计 2981.34。管理端页面显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:28:32 +08:00

985 lines
37 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 ?? [];
}
// ---- 微信支付配置(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 ?? "" };
}
// saveWechatPayapiv3_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 };
}
// ---- 支付联调:走「用户面」的真实下单/查单接口 ----
// 刻意不做 admin 专用旁路:验的就是用户真实走的那条路(下单 → 渠道 → 回调/查单 → 入账),
// 造条测试专线的话验过了也不代表线上通。因此这两个打的是 /api/v1/billing/*(非 /admin/*),
// 租户由服务端按当前登录用户解析,无需带租户头。
export interface TestOrder {
order_id: string;
code_url: string;
amount_fen: number;
expires_at: string;
}
export async function createPayOrder(packId: string): Promise<TestOrder> {
const res = guard(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({ pack_id: packId }),
}),
);
const d = (await res.json().catch(() => ({}))) as Partial<TestOrder> & { error?: string };
if (!res.ok) throw new Error(d.error ?? `下单失败: ${res.status}`);
return d as TestOrder;
}
// warn:已付但金额与订单不符 —— 服务端不入账、挂起人工核对,订单会一直停在 pending。
export async function payOrderStatus(orderId: string): Promise<{ order: PayOrder; warn?: string }> {
const res = guard(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { order?: PayOrder; warn?: string; error?: string };
if (!res.ok) throw new Error(d.error ?? `查询失败: ${res.status}`);
return { order: d.order as PayOrder, warn: d.warn };
}
// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)----
export interface SubPlan {
id: string;
name: string;
price_fen: number;
duration_days: number; // 一个周期多少天
refill_credits_micro: number; // 每次发放的积分
refill_interval_days: number; // 每几天发一次
active: boolean;
sort: number;
}
export interface SubRow {
id: string;
tenant_id: string;
tenant_name: string;
plan_name: string;
status: string; // active / expired
started_at: string;
expires_at: string;
refill_seq: number; // 已发放次数
}
export async function adminSubPlans(): Promise<SubPlan[]> {
const res = guard(await fetch(`${ADMIN}/sub-plans`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { plans?: SubPlan[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `sub plans failed: ${res.status}`);
return d.plans ?? [];
}
// refill_credits 以「积分」为单位(面向人),服务端转 micro。
export async function saveSubPlan(p: {
id?: string;
name: string;
price_fen: number;
duration_days: number;
refill_credits: number;
refill_interval_days: number;
active: boolean;
sort: number;
}): Promise<void> {
const res = guard(await fetch(`${ADMIN}/sub-plans`, { 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 async function adminSubscriptions(): Promise<SubRow[]> {
const res = guard(await fetch(`${ADMIN}/subscriptions`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { subscriptions?: SubRow[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `subscriptions failed: ${res.status}`);
return d.subscriptions ?? [];
}
// 人工退款:仅对已入账(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;
}
export interface AdminEval {
overall: number;
rule: number;
llm: number;
faithful: number;
level: string;
flags: string; // JSON 数组字符串
reason: string;
sources: number;
corrected: boolean;
}
export interface AdminTaskDetail extends AdminTask {
graph: string;
output: string;
trace: string;
eval: AdminEval | null;
}
// 任务下钻。走 /admin 而非用户面的 /tasks/:id/replay —— 后者受租户插件过滤,
// 管理员看别的租户的任务会静默拿到空输出/空轨迹(不报错),排查时极具误导性。
export async function adminTaskDetail(id: string): Promise<{ task: AdminTaskDetail; exec: unknown[] }> {
const res = guard(await fetch(`${ADMIN}/tasks/${encodeURIComponent(id)}`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { task?: AdminTaskDetail; exec?: unknown[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `task detail failed: ${res.status}`);
return { task: d.task as AdminTaskDetail, exec: d.exec ?? [] };
}
// 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;
/** 检索作用域键前半段:三库里的库名是 "<space_id>/<name>",检索试验台按此定位。 */
space_id: string;
kind: string;
tenant_id: string;
tenant_name: string;
owner: string;
doc_count: number;
total_words: number;
}
/** 检索命中:text=命中片段,score=该路的相似度/融合分(不同 mode 分数体系不同,勿跨路比较)。 */
export interface KbHit {
text: string;
score: number;
}
/** 检索模式:单路用于逐路定位是哪一路没召回;hybrid=纯 RRF 融合(不 rerank)""=生产链路(混合+rerank)。 */
export type SearchMode = "" | "vector" | "fulltext" | "graph" | "hybrid";
/** RouteDiag 一路召回的诊断。三种"空"必须能分辨:没配置(disabled)、报错(error)、确实没匹配(empty)。 */
export interface RouteDiag {
name: "vector" | "fulltext" | "graph";
status: "ok" | "empty" | "disabled" | "error";
hits: number;
ms: number;
error?: string;
note?: string;
}
// adminKbSearch 检索试验台:按完整作用域键跨租户检索任意知识库。
// kb 传 `${space_id}/${name}`(来自 adminDatasources)。返回命中 + 每一路的诊断。
export async function adminKbSearch(
kb: string,
q: string,
topK = 5,
mode: SearchMode = "",
): Promise<{ hits: KbHit[]; routes: RouteDiag[] }> {
const res = guard(
await fetch(`${ADMIN}/kb/search`, {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({ kb, q, topK, mode }),
}),
);
const d = (await res.json().catch(() => ({}))) as { hits?: KbHit[]; routes?: RouteDiag[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `search failed: ${res.status}`);
return { hits: d.hits ?? [], routes: d.routes ?? [] };
}
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 interface AuditQuery {
action?: string; // HTTP 方法,精确
path?: string; // 路径前缀
q?: string; // actor / ip / detail / path 模糊
}
export async function listAudit(limit = 50, offset = 0, f: AuditQuery = {}): Promise<AuditEntry[]> {
const p = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (f.action) p.set("action", f.action);
if (f.path) p.set("path", f.path);
if (f.q?.trim()) p.set("q", f.q.trim());
const res = guard(await fetch(`${ADMIN}/audit?${p}`, { 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}`);
}
}