Add admin dashboard filtering and sorting
Provide stable server-side list queries and focused chart controls so operators can inspect large datasets without misleading partial-page ordering.
This commit is contained in:
+41
-19
@@ -4,16 +4,21 @@ import type {
|
||||
AdminOperatorProvisioning,
|
||||
AdminSecuritySummary,
|
||||
AdminLoginResponse,
|
||||
AuditQuery,
|
||||
AuditLogEntry,
|
||||
CreditGrantRequest,
|
||||
CreditGrantResponse,
|
||||
LedgerQuery,
|
||||
LedgerEntry,
|
||||
OperatorsQuery,
|
||||
Overview,
|
||||
PageResult,
|
||||
ProductAnalyticsOverview,
|
||||
ReferralsQuery,
|
||||
ReferralOverview,
|
||||
SessionResponse,
|
||||
UserDetail,
|
||||
UsersQuery,
|
||||
UserSummary,
|
||||
} from "./types";
|
||||
|
||||
@@ -140,15 +145,21 @@ async function request<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function query(params: Record<string, string | undefined>): string {
|
||||
export function encodeQuery<T extends object>(params: T): string {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) search.set(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 };
|
||||
|
||||
export const adminApi = {
|
||||
session: () => request<SessionResponse>("/auth/session"),
|
||||
|
||||
@@ -161,28 +172,35 @@ export const adminApi = {
|
||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||
|
||||
overview: (range: string) =>
|
||||
request<Overview>(`/overview${query({ range })}`),
|
||||
request<Overview>(`/overview${encodeQuery({ range })}`),
|
||||
|
||||
referrals: (range: string) =>
|
||||
request<ReferralOverview>(`/referrals${query({ 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${query({ range })}`),
|
||||
request<ProductAnalyticsOverview>(`/analytics${encodeQuery({ range })}`),
|
||||
|
||||
users: (search = "", cursor?: string) =>
|
||||
request<PageResult<UserSummary>>(
|
||||
`/users${query({ q: search.trim(), cursor })}`,
|
||||
),
|
||||
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: (cursor?: string) =>
|
||||
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
|
||||
|
||||
ledger: (userId: string, cursor?: string) =>
|
||||
latestLedger: (value?: string | LedgerQuery) =>
|
||||
request<PageResult<LedgerEntry>>(
|
||||
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
|
||||
`/credits/ledger${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
ledger: (userId: string, value?: string | LedgerQuery) =>
|
||||
request<PageResult<LedgerEntry>>(
|
||||
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
grantCredits: (payload: CreditGrantRequest) =>
|
||||
@@ -196,11 +214,15 @@ export const adminApi = {
|
||||
headers: { "Idempotency-Key": payload.idempotencyKey },
|
||||
}),
|
||||
|
||||
auditLogs: (cursor?: string) =>
|
||||
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`),
|
||||
auditLogs: (value?: string | AuditQuery) =>
|
||||
request<PageResult<AuditLogEntry>>(
|
||||
`/audit${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
operators: (cursor?: string) =>
|
||||
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`),
|
||||
operators: (value?: string | OperatorsQuery) =>
|
||||
request<PageResult<AdminOperator>>(
|
||||
`/operators${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
operatorSummary: () =>
|
||||
request<AdminSecuritySummary>("/operators/summary"),
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST";
|
||||
|
||||
export type SortOrder = "asc" | "desc";
|
||||
export type AdminAuditAction =
|
||||
| "LOGIN_SUCCEEDED"
|
||||
| "LOGIN_FAILED"
|
||||
| "SESSION_REVOKED"
|
||||
| "OPERATOR_CREATED"
|
||||
| "OPERATOR_ENABLED"
|
||||
| "OPERATOR_DISABLED"
|
||||
| "OPERATOR_UNLOCKED"
|
||||
| "OPERATOR_CREDENTIALS_RESET"
|
||||
| "OPERATOR_SESSIONS_REVOKED"
|
||||
| "MANUAL_CREDIT_GRANTED";
|
||||
|
||||
export interface CursorPageQuery {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
from?: string;
|
||||
until?: string;
|
||||
sort?: "createdAt";
|
||||
order?: SortOrder;
|
||||
}
|
||||
|
||||
export interface UsersQuery extends CursorPageQuery {
|
||||
q?: string;
|
||||
status?: UserSummary["status"];
|
||||
}
|
||||
|
||||
export interface LedgerQuery extends CursorPageQuery {
|
||||
type?: LedgerEntryType;
|
||||
}
|
||||
|
||||
export interface AuditQuery extends CursorPageQuery {
|
||||
action?: AdminAuditAction;
|
||||
result?: AuditLogEntry["result"];
|
||||
}
|
||||
|
||||
export interface OperatorsQuery extends Omit<CursorPageQuery, "sort"> {
|
||||
role?: AdminRole;
|
||||
enabled?: boolean;
|
||||
locked?: boolean;
|
||||
sort?: "createdAt" | "username" | "lastLoginAt";
|
||||
}
|
||||
|
||||
export interface ReferralsQuery {
|
||||
range: string;
|
||||
sort?: "invited" | "qualified" | "creditsEarned";
|
||||
order?: SortOrder;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type AuthState =
|
||||
| { status: "anonymous" }
|
||||
| { status: "authenticated"; operatorName: string; role: AdminRole };
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ChartToolbar({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 px-5 py-4"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import type { TrendPoint } from "../../api/types";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
|
||||
export function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
export function TrendChart({
|
||||
points,
|
||||
showRegistrations = true,
|
||||
showCredits = true,
|
||||
}: {
|
||||
points: TrendPoint[];
|
||||
showRegistrations?: boolean;
|
||||
showCredits?: boolean;
|
||||
}) {
|
||||
if (points.length === 0) {
|
||||
return <div className="grid h-full place-items-center text-sm text-muted">暂无趋势数据</div>;
|
||||
}
|
||||
if (!showRegistrations && !showCredits) {
|
||||
return <div className="grid h-full place-items-center text-sm text-muted">请选择至少一个趋势系列</div>;
|
||||
}
|
||||
|
||||
const width = 760;
|
||||
const height = 300;
|
||||
@@ -53,26 +64,38 @@ export function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
className="chart-grid-line"
|
||||
/>
|
||||
))}
|
||||
<text x={paddingX} y={paddingY - 9} className="chart-label">
|
||||
{formatNumber(registrationMax)} 用户
|
||||
</text>
|
||||
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
|
||||
{formatNumber(creditMax)} 积分
|
||||
</text>
|
||||
<polyline points={registrationLine} className="chart-line chart-line--primary" />
|
||||
<polyline points={creditLine} className="chart-line chart-line--violet" />
|
||||
{showRegistrations ? (
|
||||
<text x={paddingX} y={paddingY - 9} className="chart-label">
|
||||
{formatNumber(registrationMax)} 用户
|
||||
</text>
|
||||
) : null}
|
||||
{showCredits ? (
|
||||
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
|
||||
{formatNumber(creditMax)} 积分
|
||||
</text>
|
||||
) : null}
|
||||
{showRegistrations ? (
|
||||
<polyline points={registrationLine} className="chart-line chart-line--primary" />
|
||||
) : null}
|
||||
{showCredits ? (
|
||||
<polyline points={creditLine} className="chart-line chart-line--violet" />
|
||||
) : null}
|
||||
{coordinates.map(({ point, x, registrationY, creditY }, index) => (
|
||||
<g key={point.date}>
|
||||
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
|
||||
<title>
|
||||
{point.date}:新增 {formatNumber(point.registrations)} 位用户
|
||||
</title>
|
||||
</circle>
|
||||
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
|
||||
<title>
|
||||
{point.date}:消耗 {formatNumber(point.creditsUsed)} 积分
|
||||
</title>
|
||||
</circle>
|
||||
{showRegistrations ? (
|
||||
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
|
||||
<title>
|
||||
{point.date}:新增 {formatNumber(point.registrations)} 位用户
|
||||
</title>
|
||||
</circle>
|
||||
) : null}
|
||||
{showCredits ? (
|
||||
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
|
||||
<title>
|
||||
{point.date}:消耗 {formatNumber(point.creditsUsed)} 积分
|
||||
</title>
|
||||
</circle>
|
||||
) : null}
|
||||
{index % labelEvery === 0 || index === points.length - 1 ? (
|
||||
<text
|
||||
x={x}
|
||||
@@ -92,16 +115,16 @@ export function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">UTC 日期</th>
|
||||
<th scope="col">新增用户</th>
|
||||
<th scope="col">消耗积分</th>
|
||||
{showRegistrations ? <th scope="col">新增用户</th> : null}
|
||||
{showCredits ? <th scope="col">消耗积分</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{points.map((point) => (
|
||||
<tr key={point.date}>
|
||||
<td>{point.date}</td>
|
||||
<td>{formatNumber(point.registrations)}</td>
|
||||
<td>{formatNumber(point.creditsUsed)}</td>
|
||||
{showRegistrations ? <td>{formatNumber(point.registrations)}</td> : null}
|
||||
{showCredits ? <td>{formatNumber(point.creditsUsed)}</td> : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
type LegacyColumnDef,
|
||||
useLegacyTable,
|
||||
} from "@tanstack/react-table/legacy";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
import type { SortOrder } from "../api/types";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EmptyState } from "./primitives";
|
||||
|
||||
@@ -18,6 +20,9 @@ export function DataTable<T extends RowData>({
|
||||
emptyTitle = "暂无数据",
|
||||
footer,
|
||||
className,
|
||||
sort,
|
||||
sortableColumns,
|
||||
onSortChange,
|
||||
}: {
|
||||
data: T[];
|
||||
columns: DataColumn<T>[];
|
||||
@@ -25,6 +30,9 @@ export function DataTable<T extends RowData>({
|
||||
emptyTitle?: string;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
sort?: { key: string; order: SortOrder };
|
||||
sortableColumns?: Record<string, string>;
|
||||
onSortChange?: (key: string, order: SortOrder) => void;
|
||||
}) {
|
||||
const table = useLegacyTable({
|
||||
data,
|
||||
@@ -42,16 +50,50 @@ export function DataTable<T extends RowData>({
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
{headerGroup.headers.map((header) => {
|
||||
const sortKey = sortableColumns?.[header.column.id];
|
||||
const active = sortKey != null && sort?.key === sortKey;
|
||||
const ariaSort = active
|
||||
? sort.order === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: sortKey
|
||||
? "none"
|
||||
: undefined;
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
|
||||
aria-sort={ariaSort}
|
||||
>
|
||||
{header.isPlaceholder ? null : sortKey && onSortChange ? (
|
||||
<button
|
||||
className="inline-flex min-h-8 items-center gap-1.5 rounded-lg px-1 text-left transition hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSortChange(
|
||||
sortKey,
|
||||
active && sort.order === "desc" ? "asc" : "desc",
|
||||
)
|
||||
}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{active ? (
|
||||
sort.order === "asc" ? (
|
||||
<ArrowUp className="size-3.5" aria-hidden />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" aria-hidden />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 opacity-60" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface DateRangeValue {
|
||||
from?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
function datePart(value?: string, exclusiveEnd = false): string {
|
||||
if (!value) return "";
|
||||
if (!exclusiveEnd) return value.slice(0, 10);
|
||||
const date = new Date(value);
|
||||
date.setUTCDate(date.getUTCDate() - 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function utcDate(value: string, exclusiveEnd: boolean): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
if (exclusiveEnd) date.setUTCDate(date.getUTCDate() + 1);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function DateRangeControl({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
|
||||
<legend className="sr-only">日期范围</legend>
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
type="date"
|
||||
value={datePart(value.from)}
|
||||
max={datePart(value.until, true) || undefined}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, from: utcDate(event.target.value, false) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
type="date"
|
||||
value={datePart(value.until, true)}
|
||||
min={datePart(value.from) || undefined}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, until: utcDate(event.target.value, true) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface FilterOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function FilterControl<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly FilterOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value as T)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToggleFilter({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-h-9 cursor-pointer items-center gap-2 rounded-lg border border-border bg-input px-3 text-xs font-semibold text-foreground">
|
||||
<input
|
||||
className="size-4 accent-primary"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SortOrder } from "../api/types";
|
||||
|
||||
export interface SortOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SortControl<T extends string>({
|
||||
value,
|
||||
order,
|
||||
options,
|
||||
onChange,
|
||||
label = "排序",
|
||||
}: {
|
||||
value: T;
|
||||
order: SortOrder;
|
||||
options: readonly SortOption<T>[];
|
||||
onChange: (value: T, order: SortOrder) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
|
||||
<legend className="sr-only">{label}</legend>
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value as T, order)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
||||
{(["desc", "asc"] as const).map((direction) => (
|
||||
<button
|
||||
key={direction}
|
||||
className={`min-h-7 rounded-lg px-3 text-xs font-semibold transition ${
|
||||
order === direction
|
||||
? "bg-surface text-foreground shadow-sm"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
type="button"
|
||||
aria-pressed={order === direction}
|
||||
onClick={() => onChange(value, direction)}
|
||||
>
|
||||
{direction === "desc" ? "降序" : "升序"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
export function TableToolbar({
|
||||
children,
|
||||
active,
|
||||
onClear,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
active: boolean;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 p-4 sm:p-5"
|
||||
aria-label="表格筛选与排序"
|
||||
>
|
||||
{children}
|
||||
<Button
|
||||
className="sm:ml-auto"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
disabled={!active}
|
||||
onClick={onClear}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,27 +9,47 @@ import {
|
||||
Target,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { AnalyticsRate, ProductAnalyticsOverview } from "../../api/types";
|
||||
import type {
|
||||
AnalyticsRate,
|
||||
ProductAnalyticsOverview,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { CohortHeatmap } from "../../components/charts/cohort-heatmap";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||
import { FilterControl, ToggleFilter } from "../../components/filter-control";
|
||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
import { stableSort } from "../../lib/sort";
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [data, setData] = useState<ProductAnalyticsOverview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [executionMode, setExecutionMode] = useState("");
|
||||
const [aiSort, setAiSort] = useState<"successes" | "users">("successes");
|
||||
const [aiOrder, setAiOrder] = useState<SortOrder>("desc");
|
||||
const [channelSort, setChannelSort] = useState<"installs" | "activated" | "rate">("installs");
|
||||
const [channelOrder, setChannelOrder] = useState<SortOrder>("desc");
|
||||
const [hideUnknown, setHideUnknown] = useState(false);
|
||||
const [cohortOrder, setCohortOrder] = useState<SortOrder>("desc");
|
||||
const [minimumCohortSize, setMinimumCohortSize] = useState(0);
|
||||
const [matureOnly, setMatureOnly] = useState(false);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
try {
|
||||
setData(await adminApi.productAnalytics(range));
|
||||
const response = await adminApi.productAnalytics(range);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
@@ -37,6 +57,47 @@ export function AnalyticsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const visibleAiFeatures = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.aiFeatures ?? []).filter(
|
||||
(item) => !executionMode || item.executionMode === executionMode,
|
||||
),
|
||||
(item) => (aiSort === "successes" ? item.successes : item.users),
|
||||
aiOrder,
|
||||
),
|
||||
[aiOrder, aiSort, data?.aiFeatures, executionMode],
|
||||
);
|
||||
const visibleChannels = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.growth.channels ?? []).filter(
|
||||
(channel) => !hideUnknown || channel.channel !== "UNKNOWN",
|
||||
),
|
||||
(channel) =>
|
||||
channelSort === "installs"
|
||||
? channel.installations
|
||||
: channelSort === "activated"
|
||||
? channel.activated
|
||||
: channel.activationRate.percent ?? -1,
|
||||
channelOrder,
|
||||
),
|
||||
[channelOrder, channelSort, data?.growth.channels, hideUnknown],
|
||||
);
|
||||
const visibleCohorts = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.retention ?? []).filter(
|
||||
(cohort) =>
|
||||
cohort.size >= minimumCohortSize &&
|
||||
(!matureOnly || cohort.d30?.percent != null),
|
||||
),
|
||||
(cohort) => cohort.cohortDate,
|
||||
cohortOrder,
|
||||
),
|
||||
[cohortOrder, data?.retention, matureOnly, minimumCohortSize],
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||
if (!data) return <LoadingState label="加载产品数据" />;
|
||||
|
||||
@@ -100,7 +161,28 @@ export function AnalyticsPage() {
|
||||
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
||||
icon={ChartNoAxesCombined}
|
||||
/>
|
||||
<CohortHeatmap cohorts={data.retention} />
|
||||
<ChartToolbar label="留存 cohort 筛选与排序">
|
||||
<SortControl
|
||||
label="激活日期"
|
||||
value="date"
|
||||
order={cohortOrder}
|
||||
options={[{ value: "date", label: "激活日期" }]}
|
||||
onChange={(_, order) => setCohortOrder(order)}
|
||||
/>
|
||||
<FilterControl
|
||||
label="最小样本量"
|
||||
value={String(minimumCohortSize)}
|
||||
options={[
|
||||
{ value: "0", label: "不限" },
|
||||
{ value: "10", label: "至少 10 人" },
|
||||
{ value: "50", label: "至少 50 人" },
|
||||
{ value: "100", label: "至少 100 人" },
|
||||
]}
|
||||
onChange={(value) => setMinimumCohortSize(Number(value))}
|
||||
/>
|
||||
<ToggleFilter label="仅显示 D30 已成熟" checked={matureOnly} onChange={setMatureOnly} />
|
||||
</ChartToolbar>
|
||||
<CohortHeatmap cohorts={visibleCohorts} />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -111,13 +193,36 @@ export function AnalyticsPage() {
|
||||
description="客户端功能与执行模式,仅包含白名单元数据"
|
||||
icon={BrainCircuit}
|
||||
/>
|
||||
{data.aiFeatures.length === 0 ? (
|
||||
<p className="p-8 text-center text-sm text-muted">等待客户端接入事件后显示</p>
|
||||
<ChartToolbar label="AI 使用结构筛选与排序">
|
||||
<FilterControl
|
||||
label="执行模式"
|
||||
value={executionMode}
|
||||
options={[
|
||||
{ value: "", label: "全部模式" },
|
||||
{ value: "MANAGED", label: "托管" },
|
||||
{ value: "LOCAL", label: "本地" },
|
||||
{ value: "BYOK", label: "BYOK" },
|
||||
]}
|
||||
onChange={setExecutionMode}
|
||||
/>
|
||||
<SortControl
|
||||
value={aiSort}
|
||||
order={aiOrder}
|
||||
options={[
|
||||
{ value: "successes", label: "成功次数" },
|
||||
{ value: "users", label: "成功用户" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setAiSort(value);
|
||||
setAiOrder(order);
|
||||
}}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
{visibleAiFeatures.length === 0 ? (
|
||||
<p className="p-8 text-center text-sm text-muted">当前筛选条件下暂无 AI 使用数据</p>
|
||||
) : (
|
||||
<ComparisonBarChart
|
||||
items={[...data.aiFeatures]
|
||||
.sort((left, right) => right.successes - left.successes)
|
||||
.map((item) => ({
|
||||
items={visibleAiFeatures.map((item) => ({
|
||||
id: `${item.feature}-${item.executionMode}`,
|
||||
label: featureLabel(item.feature),
|
||||
value: item.successes,
|
||||
@@ -227,9 +332,25 @@ export function AnalyticsPage() {
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
||||
<ChartToolbar label="渠道质量筛选与排序">
|
||||
<SortControl
|
||||
value={channelSort}
|
||||
order={channelOrder}
|
||||
options={[
|
||||
{ value: "installs", label: "新增安装" },
|
||||
{ value: "activated", label: "激活人数" },
|
||||
{ value: "rate", label: "激活率" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setChannelSort(value);
|
||||
setChannelOrder(order);
|
||||
}}
|
||||
/>
|
||||
<ToggleFilter label="隐藏未知来源" checked={hideUnknown} onChange={setHideUnknown} />
|
||||
</ChartToolbar>
|
||||
<div className="grid gap-0 xl:grid-cols-[1fr_260px]">
|
||||
<ComparisonBarChart
|
||||
items={data.growth.channels.map((channel) => ({
|
||||
items={visibleChannels.map((channel) => ({
|
||||
label: channelLabel(channel.channel),
|
||||
value: channel.installations,
|
||||
secondaryValue: channel.activated,
|
||||
@@ -237,7 +358,7 @@ export function AnalyticsPage() {
|
||||
}))}
|
||||
primaryLabel="新增安装"
|
||||
secondaryLabel="24 小时激活"
|
||||
emptyText="暂无渠道归因数据"
|
||||
emptyText="当前筛选条件下暂无渠道归因数据"
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { FileCheck2, ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type { AuditLogEntry } from "../../api/types";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type {
|
||||
AdminAuditAction,
|
||||
AuditLogEntry,
|
||||
AuditQuery,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { DateRangeControl } from "../../components/date-range-control";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -12,48 +19,56 @@ import {
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import { formatDateTime, statusLabel } from "../../lib/format";
|
||||
|
||||
export function AuditPage() {
|
||||
const [items, setItems] = useState<AuditLogEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const page = await adminApi.auditLogs();
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [action, setAction] = useState<"" | AdminAuditAction>(
|
||||
(searchParams.get("action") as AdminAuditAction | null) ?? "",
|
||||
);
|
||||
const [result, setResult] = useState<"" | AuditLogEntry["result"]>(
|
||||
(searchParams.get("result") as AuditLogEntry["result"] | null) ?? "",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const auditQuery = useMemo<AuditQuery>(
|
||||
() => ({
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
action: action || undefined,
|
||||
result: result || undefined,
|
||||
sort: "createdAt",
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[action, from, order, result, until],
|
||||
);
|
||||
const fetchAudit = useCallback((value: AuditQuery) => adminApi.auditLogs(value), []);
|
||||
const {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(auditQuery, fetchAudit);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await adminApi.auditLogs(nextCursor);
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(
|
||||
requestError instanceof ApiError ? requestError.message : "加载审计记录失败",
|
||||
);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (action) params.set("action", action);
|
||||
if (result) params.set("result", result);
|
||||
params.set("sort", "createdAt");
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [action, from, order, result, setSearchParams, until]);
|
||||
|
||||
const columns = useMemo<DataColumn<AuditLogEntry>[]>(
|
||||
() => [
|
||||
@@ -140,9 +155,55 @@ export function AuditPage() {
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<TableToolbar
|
||||
active={Boolean(from || until || action || result || order !== "desc")}
|
||||
onClear={() => {
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setAction("");
|
||||
setResult("");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="操作类型"
|
||||
value={action}
|
||||
options={[
|
||||
{ value: "", label: "全部操作" },
|
||||
{ value: "LOGIN_SUCCEEDED", label: "登录成功" },
|
||||
{ value: "LOGIN_FAILED", label: "登录失败" },
|
||||
{ value: "SESSION_REVOKED", label: "会话撤销" },
|
||||
{ value: "OPERATOR_CREATED", label: "创建管理员" },
|
||||
{ value: "OPERATOR_ENABLED", label: "启用管理员" },
|
||||
{ value: "OPERATOR_DISABLED", label: "停用管理员" },
|
||||
{ value: "OPERATOR_UNLOCKED", label: "解锁管理员" },
|
||||
{ value: "OPERATOR_CREDENTIALS_RESET", label: "重置管理员凭据" },
|
||||
{ value: "OPERATOR_SESSIONS_REVOKED", label: "撤销管理员会话" },
|
||||
{ value: "MANUAL_CREDIT_GRANTED", label: "人工赠送积分" },
|
||||
]}
|
||||
onChange={setAction}
|
||||
/>
|
||||
<FilterControl
|
||||
label="执行结果"
|
||||
value={result}
|
||||
options={[
|
||||
{ value: "", label: "全部结果" },
|
||||
{ value: "success", label: "成功" },
|
||||
{ value: "rejected", label: "已拒绝" },
|
||||
]}
|
||||
onChange={setResult}
|
||||
/>
|
||||
</TableToolbar>
|
||||
{error ? (
|
||||
<div className="p-6">
|
||||
<ErrorState error={error} retry={() => void load()} />
|
||||
<ErrorState error={error} retry={reload} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载审计日志" />
|
||||
@@ -151,7 +212,14 @@ export function AuditPage() {
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption="管理员审计事件"
|
||||
emptyTitle="暂无审计记录"
|
||||
emptyTitle={
|
||||
from || until || action || result
|
||||
? "没有符合当前筛选条件的审计记录"
|
||||
: "暂无审计记录"
|
||||
}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ArrowDownUp, Coins, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type { LedgerEntry } from "../../api/types";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { LedgerEntry, LedgerEntryType, LedgerQuery, SortOrder } from "../../api/types";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { DateRangeControl } from "../../components/date-range-control";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -12,6 +15,8 @@ import {
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatNumber,
|
||||
@@ -19,58 +24,63 @@ import {
|
||||
statusLabel,
|
||||
usageTypeLabel,
|
||||
} from "../../lib/format";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function CreditsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeUserId, setActiveUserId] = useState<string>();
|
||||
const [items, setItems] = useState<LedgerEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const load = useCallback(async (userId?: string) => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const page = userId
|
||||
? await adminApi.ledger(userId)
|
||||
: await adminApi.latestLedger();
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialUserId = searchParams.get("userId") ?? "";
|
||||
const [query, setQuery] = useState(initialUserId);
|
||||
const [activeUserId, setActiveUserId] = useState(initialUserId);
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [type, setType] = useState<"" | LedgerEntryType>(
|
||||
(searchParams.get("type") as LedgerEntryType | null) ?? "",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const ledgerQuery = useMemo<LedgerQuery>(
|
||||
() => ({
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
type: type || undefined,
|
||||
sort: "createdAt",
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[from, order, type, until],
|
||||
);
|
||||
const fetchLedger = useCallback(
|
||||
(value: LedgerQuery) =>
|
||||
activeUserId
|
||||
? adminApi.ledger(activeUserId, value)
|
||||
: adminApi.latestLedger(value),
|
||||
[activeUserId],
|
||||
);
|
||||
const {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(ledgerQuery, fetchLedger);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = activeUserId
|
||||
? await adminApi.ledger(activeUserId, nextCursor)
|
||||
: await adminApi.latestLedger(nextCursor);
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(requestError instanceof ApiError ? requestError.message : "加载流水失败");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (activeUserId) params.set("userId", activeUserId);
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (type) params.set("type", type);
|
||||
params.set("sort", "createdAt");
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [activeUserId, from, order, setSearchParams, type, until]);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const userId = query.trim() || undefined;
|
||||
setActiveUserId(userId);
|
||||
void load(userId);
|
||||
setActiveUserId(userId ?? "");
|
||||
}
|
||||
|
||||
const columns = useMemo<DataColumn<LedgerEntry>[]>(
|
||||
@@ -171,6 +181,38 @@ export function CreditsPage() {
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<TableToolbar
|
||||
active={Boolean(activeUserId || from || until || type || order !== "desc")}
|
||||
onClear={() => {
|
||||
setQuery("");
|
||||
setActiveUserId("");
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setType("");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="流水类型"
|
||||
value={type}
|
||||
options={[
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "grant", label: "赠送" },
|
||||
{ value: "reserve", label: "预留" },
|
||||
{ value: "settle", label: "结算" },
|
||||
{ value: "refund", label: "退款" },
|
||||
{ value: "adjustment", label: "调整" },
|
||||
]}
|
||||
onChange={setType}
|
||||
/>
|
||||
</TableToolbar>
|
||||
|
||||
<div className="flex items-center gap-3 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
|
||||
<Coins className="size-4 text-primary" aria-hidden />
|
||||
@@ -186,7 +228,7 @@ export function CreditsPage() {
|
||||
|
||||
{error ? (
|
||||
<div className="p-6">
|
||||
<ErrorState error={error} retry={() => void load(activeUserId)} />
|
||||
<ErrorState error={error} retry={reload} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载积分流水" />
|
||||
@@ -195,7 +237,14 @@ export function CreditsPage() {
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
|
||||
emptyTitle={activeUserId ? "该用户暂无积分流水" : "暂无积分流水"}
|
||||
emptyTitle={
|
||||
activeUserId || from || until || type
|
||||
? "没有符合当前筛选条件的流水"
|
||||
: "暂无积分流水"
|
||||
}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
|
||||
@@ -4,28 +4,39 @@ import {
|
||||
CreditCard,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { Overview } from "../../api/types";
|
||||
import type { Overview, SortOrder } from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { ChartLegend } from "../../components/charts/chart-legend";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||
import { TrendChart } from "../../components/charts/trend-chart";
|
||||
import { ToggleFilter } from "../../components/filter-control";
|
||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber, usageTypeLabel } from "../../lib/format";
|
||||
import { stableSort } from "../../lib/sort";
|
||||
|
||||
export function OverviewPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [data, setData] = useState<Overview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [showRegistrations, setShowRegistrations] = useState(true);
|
||||
const [showCredits, setShowCredits] = useState(true);
|
||||
const [usageSort, setUsageSort] = useState<"credits" | "requests">("credits");
|
||||
const [usageOrder, setUsageOrder] = useState<SortOrder>("desc");
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
try {
|
||||
setData(await adminApi.overview(range));
|
||||
const response = await adminApi.overview(range);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
@@ -33,6 +44,16 @@ export function OverviewPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const sortedUsage = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
data?.usage ?? [],
|
||||
(item) => (usageSort === "credits" ? item.chargedCredits : item.requests),
|
||||
usageOrder,
|
||||
),
|
||||
[data?.usage, usageOrder, usageSort],
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||
if (!data) return <LoadingState label="加载运营总览" />;
|
||||
|
||||
@@ -88,13 +109,29 @@ export function OverviewPage() {
|
||||
</div>
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ label: "新增用户", tone: "primary" },
|
||||
{ label: "积分消耗", tone: "violet" },
|
||||
...(showRegistrations ? [{ label: "新增用户", tone: "primary" as const }] : []),
|
||||
...(showCredits ? [{ label: "积分消耗", tone: "violet" as const }] : []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap gap-2" aria-label="趋势系列显隐">
|
||||
<ToggleFilter
|
||||
label="新增用户"
|
||||
checked={showRegistrations}
|
||||
onChange={setShowRegistrations}
|
||||
/>
|
||||
<ToggleFilter
|
||||
label="积分消耗"
|
||||
checked={showCredits}
|
||||
onChange={setShowCredits}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[320px] w-full">
|
||||
<TrendChart points={data.trend} />
|
||||
<TrendChart
|
||||
points={data.trend}
|
||||
showRegistrations={showRegistrations}
|
||||
showCredits={showCredits}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -129,13 +166,25 @@ export function OverviewPage() {
|
||||
<p className="mt-1 text-xs text-muted">仅聚合计量数据,不包含任何用户内容</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChartToolbar label="使用构成排序">
|
||||
<SortControl
|
||||
value={usageSort}
|
||||
order={usageOrder}
|
||||
options={[
|
||||
{ value: "credits", label: "消耗积分" },
|
||||
{ value: "requests", label: "请求次数" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setUsageSort(value);
|
||||
setUsageOrder(order);
|
||||
}}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
{data.usage.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-muted">当前周期暂无使用记录</div>
|
||||
) : (
|
||||
<ComparisonBarChart
|
||||
items={[...data.usage]
|
||||
.sort((left, right) => right.chargedCredits - left.chargedCredits)
|
||||
.map((item) => ({
|
||||
items={sortedUsage.map((item) => ({
|
||||
label: usageTypeLabel(item.kind),
|
||||
value: item.chargedCredits,
|
||||
hint: `${formatNumber(item.requests)} 次请求`,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { ReferralOverview, ReferralRankingItem } from "../../api/types";
|
||||
import type {
|
||||
ReferralOverview,
|
||||
ReferralRankingItem,
|
||||
ReferralsQuery,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Card,
|
||||
ErrorState,
|
||||
@@ -14,26 +22,51 @@ import {
|
||||
StatCard,
|
||||
} from "../../components/primitives";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
|
||||
export function ReferralsPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [range, setRange] = useState(searchParams.get("range") ?? "30d");
|
||||
const [sort, setSort] = useState<NonNullable<ReferralsQuery["sort"]>>(
|
||||
(searchParams.get("sort") as ReferralsQuery["sort"] | null) ?? "qualified",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const [limit, setLimit] = useState(() => {
|
||||
const value = Number(searchParams.get("limit"));
|
||||
return [20, 50, 100].includes(value) ? value : 20;
|
||||
});
|
||||
const [data, setData] = useState<ReferralOverview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const requestVersion = useRef(0);
|
||||
const query = useMemo<ReferralsQuery>(
|
||||
() => ({ range, sort, order, limit }),
|
||||
[limit, order, range, sort],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
setData(undefined);
|
||||
try {
|
||||
setData(await adminApi.referrals(range));
|
||||
const response = await adminApi.referrals(query);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [range]);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({ range, sort, order, limit: String(limit) });
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [limit, order, range, setSearchParams, sort]);
|
||||
|
||||
const columns = useMemo<DataColumn<ReferralRankingItem>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -117,6 +150,35 @@ export function ReferralsPage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<ChartToolbar label="邀请排行设置">
|
||||
<SortControl
|
||||
label="排行指标"
|
||||
value={sort}
|
||||
order={order}
|
||||
options={[
|
||||
{ value: "invited", label: "邀请人数" },
|
||||
{ value: "qualified", label: "有效邀请" },
|
||||
{ value: "creditsEarned", label: "奖励积分" },
|
||||
]}
|
||||
onChange={(value, nextOrder) => {
|
||||
setSort(value);
|
||||
setOrder(nextOrder);
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="排行数量"
|
||||
value={String(limit)}
|
||||
options={[
|
||||
{ value: "20", label: "前 20 名" },
|
||||
{ value: "50", label: "前 50 名" },
|
||||
{ value: "100", label: "前 100 名" },
|
||||
]}
|
||||
onChange={(value) => setLimit(Number(value))}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border p-5 sm:p-6">
|
||||
@@ -180,6 +242,16 @@ export function ReferralsPage() {
|
||||
columns={columns}
|
||||
caption="有效邀请用户排行"
|
||||
emptyTitle="当前周期暂无排行数据"
|
||||
sort={{ key: sort, order }}
|
||||
sortableColumns={{
|
||||
invited: "invited",
|
||||
qualified: "qualified",
|
||||
creditsEarned: "creditsEarned",
|
||||
}}
|
||||
onSortChange={(key, nextOrder) => {
|
||||
setSort(key as NonNullable<ReferralsQuery["sort"]>);
|
||||
setOrder(nextOrder);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
@@ -25,8 +26,12 @@ import type {
|
||||
AdminOperatorProvisioning,
|
||||
AdminRole,
|
||||
AdminSecuritySummary,
|
||||
OperatorsQuery,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { DateRangeControl } from "../../components/date-range-control";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -38,6 +43,8 @@ import {
|
||||
PageHeader,
|
||||
StatCard,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import { formatDateTime, formatNumber } from "../../lib/format";
|
||||
import { useAuth } from "../auth/auth-context";
|
||||
|
||||
@@ -51,12 +58,22 @@ interface ConfirmationState {
|
||||
export function SecurityPage() {
|
||||
const { auth } = useAuth();
|
||||
const currentUsername = auth.status === "authenticated" ? auth.operatorName : "";
|
||||
const [operators, setOperators] = useState<AdminOperator[]>([]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [summary, setSummary] = useState<AdminSecuritySummary>();
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [summaryError, setSummaryError] = useState<unknown>();
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [role, setRole] = useState<"" | AdminRole>(
|
||||
(searchParams.get("role") as AdminRole | null) ?? "",
|
||||
);
|
||||
const [enabled, setEnabled] = useState(searchParams.get("enabled") ?? "");
|
||||
const [locked, setLocked] = useState(searchParams.get("locked") ?? "");
|
||||
const [sort, setSort] = useState<NonNullable<OperatorsQuery["sort"]>>(
|
||||
(searchParams.get("sort") as OperatorsQuery["sort"] | null) ?? "createdAt",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [resetOperator, setResetOperator] = useState<AdminOperator>();
|
||||
const [confirmation, setConfirmation] = useState<ConfirmationState>();
|
||||
@@ -64,44 +81,60 @@ export function SecurityPage() {
|
||||
data: AdminOperatorProvisioning;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
const operatorsQuery = useMemo<OperatorsQuery>(
|
||||
() => ({
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
role: role || undefined,
|
||||
enabled: enabled === "" ? undefined : enabled === "true",
|
||||
locked: locked === "" ? undefined : locked === "true",
|
||||
sort,
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[enabled, from, locked, order, role, sort, until],
|
||||
);
|
||||
const fetchOperators = useCallback(
|
||||
(value: OperatorsQuery) => adminApi.operators(value),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
items: operators,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(operatorsQuery, fetchOperators);
|
||||
const loadSummary = useCallback(async () => {
|
||||
setSummaryError(undefined);
|
||||
try {
|
||||
const [page, securitySummary] = await Promise.all([
|
||||
adminApi.operators(),
|
||||
adminApi.operatorSummary(),
|
||||
]);
|
||||
setOperators(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
setSummary(securitySummary);
|
||||
setSummary(await adminApi.operatorSummary());
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSummaryError(requestError);
|
||||
}
|
||||
}, []);
|
||||
const load = useCallback(async () => {
|
||||
reload();
|
||||
await loadSummary();
|
||||
}, [loadSummary, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
void loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await adminApi.operators(nextCursor);
|
||||
setOperators((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(
|
||||
requestError instanceof ApiError ? requestError.message : "加载管理员失败",
|
||||
);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (role) params.set("role", role);
|
||||
if (enabled) params.set("enabled", enabled);
|
||||
if (locked) params.set("locked", locked);
|
||||
params.set("sort", sort);
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [enabled, from, locked, order, role, setSearchParams, sort, until]);
|
||||
|
||||
async function handleConfirmedAction() {
|
||||
if (!confirmation) return;
|
||||
@@ -129,7 +162,7 @@ export function SecurityPage() {
|
||||
const columns = useMemo<DataColumn<AdminOperator>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "operator",
|
||||
id: "username",
|
||||
header: "管理员",
|
||||
cell: ({ row }) => {
|
||||
const current = row.original.username === currentUsername;
|
||||
@@ -177,6 +210,15 @@ export function SecurityPage() {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "创建时间",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted">
|
||||
{formatDateTime(String(getValue()))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
@@ -193,7 +235,9 @@ export function SecurityPage() {
|
||||
[currentUsername],
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||
if (error || summaryError) {
|
||||
return <ErrorState error={error ?? summaryError} retry={() => void load()} />;
|
||||
}
|
||||
if (loading || !summary) return <LoadingState label="加载安全中心" />;
|
||||
|
||||
return (
|
||||
@@ -258,11 +302,84 @@ export function SecurityPage() {
|
||||
</div>
|
||||
<KeyRound className="size-5 text-muted" aria-hidden />
|
||||
</div>
|
||||
<TableToolbar
|
||||
active={Boolean(
|
||||
from ||
|
||||
until ||
|
||||
role ||
|
||||
enabled ||
|
||||
locked ||
|
||||
sort !== "createdAt" ||
|
||||
order !== "desc",
|
||||
)}
|
||||
onClear={() => {
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setRole("");
|
||||
setEnabled("");
|
||||
setLocked("");
|
||||
setSort("createdAt");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="角色"
|
||||
value={role}
|
||||
options={[
|
||||
{ value: "", label: "全部角色" },
|
||||
{ value: "SUPER_ADMIN", label: "超级管理员" },
|
||||
{ value: "SUPPORT", label: "支持人员" },
|
||||
{ value: "ANALYST", label: "分析员" },
|
||||
]}
|
||||
onChange={setRole}
|
||||
/>
|
||||
<FilterControl
|
||||
label="启用状态"
|
||||
value={enabled}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "true", label: "已启用" },
|
||||
{ value: "false", label: "已停用" },
|
||||
]}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
<FilterControl
|
||||
label="锁定状态"
|
||||
value={locked}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "true", label: "已锁定" },
|
||||
{ value: "false", label: "未锁定" },
|
||||
]}
|
||||
onChange={setLocked}
|
||||
/>
|
||||
</TableToolbar>
|
||||
<DataTable
|
||||
data={operators}
|
||||
columns={columns}
|
||||
caption="管理员账户与安全状态"
|
||||
emptyTitle="暂无管理员账户"
|
||||
emptyTitle={
|
||||
from || until || role || enabled || locked
|
||||
? "没有符合当前筛选条件的管理员"
|
||||
: "暂无管理员账户"
|
||||
}
|
||||
sort={{ key: sort, order }}
|
||||
sortableColumns={{
|
||||
username: "username",
|
||||
lastLoginAt: "lastLoginAt",
|
||||
createdAt: "createdAt",
|
||||
}}
|
||||
onSortChange={(key, nextOrder) => {
|
||||
setSort(key as NonNullable<OperatorsQuery["sort"]>);
|
||||
setOrder(nextOrder);
|
||||
}}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
|
||||
@@ -11,18 +11,26 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
LedgerEntryType,
|
||||
LedgerQuery,
|
||||
LedgerEntry,
|
||||
SortOrder,
|
||||
UserDetail,
|
||||
UsersQuery,
|
||||
UserSummary,
|
||||
UserUsageAggregate,
|
||||
} from "../../api/types";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { DateRangeControl } from "../../components/date-range-control";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -34,6 +42,8 @@ import {
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
formatDateTime,
|
||||
@@ -44,6 +54,15 @@ import {
|
||||
} from "../../lib/format";
|
||||
import { useAuth } from "../auth/auth-context";
|
||||
|
||||
const ledgerTypeOptions: Array<{ value: "" | LedgerEntryType; label: string }> = [
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "grant", label: "赠送" },
|
||||
{ value: "reserve", label: "预留" },
|
||||
{ value: "settle", label: "结算" },
|
||||
{ value: "refund", label: "退款" },
|
||||
{ value: "adjustment", label: "调整" },
|
||||
];
|
||||
|
||||
export function UsersPage() {
|
||||
const { auth } = useAuth();
|
||||
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
|
||||
@@ -61,51 +80,56 @@ export function UsersPage() {
|
||||
}
|
||||
|
||||
function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeQuery, setActiveQuery] = useState("");
|
||||
const [items, setItems] = useState<UserSummary[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const load = useCallback(async (search = "") => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const page = await adminApi.users(search);
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialQuery = searchParams.get("q") ?? "";
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [activeQuery, setActiveQuery] = useState(initialQuery);
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [status, setStatus] = useState<"" | UserSummary["status"]>(
|
||||
(searchParams.get("status") as UserSummary["status"] | null) ?? "",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const usersQuery = useMemo<UsersQuery>(
|
||||
() => ({
|
||||
q: activeQuery || undefined,
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
status: status || undefined,
|
||||
sort: "createdAt",
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[activeQuery, from, order, status, until],
|
||||
);
|
||||
const fetchUsers = useCallback((value: UsersQuery) => adminApi.users(value), []);
|
||||
const {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(usersQuery, fetchUsers);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
const params = new URLSearchParams();
|
||||
if (activeQuery) params.set("q", activeQuery);
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (status) params.set("status", status);
|
||||
params.set("sort", "createdAt");
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [activeQuery, from, order, setSearchParams, status, until]);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const search = query.trim();
|
||||
setActiveQuery(search);
|
||||
void load(search);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await adminApi.users(activeQuery, nextCursor);
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
toast.error(requestError instanceof ApiError ? requestError.message : "加载用户失败");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataColumn<UserSummary>[]>(
|
||||
@@ -201,6 +225,35 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<TableToolbar
|
||||
active={Boolean(activeQuery || from || until || status || order !== "desc")}
|
||||
onClear={() => {
|
||||
setQuery("");
|
||||
setActiveQuery("");
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setStatus("");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="账户状态"
|
||||
value={status}
|
||||
options={[
|
||||
{ value: "", label: "全部状态" },
|
||||
{ value: "active", label: "正常" },
|
||||
{ value: "suspended", label: "已暂停" },
|
||||
]}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
</TableToolbar>
|
||||
<div className="flex items-center gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
|
||||
<Users className="size-4 text-primary" aria-hidden />
|
||||
{activeQuery ? "查询结果" : "全部用户"}
|
||||
@@ -209,7 +262,7 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="p-6">
|
||||
<ErrorState error={error} retry={() => void load(activeQuery)} />
|
||||
<ErrorState error={error} retry={reload} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载用户列表" />
|
||||
@@ -218,7 +271,14 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption={activeQuery ? "用户查询结果" : "全部用户"}
|
||||
emptyTitle="未找到匹配用户"
|
||||
emptyTitle={
|
||||
activeQuery || from || until || status
|
||||
? "没有符合当前筛选条件的用户"
|
||||
: "暂无用户"
|
||||
}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
@@ -244,6 +304,7 @@ function UserDetailView({
|
||||
canGrant: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [user, setUser] = useState<UserDetail>();
|
||||
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
@@ -251,34 +312,75 @@ function UserDetailView({
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
const [from, setFrom] = useState(searchParams.get("ledgerFrom") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("ledgerUntil") ?? "");
|
||||
const [type, setType] = useState<"" | LedgerEntryType>(
|
||||
(searchParams.get("ledgerType") as LedgerEntryType | null) ?? "",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("ledgerOrder") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const requestVersion = useRef(0);
|
||||
const ledgerQuery = useMemo<LedgerQuery>(
|
||||
() => ({
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
type: type || undefined,
|
||||
sort: "createdAt",
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[from, order, type, until],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setLedger([]);
|
||||
setNextCursor(undefined);
|
||||
setLoading(true);
|
||||
setLoadingMore(false);
|
||||
setError(undefined);
|
||||
try {
|
||||
const [detail, page] = await Promise.all([
|
||||
adminApi.user(userId),
|
||||
adminApi.ledger(userId),
|
||||
adminApi.ledger(userId, ledgerQuery),
|
||||
]);
|
||||
if (requestVersion.current !== version) return;
|
||||
setUser(detail);
|
||||
setLedger(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requestVersion.current === version) setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
}, [ledgerQuery, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParams((current) => {
|
||||
const params = new URLSearchParams(current);
|
||||
["ledgerFrom", "ledgerUntil", "ledgerType", "ledgerOrder"].forEach((key) =>
|
||||
params.delete(key),
|
||||
);
|
||||
if (from) params.set("ledgerFrom", from);
|
||||
if (until) params.set("ledgerUntil", until);
|
||||
if (type) params.set("ledgerType", type);
|
||||
params.set("ledgerOrder", order);
|
||||
return params;
|
||||
}, { replace: true });
|
||||
}, [from, order, setSearchParams, type, until]);
|
||||
|
||||
async function loadMoreLedger() {
|
||||
if (!nextCursor) return;
|
||||
const version = requestVersion.current;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await adminApi.ledger(userId, nextCursor);
|
||||
const page = await adminApi.ledger(userId, { ...ledgerQuery, cursor: nextCursor });
|
||||
if (requestVersion.current !== version) return;
|
||||
setLedger((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
@@ -286,7 +388,7 @@ function UserDetailView({
|
||||
requestError instanceof ApiError ? requestError.message : "加载积分流水失败",
|
||||
);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
if (requestVersion.current === version) setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,11 +472,37 @@ function UserDetailView({
|
||||
title="积分流水"
|
||||
description="所有变动均来自不可变账本"
|
||||
/>
|
||||
<TableToolbar
|
||||
active={Boolean(from || until || type || order !== "desc")}
|
||||
onClear={() => {
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setType("");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="流水类型"
|
||||
value={type}
|
||||
options={ledgerTypeOptions}
|
||||
onChange={setType}
|
||||
/>
|
||||
</TableToolbar>
|
||||
<DataTable
|
||||
data={ledger}
|
||||
columns={ledgerColumns}
|
||||
caption={`${user.displayName || user.userId} 的积分流水`}
|
||||
emptyTitle="暂无积分流水"
|
||||
emptyTitle={from || until || type ? "没有符合当前筛选条件的流水" : "暂无积分流水"}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PageResult } from "../api/types";
|
||||
|
||||
export function useCursorPage<T, Q extends { cursor?: string }>(
|
||||
query: Q,
|
||||
fetchPage: (query: Q) => Promise<PageResult<T>>,
|
||||
) {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const version = ++requestVersion.current;
|
||||
setItems([]);
|
||||
setNextCursor(undefined);
|
||||
setLoading(true);
|
||||
setLoadingMore(false);
|
||||
setError(undefined);
|
||||
void fetchPage(query)
|
||||
.then((page) => {
|
||||
if (requestVersion.current !== version) return;
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
})
|
||||
.catch((requestError) => {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestVersion.current === version) setLoading(false);
|
||||
});
|
||||
}, [fetchPage, query, refreshKey]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
const version = requestVersion.current;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await fetchPage({ ...query, cursor: nextCursor });
|
||||
if (requestVersion.current !== version) return;
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
} finally {
|
||||
if (requestVersion.current === version) setLoadingMore(false);
|
||||
}
|
||||
}, [fetchPage, loadingMore, nextCursor, query]);
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload: useCallback(() => setRefreshKey((current) => current + 1), []),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SortOrder } from "../api/types";
|
||||
|
||||
export function stableSort<T>(
|
||||
items: readonly T[],
|
||||
value: (item: T) => number | string,
|
||||
order: SortOrder,
|
||||
): T[] {
|
||||
const direction = order === "asc" ? 1 : -1;
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((left, right) => {
|
||||
const leftValue = value(left.item);
|
||||
const rightValue = value(right.item);
|
||||
const compared =
|
||||
typeof leftValue === "string" && typeof rightValue === "string"
|
||||
? leftValue.localeCompare(rightValue)
|
||||
: Number(leftValue) - Number(rightValue);
|
||||
return compared === 0 ? left.index - right.index : compared * direction;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
@@ -105,6 +105,42 @@ describe("adminApi", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("typed query 编码日期、筛选、排序与布尔值", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(async () =>
|
||||
new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await adminApi.users({
|
||||
q: "user + value",
|
||||
from: "2026-08-01T00:00:00.000Z",
|
||||
until: "2026-08-20T23:59:59.999Z",
|
||||
status: "suspended",
|
||||
sort: "createdAt",
|
||||
order: "asc",
|
||||
limit: 25,
|
||||
});
|
||||
await adminApi.operators({
|
||||
enabled: false,
|
||||
locked: true,
|
||||
sort: "username",
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toContain(
|
||||
"q=user+%2B+value&from=2026-08-01T00%3A00%3A00.000Z",
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toContain(
|
||||
"status=suspended&sort=createdAt&order=asc&limit=25",
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
"/v1/admin/operators?enabled=false&locked=true&sort=username&order=desc",
|
||||
);
|
||||
});
|
||||
|
||||
it("缺少 CSRF 时在发送变更请求前失败", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { DataTable, type DataColumn } from "../components/data-table";
|
||||
import { DateRangeControl } from "../components/date-range-control";
|
||||
import type { SortOrder } from "../api/types";
|
||||
import { stableSort } from "../lib/sort";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("筛选与排序基础能力", () => {
|
||||
it("DataTable 暴露受控 aria-sort 并通过表头切换方向", async () => {
|
||||
function TableHarness() {
|
||||
const [order, setOrder] = useState<SortOrder>("desc");
|
||||
const columns: DataColumn<{ createdAt: string }>[] = [
|
||||
{ accessorKey: "createdAt", header: "时间" },
|
||||
];
|
||||
return (
|
||||
<DataTable
|
||||
data={[{ createdAt: "2026-08-20T00:00:00Z" }]}
|
||||
columns={columns}
|
||||
caption="排序测试"
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TableHarness />);
|
||||
const header = screen.getByRole("columnheader", { name: /时间/ });
|
||||
expect(header.getAttribute("aria-sort")).toBe("descending");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /时间/ }));
|
||||
|
||||
expect(header.getAttribute("aria-sort")).toBe("ascending");
|
||||
});
|
||||
|
||||
it("稳定排序不修改输入并保留同值项目顺序", () => {
|
||||
const source = [
|
||||
{ id: "first", value: 2 },
|
||||
{ id: "second", value: 2 },
|
||||
{ id: "third", value: 1 },
|
||||
];
|
||||
const result = stableSort(source, (item) => item.value, "desc");
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(["first", "second", "third"]);
|
||||
expect(source.map((item) => item.id)).toEqual(["first", "second", "third"]);
|
||||
expect(result).not.toBe(source);
|
||||
});
|
||||
|
||||
it("日期范围将结束日期转换为次日 UTC 半开边界", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DateRangeControl value={{}} onChange={onChange} />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("结束日期"), "2026-08-20");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
until: "2026-08-21T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,12 +36,49 @@ describe("React 管理页面", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next");
|
||||
expect(adminApi.users).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ cursor: "user-next", sort: "createdAt", order: "desc" }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("第二位用户")).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("用户排序变更会清空旧游标并重新请求", async () => {
|
||||
mockSession("SUPPORT");
|
||||
vi.spyOn(adminApi, "users")
|
||||
.mockResolvedValueOnce({
|
||||
items: [user("第一页", userId)],
|
||||
nextCursor: "stale-cursor",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [user("旧游标结果", "22222222-2222-4222-8222-222222222222")],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [user("升序结果", "33333333-3333-4333-8333-333333333333")],
|
||||
});
|
||||
window.location.hash = "#/users";
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByText("第一页")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
|
||||
expect(await screen.findByText("旧游标结果")).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /注册时间/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(adminApi.users).toHaveBeenCalledTimes(3);
|
||||
const refreshedQuery = vi.mocked(adminApi.users).mock.calls[2]?.[0];
|
||||
expect(refreshedQuery).toMatchObject({ order: "asc" });
|
||||
expect(refreshedQuery).not.toHaveProperty("cursor");
|
||||
});
|
||||
expect(await screen.findByText("升序结果")).toBeTruthy();
|
||||
expect(screen.queryByText("旧游标结果")).toBeNull();
|
||||
expect(window.location.hash).toContain("order=asc");
|
||||
expect(window.location.hash).not.toContain("cursor");
|
||||
});
|
||||
|
||||
it("积分页自动显示最新不可变流水", async () => {
|
||||
mockSession("SUPPORT");
|
||||
const entry: LedgerEntry = {
|
||||
@@ -81,7 +118,10 @@ describe("React 管理页面", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
|
||||
expect(adminApi.operators).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ cursor: "operator-next", sort: "createdAt", order: "desc" }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("support")).toBeTruthy();
|
||||
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
|
||||
@@ -101,6 +141,32 @@ describe("React 管理页面", () => {
|
||||
expect(screen.getByText("文字润色")).toBeTruthy();
|
||||
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("产品图表可按执行模式筛选并按用户数稳定排序", async () => {
|
||||
mockSession("SUPPORT");
|
||||
const overview = analyticsOverview();
|
||||
overview.aiFeatures = [
|
||||
{ feature: "POLISH", executionMode: "MANAGED", users: 10, successes: 100 },
|
||||
{ feature: "AI_ASSISTANT", executionMode: "MANAGED", users: 40, successes: 50 },
|
||||
{ feature: "HOTWORD", executionMode: "LOCAL", users: 80, successes: 200 },
|
||||
];
|
||||
vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(overview);
|
||||
window.location.hash = "#/analytics";
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByText("快捷指令")).toBeTruthy();
|
||||
await userEvent.selectOptions(screen.getByLabelText("执行模式"), "MANAGED");
|
||||
expect(screen.queryByText("快捷指令")).toBeNull();
|
||||
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("成功次数"), "users");
|
||||
|
||||
const labels = screen
|
||||
.getAllByRole("progressbar")
|
||||
.map((element) => element.getAttribute("aria-label"));
|
||||
expect(labels.indexOf("AI 助手 成功次数:50")).toBeLessThan(
|
||||
labels.indexOf("文字润色 成功次数:100"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
|
||||
|
||||
+102
-9
@@ -593,13 +593,20 @@ paths:
|
||||
summary: Return referral funnel and ranking statistics
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/AdminRange"
|
||||
- name: sort
|
||||
in: query
|
||||
schema: { type: string, enum: [invited, qualified, creditsEarned] }
|
||||
- $ref: "#/components/parameters/AdminSortOrder"
|
||||
- name: limit
|
||||
in: query
|
||||
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
|
||||
responses:
|
||||
"200":
|
||||
description: Referral statistics
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AdminReferralOverview" }
|
||||
"400": { description: Range is invalid }
|
||||
"400": { description: Range, sort, order, or limit is invalid }
|
||||
"401": { description: Session is invalid }
|
||||
/v1/admin/analytics:
|
||||
get:
|
||||
@@ -631,13 +638,20 @@ paths:
|
||||
in: query
|
||||
schema: { type: string, maxLength: 256 }
|
||||
- $ref: "#/components/parameters/Limit"
|
||||
- $ref: "#/components/parameters/AdminFrom"
|
||||
- $ref: "#/components/parameters/AdminUntil"
|
||||
- name: status
|
||||
in: query
|
||||
schema: { type: string, enum: [active, suspended] }
|
||||
- $ref: "#/components/parameters/AdminCreatedAtSort"
|
||||
- $ref: "#/components/parameters/AdminSortOrder"
|
||||
responses:
|
||||
"200":
|
||||
description: Privacy-minimized user summaries
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AdminUserPage" }
|
||||
"400": { description: Query or cursor is malformed }
|
||||
"400": { description: Query, filter, sort, limit, or cursor is malformed }
|
||||
"401": { description: Session is invalid }
|
||||
"403": { description: ANALYST role cannot access user records }
|
||||
/v1/admin/users/{userId}:
|
||||
@@ -667,14 +681,19 @@ paths:
|
||||
- name: cursor
|
||||
in: query
|
||||
schema: { type: string, maxLength: 256 }
|
||||
- $ref: "#/components/parameters/Limit"
|
||||
- $ref: "#/components/parameters/AdminLedgerLimit"
|
||||
- $ref: "#/components/parameters/AdminFrom"
|
||||
- $ref: "#/components/parameters/AdminUntil"
|
||||
- $ref: "#/components/parameters/AdminLedgerType"
|
||||
- $ref: "#/components/parameters/AdminCreatedAtSort"
|
||||
- $ref: "#/components/parameters/AdminSortOrder"
|
||||
responses:
|
||||
"200":
|
||||
description: Credit ledger entries ordered by creation time and entry ID
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AdminLedgerPage" }
|
||||
"400": { description: Cursor is malformed }
|
||||
"400": { description: Filter, sort, limit, or cursor is malformed }
|
||||
"403": { description: ANALYST role cannot access credit ledger records }
|
||||
"404": { description: User was not found }
|
||||
/v1/admin/credits/ledger:
|
||||
@@ -687,14 +706,19 @@ paths:
|
||||
- name: cursor
|
||||
in: query
|
||||
schema: { type: string, maxLength: 256 }
|
||||
- $ref: "#/components/parameters/Limit"
|
||||
- $ref: "#/components/parameters/AdminLedgerLimit"
|
||||
- $ref: "#/components/parameters/AdminFrom"
|
||||
- $ref: "#/components/parameters/AdminUntil"
|
||||
- $ref: "#/components/parameters/AdminLedgerType"
|
||||
- $ref: "#/components/parameters/AdminCreatedAtSort"
|
||||
- $ref: "#/components/parameters/AdminSortOrder"
|
||||
responses:
|
||||
"200":
|
||||
description: Latest credit ledger entries ordered by creation time and entry ID
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AdminLedgerPage" }
|
||||
"400": { description: Cursor is malformed }
|
||||
"400": { description: Filter, sort, limit, or cursor is malformed }
|
||||
"403": { description: ANALYST role cannot access credit ledger records }
|
||||
/v1/admin/credits/grants:
|
||||
post:
|
||||
@@ -758,14 +782,33 @@ paths:
|
||||
in: query
|
||||
schema: { type: string, maxLength: 256 }
|
||||
- $ref: "#/components/parameters/Limit"
|
||||
- $ref: "#/components/parameters/AdminFrom"
|
||||
- $ref: "#/components/parameters/AdminUntil"
|
||||
- name: role
|
||||
in: query
|
||||
schema: { type: string, enum: [SUPER_ADMIN, SUPPORT, ANALYST] }
|
||||
- name: enabled
|
||||
in: query
|
||||
schema: { type: boolean }
|
||||
- name: locked
|
||||
in: query
|
||||
description: Whether locked_until is later than the request time.
|
||||
schema: { type: boolean }
|
||||
- name: sort
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [createdAt, username, lastLoginAt]
|
||||
default: createdAt
|
||||
- $ref: "#/components/parameters/AdminSortOrder"
|
||||
responses:
|
||||
"200":
|
||||
description: Operators ordered by creation time
|
||||
description: Operators ordered by the requested stable sort and operator ID; null lastLoginAt values are last
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AdminOperatorPage" }
|
||||
"403": { description: INSUFFICIENT_PERMISSION; SUPER_ADMIN is required }
|
||||
"400": { description: Cursor or limit is malformed }
|
||||
"400": { description: Filter, sort, limit, or cursor is malformed }
|
||||
post:
|
||||
security:
|
||||
- adminMtls: []
|
||||
@@ -874,13 +917,35 @@ paths:
|
||||
in: query
|
||||
schema: { type: string, maxLength: 256 }
|
||||
- $ref: "#/components/parameters/Limit"
|
||||
- $ref: "#/components/parameters/AdminFrom"
|
||||
- $ref: "#/components/parameters/AdminUntil"
|
||||
- name: action
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- LOGIN_SUCCEEDED
|
||||
- LOGIN_FAILED
|
||||
- SESSION_REVOKED
|
||||
- OPERATOR_CREATED
|
||||
- OPERATOR_ENABLED
|
||||
- OPERATOR_DISABLED
|
||||
- OPERATOR_UNLOCKED
|
||||
- OPERATOR_CREDENTIALS_RESET
|
||||
- OPERATOR_SESSIONS_REVOKED
|
||||
- MANUAL_CREDIT_GRANTED
|
||||
- name: result
|
||||
in: query
|
||||
schema: { type: string, enum: [success, rejected] }
|
||||
- $ref: "#/components/parameters/AdminCreatedAtSort"
|
||||
- $ref: "#/components/parameters/AdminSortOrder"
|
||||
responses:
|
||||
"200":
|
||||
description: Recent audit events
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AdminAuditPage" }
|
||||
"400": { description: Cursor is malformed }
|
||||
"400": { description: Filter, sort, limit, or cursor is malformed }
|
||||
"403": { description: Super-administrator role is required }
|
||||
components:
|
||||
securitySchemes:
|
||||
@@ -919,6 +984,34 @@ components:
|
||||
name: range
|
||||
in: query
|
||||
schema: { type: string, enum: [7d, 30d, 90d], default: 30d }
|
||||
AdminFrom:
|
||||
name: from
|
||||
in: query
|
||||
description: Inclusive UTC lower bound. When both bounds are present, from must be earlier than until.
|
||||
schema: { type: string, format: date-time }
|
||||
AdminUntil:
|
||||
name: until
|
||||
in: query
|
||||
description: Exclusive UTC upper bound.
|
||||
schema: { type: string, format: date-time }
|
||||
AdminCreatedAtSort:
|
||||
name: sort
|
||||
in: query
|
||||
schema: { type: string, enum: [createdAt], default: createdAt }
|
||||
AdminSortOrder:
|
||||
name: order
|
||||
in: query
|
||||
schema: { type: string, enum: [asc, desc] }
|
||||
AdminLedgerType:
|
||||
name: type
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [grant, reserve, settle, refund, adjustment]
|
||||
AdminLedgerLimit:
|
||||
name: limit
|
||||
in: query
|
||||
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
|
||||
AdminUserId:
|
||||
name: userId
|
||||
in: path
|
||||
|
||||
@@ -20,11 +20,6 @@ data class AdminOperatorRecord(
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
data class AdminOperatorCursor(
|
||||
val createdAt: Instant,
|
||||
val id: UUID,
|
||||
)
|
||||
|
||||
data class NewAdminOperator(
|
||||
val id: UUID,
|
||||
val normalizedUsername: String,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.osglab.account.features.admin.models
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
enum class AdminSortOrder {
|
||||
ASC,
|
||||
DESC,
|
||||
}
|
||||
|
||||
data class AdminTimeFilter(
|
||||
val from: Instant? = null,
|
||||
val until: Instant? = null,
|
||||
) {
|
||||
init {
|
||||
require(from == null || until == null || from < until) {
|
||||
"Admin query time range must be non-empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminAuditQuery(
|
||||
val time: AdminTimeFilter = AdminTimeFilter(),
|
||||
val action: AdminAuditAction? = null,
|
||||
val outcome: AdminAuditOutcome? = null,
|
||||
val order: AdminSortOrder = AdminSortOrder.DESC,
|
||||
)
|
||||
|
||||
enum class AdminOperatorSort {
|
||||
CREATED_AT,
|
||||
USERNAME,
|
||||
LAST_LOGIN_AT,
|
||||
}
|
||||
|
||||
data class AdminOperatorQuery(
|
||||
val time: AdminTimeFilter = AdminTimeFilter(),
|
||||
val role: AdminRole? = null,
|
||||
val enabled: Boolean? = null,
|
||||
val locked: Boolean? = null,
|
||||
val sort: AdminOperatorSort = AdminOperatorSort.CREATED_AT,
|
||||
val order: AdminSortOrder = AdminSortOrder.ASC,
|
||||
val now: Instant,
|
||||
)
|
||||
|
||||
data class AdminOperatorCursor(
|
||||
val value: String?,
|
||||
val id: UUID,
|
||||
)
|
||||
+149
-22
@@ -1,6 +1,7 @@
|
||||
package com.osglab.account.features.admin.repositories
|
||||
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.AdminAuditQuery
|
||||
import com.osglab.account.features.admin.models.AdminAuditCursor
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
@@ -8,10 +9,13 @@ import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
@@ -21,6 +25,7 @@ import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.greaterEq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.isNotNull
|
||||
import org.jetbrains.exposed.v1.core.isNull
|
||||
@@ -86,6 +91,7 @@ interface AdminRepository {
|
||||
suspend fun listOperatorsPage(
|
||||
limit: Int,
|
||||
before: AdminOperatorCursor? = null,
|
||||
query: AdminOperatorQuery,
|
||||
): List<AdminOperatorRecord>
|
||||
suspend fun countActiveSessions(now: Instant): Long
|
||||
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
|
||||
@@ -148,6 +154,7 @@ interface AdminRepository {
|
||||
suspend fun listAudit(
|
||||
limit: Int,
|
||||
before: AdminAuditCursor? = null,
|
||||
query: AdminAuditQuery = AdminAuditQuery(),
|
||||
): List<AdminAuditRecord>
|
||||
}
|
||||
|
||||
@@ -185,24 +192,119 @@ class ExposedAdminRepository(
|
||||
override suspend fun listOperatorsPage(
|
||||
limit: Int,
|
||||
before: AdminOperatorCursor?,
|
||||
query: AdminOperatorQuery,
|
||||
): List<AdminOperatorRecord> =
|
||||
databaseFactory.query {
|
||||
require(limit in 1..101)
|
||||
val query = AdminOperatorsTable.selectAll()
|
||||
if (before != null) {
|
||||
query.andWhere {
|
||||
(AdminOperatorsTable.createdAt greater before.createdAt) or
|
||||
(
|
||||
(AdminOperatorsTable.createdAt eq before.createdAt) and
|
||||
(AdminOperatorsTable.id greater before.id.toString())
|
||||
)
|
||||
val statement = AdminOperatorsTable.selectAll()
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminOperatorsTable.createdAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminOperatorsTable.createdAt less until }
|
||||
}
|
||||
query.role?.let { role ->
|
||||
statement.andWhere { AdminOperatorsTable.role eq role.name }
|
||||
}
|
||||
query.enabled?.let { enabled ->
|
||||
statement.andWhere {
|
||||
if (enabled) AdminOperatorsTable.disabledAt.isNull()
|
||||
else AdminOperatorsTable.disabledAt.isNotNull()
|
||||
}
|
||||
}
|
||||
query
|
||||
.orderBy(
|
||||
AdminOperatorsTable.createdAt to SortOrder.ASC,
|
||||
AdminOperatorsTable.id to SortOrder.ASC,
|
||||
query.locked?.let { locked ->
|
||||
statement.andWhere {
|
||||
if (locked) {
|
||||
AdminOperatorsTable.lockedUntil.isNotNull() and
|
||||
(AdminOperatorsTable.lockedUntil greater query.now)
|
||||
} else {
|
||||
AdminOperatorsTable.lockedUntil.isNull() or
|
||||
(AdminOperatorsTable.lockedUntil lessEq query.now)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (before != null) {
|
||||
statement.andWhere {
|
||||
val ascending = query.order == AdminSortOrder.ASC
|
||||
when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT -> {
|
||||
val value = Instant.parse(requireNotNull(before.value))
|
||||
if (ascending) {
|
||||
(AdminOperatorsTable.createdAt greater value) or
|
||||
(
|
||||
(AdminOperatorsTable.createdAt eq value) and
|
||||
(AdminOperatorsTable.id greater before.id.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminOperatorsTable.createdAt less value) or
|
||||
(
|
||||
(AdminOperatorsTable.createdAt eq value) and
|
||||
(AdminOperatorsTable.id less before.id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
AdminOperatorSort.USERNAME -> {
|
||||
val value = requireNotNull(before.value)
|
||||
if (ascending) {
|
||||
(AdminOperatorsTable.username greater value) or
|
||||
(
|
||||
(AdminOperatorsTable.username eq value) and
|
||||
(AdminOperatorsTable.id greater before.id.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminOperatorsTable.username less value) or
|
||||
(
|
||||
(AdminOperatorsTable.username eq value) and
|
||||
(AdminOperatorsTable.id less before.id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> {
|
||||
val value = before.value?.let(Instant::parse)
|
||||
if (value == null) {
|
||||
AdminOperatorsTable.lastLoginAt.isNull() and
|
||||
if (ascending) {
|
||||
AdminOperatorsTable.id greater before.id.toString()
|
||||
} else {
|
||||
AdminOperatorsTable.id less before.id.toString()
|
||||
}
|
||||
} else {
|
||||
val nonNullAfter = if (ascending) {
|
||||
(AdminOperatorsTable.lastLoginAt greater value) or
|
||||
(
|
||||
(AdminOperatorsTable.lastLoginAt eq value) and
|
||||
(AdminOperatorsTable.id greater before.id.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminOperatorsTable.lastLoginAt less value) or
|
||||
(
|
||||
(AdminOperatorsTable.lastLoginAt eq value) and
|
||||
(AdminOperatorsTable.id less before.id.toString())
|
||||
)
|
||||
}
|
||||
nonNullAfter or AdminOperatorsTable.lastLoginAt.isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT -> statement.orderBy(
|
||||
AdminOperatorsTable.createdAt to sortOrder,
|
||||
AdminOperatorsTable.id to sortOrder,
|
||||
)
|
||||
AdminOperatorSort.USERNAME -> statement.orderBy(
|
||||
AdminOperatorsTable.username to sortOrder,
|
||||
AdminOperatorsTable.id to sortOrder,
|
||||
)
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> statement.orderBy(
|
||||
AdminOperatorsTable.lastLoginAt.isNull() to SortOrder.ASC,
|
||||
AdminOperatorsTable.lastLoginAt to sortOrder,
|
||||
AdminOperatorsTable.id to sortOrder,
|
||||
)
|
||||
}
|
||||
statement
|
||||
.limit(limit)
|
||||
.map(ResultRow::toOperatorRecord)
|
||||
}
|
||||
@@ -495,23 +597,45 @@ class ExposedAdminRepository(
|
||||
override suspend fun listAudit(
|
||||
limit: Int,
|
||||
before: AdminAuditCursor?,
|
||||
query: AdminAuditQuery,
|
||||
): List<AdminAuditRecord> =
|
||||
databaseFactory.query {
|
||||
require(limit in 1..101)
|
||||
val query = AdminAuditLogTable.selectAll()
|
||||
val statement = AdminAuditLogTable.selectAll()
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminAuditLogTable.occurredAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminAuditLogTable.occurredAt less until }
|
||||
}
|
||||
query.action?.let { action ->
|
||||
statement.andWhere { AdminAuditLogTable.action eq action.name }
|
||||
}
|
||||
query.outcome?.let { outcome ->
|
||||
statement.andWhere { AdminAuditLogTable.outcome eq outcome.name }
|
||||
}
|
||||
if (before != null) {
|
||||
query.andWhere {
|
||||
(AdminAuditLogTable.occurredAt less before.occurredAt) or
|
||||
(
|
||||
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
|
||||
(AdminAuditLogTable.id less before.id.toString())
|
||||
)
|
||||
statement.andWhere {
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
(AdminAuditLogTable.occurredAt greater before.occurredAt) or
|
||||
(
|
||||
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
|
||||
(AdminAuditLogTable.id greater before.id.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminAuditLogTable.occurredAt less before.occurredAt) or
|
||||
(
|
||||
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
|
||||
(AdminAuditLogTable.id less before.id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
query
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
statement
|
||||
.orderBy(
|
||||
AdminAuditLogTable.occurredAt to SortOrder.DESC,
|
||||
AdminAuditLogTable.id to SortOrder.DESC,
|
||||
AdminAuditLogTable.occurredAt to sortOrder,
|
||||
AdminAuditLogTable.id to sortOrder,
|
||||
)
|
||||
.limit(limit)
|
||||
.map {
|
||||
@@ -528,6 +652,9 @@ class ExposedAdminRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun AdminSortOrder.toExposedSortOrder(): SortOrder =
|
||||
if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC
|
||||
|
||||
private fun insertAudit(event: NewAdminAuditEvent) {
|
||||
AdminAuditLogTable.insert {
|
||||
it[id] = event.id.toString()
|
||||
|
||||
@@ -3,10 +3,17 @@ package com.osglab.account.features.admin.routes
|
||||
import com.osglab.account.config.AppConfig
|
||||
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
|
||||
import com.osglab.account.features.admin.grants.services.AdminGrantService
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminAuditQuery
|
||||
import com.osglab.account.features.admin.models.AdminLoginResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.services.AdminAuditCursorException
|
||||
import com.osglab.account.features.admin.services.AdminAuditService
|
||||
import com.osglab.account.features.admin.services.AdminAuthService
|
||||
@@ -19,11 +26,16 @@ import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.admin.stats.models.AdminStatsDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
|
||||
import com.osglab.account.features.admin.stats.services.AdminReferralSort
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
|
||||
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
@@ -51,6 +63,9 @@ import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
fun Route.adminWebRoutes(config: AppConfig) {
|
||||
@@ -159,7 +174,18 @@ fun Route.adminApiRoutes(
|
||||
|
||||
get("/referrals") {
|
||||
if (call.requirePrincipal(config, sessionService) == null) return@get
|
||||
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
|
||||
val options = runCatching { call.adminReferralQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val stats = statsService.getRange(
|
||||
range = call.request.queryParameters["range"],
|
||||
clock = clock,
|
||||
referralRankLimit = options.limit,
|
||||
referralSort = options.sort,
|
||||
referralOrder = options.order,
|
||||
)
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -185,6 +211,11 @@ fun Route.adminApiRoutes(
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
) return@get
|
||||
val listQuery = runCatching { call.adminUserListQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -195,9 +226,10 @@ fun Route.adminApiRoutes(
|
||||
usersService.list(
|
||||
limit = limit,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
query = listQuery,
|
||||
)
|
||||
} else {
|
||||
usersService.searchByInternalId(query)
|
||||
usersService.searchByInternalId(query, listQuery)
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
@@ -236,6 +268,11 @@ fun Route.adminApiRoutes(
|
||||
) == null
|
||||
) return@get
|
||||
val userId = call.uuidPathParameter("userId") ?: return@get
|
||||
val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -245,6 +282,7 @@ fun Route.adminApiRoutes(
|
||||
userId = userId,
|
||||
limit = limit,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
query = ledgerQuery,
|
||||
)
|
||||
call.respond(
|
||||
PageResponse(
|
||||
@@ -267,6 +305,11 @@ fun Route.adminApiRoutes(
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
) return@get
|
||||
val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -275,6 +318,7 @@ fun Route.adminApiRoutes(
|
||||
val page = usersService.latestLedger(
|
||||
limit = limit,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
query = ledgerQuery,
|
||||
)
|
||||
call.respond(
|
||||
PageResponse(
|
||||
@@ -353,6 +397,11 @@ fun Route.adminApiRoutes(
|
||||
|
||||
get("/operators") {
|
||||
val principal = call.requirePrincipal(config, sessionService) ?: return@get
|
||||
val operatorQuery = runCatching { call.adminOperatorQuery(clock.instant()) }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -362,6 +411,7 @@ fun Route.adminApiRoutes(
|
||||
actor = principal,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
limit = limit,
|
||||
query = operatorQuery,
|
||||
)
|
||||
call.respond(
|
||||
PageResponse(
|
||||
@@ -480,6 +530,11 @@ fun Route.adminApiRoutes(
|
||||
|
||||
get("/audit") {
|
||||
val principal = call.requirePrincipal(config, sessionService) ?: return@get
|
||||
val auditQuery = runCatching { call.adminAuditQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -489,6 +544,7 @@ fun Route.adminApiRoutes(
|
||||
actor = principal,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
limit = limit,
|
||||
query = auditQuery,
|
||||
)
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
@@ -522,9 +578,18 @@ fun Route.adminApiRoutes(
|
||||
private suspend fun AdminStatsService.getRange(
|
||||
range: String?,
|
||||
clock: Clock,
|
||||
referralRankLimit: Int = 20,
|
||||
referralSort: AdminReferralSort? = null,
|
||||
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
|
||||
): AdminStatsDto? {
|
||||
val window = parseAdminStatsRange(range, clock) ?: return null
|
||||
return get(window.first, window.second)
|
||||
return get(
|
||||
from = window.first,
|
||||
until = window.second,
|
||||
referralRankLimit = referralRankLimit,
|
||||
referralSort = referralSort,
|
||||
referralOrder = referralOrder,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.Instant, java.time.Instant>? {
|
||||
@@ -538,6 +603,161 @@ private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.I
|
||||
return until.minus(Duration.ofDays(days)) to until
|
||||
}
|
||||
|
||||
private data class AdminReferralQueryOptions(
|
||||
val sort: AdminReferralSort?,
|
||||
val order: AdminSortOrder,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
private fun ApplicationCall.adminReferralQuery(): AdminReferralQueryOptions {
|
||||
requireQueryParameters(setOf("range", "sort", "order", "limit"))
|
||||
val explicitSort = request.queryParameters["sort"]?.let {
|
||||
when (it) {
|
||||
"invited" -> AdminReferralSort.INVITED
|
||||
"qualified" -> AdminReferralSort.QUALIFIED
|
||||
"creditsEarned" -> AdminReferralSort.CREDITS_EARNED
|
||||
else -> throw IllegalArgumentException("Invalid referral sort")
|
||||
}
|
||||
}
|
||||
val orderValue = request.queryParameters["order"]
|
||||
val order = parseSortOrder(orderValue, AdminSortOrder.DESC)
|
||||
val limit = request.queryParameters["limit"]?.toIntOrNull()?.takeIf { it in 1..100 } ?: run {
|
||||
require(request.queryParameters["limit"] == null)
|
||||
20
|
||||
}
|
||||
return AdminReferralQueryOptions(
|
||||
sort = explicitSort ?: if (orderValue != null) AdminReferralSort.INVITED else null,
|
||||
order = order,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminUserListQuery(): AdminUserListQuery {
|
||||
requireQueryParameters(setOf("q", "cursor", "limit", "from", "until", "status", "sort", "order"))
|
||||
request.queryParameters["q"]?.let { require(it.trim().length <= 36) }
|
||||
requireCreatedAtSort()
|
||||
val status = request.queryParameters["status"]?.let {
|
||||
when (it) {
|
||||
"active" -> AdminUserStatus.ACTIVE
|
||||
"suspended" -> AdminUserStatus.SUSPENDED
|
||||
else -> throw IllegalArgumentException("Invalid user status")
|
||||
}
|
||||
}
|
||||
return AdminUserListQuery(
|
||||
time = adminTimeFilter(),
|
||||
status = status,
|
||||
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery {
|
||||
requireQueryParameters(setOf("cursor", "limit", "from", "until", "type", "sort", "order"))
|
||||
requireCreatedAtSort()
|
||||
val type = request.queryParameters["type"]?.let {
|
||||
when (it) {
|
||||
"reserve" -> AdminLedgerType.RESERVE
|
||||
"settle" -> AdminLedgerType.SETTLE
|
||||
"refund" -> AdminLedgerType.REFUND
|
||||
"grant" -> AdminLedgerType.GRANT
|
||||
"adjustment" -> AdminLedgerType.ADJUSTMENT
|
||||
else -> throw IllegalArgumentException("Invalid ledger type")
|
||||
}
|
||||
}
|
||||
return AdminLedgerQuery(
|
||||
time = adminTimeFilter(),
|
||||
type = type,
|
||||
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminOperatorQuery(now: Instant): AdminOperatorQuery {
|
||||
requireQueryParameters(
|
||||
setOf("cursor", "limit", "from", "until", "role", "enabled", "locked", "sort", "order"),
|
||||
)
|
||||
val role = request.queryParameters["role"]?.let {
|
||||
runCatching { AdminRole.valueOf(it) }.getOrNull()
|
||||
?: throw IllegalArgumentException("Invalid operator role")
|
||||
}
|
||||
val sort = request.queryParameters["sort"]?.let {
|
||||
when (it) {
|
||||
"createdAt" -> AdminOperatorSort.CREATED_AT
|
||||
"username" -> AdminOperatorSort.USERNAME
|
||||
"lastLoginAt" -> AdminOperatorSort.LAST_LOGIN_AT
|
||||
else -> throw IllegalArgumentException("Invalid operator sort")
|
||||
}
|
||||
} ?: AdminOperatorSort.CREATED_AT
|
||||
return AdminOperatorQuery(
|
||||
time = adminTimeFilter(),
|
||||
role = role,
|
||||
enabled = request.queryParameters["enabled"]?.let(::parseStrictBoolean),
|
||||
locked = request.queryParameters["locked"]?.let(::parseStrictBoolean),
|
||||
sort = sort,
|
||||
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.ASC),
|
||||
now = now,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminAuditQuery(): AdminAuditQuery {
|
||||
requireQueryParameters(
|
||||
setOf("cursor", "limit", "from", "until", "action", "result", "sort", "order"),
|
||||
)
|
||||
requireCreatedAtSort()
|
||||
val action = request.queryParameters["action"]?.let {
|
||||
runCatching { AdminAuditAction.valueOf(it) }.getOrNull()
|
||||
?: throw IllegalArgumentException("Invalid audit action")
|
||||
}
|
||||
val outcome = request.queryParameters["result"]?.let {
|
||||
when (it) {
|
||||
"success" -> AdminAuditOutcome.SUCCESS
|
||||
"rejected" -> AdminAuditOutcome.DENIED
|
||||
else -> throw IllegalArgumentException("Invalid audit result")
|
||||
}
|
||||
}
|
||||
return AdminAuditQuery(
|
||||
time = adminTimeFilter(),
|
||||
action = action,
|
||||
outcome = outcome,
|
||||
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminTimeFilter(): AdminTimeFilter =
|
||||
AdminTimeFilter(
|
||||
from = request.queryParameters["from"]?.let(::parseUtcInstant),
|
||||
until = request.queryParameters["until"]?.let(::parseUtcInstant),
|
||||
)
|
||||
|
||||
private fun parseUtcInstant(value: String): Instant {
|
||||
val parsed = OffsetDateTime.parse(value)
|
||||
require(parsed.offset == ZoneOffset.UTC)
|
||||
return parsed.toInstant()
|
||||
}
|
||||
|
||||
private fun ApplicationCall.requireCreatedAtSort() {
|
||||
request.queryParameters["sort"]?.let { require(it == "createdAt") }
|
||||
}
|
||||
|
||||
private fun ApplicationCall.requireQueryParameters(allowed: Set<String>) {
|
||||
val parameters = request.queryParameters
|
||||
require(parameters.names().all { it in allowed })
|
||||
require(parameters.names().all { parameters.getAll(it).orEmpty().size == 1 })
|
||||
}
|
||||
|
||||
private fun parseSortOrder(value: String?, default: AdminSortOrder): AdminSortOrder =
|
||||
when (value) {
|
||||
null -> default
|
||||
"asc" -> AdminSortOrder.ASC
|
||||
"desc" -> AdminSortOrder.DESC
|
||||
else -> throw IllegalArgumentException("Invalid sort order")
|
||||
}
|
||||
|
||||
private fun parseStrictBoolean(value: String): Boolean =
|
||||
when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> throw IllegalArgumentException("Invalid boolean filter")
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requirePrincipal(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminAuditCursor
|
||||
import com.osglab.account.features.admin.models.AdminAuditQuery
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Instant
|
||||
@@ -29,13 +31,14 @@ class AdminAuditService(
|
||||
actor: AdminPrincipal,
|
||||
cursor: String?,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
query: AdminAuditQuery = AdminAuditQuery(),
|
||||
): AdminAuditPage {
|
||||
if (actor.role != AdminRole.SUPER_ADMIN) {
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
||||
}
|
||||
require(limit in 1..MAX_PAGE_SIZE)
|
||||
val decodedCursor = cursor?.let(::decodeCursor)
|
||||
val records = repository.listAudit(limit + 1, decodedCursor)
|
||||
val decodedCursor = cursor?.let { decodeCursor(it, query.order) }
|
||||
val records = repository.listAudit(limit + 1, decodedCursor, query)
|
||||
val pageRecords = records.take(limit)
|
||||
val operatorNames = repository.listOperators().associate {
|
||||
it.id to it.normalizedUsername
|
||||
@@ -50,36 +53,41 @@ class AdminAuditService(
|
||||
)
|
||||
},
|
||||
nextCursor = if (records.size > limit) {
|
||||
pageRecords.lastOrNull()?.let(::encodeCursor)
|
||||
pageRecords.lastOrNull()?.let { encodeCursor(it, query.order) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeCursor(value: String): AdminAuditCursor {
|
||||
private fun decodeCursor(value: String, expectedOrder: AdminSortOrder): AdminAuditCursor {
|
||||
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException()
|
||||
return runCatching {
|
||||
val decoded = String(
|
||||
Base64.getUrlDecoder().decode(value),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split(':', limit = 3)
|
||||
require(parts.size == 3)
|
||||
val parts = decoded.split(':', limit = 5)
|
||||
require(parts.size == 5)
|
||||
require(parts[0] == "v1")
|
||||
require(parts[1] == expectedOrder.name)
|
||||
AdminAuditCursor(
|
||||
occurredAt = Instant.ofEpochSecond(
|
||||
parts[0].toLong(),
|
||||
parts[1].toLong(),
|
||||
parts[2].toLong(),
|
||||
parts[3].toLong(),
|
||||
),
|
||||
id = UUID.fromString(parts[2]),
|
||||
id = UUID.fromString(parts[4]),
|
||||
)
|
||||
}.getOrElse {
|
||||
throw AdminAuditCursorException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeCursor(record: AdminAuditRecord): String {
|
||||
private fun encodeCursor(record: AdminAuditRecord, order: AdminSortOrder): String {
|
||||
val payload = buildString {
|
||||
append("v1:")
|
||||
append(order.name)
|
||||
append(':')
|
||||
append(record.occurredAt.epochSecond)
|
||||
append(':')
|
||||
append(record.occurredAt.nano)
|
||||
|
||||
+39
-12
@@ -5,9 +5,12 @@ import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
@@ -15,9 +18,8 @@ import com.osglab.account.features.admin.security.AdminPasswordHasher
|
||||
import com.osglab.account.features.admin.security.AdminTotpProvisioning
|
||||
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
|
||||
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Clock
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
@@ -73,17 +75,33 @@ class AdminOperatorService(
|
||||
actor: AdminPrincipal,
|
||||
cursor: String?,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
): AdminOperatorPage = listPage(
|
||||
actor = actor,
|
||||
cursor = cursor,
|
||||
limit = limit,
|
||||
query = AdminOperatorQuery(now = clock.instant()),
|
||||
)
|
||||
|
||||
suspend fun listPage(
|
||||
actor: AdminPrincipal,
|
||||
cursor: String?,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
query: AdminOperatorQuery,
|
||||
): AdminOperatorPage {
|
||||
requireSuperAdministrator(actor)
|
||||
if (limit !in 1..MAX_PAGE_SIZE) {
|
||||
throw AdminOperatorCursorException()
|
||||
}
|
||||
val decodedCursor = cursor?.let(::decodeCursor)
|
||||
val records = repository.listOperatorsPage(limit + 1, decodedCursor)
|
||||
val decodedCursor = cursor?.let { decodeCursor(it, query) }
|
||||
val records = repository.listOperatorsPage(limit + 1, decodedCursor, query)
|
||||
val items = records.take(limit)
|
||||
return AdminOperatorPage(
|
||||
items = items,
|
||||
nextCursor = if (records.size > limit) items.lastOrNull()?.let(::encodeCursor) else null,
|
||||
nextCursor = if (records.size > limit) {
|
||||
items.lastOrNull()?.let { encodeCursor(it, query) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -370,28 +388,37 @@ class AdminOperatorService(
|
||||
const val MAX_PASSWORD_CHARS = 1_024
|
||||
const val MAX_AUDIT_TARGET_CHARS = 128
|
||||
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
|
||||
const val NULL_CURSOR_VALUE = "~"
|
||||
}
|
||||
|
||||
private fun decodeCursor(value: String): AdminOperatorCursor {
|
||||
private fun decodeCursor(value: String, query: AdminOperatorQuery): AdminOperatorCursor {
|
||||
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException()
|
||||
return runCatching {
|
||||
val decoded = String(
|
||||
Base64.getUrlDecoder().decode(value),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split(':', limit = 3)
|
||||
require(parts.size == 3)
|
||||
val parts = decoded.split('|')
|
||||
require(parts.size == 5)
|
||||
require(parts[0] == "v1")
|
||||
require(parts[1] == query.sort.name)
|
||||
require(parts[2] == query.order.name)
|
||||
AdminOperatorCursor(
|
||||
createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()),
|
||||
id = UUID.fromString(parts[2]),
|
||||
value = parts[3].takeUnless { it == NULL_CURSOR_VALUE },
|
||||
id = UUID.fromString(parts[4]),
|
||||
)
|
||||
}.getOrElse {
|
||||
throw AdminOperatorCursorException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeCursor(record: AdminOperatorRecord): String {
|
||||
val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}"
|
||||
private fun encodeCursor(record: AdminOperatorRecord, query: AdminOperatorQuery): String {
|
||||
val value = when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT -> record.createdAt.toString()
|
||||
AdminOperatorSort.USERNAME -> record.normalizedUsername
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> record.lastLoginAt?.toString() ?: NULL_CURSOR_VALUE
|
||||
}
|
||||
val payload = "v1|${query.sort.name}|${query.order.name}|$value|${record.id}"
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||
payload.toByteArray(StandardCharsets.UTF_8),
|
||||
)
|
||||
|
||||
+29
-1
@@ -1,6 +1,8 @@
|
||||
package com.osglab.account.features.admin.stats.services
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.stats.models.AdminCreditFlowPointDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminRegistrationPointDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminStatsDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto
|
||||
@@ -10,6 +12,12 @@ import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
|
||||
enum class AdminReferralSort {
|
||||
INVITED,
|
||||
QUALIFIED,
|
||||
CREDITS_EARNED,
|
||||
}
|
||||
|
||||
class AdminStatsService(
|
||||
private val repository: AdminStatsRepository,
|
||||
) {
|
||||
@@ -17,6 +25,8 @@ class AdminStatsService(
|
||||
from: Instant,
|
||||
until: Instant,
|
||||
referralRankLimit: Int = 20,
|
||||
referralSort: AdminReferralSort? = null,
|
||||
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
|
||||
): AdminStatsDto {
|
||||
require(from < until) { "Statistics range must be non-empty" }
|
||||
require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" }
|
||||
@@ -40,12 +50,30 @@ class AdminStatsService(
|
||||
)
|
||||
},
|
||||
referralFunnel = snapshot.referralFunnel,
|
||||
referralRanking = snapshot.referralRanking.take(referralRankLimit),
|
||||
referralRanking = snapshot.referralRanking
|
||||
.sortedForReferralRanking(referralSort, referralOrder)
|
||||
.take(referralRankLimit),
|
||||
usage = snapshot.usage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<AdminReferralRankDto>.sortedForReferralRanking(
|
||||
sort: AdminReferralSort?,
|
||||
order: AdminSortOrder,
|
||||
): List<AdminReferralRankDto> {
|
||||
if (sort == null) return this
|
||||
val direction = if (order == AdminSortOrder.ASC) 1 else -1
|
||||
return sortedWith { left, right ->
|
||||
val primary = when (sort) {
|
||||
AdminReferralSort.INVITED -> left.invitedUsers.compareTo(right.invitedUsers)
|
||||
AdminReferralSort.QUALIFIED -> left.rewardedUsers.compareTo(right.rewardedUsers)
|
||||
AdminReferralSort.CREDITS_EARNED -> left.earnedCredits.compareTo(right.earnedCredits)
|
||||
} * direction
|
||||
if (primary != 0) primary else left.userId.compareTo(right.userId) * direction
|
||||
}
|
||||
}
|
||||
|
||||
private fun utcDates(range: AdminStatsRange): List<LocalDate> {
|
||||
val dates = mutableListOf<LocalDate>()
|
||||
var date = range.from.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
+170
-43
@@ -1,6 +1,8 @@
|
||||
package com.osglab.account.features.admin.users.repositories
|
||||
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
@@ -14,11 +16,14 @@ import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.greaterEq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
@@ -33,10 +38,54 @@ data class AdminUserLedgerCursor(
|
||||
val ledgerEntryId: UUID,
|
||||
)
|
||||
|
||||
interface AdminUsersRepository {
|
||||
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
|
||||
enum class AdminUserStatus {
|
||||
ACTIVE,
|
||||
SUSPENDED,
|
||||
}
|
||||
|
||||
suspend fun findByIdSuffix(suffix: String, limit: Int): List<AdminUserSummaryDto>
|
||||
data class AdminUserListQuery(
|
||||
val time: AdminTimeFilter = AdminTimeFilter(),
|
||||
val status: AdminUserStatus? = null,
|
||||
val order: AdminSortOrder = AdminSortOrder.DESC,
|
||||
)
|
||||
|
||||
enum class AdminLedgerType(
|
||||
internal val entryTypes: Set<LedgerEntryType>,
|
||||
) {
|
||||
RESERVE(setOf(LedgerEntryType.USAGE_RESERVE)),
|
||||
SETTLE(setOf(LedgerEntryType.USAGE_SETTLE)),
|
||||
REFUND(setOf(LedgerEntryType.USAGE_RELEASE, LedgerEntryType.USAGE_REFUND)),
|
||||
GRANT(
|
||||
setOf(
|
||||
LedgerEntryType.SIGNUP_TRIAL,
|
||||
LedgerEntryType.MANUAL_GRANT,
|
||||
LedgerEntryType.REFERRAL_INVITER,
|
||||
LedgerEntryType.REFERRAL_INVITEE,
|
||||
LedgerEntryType.STOREKIT_PURCHASE,
|
||||
LedgerEntryType.SUBSCRIPTION_GRANT,
|
||||
),
|
||||
),
|
||||
ADJUSTMENT(emptySet()),
|
||||
}
|
||||
|
||||
data class AdminLedgerQuery(
|
||||
val time: AdminTimeFilter = AdminTimeFilter(),
|
||||
val type: AdminLedgerType? = null,
|
||||
val order: AdminSortOrder = AdminSortOrder.DESC,
|
||||
)
|
||||
|
||||
interface AdminUsersRepository {
|
||||
suspend fun list(
|
||||
limit: Int,
|
||||
cursor: AdminUserCursor?,
|
||||
query: AdminUserListQuery,
|
||||
): List<AdminUserSummaryDto>
|
||||
|
||||
suspend fun findByIdSuffix(
|
||||
suffix: String,
|
||||
limit: Int,
|
||||
query: AdminUserListQuery,
|
||||
): List<AdminUserSummaryDto>
|
||||
|
||||
suspend fun exists(userId: UUID): Boolean
|
||||
|
||||
@@ -46,11 +95,13 @@ interface AdminUsersRepository {
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto>
|
||||
|
||||
suspend fun listLatestLedger(
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto>
|
||||
}
|
||||
|
||||
@@ -60,21 +111,43 @@ class ExposedAdminUsersRepository(
|
||||
override suspend fun list(
|
||||
limit: Int,
|
||||
cursor: AdminUserCursor?,
|
||||
query: AdminUserListQuery,
|
||||
): List<AdminUserSummaryDto> = databaseFactory.query {
|
||||
val query = AdminUsersAccountsTable.selectAll()
|
||||
if (cursor != null) {
|
||||
query.where {
|
||||
(AdminUsersAccountsTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersAccountsTable.id less cursor.userId.toString())
|
||||
)
|
||||
val statement = AdminUsersAccountsTable.selectAll()
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminUsersAccountsTable.createdAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminUsersAccountsTable.createdAt less until }
|
||||
}
|
||||
query.status?.let { status ->
|
||||
statement.andWhere {
|
||||
AdminUsersAccountsTable.antiAbuseRestricted eq
|
||||
(status == AdminUserStatus.SUSPENDED)
|
||||
}
|
||||
}
|
||||
val accountRows = query
|
||||
if (cursor != null) {
|
||||
statement.andWhere {
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
(AdminUsersAccountsTable.createdAt greater cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersAccountsTable.id greater cursor.userId.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminUsersAccountsTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersAccountsTable.id less cursor.userId.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
val accountRows = statement
|
||||
.orderBy(
|
||||
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
|
||||
AdminUsersAccountsTable.id to SortOrder.DESC,
|
||||
AdminUsersAccountsTable.createdAt to sortOrder,
|
||||
AdminUsersAccountsTable.id to sortOrder,
|
||||
)
|
||||
.limit(limit)
|
||||
.toList()
|
||||
@@ -85,12 +158,27 @@ class ExposedAdminUsersRepository(
|
||||
override suspend fun findByIdSuffix(
|
||||
suffix: String,
|
||||
limit: Int,
|
||||
query: AdminUserListQuery,
|
||||
): List<AdminUserSummaryDto> = databaseFactory.query {
|
||||
val accountRows = AdminUsersAccountsTable.selectAll()
|
||||
val statement = AdminUsersAccountsTable.selectAll()
|
||||
.where { AdminUsersAccountsTable.id like "%$suffix" }
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminUsersAccountsTable.createdAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminUsersAccountsTable.createdAt less until }
|
||||
}
|
||||
query.status?.let { status ->
|
||||
statement.andWhere {
|
||||
AdminUsersAccountsTable.antiAbuseRestricted eq
|
||||
(status == AdminUserStatus.SUSPENDED)
|
||||
}
|
||||
}
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
val accountRows = statement
|
||||
.orderBy(
|
||||
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
|
||||
AdminUsersAccountsTable.id to SortOrder.DESC,
|
||||
AdminUsersAccountsTable.createdAt to sortOrder,
|
||||
AdminUsersAccountsTable.id to sortOrder,
|
||||
)
|
||||
.limit(limit)
|
||||
.toList()
|
||||
@@ -155,25 +243,41 @@ class ExposedAdminUsersRepository(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
|
||||
val query = AdminUsersCreditLedgerTable.selectAll()
|
||||
if (cursor == null) {
|
||||
query.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
|
||||
} else {
|
||||
query.where {
|
||||
(AdminUsersCreditLedgerTable.userId eq userId.toString()) and
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
)
|
||||
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
|
||||
val statement = AdminUsersCreditLedgerTable.selectAll()
|
||||
.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
|
||||
}
|
||||
query.type?.let { type ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
|
||||
}
|
||||
if (cursor != null) {
|
||||
statement.andWhere {
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val ledger = query.orderBy(
|
||||
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
|
||||
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
val ledger = statement.orderBy(
|
||||
AdminUsersCreditLedgerTable.createdAt to sortOrder,
|
||||
AdminUsersCreditLedgerTable.id to sortOrder,
|
||||
)
|
||||
.limit(limit)
|
||||
.map(ResultRow::toUserLedgerRow)
|
||||
@@ -184,20 +288,40 @@ class ExposedAdminUsersRepository(
|
||||
override suspend fun listLatestLedger(
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
|
||||
val query = AdminUsersCreditLedgerTable.selectAll()
|
||||
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
|
||||
val statement = AdminUsersCreditLedgerTable.selectAll()
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
|
||||
}
|
||||
query.type?.let { type ->
|
||||
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
|
||||
}
|
||||
if (cursor != null) {
|
||||
query.where {
|
||||
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
statement.andWhere {
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
|
||||
(
|
||||
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
|
||||
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val ledger = query.orderBy(
|
||||
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
|
||||
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
val ledger = statement.orderBy(
|
||||
AdminUsersCreditLedgerTable.createdAt to sortOrder,
|
||||
AdminUsersCreditLedgerTable.id to sortOrder,
|
||||
)
|
||||
.limit(limit)
|
||||
.map(ResultRow::toUserLedgerRow)
|
||||
@@ -413,3 +537,6 @@ private fun ResultRow.toUserReferralBindingRow() = UserReferralBindingRow(
|
||||
|
||||
private inline fun <T> Iterable<T>.exactSumOf(value: (T) -> Long): Long =
|
||||
fold(0L) { total, item -> Math.addExact(total, value(item)) }
|
||||
|
||||
private fun AdminSortOrder.toExposedSortOrder(): SortOrder =
|
||||
if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC
|
||||
|
||||
+57
-20
@@ -1,11 +1,15 @@
|
||||
package com.osglab.account.features.admin.users.services
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserPageDto
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Instant
|
||||
@@ -17,18 +21,26 @@ class AdminUserNotFoundException : RuntimeException("Admin user view does not ex
|
||||
class AdminUsersService(
|
||||
private val repository: AdminUsersRepository,
|
||||
) {
|
||||
suspend fun searchByInternalId(query: String): AdminUserPageDto {
|
||||
suspend fun searchByInternalId(
|
||||
query: String,
|
||||
listQuery: AdminUserListQuery = AdminUserListQuery(),
|
||||
): AdminUserPageDto {
|
||||
val normalized = query.trim()
|
||||
val userId = runCatching { UUID.fromString(normalized) }.getOrNull()
|
||||
if (userId != null) {
|
||||
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
|
||||
?.takeIf { it.matches(listQuery) }
|
||||
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
|
||||
}
|
||||
if (!SHORT_INTERNAL_ID.matches(normalized)) {
|
||||
return AdminUserPageDto(emptyList(), null)
|
||||
}
|
||||
return AdminUserPageDto(
|
||||
items = repository.findByIdSuffix(normalized.lowercase(), limit = MAX_SHORT_ID_MATCHES),
|
||||
items = repository.findByIdSuffix(
|
||||
normalized.lowercase(),
|
||||
limit = MAX_SHORT_ID_MATCHES,
|
||||
query = listQuery,
|
||||
),
|
||||
nextCursor = null,
|
||||
)
|
||||
}
|
||||
@@ -36,10 +48,11 @@ class AdminUsersService(
|
||||
suspend fun list(
|
||||
limit: Int = 50,
|
||||
cursor: String? = null,
|
||||
query: AdminUserListQuery = AdminUserListQuery(),
|
||||
): AdminUserPageDto {
|
||||
require(limit in 1..100) { "User page limit must be between 1 and 100" }
|
||||
val decodedCursor = cursor?.let(AdminUserCursorCodec::decode)
|
||||
val results = repository.list(limit + 1, decodedCursor)
|
||||
val decodedCursor = cursor?.let { AdminUserCursorCodec.decode(it, query.order) }
|
||||
val results = repository.list(limit + 1, decodedCursor, query)
|
||||
val hasMore = results.size > limit
|
||||
val items = results.take(limit)
|
||||
val nextCursor = if (hasMore) {
|
||||
@@ -49,6 +62,7 @@ class AdminUsersService(
|
||||
createdAt = Instant.parse(last.createdAt),
|
||||
userId = UUID.fromString(last.id),
|
||||
),
|
||||
query.order,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -68,26 +82,31 @@ class AdminUsersService(
|
||||
userId: UUID,
|
||||
limit: Int = 50,
|
||||
cursor: String? = null,
|
||||
query: AdminLedgerQuery = AdminLedgerQuery(),
|
||||
): AdminUserLedgerPageDto {
|
||||
return ledgerPage(limit, cursor) { pageSize, decodedCursor ->
|
||||
return ledgerPage(limit, cursor, query) { pageSize, decodedCursor ->
|
||||
if (!repository.exists(userId)) throw AdminUserNotFoundException()
|
||||
repository.listLedger(userId, pageSize, decodedCursor)
|
||||
repository.listLedger(userId, pageSize, decodedCursor, query)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun latestLedger(
|
||||
limit: Int = 100,
|
||||
cursor: String? = null,
|
||||
query: AdminLedgerQuery = AdminLedgerQuery(),
|
||||
): AdminUserLedgerPageDto =
|
||||
ledgerPage(limit, cursor, repository::listLatestLedger)
|
||||
ledgerPage(limit, cursor, query) { pageSize, decodedCursor ->
|
||||
repository.listLatestLedger(pageSize, decodedCursor, query)
|
||||
}
|
||||
|
||||
private suspend fun ledgerPage(
|
||||
limit: Int,
|
||||
cursor: String?,
|
||||
query: AdminLedgerQuery,
|
||||
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
|
||||
): AdminUserLedgerPageDto {
|
||||
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
|
||||
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
|
||||
val decodedCursor = cursor?.let { AdminUserLedgerCursorCodec.decode(it, query.order) }
|
||||
val results = load(limit + 1, decodedCursor)
|
||||
val hasMore = results.size > limit
|
||||
val items = results.take(limit)
|
||||
@@ -98,6 +117,7 @@ class AdminUsersService(
|
||||
createdAt = Instant.parse(last.createdAt),
|
||||
ledgerEntryId = UUID.fromString(last.id),
|
||||
),
|
||||
query.order,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -109,14 +129,27 @@ class AdminUsersService(
|
||||
private val SHORT_INTERNAL_ID = Regex("^[A-Fa-f0-9]{8}$")
|
||||
private const val MAX_SHORT_ID_MATCHES = 100
|
||||
|
||||
private fun com.osglab.account.features.admin.users.models.AdminUserSummaryDto.matches(
|
||||
query: AdminUserListQuery,
|
||||
): Boolean {
|
||||
val createdAt = Instant.parse(createdAt)
|
||||
return query.time.from?.let { createdAt >= it } != false &&
|
||||
query.time.until?.let { createdAt < it } != false &&
|
||||
when (query.status) {
|
||||
AdminUserStatus.ACTIVE -> !antiAbuseRestricted
|
||||
AdminUserStatus.SUSPENDED -> antiAbuseRestricted
|
||||
null -> true
|
||||
}
|
||||
}
|
||||
|
||||
internal object AdminUserCursorCodec {
|
||||
fun encode(cursor: AdminUserCursor): String {
|
||||
val value = "${cursor.createdAt}|${cursor.userId}"
|
||||
fun encode(cursor: AdminUserCursor, order: AdminSortOrder): String {
|
||||
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.userId}"
|
||||
return Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
fun decode(value: String): AdminUserCursor {
|
||||
fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserCursor {
|
||||
require(value.length in 1..256) { "User cursor is invalid" }
|
||||
return try {
|
||||
val decoded = String(
|
||||
@@ -124,10 +157,12 @@ internal object AdminUserCursorCodec {
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split('|')
|
||||
require(parts.size == 2)
|
||||
require(parts.size == 4)
|
||||
require(parts[0] == "v1")
|
||||
require(parts[1] == expectedOrder.name)
|
||||
AdminUserCursor(
|
||||
createdAt = Instant.parse(parts[0]),
|
||||
userId = UUID.fromString(parts[1]),
|
||||
createdAt = Instant.parse(parts[2]),
|
||||
userId = UUID.fromString(parts[3]),
|
||||
)
|
||||
} catch (failure: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("User cursor is invalid", failure)
|
||||
@@ -138,13 +173,13 @@ internal object AdminUserCursorCodec {
|
||||
internal object AdminUserLedgerCursorCodec {
|
||||
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
|
||||
|
||||
fun encode(cursor: AdminUserLedgerCursor): String {
|
||||
val value = "${cursor.createdAt}|${cursor.ledgerEntryId}"
|
||||
fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String {
|
||||
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}"
|
||||
return Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
fun decode(value: String): AdminUserLedgerCursor {
|
||||
fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserLedgerCursor {
|
||||
require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
|
||||
return try {
|
||||
val decoded = String(
|
||||
@@ -152,10 +187,12 @@ internal object AdminUserLedgerCursorCodec {
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split('|')
|
||||
require(parts.size == 2)
|
||||
require(parts.size == 4)
|
||||
require(parts[0] == "v1")
|
||||
require(parts[1] == expectedOrder.name)
|
||||
AdminUserLedgerCursor(
|
||||
createdAt = Instant.parse(parts[0]),
|
||||
ledgerEntryId = UUID.fromString(parts[1]),
|
||||
createdAt = Instant.parse(parts[2]),
|
||||
ledgerEntryId = UUID.fromString(parts[3]),
|
||||
)
|
||||
} catch (failure: IllegalArgumentException) {
|
||||
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE INDEX idx_accounts_restricted_created_id
|
||||
ON accounts (anti_abuse_restricted, created_at, id);
|
||||
|
||||
CREATE INDEX idx_credit_ledger_type_created_id
|
||||
ON credit_ledger (entry_type, created_at, id);
|
||||
|
||||
CREATE INDEX idx_credit_ledger_user_type_created_id
|
||||
ON credit_ledger (user_id, entry_type, created_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_audit_action_outcome_occurred_id
|
||||
ON admin_audit_log (action, outcome, occurred_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_audit_outcome_occurred_id
|
||||
ON admin_audit_log (outcome, occurred_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_operators_created_id
|
||||
ON admin_operators (created_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_operators_role_status_created_id
|
||||
ON admin_operators (role, disabled_at, created_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_operators_locked_created_id
|
||||
ON admin_operators (locked_until, created_at, id);
|
||||
|
||||
CREATE INDEX idx_admin_operators_last_login_id
|
||||
ON admin_operators (last_login_at, id);
|
||||
@@ -1,14 +1,18 @@
|
||||
package com.osglab.account.features.admin
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminAuditCursor
|
||||
import com.osglab.account.features.admin.models.AdminAuditQuery
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
@@ -24,6 +28,7 @@ internal class InMemoryAdminRepository(
|
||||
var passwordHash: String = "valid-password-hash",
|
||||
var encryptedTotpSecret: String,
|
||||
var role: AdminRole = AdminRole.SUPER_ADMIN,
|
||||
var lastLoginAt: Instant? = null,
|
||||
) : AdminRepository {
|
||||
private val mutex = Mutex()
|
||||
var lockState = AdminLockState(0, null)
|
||||
@@ -66,19 +71,22 @@ internal class InMemoryAdminRepository(
|
||||
override suspend fun listOperatorsPage(
|
||||
limit: Int,
|
||||
before: AdminOperatorCursor?,
|
||||
query: AdminOperatorQuery,
|
||||
): List<AdminOperatorRecord> = mutex.withLock {
|
||||
require(limit in 1..101)
|
||||
(listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord))
|
||||
.asSequence()
|
||||
.filter {
|
||||
before == null ||
|
||||
it.createdAt.isAfter(before.createdAt) ||
|
||||
(it.createdAt == before.createdAt && it.id.toString() > before.id.toString())
|
||||
query.time.from?.let { from -> it.createdAt >= from } != false &&
|
||||
query.time.until?.let { until -> it.createdAt < until } != false &&
|
||||
query.role?.let { role -> it.role == role } != false &&
|
||||
query.enabled?.let { enabled -> (it.disabledAt == null) == enabled } != false &&
|
||||
query.locked?.let { locked ->
|
||||
(it.lockState.lockedUntil?.isAfter(query.now) == true) == locked
|
||||
} != false
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<AdminOperatorRecord> { it.createdAt }
|
||||
.thenBy { it.id.toString() },
|
||||
)
|
||||
.filter { before == null || operatorAfter(it, before, query) }
|
||||
.sortedWith(operatorComparator(query))
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
@@ -307,20 +315,40 @@ internal class InMemoryAdminRepository(
|
||||
override suspend fun listAudit(
|
||||
limit: Int,
|
||||
before: AdminAuditCursor?,
|
||||
query: AdminAuditQuery,
|
||||
): List<AdminAuditRecord> =
|
||||
mutex.withLock {
|
||||
audits.asSequence()
|
||||
.filter {
|
||||
query.time.from?.let { from -> it.occurredAt >= from } != false &&
|
||||
query.time.until?.let { until -> it.occurredAt < until } != false &&
|
||||
query.action?.let { action -> it.action == action } != false &&
|
||||
query.outcome?.let { outcome -> it.outcome == outcome } != false
|
||||
}
|
||||
.filter {
|
||||
before == null ||
|
||||
it.occurredAt.isBefore(before.occurredAt) ||
|
||||
(
|
||||
it.occurredAt == before.occurredAt &&
|
||||
it.id.toString() < before.id.toString()
|
||||
)
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
it.occurredAt > before.occurredAt ||
|
||||
(
|
||||
it.occurredAt == before.occurredAt &&
|
||||
it.id.toString() > before.id.toString()
|
||||
)
|
||||
} else {
|
||||
it.occurredAt < before.occurredAt ||
|
||||
(
|
||||
it.occurredAt == before.occurredAt &&
|
||||
it.id.toString() < before.id.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
|
||||
.thenByDescending { it.id.toString() },
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
compareBy<NewAdminAuditEvent> { it.occurredAt }
|
||||
.thenBy { it.id.toString() }
|
||||
} else {
|
||||
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
|
||||
.thenByDescending { it.id.toString() }
|
||||
},
|
||||
)
|
||||
.take(limit)
|
||||
.map {
|
||||
@@ -344,6 +372,27 @@ internal class InMemoryAdminRepository(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun configureOperatorListState(
|
||||
targetId: UUID,
|
||||
disabledAt: Instant? = null,
|
||||
lockState: AdminLockState = AdminLockState(0, null),
|
||||
lastLoginAt: Instant? = null,
|
||||
) {
|
||||
mutex.withLock {
|
||||
if (targetId == operatorId) {
|
||||
this.disabledAt = disabledAt
|
||||
this.lockState = lockState
|
||||
this.lastLoginAt = lastLoginAt
|
||||
} else {
|
||||
additionalOperators.getValue(targetId).apply {
|
||||
this.disabledAt = disabledAt
|
||||
this.lockState = lockState
|
||||
this.lastLoginAt = lastLoginAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authRecord() = AdminOperatorAuthRecord(
|
||||
id = operatorId,
|
||||
normalizedUsername = username,
|
||||
@@ -360,7 +409,7 @@ internal class InMemoryAdminRepository(
|
||||
role = role,
|
||||
lockState = lockState,
|
||||
disabledAt = disabledAt,
|
||||
lastLoginAt = null,
|
||||
lastLoginAt = lastLoginAt,
|
||||
createdAt = Instant.EPOCH,
|
||||
updatedAt = Instant.EPOCH,
|
||||
)
|
||||
@@ -391,6 +440,7 @@ internal class InMemoryAdminRepository(
|
||||
var lockState: AdminLockState = AdminLockState(0, null),
|
||||
var lastTotpCounter: Long? = null,
|
||||
var disabledAt: Instant? = null,
|
||||
var lastLoginAt: Instant? = null,
|
||||
var updatedAt: Instant = operator.createdAt,
|
||||
) {
|
||||
fun toAuthRecord() = AdminOperatorAuthRecord(
|
||||
@@ -409,7 +459,7 @@ internal class InMemoryAdminRepository(
|
||||
role = operator.role,
|
||||
lockState = lockState,
|
||||
disabledAt = disabledAt,
|
||||
lastLoginAt = null,
|
||||
lastLoginAt = lastLoginAt,
|
||||
createdAt = operator.createdAt,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
@@ -424,3 +474,64 @@ private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): N
|
||||
com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED
|
||||
},
|
||||
)
|
||||
|
||||
private fun operatorComparator(query: AdminOperatorQuery): Comparator<AdminOperatorRecord> =
|
||||
Comparator { left, right ->
|
||||
val primary = when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT ->
|
||||
orderedComparison(left.createdAt.compareTo(right.createdAt), query.order)
|
||||
AdminOperatorSort.USERNAME ->
|
||||
orderedComparison(left.normalizedUsername.compareTo(right.normalizedUsername), query.order)
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> compareNullableLast(
|
||||
left.lastLoginAt,
|
||||
right.lastLoginAt,
|
||||
query.order,
|
||||
)
|
||||
}
|
||||
if (primary != 0) {
|
||||
primary
|
||||
} else {
|
||||
orderedComparison(left.id.toString().compareTo(right.id.toString()), query.order)
|
||||
}
|
||||
}
|
||||
|
||||
private fun operatorAfter(
|
||||
record: AdminOperatorRecord,
|
||||
cursor: AdminOperatorCursor,
|
||||
query: AdminOperatorQuery,
|
||||
): Boolean {
|
||||
val primary = when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT -> orderedComparison(
|
||||
record.createdAt.compareTo(Instant.parse(requireNotNull(cursor.value))),
|
||||
query.order,
|
||||
)
|
||||
AdminOperatorSort.USERNAME -> orderedComparison(
|
||||
record.normalizedUsername.compareTo(requireNotNull(cursor.value)),
|
||||
query.order,
|
||||
)
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> compareNullableLast(
|
||||
record.lastLoginAt,
|
||||
cursor.value?.let(Instant::parse),
|
||||
query.order,
|
||||
)
|
||||
}
|
||||
return primary > 0 ||
|
||||
(
|
||||
primary == 0 &&
|
||||
orderedComparison(record.id.toString().compareTo(cursor.id.toString()), query.order) > 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T : Comparable<T>> compareNullableLast(
|
||||
left: T?,
|
||||
right: T?,
|
||||
order: AdminSortOrder,
|
||||
): Int = when {
|
||||
left == null && right == null -> 0
|
||||
left == null -> 1
|
||||
right == null -> -1
|
||||
else -> orderedComparison(left.compareTo(right), order)
|
||||
}
|
||||
|
||||
private fun orderedComparison(value: Int, order: AdminSortOrder): Int =
|
||||
if (order == AdminSortOrder.ASC) value else -value
|
||||
|
||||
+85
@@ -4,12 +4,18 @@ import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminAuditQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.async
|
||||
@@ -175,6 +181,85 @@ class AdminOperatorRepositoryIntegrationTest : FunSpec({
|
||||
(repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true
|
||||
}
|
||||
}
|
||||
|
||||
test("MySQL list queries preserve filters keyset order and null-last semantics") {
|
||||
withAdminRepositories { repository, _ ->
|
||||
val from = Instant.parse("2026-08-17T00:00:00Z")
|
||||
val until = from.plusSeconds(120)
|
||||
val neverLoggedIn = UUID.fromString("10000000-0000-0000-0000-000000000001")
|
||||
val earlier = UUID.fromString("20000000-0000-0000-0000-000000000002")
|
||||
val later = UUID.fromString("30000000-0000-0000-0000-000000000003")
|
||||
listOf(neverLoggedIn, earlier, later).forEachIndexed { index, id ->
|
||||
repository.createOperatorIfAbsent(
|
||||
newOperator(id, "query-operator-$index", AdminRole.SUPPORT, from),
|
||||
)
|
||||
}
|
||||
listOf(earlier to from.plusSeconds(10), later to from.plusSeconds(20)).forEach {
|
||||
(operatorId, loginAt) ->
|
||||
repository.createSessionIfTotpCounterFresh(
|
||||
session = NewAdminSession(
|
||||
id = UUID.randomUUID(),
|
||||
operatorId = operatorId,
|
||||
tokenHash = operatorId.toString().replace("-", "").padEnd(64, '0'),
|
||||
csrfTokenHash = operatorId.toString().replace("-", "").padEnd(64, 'f'),
|
||||
createdAt = loginAt,
|
||||
expiresAt = loginAt.plusSeconds(60),
|
||||
),
|
||||
totpCounter = 1,
|
||||
now = loginAt,
|
||||
auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, loginAt),
|
||||
)
|
||||
}
|
||||
|
||||
val operators = repository.listOperatorsPage(
|
||||
limit = 10,
|
||||
query = AdminOperatorQuery(
|
||||
time = AdminTimeFilter(from, until),
|
||||
role = AdminRole.SUPPORT,
|
||||
sort = AdminOperatorSort.LAST_LOGIN_AT,
|
||||
order = AdminSortOrder.DESC,
|
||||
now = until,
|
||||
),
|
||||
)
|
||||
operators.map { it.id } shouldContainExactly listOf(later, earlier, neverLoggedIn)
|
||||
|
||||
val lowerAuditId = UUID.fromString("40000000-0000-0000-0000-000000000004")
|
||||
val higherAuditId = UUID.fromString("50000000-0000-0000-0000-000000000005")
|
||||
listOf(
|
||||
NewAdminAuditEvent(
|
||||
id = higherAuditId,
|
||||
actorOperatorId = null,
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
occurredAt = from,
|
||||
),
|
||||
NewAdminAuditEvent(
|
||||
id = lowerAuditId,
|
||||
actorOperatorId = null,
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
occurredAt = from,
|
||||
),
|
||||
NewAdminAuditEvent(
|
||||
actorOperatorId = null,
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
occurredAt = until,
|
||||
),
|
||||
).forEach { repository.appendAudit(it) }
|
||||
val audits = repository.listAudit(
|
||||
limit = 10,
|
||||
query = AdminAuditQuery(
|
||||
time = AdminTimeFilter(from, until),
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
order = AdminSortOrder.ASC,
|
||||
),
|
||||
)
|
||||
|
||||
audits.map { it.id } shouldContainExactly listOf(lowerAuditId, higherAuditId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private suspend fun withAdminRepositories(
|
||||
|
||||
@@ -188,7 +188,7 @@ class AdminRoutesTest {
|
||||
fun `operator list maps non-super authorization to stable forbidden response`() = testApplication {
|
||||
val sessionService = sessionFixture(AdminRole.SUPPORT)
|
||||
val operatorService = mockk<AdminOperatorService>()
|
||||
coEvery { operatorService.listPage(any(), any(), any()) } throws
|
||||
coEvery { operatorService.listPage(any(), any(), any(), any()) } throws
|
||||
AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
@@ -259,6 +259,34 @@ class AdminRoutesTest {
|
||||
response.bodyAsText() shouldContain """"usageType":"hotword""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin list routes reject non-whitelisted filters and invalid ranges`() = testApplication {
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
|
||||
)
|
||||
}
|
||||
val invalidPaths = listOf(
|
||||
"/v1/admin/users?from=2026-08-20T00:00:00Z&until=2026-08-20T00:00:00Z",
|
||||
"/v1/admin/credits/ledger?type=unknown",
|
||||
"/v1/admin/operators?enabled=1",
|
||||
"/v1/admin/audit?action=NOT_AN_ACTION",
|
||||
"/v1/admin/referrals?range=30d&limit=101",
|
||||
"/v1/admin/users?unexpected=value",
|
||||
"/v1/admin/users?from=2026-08-20T08:00:00%2B08:00",
|
||||
)
|
||||
|
||||
invalidPaths.forEach { path ->
|
||||
val response = client.get(path) {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status, path)
|
||||
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `operator creation maps normalized username conflict to 409`() = testApplication {
|
||||
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.features.admin.InMemoryAdminRepository
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminAuditQuery
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
@@ -66,6 +71,54 @@ class AdminAuditServiceTest : FunSpec({
|
||||
service.list(support, null)
|
||||
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
|
||||
}
|
||||
|
||||
test("audit filters use half-open boundaries and ascending ID tie-break") {
|
||||
val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted")
|
||||
val actor = superAdministrator().copy(operatorId = repository.operatorId)
|
||||
val from = Instant.parse("2026-08-17T00:00:00Z")
|
||||
val until = from.plusSeconds(60)
|
||||
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
|
||||
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
|
||||
listOf(
|
||||
auditEvent(lowerId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
|
||||
auditEvent(higherId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
|
||||
auditEvent(UUID.randomUUID(), from.minusNanos(1), AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
|
||||
auditEvent(UUID.randomUUID(), until, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
|
||||
auditEvent(UUID.randomUUID(), from.plusSeconds(1), AdminAuditAction.LOGIN_SUCCEEDED, AdminAuditOutcome.SUCCESS),
|
||||
).forEach { repository.appendAudit(it) }
|
||||
|
||||
val page = AdminAuditService(repository).list(
|
||||
actor = actor,
|
||||
cursor = null,
|
||||
query = AdminAuditQuery(
|
||||
time = AdminTimeFilter(from, until),
|
||||
action = AdminAuditAction.LOGIN_FAILED,
|
||||
outcome = AdminAuditOutcome.DENIED,
|
||||
order = AdminSortOrder.ASC,
|
||||
),
|
||||
)
|
||||
|
||||
page.items.map { it.record.id } shouldBe listOf(lowerId, higherId)
|
||||
}
|
||||
|
||||
test("audit cursor rejects the opposite order") {
|
||||
val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted")
|
||||
val actor = superAdministrator().copy(operatorId = repository.operatorId)
|
||||
listOf(
|
||||
auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:02Z")),
|
||||
auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:01Z")),
|
||||
).forEach { repository.appendAudit(it) }
|
||||
val service = AdminAuditService(repository)
|
||||
val first = service.list(actor, null, limit = 1)
|
||||
|
||||
shouldThrow<AdminAuditCursorException> {
|
||||
service.list(
|
||||
actor,
|
||||
first.nextCursor,
|
||||
query = AdminAuditQuery(order = AdminSortOrder.ASC),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private fun auditRecord(occurredAt: String) = AdminAuditRecord(
|
||||
@@ -79,6 +132,19 @@ private fun auditRecord(occurredAt: String) = AdminAuditRecord(
|
||||
occurredAt = Instant.parse(occurredAt),
|
||||
)
|
||||
|
||||
private fun auditEvent(
|
||||
id: UUID,
|
||||
occurredAt: Instant,
|
||||
action: AdminAuditAction = AdminAuditAction.LOGIN_SUCCEEDED,
|
||||
outcome: AdminAuditOutcome = AdminAuditOutcome.SUCCESS,
|
||||
) = NewAdminAuditEvent(
|
||||
id = id,
|
||||
actorOperatorId = null,
|
||||
action = action,
|
||||
outcome = outcome,
|
||||
occurredAt = occurredAt,
|
||||
)
|
||||
|
||||
private fun superAdministrator() = AdminPrincipal(
|
||||
operatorId = UUID.randomUUID(),
|
||||
sessionId = UUID.randomUUID(),
|
||||
|
||||
+102
@@ -6,9 +6,13 @@ import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.security.AdminPasswordHasher
|
||||
import com.osglab.account.features.admin.security.AdminTotpProvisioning
|
||||
@@ -216,6 +220,104 @@ class AdminOperatorServiceTest : FunSpec({
|
||||
fixture.service.listPage(fixture.owner, cursor = "not-a-cursor")
|
||||
}
|
||||
}
|
||||
|
||||
test("operator filters apply half-open time role enabled and current lock state") {
|
||||
val fixture = operatorFixture()
|
||||
val matching = fixture.seedOperator("locked-support", AdminRole.SUPPORT)
|
||||
val expired = fixture.seedOperator("expired-support", AdminRole.SUPPORT)
|
||||
val disabled = fixture.seedOperator("disabled-support", AdminRole.SUPPORT)
|
||||
fixture.repository.configureOperatorListState(
|
||||
matching,
|
||||
lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)),
|
||||
)
|
||||
fixture.repository.configureOperatorListState(
|
||||
expired,
|
||||
lockState = AdminLockState(2, fixture.clock.instant()),
|
||||
)
|
||||
fixture.repository.configureOperatorListState(
|
||||
disabled,
|
||||
disabledAt = fixture.clock.instant(),
|
||||
lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)),
|
||||
)
|
||||
|
||||
val page = fixture.service.listPage(
|
||||
actor = fixture.owner,
|
||||
cursor = null,
|
||||
query = AdminOperatorQuery(
|
||||
time = AdminTimeFilter(
|
||||
fixture.clock.instant(),
|
||||
fixture.clock.instant().plusNanos(1),
|
||||
),
|
||||
role = AdminRole.SUPPORT,
|
||||
enabled = true,
|
||||
locked = true,
|
||||
now = fixture.clock.instant(),
|
||||
),
|
||||
)
|
||||
|
||||
page.items.map(AdminOperatorRecord::id) shouldBe listOf(matching)
|
||||
}
|
||||
|
||||
test("last login sorting keeps null values last in both directions") {
|
||||
val fixture = operatorFixture()
|
||||
val earlier = fixture.seedOperator("earlier-login", AdminRole.SUPPORT)
|
||||
val later = fixture.seedOperator("later-login", AdminRole.SUPPORT)
|
||||
fixture.repository.configureOperatorListState(
|
||||
earlier,
|
||||
lastLoginAt = fixture.clock.instant().minusSeconds(60),
|
||||
)
|
||||
fixture.repository.configureOperatorListState(
|
||||
later,
|
||||
lastLoginAt = fixture.clock.instant(),
|
||||
)
|
||||
|
||||
val ascending = fixture.service.listPage(
|
||||
fixture.owner,
|
||||
cursor = null,
|
||||
query = AdminOperatorQuery(
|
||||
sort = AdminOperatorSort.LAST_LOGIN_AT,
|
||||
order = AdminSortOrder.ASC,
|
||||
now = fixture.clock.instant(),
|
||||
),
|
||||
)
|
||||
val descending = fixture.service.listPage(
|
||||
fixture.owner,
|
||||
cursor = null,
|
||||
query = AdminOperatorQuery(
|
||||
sort = AdminOperatorSort.LAST_LOGIN_AT,
|
||||
order = AdminSortOrder.DESC,
|
||||
now = fixture.clock.instant(),
|
||||
),
|
||||
)
|
||||
|
||||
ascending.items.map(AdminOperatorRecord::id) shouldBe
|
||||
listOf(earlier, later, fixture.owner.operatorId)
|
||||
descending.items.map(AdminOperatorRecord::id) shouldBe
|
||||
listOf(later, earlier, fixture.owner.operatorId)
|
||||
}
|
||||
|
||||
test("operator cursor rejects a different sort contract") {
|
||||
val fixture = operatorFixture()
|
||||
fixture.seedOperator("cursor-a", AdminRole.SUPPORT)
|
||||
fixture.seedOperator("cursor-b", AdminRole.SUPPORT)
|
||||
val first = fixture.service.listPage(
|
||||
fixture.owner,
|
||||
cursor = null,
|
||||
limit = 1,
|
||||
query = AdminOperatorQuery(now = fixture.clock.instant()),
|
||||
)
|
||||
|
||||
shouldThrow<AdminOperatorCursorException> {
|
||||
fixture.service.listPage(
|
||||
fixture.owner,
|
||||
cursor = first.nextCursor,
|
||||
query = AdminOperatorQuery(
|
||||
sort = AdminOperatorSort.USERNAME,
|
||||
now = fixture.clock.instant(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private data class OperatorFixture(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.osglab.account.features.admin.stats
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
|
||||
@@ -11,6 +13,7 @@ import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggre
|
||||
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
|
||||
import com.osglab.account.features.admin.stats.repositories.toExactLong
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.stats.services.AdminReferralSort
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
@@ -159,4 +162,40 @@ class AdminStatsRepositoryTest : FunSpec({
|
||||
BigDecimal("1.5").toExactLong()
|
||||
}
|
||||
}
|
||||
|
||||
test("referral ranking supports explicit metric order stable ID tie-break and limit") {
|
||||
val snapshot = AdminStatsSnapshot(
|
||||
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
|
||||
registrationsByDate = emptyMap(),
|
||||
issuedCreditsByDate = emptyMap(),
|
||||
consumedCreditsByDate = emptyMap(),
|
||||
referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0),
|
||||
referralRanking = listOf(
|
||||
AdminReferralRankDto("user-c", 2, 1, 20),
|
||||
AdminReferralRankDto("user-a", 3, 1, 20),
|
||||
AdminReferralRankDto("user-b", 1, 2, 10),
|
||||
),
|
||||
usage = emptyList(),
|
||||
)
|
||||
val service = AdminStatsService(AdminStatsRepository { snapshot })
|
||||
|
||||
val descending = service.get(
|
||||
from,
|
||||
until,
|
||||
referralRankLimit = 2,
|
||||
referralSort = AdminReferralSort.CREDITS_EARNED,
|
||||
referralOrder = AdminSortOrder.DESC,
|
||||
)
|
||||
val ascending = service.get(
|
||||
from,
|
||||
until,
|
||||
referralSort = AdminReferralSort.QUALIFIED,
|
||||
referralOrder = AdminSortOrder.ASC,
|
||||
)
|
||||
|
||||
descending.referralRanking.map(AdminReferralRankDto::userId) shouldBe
|
||||
listOf("user-c", "user-a")
|
||||
ascending.referralRanking.map(AdminReferralRankDto::userId) shouldBe
|
||||
listOf("user-a", "user-c", "user-b")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,8 +5,14 @@ import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
||||
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
@@ -223,6 +229,112 @@ class AdminUsersServiceTest : FunSpec({
|
||||
service.detail(UUID.randomUUID())
|
||||
}
|
||||
}
|
||||
|
||||
test("user filters use a UTC half-open range and ascending ID tie-break") {
|
||||
val boundary = Instant.parse("2026-08-15T00:00:00Z")
|
||||
val until = Instant.parse("2026-08-16T00:00:00Z")
|
||||
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
|
||||
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(
|
||||
listOf(
|
||||
summary(higherId, boundary, restricted = true),
|
||||
summary(lowerId, boundary, restricted = true),
|
||||
summary(UUID.randomUUID(), boundary.minusNanos(1), restricted = true),
|
||||
summary(UUID.randomUUID(), until, restricted = true),
|
||||
summary(UUID.randomUUID(), boundary.plusSeconds(1), restricted = false),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val page = service.list(
|
||||
query = AdminUserListQuery(
|
||||
time = AdminTimeFilter(boundary, until),
|
||||
status = AdminUserStatus.SUSPENDED,
|
||||
order = AdminSortOrder.ASC,
|
||||
),
|
||||
)
|
||||
|
||||
page.items.map(AdminUserSummaryDto::id) shouldBe
|
||||
listOf(lowerId.toString(), higherId.toString())
|
||||
}
|
||||
|
||||
test("ledger filters type and half-open range in ascending stable order") {
|
||||
val userId = UUID.randomUUID()
|
||||
val from = Instant.parse("2026-08-15T00:00:00Z")
|
||||
val until = from.plusSeconds(60)
|
||||
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
|
||||
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
|
||||
val entries = listOf(
|
||||
ledgerEntry(higherId, from, userId),
|
||||
ledgerEntry(lowerId, from, userId),
|
||||
ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId),
|
||||
ledgerEntry(UUID.randomUUID(), until, userId),
|
||||
ledgerEntry(UUID.randomUUID(), from.plusSeconds(1), userId, type = "USAGE_SETTLE"),
|
||||
)
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
|
||||
)
|
||||
|
||||
val page = service.ledger(
|
||||
userId = userId,
|
||||
query = AdminLedgerQuery(
|
||||
time = AdminTimeFilter(from, until),
|
||||
type = AdminLedgerType.GRANT,
|
||||
order = AdminSortOrder.ASC,
|
||||
),
|
||||
)
|
||||
|
||||
page.items.map(AdminUserLedgerEntryDto::id) shouldBe
|
||||
listOf(lowerId.toString(), higherId.toString())
|
||||
}
|
||||
|
||||
test("versioned user cursor rejects the opposite sort direction") {
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(
|
||||
listOf(
|
||||
summary(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")),
|
||||
summary(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")),
|
||||
),
|
||||
),
|
||||
)
|
||||
val first = service.list(limit = 1, query = AdminUserListQuery(order = AdminSortOrder.DESC))
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
service.list(
|
||||
cursor = first.nextCursor.shouldNotBeNull(),
|
||||
query = AdminUserListQuery(order = AdminSortOrder.ASC),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("versioned ledger cursor rejects the opposite sort direction") {
|
||||
val userId = UUID.randomUUID()
|
||||
val service = AdminUsersService(
|
||||
PagingUsersRepository(
|
||||
emptyList(),
|
||||
ledger = mapOf(
|
||||
userId to listOf(
|
||||
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z"), userId),
|
||||
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z"), userId),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val first = service.ledger(
|
||||
userId,
|
||||
limit = 1,
|
||||
query = AdminLedgerQuery(order = AdminSortOrder.DESC),
|
||||
)
|
||||
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
service.ledger(
|
||||
userId,
|
||||
cursor = first.nextCursor.shouldNotBeNull(),
|
||||
query = AdminLedgerQuery(order = AdminSortOrder.ASC),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private class PagingUsersRepository(
|
||||
@@ -233,15 +345,24 @@ private class PagingUsersRepository(
|
||||
override suspend fun list(
|
||||
limit: Int,
|
||||
cursor: AdminUserCursor?,
|
||||
query: AdminUserListQuery,
|
||||
): List<AdminUserSummaryDto> =
|
||||
users.filter {
|
||||
cursor == null ||
|
||||
Instant.parse(it.createdAt) < cursor.createdAt ||
|
||||
(
|
||||
Instant.parse(it.createdAt) == cursor.createdAt &&
|
||||
UUID.fromString(it.id).toString() < cursor.userId.toString()
|
||||
)
|
||||
}.take(limit)
|
||||
users.asSequence()
|
||||
.filter { it.matches(query) }
|
||||
.filter {
|
||||
cursor == null || userAfter(it, cursor, query.order)
|
||||
}
|
||||
.sortedWith(
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
compareBy<AdminUserSummaryDto> { Instant.parse(it.createdAt) }
|
||||
.thenBy(AdminUserSummaryDto::id)
|
||||
} else {
|
||||
compareByDescending<AdminUserSummaryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserSummaryDto::id)
|
||||
},
|
||||
)
|
||||
.take(limit)
|
||||
.toList()
|
||||
|
||||
override suspend fun findDetail(
|
||||
userId: UUID,
|
||||
@@ -251,8 +372,11 @@ private class PagingUsersRepository(
|
||||
override suspend fun findByIdSuffix(
|
||||
suffix: String,
|
||||
limit: Int,
|
||||
query: AdminUserListQuery,
|
||||
): List<AdminUserSummaryDto> =
|
||||
users.filter { it.id.endsWith(suffix, ignoreCase = true) }.take(limit)
|
||||
users.filter {
|
||||
it.id.endsWith(suffix, ignoreCase = true) && it.matches(query)
|
||||
}.take(limit)
|
||||
|
||||
override suspend fun exists(userId: UUID): Boolean =
|
||||
userId in details || userId in ledger || users.any { it.id == userId.toString() }
|
||||
@@ -261,48 +385,108 @@ private class PagingUsersRepository(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto> =
|
||||
ledger[userId].orEmpty()
|
||||
.filter { it.matches(query) }
|
||||
.filter {
|
||||
val createdAt = Instant.parse(it.createdAt)
|
||||
cursor == null ||
|
||||
createdAt < cursor.createdAt ||
|
||||
(
|
||||
createdAt == cursor.createdAt &&
|
||||
it.id < cursor.ledgerEntryId.toString()
|
||||
)
|
||||
cursor == null || ledgerAfter(it, cursor, query.order)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id),
|
||||
ledgerComparator(query.order),
|
||||
)
|
||||
.take(limit)
|
||||
|
||||
override suspend fun listLatestLedger(
|
||||
limit: Int,
|
||||
cursor: AdminUserLedgerCursor?,
|
||||
query: AdminLedgerQuery,
|
||||
): List<AdminUserLedgerEntryDto> =
|
||||
ledger.values.flatten()
|
||||
.filter { it.matches(query) }
|
||||
.filter {
|
||||
val createdAt = Instant.parse(it.createdAt)
|
||||
cursor == null ||
|
||||
createdAt < cursor.createdAt ||
|
||||
(
|
||||
createdAt == cursor.createdAt &&
|
||||
it.id < cursor.ledgerEntryId.toString()
|
||||
)
|
||||
cursor == null || ledgerAfter(it, cursor, query.order)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id),
|
||||
ledgerComparator(query.order),
|
||||
)
|
||||
.take(limit)
|
||||
}
|
||||
|
||||
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
|
||||
private fun AdminUserSummaryDto.matches(query: AdminUserListQuery): Boolean {
|
||||
val instant = Instant.parse(createdAt)
|
||||
return query.time.from?.let { instant >= it } != false &&
|
||||
query.time.until?.let { instant < it } != false &&
|
||||
when (query.status) {
|
||||
AdminUserStatus.ACTIVE -> !antiAbuseRestricted
|
||||
AdminUserStatus.SUSPENDED -> antiAbuseRestricted
|
||||
null -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun userAfter(
|
||||
item: AdminUserSummaryDto,
|
||||
cursor: AdminUserCursor,
|
||||
order: AdminSortOrder,
|
||||
): Boolean {
|
||||
val createdAt = Instant.parse(item.createdAt)
|
||||
return if (order == AdminSortOrder.ASC) {
|
||||
createdAt > cursor.createdAt ||
|
||||
(createdAt == cursor.createdAt && item.id > cursor.userId.toString())
|
||||
} else {
|
||||
createdAt < cursor.createdAt ||
|
||||
(createdAt == cursor.createdAt && item.id < cursor.userId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean {
|
||||
val instant = Instant.parse(createdAt)
|
||||
val category = when (type) {
|
||||
"USAGE_RESERVE" -> AdminLedgerType.RESERVE
|
||||
"USAGE_SETTLE" -> AdminLedgerType.SETTLE
|
||||
"USAGE_RELEASE", "USAGE_REFUND" -> AdminLedgerType.REFUND
|
||||
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
|
||||
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT",
|
||||
-> AdminLedgerType.GRANT
|
||||
else -> AdminLedgerType.ADJUSTMENT
|
||||
}
|
||||
return query.time.from?.let { instant >= it } != false &&
|
||||
query.time.until?.let { instant < it } != false &&
|
||||
(query.type == null || query.type == category)
|
||||
}
|
||||
|
||||
private fun ledgerAfter(
|
||||
item: AdminUserLedgerEntryDto,
|
||||
cursor: AdminUserLedgerCursor,
|
||||
order: AdminSortOrder,
|
||||
): Boolean {
|
||||
val createdAt = Instant.parse(item.createdAt)
|
||||
return if (order == AdminSortOrder.ASC) {
|
||||
createdAt > cursor.createdAt ||
|
||||
(createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString())
|
||||
} else {
|
||||
createdAt < cursor.createdAt ||
|
||||
(createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun ledgerComparator(order: AdminSortOrder): Comparator<AdminUserLedgerEntryDto> =
|
||||
if (order == AdminSortOrder.ASC) {
|
||||
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenBy(AdminUserLedgerEntryDto::id)
|
||||
} else {
|
||||
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
|
||||
.thenByDescending(AdminUserLedgerEntryDto::id)
|
||||
}
|
||||
|
||||
private fun summary(
|
||||
id: UUID,
|
||||
createdAt: Instant,
|
||||
restricted: Boolean = false,
|
||||
) = AdminUserSummaryDto(
|
||||
id = id.toString(),
|
||||
createdAt = createdAt.toString(),
|
||||
antiAbuseRestricted = false,
|
||||
antiAbuseRestricted = restricted,
|
||||
creditBalance = 0,
|
||||
consumedCredits = 0,
|
||||
manualGrantedCredits = 0,
|
||||
@@ -316,10 +500,11 @@ private fun ledgerEntry(
|
||||
id: UUID,
|
||||
createdAt: Instant,
|
||||
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
|
||||
type: String = "MANUAL_GRANT",
|
||||
) = AdminUserLedgerEntryDto(
|
||||
id = id.toString(),
|
||||
userId = userId.toString(),
|
||||
type = "MANUAL_GRANT",
|
||||
type = type,
|
||||
amountDelta = 10,
|
||||
balanceAfter = 10,
|
||||
referenceId = null,
|
||||
|
||||
Reference in New Issue
Block a user