75c046d91d
Expose a paginated global ledger timeline and load it automatically so operators can see recent credit activity without first locating a user.
236 lines
7.1 KiB
TypeScript
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" },
|
|
),
|
|
};
|