9fb947aa7d
Manage provider keys at runtime, route current-information questions through server-side search with safe fallback, and scope OOBE usage claims to grants.
353 lines
11 KiB
TypeScript
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" },
|
|
),
|
|
};
|