Files
OSGAccountServer/admin-web/src/api/client.ts
T
Rocky 75c046d91d Show latest credit ledger in admin
Expose a paginated global ledger timeline and load it automatically so operators can see recent credit activity without first locating a user.
2026-08-19 18:05:54 +08:00

236 lines
7.1 KiB
TypeScript

import type {
AdminOperator,
AdminOperatorCreateRequest,
AdminOperatorProvisioning,
AdminSecuritySummary,
AdminLoginResponse,
AuditLogEntry,
CreditGrantRequest,
CreditGrantResponse,
LedgerEntry,
Overview,
PageResult,
ReferralOverview,
SessionResponse,
UserDetail,
UserSummary,
} 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: "必须至少保留一名启用的超级管理员",
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);
}
}
function query(params: Record<string, string | undefined>): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value) search.set(key, value);
});
const result = search.toString();
return result ? `?${result}` : "";
}
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${query({ range })}`),
referrals: (range: string) =>
request<ReferralOverview>(`/referrals${query({ range })}`),
users: (search = "", cursor?: string) =>
request<PageResult<UserSummary>>(
`/users${query({ q: search.trim(), cursor })}`,
),
user: (userId: string) =>
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
latestLedger: (cursor?: string) =>
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
ledger: (userId: string, cursor?: string) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
),
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 },
}),
auditLogs: (cursor?: string) =>
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`),
operators: (cursor?: string) =>
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`),
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" },
),
};