Files
OSGAccountServer/admin-web/src/api/client.ts
T
Rocky 9fb947aa7d
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Add runtime provider controls and searchable AI routing
Manage provider keys at runtime, route current-information questions through server-side search with safe fallback, and scope OOBE usage claims to grants.
2026-08-22 16:33:17 +08:00

353 lines
11 KiB
TypeScript

import type {
AdminOperator,
AdminOperatorCreateRequest,
AdminOperatorProvisioning,
AdminSecuritySummary,
AdminHintPack,
HintFeedGenerationResponse,
HintFeedGenerationStatus,
HintFeedSettings,
AdminLoginResponse,
AuditQuery,
AuditLogEntry,
CreditGrantRequest,
CreditGrantResponse,
LedgerQuery,
LedgerEntry,
ManagedProviderId,
ManagedProviderOverview,
ManagedProviderStatus,
OperatorsQuery,
Overview,
PageResult,
ProductAnalyticsOverview,
CreateOfficialSkillRequest,
OfficialSkill,
OfficialSkillCatalog,
ReferralsQuery,
ReferralOverview,
SessionResponse,
UserDetail,
UsersQuery,
UserSummary,
UpdateHintPackRequest,
UpdateHintFeedSettingsRequest,
UpdateOfficialSkillRequest,
UpdateProviderApiKeyRequest,
} from "./types";
const API_BASE = "/v1/admin";
const REQUEST_TIMEOUT_MS = 12_000;
interface ApiErrorBody {
code?: string;
error?: {
code?: string;
};
}
export class ApiError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly status: number,
) {
super(message);
this.name = "ApiError";
}
}
let csrfToken = "";
export function setCsrfToken(token?: string): void {
csrfToken = token?.trim() ?? "";
}
function csrfTokenFromCookie(): string {
if (typeof document === "undefined") return "";
const prefix = "osg_admin_csrf=";
return (
document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(prefix))
?.slice(prefix.length) ?? ""
);
}
function safeMessage(status: number, code?: string): string {
const messages: Record<string, string> = {
INVALID_CREDENTIALS: "用户名、密码或动态验证码错误",
INVALID_TOTP: "动态验证码无效或已过期",
VALIDATION_ERROR: "提交内容不符合要求",
USER_NOT_FOUND: "未找到该用户",
INSUFFICIENT_PERMISSION: "无权执行此操作",
IDEMPOTENCY_CONFLICT: "该赠送请求与已有记录冲突,请核对审计日志",
ADMIN_OPERATOR_NOT_FOUND: "未找到该管理员",
ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在",
CANNOT_DISABLE_SELF: "不能停用当前登录的管理员",
LAST_SUPER_ADMIN_REQUIRED: "必须至少保留一名启用的超级管理员",
CONTENT_SKILL_NOT_FOUND: "未找到该官方 Skill",
CONTENT_SKILL_CONFLICT: "该官方 Skill ID 已存在",
CONTENT_HINT_PACK_NOT_FOUND: "该语言的 Hint pack 尚未发布",
HINT_FEED_GENERATION_IN_PROGRESS: "Hint 提示包正在生成,请稍后刷新",
HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求",
HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用",
PROVIDER_API_KEY_INVALID: "API Key 不符合要求",
PROVIDER_NOT_FOUND: "不支持该 Provider",
RATE_LIMITED: "操作过于频繁,请稍后再试",
};
if (code && messages[code]) return messages[code];
if (status === 401) return "登录状态已失效,请重新登录";
if (status === 403) return "无权执行此操作";
if (status === 409) return "请求冲突,请刷新后重试";
if (status === 429) return "操作过于频繁,请稍后再试";
if (status >= 500) return "服务暂时不可用,请稍后再试";
return "请求失败,请检查后重试";
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const method = options.method?.toUpperCase() ?? "GET";
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
if (options.body) headers.set("Content-Type", "application/json");
const csrfRequired =
!["GET", "HEAD", "OPTIONS"].includes(method) && path !== "/auth/login";
if (csrfRequired) {
const requestToken =
csrfToken || csrfTokenFromCookie() || headers.get("X-CSRF-Token") || "";
if (!requestToken) {
throw new ApiError("CSRF_TOKEN_MISSING", "安全令牌缺失,请重新登录", 403);
}
headers.set("X-CSRF-Token", requestToken);
}
try {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: "include",
signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
let body: ApiErrorBody = {};
try {
body = (await response.json()) as ApiErrorBody;
} catch {
// 非 JSON 错误响应不会暴露给界面。
}
const apiError = new ApiError(
body.code ?? body.error?.code ?? `HTTP_${response.status}`,
safeMessage(response.status, body.code ?? body.error?.code),
response.status,
);
if (
response.status === 401 &&
!path.startsWith("/auth/") &&
typeof window !== "undefined"
) {
window.dispatchEvent(new Event("admin:unauthorized"));
}
throw apiError;
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
} catch (error) {
if (error instanceof ApiError) throw error;
if (error instanceof DOMException && error.name === "TimeoutError") {
throw new ApiError("REQUEST_TIMEOUT", "请求超时,请稍后再试", 0);
}
throw new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0);
}
}
export function encodeQuery<T extends object>(params: T): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") search.set(key, String(value));
});
const result = search.toString();
return result ? `?${result}` : "";
}
function cursorQuery<T extends CursorQuery>(value?: string | T): CursorQuery | T {
return typeof value === "string" ? { cursor: value } : (value ?? {});
}
type CursorQuery = { cursor?: string };
function ledgerQuery(value?: string | LedgerQuery): CursorQuery | LedgerQuery {
if (typeof value === "string") return { cursor: value };
return value ? { ...value, referenceId: value.referenceId?.trim() } : {};
}
export const adminApi = {
session: () => request<SessionResponse>("/auth/session"),
login: (username: string, password: string, totpCode: string) =>
request<AdminLoginResponse>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password, totpCode }),
}),
logout: () => request<void>("/auth/logout", { method: "POST" }),
overview: (range: string) =>
request<Overview>(`/overview${encodeQuery({ range })}`),
referrals: (value: string | ReferralsQuery) => {
const params = typeof value === "string" ? { range: value } : value;
return request<ReferralOverview>(`/referrals${encodeQuery(params)}`);
},
productAnalytics: (range: string) =>
request<ProductAnalyticsOverview>(`/analytics${encodeQuery({ range })}`),
contentSkills: () => request<OfficialSkillCatalog>("/content/skills"),
createContentSkill: (payload: CreateOfficialSkillRequest) =>
request<OfficialSkill>("/content/skills", {
method: "POST",
body: JSON.stringify(payload),
}),
updateContentSkill: (id: string, payload: UpdateOfficialSkillRequest) =>
request<OfficialSkill>(`/content/skills/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
setContentSkillEnabled: (id: string, enabled: boolean) =>
request<void>(
`/content/skills/${encodeURIComponent(id)}/${enabled ? "enable" : "disable"}`,
{ method: "POST" },
),
contentHintPack: (locale: "zh" | "en") =>
request<AdminHintPack>(`/content/hints/${locale}`),
updateContentHintPack: (
locale: "zh" | "en",
payload: UpdateHintPackRequest,
) =>
request<AdminHintPack>(`/content/hints/${locale}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
hintFeedSettings: () =>
request<HintFeedSettings>("/content/hints/generation/settings"),
updateHintFeedSettings: (payload: UpdateHintFeedSettingsRequest) =>
request<HintFeedSettings>("/content/hints/generation/settings", {
method: "PUT",
body: JSON.stringify(payload),
}),
hintFeedStatus: () =>
request<HintFeedGenerationStatus>("/content/hints/generation/status"),
regenerateHintFeed: () =>
request<HintFeedGenerationResponse>("/content/hints/generation/regenerate", {
method: "POST",
signal: AbortSignal.timeout(130_000),
}),
users: (value: string | UsersQuery = "", legacyCursor?: string) => {
const params =
typeof value === "string"
? { q: value.trim(), cursor: legacyCursor }
: { ...value, q: value.q?.trim() };
return request<PageResult<UserSummary>>(`/users${encodeQuery(params)}`);
},
user: (userId: string) =>
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
latestLedger: (value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>(
`/credits/ledger${encodeQuery(ledgerQuery(value))}`,
),
ledger: (userId: string, value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(ledgerQuery(value))}`,
),
grantCredits: (payload: CreditGrantRequest) =>
request<CreditGrantResponse>("/credits/grants", {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
amount: payload.amount,
reason: payload.reason,
}),
headers: { "Idempotency-Key": payload.idempotencyKey },
}),
providers: () => request<ManagedProviderOverview>("/providers"),
updateProviderApiKey: (
providerId: ManagedProviderId,
payload: UpdateProviderApiKeyRequest,
) =>
request<ManagedProviderStatus>(
`/providers/${encodeURIComponent(providerId)}/api-key`,
{
method: "PUT",
body: JSON.stringify(payload),
},
),
auditLogs: (value?: string | AuditQuery) =>
request<PageResult<AuditLogEntry>>(
`/audit${encodeQuery(cursorQuery(value))}`,
),
operators: (value?: string | OperatorsQuery) =>
request<PageResult<AdminOperator>>(
`/operators${encodeQuery(cursorQuery(value))}`,
),
operatorSummary: () =>
request<AdminSecuritySummary>("/operators/summary"),
createOperator: (payload: AdminOperatorCreateRequest) =>
request<AdminOperatorProvisioning>("/operators", {
method: "POST",
body: JSON.stringify(payload),
}),
setOperatorEnabled: (operatorId: string, enabled: boolean) =>
request<void>(
`/operators/${encodeURIComponent(operatorId)}/${enabled ? "enable" : "disable"}`,
{ method: "POST" },
),
unlockOperator: (operatorId: string) =>
request<void>(`/operators/${encodeURIComponent(operatorId)}/unlock`, {
method: "POST",
}),
resetOperatorCredentials: (operatorId: string, password: string) =>
request<AdminOperatorProvisioning>(
`/operators/${encodeURIComponent(operatorId)}/credentials/reset`,
{
method: "POST",
body: JSON.stringify({ password }),
},
),
revokeOperatorSessions: (operatorId: string) =>
request<void>(
`/operators/${encodeURIComponent(operatorId)}/sessions/revoke`,
{ method: "POST" },
),
};