Add admin dashboard filtering and sorting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

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:
Rocky
2026-08-20 18:05:49 +08:00
parent 034a3e8745
commit 74c3fcd45f
39 changed files with 2990 additions and 431 deletions
+41 -19
View File
@@ -4,16 +4,21 @@ import type {
AdminOperatorProvisioning, AdminOperatorProvisioning,
AdminSecuritySummary, AdminSecuritySummary,
AdminLoginResponse, AdminLoginResponse,
AuditQuery,
AuditLogEntry, AuditLogEntry,
CreditGrantRequest, CreditGrantRequest,
CreditGrantResponse, CreditGrantResponse,
LedgerQuery,
LedgerEntry, LedgerEntry,
OperatorsQuery,
Overview, Overview,
PageResult, PageResult,
ProductAnalyticsOverview, ProductAnalyticsOverview,
ReferralsQuery,
ReferralOverview, ReferralOverview,
SessionResponse, SessionResponse,
UserDetail, UserDetail,
UsersQuery,
UserSummary, UserSummary,
} from "./types"; } 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(); const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { 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(); const result = search.toString();
return result ? `?${result}` : ""; 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 = { export const adminApi = {
session: () => request<SessionResponse>("/auth/session"), session: () => request<SessionResponse>("/auth/session"),
@@ -161,28 +172,35 @@ export const adminApi = {
logout: () => request<void>("/auth/logout", { method: "POST" }), logout: () => request<void>("/auth/logout", { method: "POST" }),
overview: (range: string) => overview: (range: string) =>
request<Overview>(`/overview${query({ range })}`), request<Overview>(`/overview${encodeQuery({ range })}`),
referrals: (range: string) => referrals: (value: string | ReferralsQuery) => {
request<ReferralOverview>(`/referrals${query({ range })}`), const params = typeof value === "string" ? { range: value } : value;
return request<ReferralOverview>(`/referrals${encodeQuery(params)}`);
},
productAnalytics: (range: string) => productAnalytics: (range: string) =>
request<ProductAnalyticsOverview>(`/analytics${query({ range })}`), request<ProductAnalyticsOverview>(`/analytics${encodeQuery({ range })}`),
users: (search = "", cursor?: string) => users: (value: string | UsersQuery = "", legacyCursor?: string) => {
request<PageResult<UserSummary>>( const params =
`/users${query({ q: search.trim(), cursor })}`, typeof value === "string"
), ? { q: value.trim(), cursor: legacyCursor }
: { ...value, q: value.q?.trim() };
return request<PageResult<UserSummary>>(`/users${encodeQuery(params)}`);
},
user: (userId: string) => user: (userId: string) =>
request<UserDetail>(`/users/${encodeURIComponent(userId)}`), request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
latestLedger: (cursor?: string) => latestLedger: (value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
ledger: (userId: string, cursor?: string) =>
request<PageResult<LedgerEntry>>( 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) => grantCredits: (payload: CreditGrantRequest) =>
@@ -196,11 +214,15 @@ export const adminApi = {
headers: { "Idempotency-Key": payload.idempotencyKey }, headers: { "Idempotency-Key": payload.idempotencyKey },
}), }),
auditLogs: (cursor?: string) => auditLogs: (value?: string | AuditQuery) =>
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`), request<PageResult<AuditLogEntry>>(
`/audit${encodeQuery(cursorQuery(value))}`,
),
operators: (cursor?: string) => operators: (value?: string | OperatorsQuery) =>
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`), request<PageResult<AdminOperator>>(
`/operators${encodeQuery(cursorQuery(value))}`,
),
operatorSummary: () => operatorSummary: () =>
request<AdminSecuritySummary>("/operators/summary"), request<AdminSecuritySummary>("/operators/summary"),
+50
View File
@@ -1,5 +1,55 @@
export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST"; 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 = export type AuthState =
| { status: "anonymous" } | { status: "anonymous" }
| { status: "authenticated"; operatorName: string; role: AdminRole }; | { 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 type { TrendPoint } from "../../api/types";
import { formatNumber } from "../../lib/format"; 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) { if (points.length === 0) {
return <div className="grid h-full place-items-center text-sm text-muted"></div>; 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 width = 760;
const height = 300; const height = 300;
@@ -53,26 +64,38 @@ export function TrendChart({ points }: { points: TrendPoint[] }) {
className="chart-grid-line" className="chart-grid-line"
/> />
))} ))}
{showRegistrations ? (
<text x={paddingX} y={paddingY - 9} className="chart-label"> <text x={paddingX} y={paddingY - 9} className="chart-label">
{formatNumber(registrationMax)} {formatNumber(registrationMax)}
</text> </text>
) : null}
{showCredits ? (
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label"> <text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
{formatNumber(creditMax)} {formatNumber(creditMax)}
</text> </text>
) : null}
{showRegistrations ? (
<polyline points={registrationLine} className="chart-line chart-line--primary" /> <polyline points={registrationLine} className="chart-line chart-line--primary" />
) : null}
{showCredits ? (
<polyline points={creditLine} className="chart-line chart-line--violet" /> <polyline points={creditLine} className="chart-line chart-line--violet" />
) : null}
{coordinates.map(({ point, x, registrationY, creditY }, index) => ( {coordinates.map(({ point, x, registrationY, creditY }, index) => (
<g key={point.date}> <g key={point.date}>
{showRegistrations ? (
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary"> <circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
<title> <title>
{point.date} {formatNumber(point.registrations)} {point.date} {formatNumber(point.registrations)}
</title> </title>
</circle> </circle>
) : null}
{showCredits ? (
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet"> <circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
<title> <title>
{point.date} {formatNumber(point.creditsUsed)} {point.date} {formatNumber(point.creditsUsed)}
</title> </title>
</circle> </circle>
) : null}
{index % labelEvery === 0 || index === points.length - 1 ? ( {index % labelEvery === 0 || index === points.length - 1 ? (
<text <text
x={x} x={x}
@@ -92,16 +115,16 @@ export function TrendChart({ points }: { points: TrendPoint[] }) {
<thead> <thead>
<tr> <tr>
<th scope="col">UTC </th> <th scope="col">UTC </th>
<th scope="col"></th> {showRegistrations ? <th scope="col"></th> : null}
<th scope="col"></th> {showCredits ? <th scope="col"></th> : null}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{points.map((point) => ( {points.map((point) => (
<tr key={point.date}> <tr key={point.date}>
<td>{point.date}</td> <td>{point.date}</td>
<td>{formatNumber(point.registrations)}</td> {showRegistrations ? <td>{formatNumber(point.registrations)}</td> : null}
<td>{formatNumber(point.creditsUsed)}</td> {showCredits ? <td>{formatNumber(point.creditsUsed)}</td> : null}
</tr> </tr>
))} ))}
</tbody> </tbody>
+47 -5
View File
@@ -5,7 +5,9 @@ import {
type LegacyColumnDef, type LegacyColumnDef,
useLegacyTable, useLegacyTable,
} from "@tanstack/react-table/legacy"; } from "@tanstack/react-table/legacy";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import type { SortOrder } from "../api/types";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { EmptyState } from "./primitives"; import { EmptyState } from "./primitives";
@@ -18,6 +20,9 @@ export function DataTable<T extends RowData>({
emptyTitle = "暂无数据", emptyTitle = "暂无数据",
footer, footer,
className, className,
sort,
sortableColumns,
onSortChange,
}: { }: {
data: T[]; data: T[];
columns: DataColumn<T>[]; columns: DataColumn<T>[];
@@ -25,6 +30,9 @@ export function DataTable<T extends RowData>({
emptyTitle?: string; emptyTitle?: string;
footer?: ReactNode; footer?: ReactNode;
className?: string; className?: string;
sort?: { key: string; order: SortOrder };
sortableColumns?: Record<string, string>;
onSortChange?: (key: string, order: SortOrder) => void;
}) { }) {
const table = useLegacyTable({ const table = useLegacyTable({
data, data,
@@ -42,16 +50,50 @@ export function DataTable<T extends RowData>({
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {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 <th
key={header.id} 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" 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 {header.isPlaceholder ? null : sortKey && onSortChange ? (
? null <button
: flexRender(header.column.columnDef.header, header.getContext())} 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> </th>
))} );
})}
</tr> </tr>
))} ))}
</thead> </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>
);
}
+57
View File
@@ -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, Target,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { adminApi } from "../../api/client"; 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 { CohortHeatmap } from "../../components/charts/cohort-heatmap";
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart"; import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
import { FunnelChart } from "../../components/charts/funnel-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 { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { RangeControl } from "../../components/range-control"; import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control";
import { formatNumber } from "../../lib/format"; import { formatNumber } from "../../lib/format";
import { stableSort } from "../../lib/sort";
export function AnalyticsPage() { export function AnalyticsPage() {
const [range, setRange] = useState("30d"); const [range, setRange] = useState("30d");
const [data, setData] = useState<ProductAnalyticsOverview>(); const [data, setData] = useState<ProductAnalyticsOverview>();
const [error, setError] = useState<unknown>(); 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 load = useCallback(async () => {
const version = ++requestVersion.current;
setError(undefined); setError(undefined);
try { try {
setData(await adminApi.productAnalytics(range)); const response = await adminApi.productAnalytics(range);
if (requestVersion.current === version) setData(response);
} catch (requestError) { } catch (requestError) {
setError(requestError); if (requestVersion.current === version) setError(requestError);
} }
}, [range]); }, [range]);
@@ -37,6 +57,47 @@ export function AnalyticsPage() {
void load(); void load();
}, [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 (error) return <ErrorState error={error} retry={() => void load()} />;
if (!data) return <LoadingState label="加载产品数据" />; if (!data) return <LoadingState label="加载产品数据" />;
@@ -100,7 +161,28 @@ export function AnalyticsPage() {
description="按首次 AI 成功日分组,未成熟窗口显示为 —" description="按首次 AI 成功日分组,未成熟窗口显示为 —"
icon={ChartNoAxesCombined} 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> </Card>
</section> </section>
@@ -111,13 +193,36 @@ export function AnalyticsPage() {
description="客户端功能与执行模式,仅包含白名单元数据" description="客户端功能与执行模式,仅包含白名单元数据"
icon={BrainCircuit} icon={BrainCircuit}
/> />
{data.aiFeatures.length === 0 ? ( <ChartToolbar label="AI 使用结构筛选与排序">
<p className="p-8 text-center text-sm text-muted"></p> <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 <ComparisonBarChart
items={[...data.aiFeatures] items={visibleAiFeatures.map((item) => ({
.sort((left, right) => right.successes - left.successes)
.map((item) => ({
id: `${item.feature}-${item.executionMode}`, id: `${item.feature}-${item.executionMode}`,
label: featureLabel(item.feature), label: featureLabel(item.feature),
value: item.successes, value: item.successes,
@@ -227,9 +332,25 @@ export function AnalyticsPage() {
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} /> <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]"> <div className="grid gap-0 xl:grid-cols-[1fr_260px]">
<ComparisonBarChart <ComparisonBarChart
items={data.growth.channels.map((channel) => ({ items={visibleChannels.map((channel) => ({
label: channelLabel(channel.channel), label: channelLabel(channel.channel),
value: channel.installations, value: channel.installations,
secondaryValue: channel.activated, secondaryValue: channel.activated,
@@ -237,7 +358,7 @@ export function AnalyticsPage() {
}))} }))}
primaryLabel="新增安装" primaryLabel="新增安装"
secondaryLabel="24 小时激活" secondaryLabel="24 小时激活"
emptyText="暂无渠道归因数据" emptyText="当前筛选条件下暂无渠道归因数据"
/> />
<MetricRows <MetricRows
rows={[ rows={[
+110 -42
View File
@@ -1,9 +1,16 @@
import { FileCheck2, ShieldCheck } from "lucide-react"; import { FileCheck2, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { useSearchParams } from "react-router-dom";
import { adminApi, ApiError } from "../../api/client"; import { adminApi } from "../../api/client";
import type { AuditLogEntry } from "../../api/types"; import type {
AdminAuditAction,
AuditLogEntry,
AuditQuery,
SortOrder,
} from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table"; import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import { import {
Badge, Badge,
Button, Button,
@@ -12,48 +19,56 @@ import {
LoadingState, LoadingState,
PageHeader, PageHeader,
} from "../../components/primitives"; } from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import { formatDateTime, statusLabel } from "../../lib/format"; import { formatDateTime, statusLabel } from "../../lib/format";
export function AuditPage() { export function AuditPage() {
const [items, setItems] = useState<AuditLogEntry[]>([]); const [searchParams, setSearchParams] = useSearchParams();
const [nextCursor, setNextCursor] = useState<string>(); const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [loading, setLoading] = useState(true); const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [loadingMore, setLoadingMore] = useState(false); const [action, setAction] = useState<"" | AdminAuditAction>(
const [error, setError] = useState<unknown>(); (searchParams.get("action") as AdminAuditAction | null) ?? "",
);
const load = useCallback(async () => { const [result, setResult] = useState<"" | AuditLogEntry["result"]>(
setLoading(true); (searchParams.get("result") as AuditLogEntry["result"] | null) ?? "",
setError(undefined); );
try { const [order, setOrder] = useState<SortOrder>(
const page = await adminApi.auditLogs(); searchParams.get("order") === "asc" ? "asc" : "desc",
setItems(page.items); );
setNextCursor(page.nextCursor); const auditQuery = useMemo<AuditQuery>(
} catch (requestError) { () => ({
setError(requestError); from: from || undefined,
} finally { until: until || undefined,
setLoading(false); 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(() => { useEffect(() => {
void load(); const params = new URLSearchParams();
}, [load]); if (from) params.set("from", from);
if (until) params.set("until", until);
async function loadMore() { if (action) params.set("action", action);
if (!nextCursor) return; if (result) params.set("result", result);
setLoadingMore(true); params.set("sort", "createdAt");
try { params.set("order", order);
const page = await adminApi.auditLogs(nextCursor); setSearchParams(params, { replace: true });
setItems((current) => [...current, ...page.items]); }, [action, from, order, result, setSearchParams, until]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载审计记录失败",
);
} finally {
setLoadingMore(false);
}
}
const columns = useMemo<DataColumn<AuditLogEntry>[]>( const columns = useMemo<DataColumn<AuditLogEntry>[]>(
() => [ () => [
@@ -140,9 +155,55 @@ export function AuditPage() {
</Card> </Card>
<Card className="overflow-hidden"> <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 ? ( {error ? (
<div className="p-6"> <div className="p-6">
<ErrorState error={error} retry={() => void load()} /> <ErrorState error={error} retry={reload} />
</div> </div>
) : loading ? ( ) : loading ? (
<LoadingState label="加载审计日志" /> <LoadingState label="加载审计日志" />
@@ -151,7 +212,14 @@ export function AuditPage() {
data={items} data={items}
columns={columns} columns={columns}
caption="管理员审计事件" caption="管理员审计事件"
emptyTitle="暂无审计记录" emptyTitle={
from || until || action || result
? "没有符合当前筛选条件的审计记录"
: "暂无审计记录"
}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
footer={ footer={
nextCursor ? ( nextCursor ? (
<div className="flex justify-center border-t border-border p-5"> <div className="flex justify-center border-t border-border p-5">
+97 -48
View File
@@ -1,8 +1,11 @@
import { ArrowDownUp, Coins, Search } from "lucide-react"; import { ArrowDownUp, Coins, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { adminApi, ApiError } from "../../api/client"; import { useSearchParams } from "react-router-dom";
import type { LedgerEntry } from "../../api/types"; import { adminApi } from "../../api/client";
import type { LedgerEntry, LedgerEntryType, LedgerQuery, SortOrder } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table"; import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import { import {
Badge, Badge,
Button, Button,
@@ -12,6 +15,8 @@ import {
LoadingState, LoadingState,
PageHeader, PageHeader,
} from "../../components/primitives"; } from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import { import {
formatDateTime, formatDateTime,
formatNumber, formatNumber,
@@ -19,58 +24,63 @@ import {
statusLabel, statusLabel,
usageTypeLabel, usageTypeLabel,
} from "../../lib/format"; } from "../../lib/format";
import { toast } from "sonner";
export function CreditsPage() { export function CreditsPage() {
const [query, setQuery] = useState(""); const [searchParams, setSearchParams] = useSearchParams();
const [activeUserId, setActiveUserId] = useState<string>(); const initialUserId = searchParams.get("userId") ?? "";
const [items, setItems] = useState<LedgerEntry[]>([]); const [query, setQuery] = useState(initialUserId);
const [nextCursor, setNextCursor] = useState<string>(); const [activeUserId, setActiveUserId] = useState(initialUserId);
const [loading, setLoading] = useState(true); const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [loadingMore, setLoadingMore] = useState(false); const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [error, setError] = useState<unknown>(); const [type, setType] = useState<"" | LedgerEntryType>(
(searchParams.get("type") as LedgerEntryType | null) ?? "",
const load = useCallback(async (userId?: string) => { );
setLoading(true); const [order, setOrder] = useState<SortOrder>(
setError(undefined); searchParams.get("order") === "asc" ? "asc" : "desc",
try { );
const page = userId const ledgerQuery = useMemo<LedgerQuery>(
? await adminApi.ledger(userId) () => ({
: await adminApi.latestLedger(); from: from || undefined,
setItems(page.items); until: until || undefined,
setNextCursor(page.nextCursor); type: type || undefined,
} catch (requestError) { sort: "createdAt",
setError(requestError); order,
} finally { limit: 50,
setLoading(false); }),
} [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(() => { useEffect(() => {
void load(); const params = new URLSearchParams();
}, [load]); if (activeUserId) params.set("userId", activeUserId);
if (from) params.set("from", from);
async function loadMore() { if (until) params.set("until", until);
if (!nextCursor) return; if (type) params.set("type", type);
setLoadingMore(true); params.set("sort", "createdAt");
try { params.set("order", order);
const page = activeUserId setSearchParams(params, { replace: true });
? await adminApi.ledger(activeUserId, nextCursor) }, [activeUserId, from, order, setSearchParams, type, until]);
: await adminApi.latestLedger(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : "加载流水失败");
} finally {
setLoadingMore(false);
}
}
function submit(event: FormEvent<HTMLFormElement>) { function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const userId = query.trim() || undefined; const userId = query.trim() || undefined;
setActiveUserId(userId); setActiveUserId(userId ?? "");
void load(userId);
} }
const columns = useMemo<DataColumn<LedgerEntry>[]>( const columns = useMemo<DataColumn<LedgerEntry>[]>(
@@ -171,6 +181,38 @@ export function CreditsPage() {
</Button> </Button>
</form> </form>
</div> </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"> <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 /> <Coins className="size-4 text-primary" aria-hidden />
@@ -186,7 +228,7 @@ export function CreditsPage() {
{error ? ( {error ? (
<div className="p-6"> <div className="p-6">
<ErrorState error={error} retry={() => void load(activeUserId)} /> <ErrorState error={error} retry={reload} />
</div> </div>
) : loading ? ( ) : loading ? (
<LoadingState label="加载积分流水" /> <LoadingState label="加载积分流水" />
@@ -195,7 +237,14 @@ export function CreditsPage() {
data={items} data={items}
columns={columns} columns={columns}
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"} caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
emptyTitle={activeUserId ? "该用户暂无积分流水" : "暂无积分流水"} emptyTitle={
activeUserId || from || until || type
? "没有符合当前筛选条件的流水"
: "暂无积分流水"
}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
footer={ footer={
nextCursor ? ( nextCursor ? (
<div className="flex justify-center border-t border-border p-5"> <div className="flex justify-center border-t border-border p-5">
@@ -4,28 +4,39 @@ import {
CreditCard, CreditCard,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { adminApi } from "../../api/client"; 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 { ChartLegend } from "../../components/charts/chart-legend";
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart"; import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
import { RadialMetric } from "../../components/charts/radial-metric"; import { RadialMetric } from "../../components/charts/radial-metric";
import { TrendChart } from "../../components/charts/trend-chart"; import { TrendChart } from "../../components/charts/trend-chart";
import { ToggleFilter } from "../../components/filter-control";
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives"; import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { RangeControl } from "../../components/range-control"; import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control";
import { formatNumber, usageTypeLabel } from "../../lib/format"; import { formatNumber, usageTypeLabel } from "../../lib/format";
import { stableSort } from "../../lib/sort";
export function OverviewPage() { export function OverviewPage() {
const [range, setRange] = useState("30d"); const [range, setRange] = useState("30d");
const [data, setData] = useState<Overview>(); const [data, setData] = useState<Overview>();
const [error, setError] = useState<unknown>(); 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 load = useCallback(async () => {
const version = ++requestVersion.current;
setError(undefined); setError(undefined);
try { try {
setData(await adminApi.overview(range)); const response = await adminApi.overview(range);
if (requestVersion.current === version) setData(response);
} catch (requestError) { } catch (requestError) {
setError(requestError); if (requestVersion.current === version) setError(requestError);
} }
}, [range]); }, [range]);
@@ -33,6 +44,16 @@ export function OverviewPage() {
void load(); void load();
}, [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 (error) return <ErrorState error={error} retry={() => void load()} />;
if (!data) return <LoadingState label="加载运营总览" />; if (!data) return <LoadingState label="加载运营总览" />;
@@ -88,13 +109,29 @@ export function OverviewPage() {
</div> </div>
<ChartLegend <ChartLegend
items={[ items={[
{ label: "新增用户", tone: "primary" }, ...(showRegistrations ? [{ label: "新增用户", tone: "primary" as const }] : []),
{ label: "积分消耗", tone: "violet" }, ...(showCredits ? [{ label: "积分消耗", tone: "violet" as const }] : []),
]} ]}
/> />
</div> </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"> <div className="h-[320px] w-full">
<TrendChart points={data.trend} /> <TrendChart
points={data.trend}
showRegistrations={showRegistrations}
showCredits={showCredits}
/>
</div> </div>
</Card> </Card>
@@ -129,13 +166,25 @@ export function OverviewPage() {
<p className="mt-1 text-xs text-muted"></p> <p className="mt-1 text-xs text-muted"></p>
</div> </div>
</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 ? ( {data.usage.length === 0 ? (
<div className="p-8 text-center text-sm text-muted">使</div> <div className="p-8 text-center text-sm text-muted">使</div>
) : ( ) : (
<ComparisonBarChart <ComparisonBarChart
items={[...data.usage] items={sortedUsage.map((item) => ({
.sort((left, right) => right.chargedCredits - left.chargedCredits)
.map((item) => ({
label: usageTypeLabel(item.kind), label: usageTypeLabel(item.kind),
value: item.chargedCredits, value: item.chargedCredits,
hint: `${formatNumber(item.requests)} 次请求`, hint: `${formatNumber(item.requests)} 次请求`,
@@ -1,11 +1,19 @@
import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-react"; 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 { 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 { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
import { FunnelChart } from "../../components/charts/funnel-chart"; import { FunnelChart } from "../../components/charts/funnel-chart";
import { RadialMetric } from "../../components/charts/radial-metric"; import { RadialMetric } from "../../components/charts/radial-metric";
import { DataTable, type DataColumn } from "../../components/data-table"; import { DataTable, type DataColumn } from "../../components/data-table";
import { FilterControl } from "../../components/filter-control";
import { import {
Card, Card,
ErrorState, ErrorState,
@@ -14,26 +22,51 @@ import {
StatCard, StatCard,
} from "../../components/primitives"; } from "../../components/primitives";
import { RangeControl } from "../../components/range-control"; import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control";
import { formatNumber } from "../../lib/format"; import { formatNumber } from "../../lib/format";
export function ReferralsPage() { 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 [data, setData] = useState<ReferralOverview>();
const [error, setError] = useState<unknown>(); 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 load = useCallback(async () => {
const version = ++requestVersion.current;
setError(undefined); setError(undefined);
setData(undefined);
try { try {
setData(await adminApi.referrals(range)); const response = await adminApi.referrals(query);
if (requestVersion.current === version) setData(response);
} catch (requestError) { } catch (requestError) {
setError(requestError); if (requestVersion.current === version) setError(requestError);
} }
}, [range]); }, [query]);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [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>[]>( const columns = useMemo<DataColumn<ReferralRankingItem>[]>(
() => [ () => [
{ {
@@ -117,6 +150,35 @@ export function ReferralsPage() {
/> />
</section> </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]"> <section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="flex items-center justify-between border-b border-border p-5 sm:p-6"> <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} columns={columns}
caption="有效邀请用户排行" caption="有效邀请用户排行"
emptyTitle="当前周期暂无排行数据" emptyTitle="当前周期暂无排行数据"
sort={{ key: sort, order }}
sortableColumns={{
invited: "invited",
qualified: "qualified",
creditsEarned: "creditsEarned",
}}
onSortChange={(key, nextOrder) => {
setSort(key as NonNullable<ReferralsQuery["sort"]>);
setOrder(nextOrder);
}}
/> />
</Card> </Card>
+156 -39
View File
@@ -18,6 +18,7 @@ import {
useState, useState,
type FormEvent, type FormEvent,
} from "react"; } from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client"; import { adminApi, ApiError } from "../../api/client";
import type { import type {
@@ -25,8 +26,12 @@ import type {
AdminOperatorProvisioning, AdminOperatorProvisioning,
AdminRole, AdminRole,
AdminSecuritySummary, AdminSecuritySummary,
OperatorsQuery,
SortOrder,
} from "../../api/types"; } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table"; import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import { import {
Badge, Badge,
Button, Button,
@@ -38,6 +43,8 @@ import {
PageHeader, PageHeader,
StatCard, StatCard,
} from "../../components/primitives"; } from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import { formatDateTime, formatNumber } from "../../lib/format"; import { formatDateTime, formatNumber } from "../../lib/format";
import { useAuth } from "../auth/auth-context"; import { useAuth } from "../auth/auth-context";
@@ -51,12 +58,22 @@ interface ConfirmationState {
export function SecurityPage() { export function SecurityPage() {
const { auth } = useAuth(); const { auth } = useAuth();
const currentUsername = auth.status === "authenticated" ? auth.operatorName : ""; const currentUsername = auth.status === "authenticated" ? auth.operatorName : "";
const [operators, setOperators] = useState<AdminOperator[]>([]); const [searchParams, setSearchParams] = useSearchParams();
const [summary, setSummary] = useState<AdminSecuritySummary>(); const [summary, setSummary] = useState<AdminSecuritySummary>();
const [nextCursor, setNextCursor] = useState<string>(); const [summaryError, setSummaryError] = useState<unknown>();
const [loading, setLoading] = useState(true); const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [loadingMore, setLoadingMore] = useState(false); const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [error, setError] = useState<unknown>(); 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 [createOpen, setCreateOpen] = useState(false);
const [resetOperator, setResetOperator] = useState<AdminOperator>(); const [resetOperator, setResetOperator] = useState<AdminOperator>();
const [confirmation, setConfirmation] = useState<ConfirmationState>(); const [confirmation, setConfirmation] = useState<ConfirmationState>();
@@ -64,44 +81,60 @@ export function SecurityPage() {
data: AdminOperatorProvisioning; data: AdminOperatorProvisioning;
title: string; title: string;
}>(); }>();
const operatorsQuery = useMemo<OperatorsQuery>(
const load = useCallback(async () => { () => ({
setLoading(true); from: from || undefined,
setError(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 { try {
const [page, securitySummary] = await Promise.all([ setSummary(await adminApi.operatorSummary());
adminApi.operators(),
adminApi.operatorSummary(),
]);
setOperators(page.items);
setNextCursor(page.nextCursor);
setSummary(securitySummary);
} catch (requestError) { } catch (requestError) {
setError(requestError); setSummaryError(requestError);
} finally {
setLoading(false);
} }
}, []); }, []);
const load = useCallback(async () => {
reload();
await loadSummary();
}, [loadSummary, reload]);
useEffect(() => { useEffect(() => {
void load(); void loadSummary();
}, [load]); }, [loadSummary]);
async function loadMore() { useEffect(() => {
if (!nextCursor) return; const params = new URLSearchParams();
setLoadingMore(true); if (from) params.set("from", from);
try { if (until) params.set("until", until);
const page = await adminApi.operators(nextCursor); if (role) params.set("role", role);
setOperators((current) => [...current, ...page.items]); if (enabled) params.set("enabled", enabled);
setNextCursor(page.nextCursor); if (locked) params.set("locked", locked);
} catch (requestError) { params.set("sort", sort);
toast.error( params.set("order", order);
requestError instanceof ApiError ? requestError.message : "加载管理员失败", setSearchParams(params, { replace: true });
); }, [enabled, from, locked, order, role, setSearchParams, sort, until]);
} finally {
setLoadingMore(false);
}
}
async function handleConfirmedAction() { async function handleConfirmedAction() {
if (!confirmation) return; if (!confirmation) return;
@@ -129,7 +162,7 @@ export function SecurityPage() {
const columns = useMemo<DataColumn<AdminOperator>[]>( const columns = useMemo<DataColumn<AdminOperator>[]>(
() => [ () => [
{ {
id: "operator", id: "username",
header: "管理员", header: "管理员",
cell: ({ row }) => { cell: ({ row }) => {
const current = row.original.username === currentUsername; const current = row.original.username === currentUsername;
@@ -177,6 +210,15 @@ export function SecurityPage() {
</span> </span>
), ),
}, },
{
accessorKey: "createdAt",
header: "创建时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{ {
id: "actions", id: "actions",
header: "操作", header: "操作",
@@ -193,7 +235,9 @@ export function SecurityPage() {
[currentUsername], [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="加载安全中心" />; if (loading || !summary) return <LoadingState label="加载安全中心" />;
return ( return (
@@ -258,11 +302,84 @@ export function SecurityPage() {
</div> </div>
<KeyRound className="size-5 text-muted" aria-hidden /> <KeyRound className="size-5 text-muted" aria-hidden />
</div> </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 <DataTable
data={operators} data={operators}
columns={columns} columns={columns}
caption="管理员账户与安全状态" 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={ footer={
nextCursor ? ( nextCursor ? (
<div className="flex justify-center border-t border-border p-5"> <div className="flex justify-center border-t border-border p-5">
+175 -47
View File
@@ -11,18 +11,26 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
type FormEvent, type FormEvent,
} from "react"; } from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client"; import { adminApi, ApiError } from "../../api/client";
import type { import type {
LedgerEntryType,
LedgerQuery,
LedgerEntry, LedgerEntry,
SortOrder,
UserDetail, UserDetail,
UsersQuery,
UserSummary, UserSummary,
UserUsageAggregate, UserUsageAggregate,
} from "../../api/types"; } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table"; import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import { import {
Badge, Badge,
Button, Button,
@@ -34,6 +42,8 @@ import {
PageHeader, PageHeader,
Textarea, Textarea,
} from "../../components/primitives"; } from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import { import {
createIdempotencyKey, createIdempotencyKey,
formatDateTime, formatDateTime,
@@ -44,6 +54,15 @@ import {
} from "../../lib/format"; } from "../../lib/format";
import { useAuth } from "../auth/auth-context"; 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() { export function UsersPage() {
const { auth } = useAuth(); const { auth } = useAuth();
const role = auth.status === "authenticated" ? auth.role : "ANALYST"; const role = auth.status === "authenticated" ? auth.role : "ANALYST";
@@ -61,51 +80,56 @@ export function UsersPage() {
} }
function UserList({ onSelect }: { onSelect: (userId: string) => void }) { function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
const [query, setQuery] = useState(""); const [searchParams, setSearchParams] = useSearchParams();
const [activeQuery, setActiveQuery] = useState(""); const initialQuery = searchParams.get("q") ?? "";
const [items, setItems] = useState<UserSummary[]>([]); const [query, setQuery] = useState(initialQuery);
const [nextCursor, setNextCursor] = useState<string>(); const [activeQuery, setActiveQuery] = useState(initialQuery);
const [loading, setLoading] = useState(true); const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [loadingMore, setLoadingMore] = useState(false); const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [error, setError] = useState<unknown>(); const [status, setStatus] = useState<"" | UserSummary["status"]>(
(searchParams.get("status") as UserSummary["status"] | null) ?? "",
const load = useCallback(async (search = "") => { );
setLoading(true); const [order, setOrder] = useState<SortOrder>(
setError(undefined); searchParams.get("order") === "asc" ? "asc" : "desc",
try { );
const page = await adminApi.users(search); const usersQuery = useMemo<UsersQuery>(
setItems(page.items); () => ({
setNextCursor(page.nextCursor); q: activeQuery || undefined,
} catch (requestError) { from: from || undefined,
setError(requestError); until: until || undefined,
} finally { status: status || undefined,
setLoading(false); 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(() => { useEffect(() => {
void load(); const params = new URLSearchParams();
}, [load]); 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>) { function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const search = query.trim(); const search = query.trim();
setActiveQuery(search); 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>[]>( const columns = useMemo<DataColumn<UserSummary>[]>(
@@ -201,6 +225,35 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
</Button> </Button>
</form> </form>
</div> </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"> <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 /> <Users className="size-4 text-primary" aria-hidden />
{activeQuery ? "查询结果" : "全部用户"} {activeQuery ? "查询结果" : "全部用户"}
@@ -209,7 +262,7 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
</div> </div>
{error ? ( {error ? (
<div className="p-6"> <div className="p-6">
<ErrorState error={error} retry={() => void load(activeQuery)} /> <ErrorState error={error} retry={reload} />
</div> </div>
) : loading ? ( ) : loading ? (
<LoadingState label="加载用户列表" /> <LoadingState label="加载用户列表" />
@@ -218,7 +271,14 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
data={items} data={items}
columns={columns} columns={columns}
caption={activeQuery ? "用户查询结果" : "全部用户"} caption={activeQuery ? "用户查询结果" : "全部用户"}
emptyTitle="未找到匹配用户" emptyTitle={
activeQuery || from || until || status
? "没有符合当前筛选条件的用户"
: "暂无用户"
}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
footer={ footer={
nextCursor ? ( nextCursor ? (
<div className="flex justify-center border-t border-border p-5"> <div className="flex justify-center border-t border-border p-5">
@@ -244,6 +304,7 @@ function UserDetailView({
canGrant: boolean; canGrant: boolean;
onBack: () => void; onBack: () => void;
}) { }) {
const [searchParams, setSearchParams] = useSearchParams();
const [user, setUser] = useState<UserDetail>(); const [user, setUser] = useState<UserDetail>();
const [ledger, setLedger] = useState<LedgerEntry[]>([]); const [ledger, setLedger] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>(); const [nextCursor, setNextCursor] = useState<string>();
@@ -251,34 +312,75 @@ function UserDetailView({
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [grantOpen, setGrantOpen] = useState(false); 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 load = useCallback(async () => {
const version = ++requestVersion.current;
setLedger([]);
setNextCursor(undefined);
setLoading(true); setLoading(true);
setLoadingMore(false);
setError(undefined); setError(undefined);
try { try {
const [detail, page] = await Promise.all([ const [detail, page] = await Promise.all([
adminApi.user(userId), adminApi.user(userId),
adminApi.ledger(userId), adminApi.ledger(userId, ledgerQuery),
]); ]);
if (requestVersion.current !== version) return;
setUser(detail); setUser(detail);
setLedger(page.items); setLedger(page.items);
setNextCursor(page.nextCursor); setNextCursor(page.nextCursor);
} catch (requestError) { } catch (requestError) {
setError(requestError); if (requestVersion.current === version) setError(requestError);
} finally { } finally {
setLoading(false); if (requestVersion.current === version) setLoading(false);
} }
}, [userId]); }, [ledgerQuery, userId]);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [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() { async function loadMoreLedger() {
if (!nextCursor) return; if (!nextCursor) return;
const version = requestVersion.current;
setLoadingMore(true); setLoadingMore(true);
try { 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]); setLedger((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor); setNextCursor(page.nextCursor);
} catch (requestError) { } catch (requestError) {
@@ -286,7 +388,7 @@ function UserDetailView({
requestError instanceof ApiError ? requestError.message : "加载积分流水失败", requestError instanceof ApiError ? requestError.message : "加载积分流水失败",
); );
} finally { } finally {
setLoadingMore(false); if (requestVersion.current === version) setLoadingMore(false);
} }
} }
@@ -370,11 +472,37 @@ function UserDetailView({
title="积分流水" title="积分流水"
description="所有变动均来自不可变账本" 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 <DataTable
data={ledger} data={ledger}
columns={ledgerColumns} columns={ledgerColumns}
caption={`${user.displayName || user.userId} 的积分流水`} caption={`${user.displayName || user.userId} 的积分流水`}
emptyTitle="暂无积分流水" emptyTitle={from || until || type ? "没有符合当前筛选条件的流水" : "暂无积分流水"}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
footer={ footer={
nextCursor ? ( nextCursor ? (
<div className="flex justify-center border-t border-border p-5"> <div className="flex justify-center border-t border-border p-5">
+62
View File
@@ -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), []),
};
}
+21
View File
@@ -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);
}
+36
View File
@@ -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 () => { it("缺少 CSRF 时在发送变更请求前失败", async () => {
const fetchMock = vi.fn(); const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
+63
View File
@@ -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",
});
});
});
+68 -2
View File
@@ -36,12 +36,49 @@ describe("React 管理页面", () => {
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" })); await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
await waitFor(() => { 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(await screen.findByText("第二位用户")).toBeTruthy();
expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull(); 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 () => { it("积分页自动显示最新不可变流水", async () => {
mockSession("SUPPORT"); mockSession("SUPPORT");
const entry: LedgerEntry = { const entry: LedgerEntry = {
@@ -81,7 +118,10 @@ describe("React 管理页面", () => {
await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" })); await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" }));
await waitFor(() => { 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(await screen.findByText("support")).toBeTruthy();
expect(screen.getByText("已加载 2 个账户")).toBeTruthy(); expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
@@ -101,6 +141,32 @@ describe("React 管理页面", () => {
expect(screen.getByText("文字润色")).toBeTruthy(); expect(screen.getByText("文字润色")).toBeTruthy();
expect(screen.getByText("7 天免费转付费")).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") { function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
+102 -9
View File
@@ -593,13 +593,20 @@ paths:
summary: Return referral funnel and ranking statistics summary: Return referral funnel and ranking statistics
parameters: parameters:
- $ref: "#/components/parameters/AdminRange" - $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: responses:
"200": "200":
description: Referral statistics description: Referral statistics
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/AdminReferralOverview" } schema: { $ref: "#/components/schemas/AdminReferralOverview" }
"400": { description: Range is invalid } "400": { description: Range, sort, order, or limit is invalid }
"401": { description: Session is invalid } "401": { description: Session is invalid }
/v1/admin/analytics: /v1/admin/analytics:
get: get:
@@ -631,13 +638,20 @@ paths:
in: query in: query
schema: { type: string, maxLength: 256 } schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit" - $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: responses:
"200": "200":
description: Privacy-minimized user summaries description: Privacy-minimized user summaries
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/AdminUserPage" } 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 } "401": { description: Session is invalid }
"403": { description: ANALYST role cannot access user records } "403": { description: ANALYST role cannot access user records }
/v1/admin/users/{userId}: /v1/admin/users/{userId}:
@@ -667,14 +681,19 @@ paths:
- name: cursor - name: cursor
in: query in: query
schema: { type: string, maxLength: 256 } 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: responses:
"200": "200":
description: Credit ledger entries ordered by creation time and entry ID description: Credit ledger entries ordered by creation time and entry ID
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" } 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 } "403": { description: ANALYST role cannot access credit ledger records }
"404": { description: User was not found } "404": { description: User was not found }
/v1/admin/credits/ledger: /v1/admin/credits/ledger:
@@ -687,14 +706,19 @@ paths:
- name: cursor - name: cursor
in: query in: query
schema: { type: string, maxLength: 256 } 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: responses:
"200": "200":
description: Latest credit ledger entries ordered by creation time and entry ID description: Latest credit ledger entries ordered by creation time and entry ID
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" } 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 } "403": { description: ANALYST role cannot access credit ledger records }
/v1/admin/credits/grants: /v1/admin/credits/grants:
post: post:
@@ -758,14 +782,33 @@ paths:
in: query in: query
schema: { type: string, maxLength: 256 } schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit" - $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: responses:
"200": "200":
description: Operators ordered by creation time description: Operators ordered by the requested stable sort and operator ID; null lastLoginAt values are last
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/AdminOperatorPage" } schema: { $ref: "#/components/schemas/AdminOperatorPage" }
"403": { description: INSUFFICIENT_PERMISSION; SUPER_ADMIN is required } "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: post:
security: security:
- adminMtls: [] - adminMtls: []
@@ -874,13 +917,35 @@ paths:
in: query in: query
schema: { type: string, maxLength: 256 } schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit" - $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: responses:
"200": "200":
description: Recent audit events description: Recent audit events
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/AdminAuditPage" } 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 } "403": { description: Super-administrator role is required }
components: components:
securitySchemes: securitySchemes:
@@ -919,6 +984,34 @@ components:
name: range name: range
in: query in: query
schema: { type: string, enum: [7d, 30d, 90d], default: 30d } 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: AdminUserId:
name: userId name: userId
in: path in: path
@@ -20,11 +20,6 @@ data class AdminOperatorRecord(
val updatedAt: Instant, val updatedAt: Instant,
) )
data class AdminOperatorCursor(
val createdAt: Instant,
val id: UUID,
)
data class NewAdminOperator( data class NewAdminOperator(
val id: UUID, val id: UUID,
val normalizedUsername: String, 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,
)
@@ -1,6 +1,7 @@
package com.osglab.account.features.admin.repositories package com.osglab.account.features.admin.repositories
import com.osglab.account.config.DatabaseFactory 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.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminLockState import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminAuditAction 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.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor 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.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord 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.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession 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.and
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater 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.inList
import org.jetbrains.exposed.v1.core.isNotNull import org.jetbrains.exposed.v1.core.isNotNull
import org.jetbrains.exposed.v1.core.isNull import org.jetbrains.exposed.v1.core.isNull
@@ -86,6 +91,7 @@ interface AdminRepository {
suspend fun listOperatorsPage( suspend fun listOperatorsPage(
limit: Int, limit: Int,
before: AdminOperatorCursor? = null, before: AdminOperatorCursor? = null,
query: AdminOperatorQuery,
): List<AdminOperatorRecord> ): List<AdminOperatorRecord>
suspend fun countActiveSessions(now: Instant): Long suspend fun countActiveSessions(now: Instant): Long
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
@@ -148,6 +154,7 @@ interface AdminRepository {
suspend fun listAudit( suspend fun listAudit(
limit: Int, limit: Int,
before: AdminAuditCursor? = null, before: AdminAuditCursor? = null,
query: AdminAuditQuery = AdminAuditQuery(),
): List<AdminAuditRecord> ): List<AdminAuditRecord>
} }
@@ -185,24 +192,119 @@ class ExposedAdminRepository(
override suspend fun listOperatorsPage( override suspend fun listOperatorsPage(
limit: Int, limit: Int,
before: AdminOperatorCursor?, before: AdminOperatorCursor?,
query: AdminOperatorQuery,
): List<AdminOperatorRecord> = ): List<AdminOperatorRecord> =
databaseFactory.query { databaseFactory.query {
require(limit in 1..101) require(limit in 1..101)
val query = AdminOperatorsTable.selectAll() 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.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) { if (before != null) {
query.andWhere { statement.andWhere {
(AdminOperatorsTable.createdAt greater before.createdAt) or 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 before.createdAt) and (AdminOperatorsTable.createdAt eq value) and
(AdminOperatorsTable.id greater before.id.toString()) (AdminOperatorsTable.id greater before.id.toString())
) )
} } else {
} (AdminOperatorsTable.createdAt less value) or
query (
.orderBy( (AdminOperatorsTable.createdAt eq value) and
AdminOperatorsTable.createdAt to SortOrder.ASC, (AdminOperatorsTable.id less before.id.toString())
AdminOperatorsTable.id to SortOrder.ASC,
) )
}
}
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) .limit(limit)
.map(ResultRow::toOperatorRecord) .map(ResultRow::toOperatorRecord)
} }
@@ -495,12 +597,32 @@ class ExposedAdminRepository(
override suspend fun listAudit( override suspend fun listAudit(
limit: Int, limit: Int,
before: AdminAuditCursor?, before: AdminAuditCursor?,
query: AdminAuditQuery,
): List<AdminAuditRecord> = ): List<AdminAuditRecord> =
databaseFactory.query { databaseFactory.query {
require(limit in 1..101) 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) { if (before != null) {
query.andWhere { 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 less before.occurredAt) or
( (
(AdminAuditLogTable.occurredAt eq before.occurredAt) and (AdminAuditLogTable.occurredAt eq before.occurredAt) and
@@ -508,10 +630,12 @@ class ExposedAdminRepository(
) )
} }
} }
query }
val sortOrder = query.order.toExposedSortOrder()
statement
.orderBy( .orderBy(
AdminAuditLogTable.occurredAt to SortOrder.DESC, AdminAuditLogTable.occurredAt to sortOrder,
AdminAuditLogTable.id to SortOrder.DESC, AdminAuditLogTable.id to sortOrder,
) )
.limit(limit) .limit(limit)
.map { .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) { private fun insertAudit(event: NewAdminAuditEvent) {
AdminAuditLogTable.insert { AdminAuditLogTable.insert {
it[id] = event.id.toString() 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.config.AppConfig
import com.osglab.account.features.admin.grants.models.ManualGrantCommand import com.osglab.account.features.admin.grants.models.ManualGrantCommand
import com.osglab.account.features.admin.grants.services.AdminGrantService 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.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.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole 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.AdminAuditCursorException
import com.osglab.account.features.admin.services.AdminAuditService import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService 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.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto 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.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.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminUserDetailDto 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.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto 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.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.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditConflict
@@ -51,6 +63,9 @@ import io.ktor.server.routing.route
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.time.Clock import java.time.Clock
import java.time.Duration import java.time.Duration
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
fun Route.adminWebRoutes(config: AppConfig) { fun Route.adminWebRoutes(config: AppConfig) {
@@ -159,7 +174,18 @@ fun Route.adminApiRoutes(
get("/referrals") { get("/referrals") {
if (call.requirePrincipal(config, sessionService) == null) return@get 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 { ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get return@get
@@ -185,6 +211,11 @@ fun Route.adminApiRoutes(
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT), setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null ) == null
) return@get ) 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 { val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get return@get
@@ -195,9 +226,10 @@ fun Route.adminApiRoutes(
usersService.list( usersService.list(
limit = limit, limit = limit,
cursor = call.request.queryParameters["cursor"], cursor = call.request.queryParameters["cursor"],
query = listQuery,
) )
} else { } else {
usersService.searchByInternalId(query) usersService.searchByInternalId(query, listQuery)
} }
} catch (_: IllegalArgumentException) { } catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
@@ -236,6 +268,11 @@ fun Route.adminApiRoutes(
) == null ) == null
) return@get ) return@get
val userId = call.uuidPathParameter("userId") ?: 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 { val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get return@get
@@ -245,6 +282,7 @@ fun Route.adminApiRoutes(
userId = userId, userId = userId,
limit = limit, limit = limit,
cursor = call.request.queryParameters["cursor"], cursor = call.request.queryParameters["cursor"],
query = ledgerQuery,
) )
call.respond( call.respond(
PageResponse( PageResponse(
@@ -267,6 +305,11 @@ fun Route.adminApiRoutes(
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT), setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null ) == null
) return@get ) 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 { val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get return@get
@@ -275,6 +318,7 @@ fun Route.adminApiRoutes(
val page = usersService.latestLedger( val page = usersService.latestLedger(
limit = limit, limit = limit,
cursor = call.request.queryParameters["cursor"], cursor = call.request.queryParameters["cursor"],
query = ledgerQuery,
) )
call.respond( call.respond(
PageResponse( PageResponse(
@@ -353,6 +397,11 @@ fun Route.adminApiRoutes(
get("/operators") { get("/operators") {
val principal = call.requirePrincipal(config, sessionService) ?: return@get 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 { val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get return@get
@@ -362,6 +411,7 @@ fun Route.adminApiRoutes(
actor = principal, actor = principal,
cursor = call.request.queryParameters["cursor"], cursor = call.request.queryParameters["cursor"],
limit = limit, limit = limit,
query = operatorQuery,
) )
call.respond( call.respond(
PageResponse( PageResponse(
@@ -480,6 +530,11 @@ fun Route.adminApiRoutes(
get("/audit") { get("/audit") {
val principal = call.requirePrincipal(config, sessionService) ?: return@get 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 { val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get return@get
@@ -489,6 +544,7 @@ fun Route.adminApiRoutes(
actor = principal, actor = principal,
cursor = call.request.queryParameters["cursor"], cursor = call.request.queryParameters["cursor"],
limit = limit, limit = limit,
query = auditQuery,
) )
} catch (exception: AdminOperatorException) { } catch (exception: AdminOperatorException) {
call.respondOperatorError(exception) call.respondOperatorError(exception)
@@ -522,9 +578,18 @@ fun Route.adminApiRoutes(
private suspend fun AdminStatsService.getRange( private suspend fun AdminStatsService.getRange(
range: String?, range: String?,
clock: Clock, clock: Clock,
referralRankLimit: Int = 20,
referralSort: AdminReferralSort? = null,
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
): AdminStatsDto? { ): AdminStatsDto? {
val window = parseAdminStatsRange(range, clock) ?: return null 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>? { 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 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( private suspend fun ApplicationCall.requirePrincipal(
config: AppConfig, config: AppConfig,
sessions: AdminSessionService, sessions: AdminSessionService,
@@ -1,9 +1,11 @@
package com.osglab.account.features.admin.services package com.osglab.account.features.admin.services
import com.osglab.account.features.admin.models.AdminAuditCursor 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.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole 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 com.osglab.account.features.admin.repositories.AdminRepository
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.Instant import java.time.Instant
@@ -29,13 +31,14 @@ class AdminAuditService(
actor: AdminPrincipal, actor: AdminPrincipal,
cursor: String?, cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE, limit: Int = DEFAULT_PAGE_SIZE,
query: AdminAuditQuery = AdminAuditQuery(),
): AdminAuditPage { ): AdminAuditPage {
if (actor.role != AdminRole.SUPER_ADMIN) { if (actor.role != AdminRole.SUPER_ADMIN) {
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION) throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
} }
require(limit in 1..MAX_PAGE_SIZE) require(limit in 1..MAX_PAGE_SIZE)
val decodedCursor = cursor?.let(::decodeCursor) val decodedCursor = cursor?.let { decodeCursor(it, query.order) }
val records = repository.listAudit(limit + 1, decodedCursor) val records = repository.listAudit(limit + 1, decodedCursor, query)
val pageRecords = records.take(limit) val pageRecords = records.take(limit)
val operatorNames = repository.listOperators().associate { val operatorNames = repository.listOperators().associate {
it.id to it.normalizedUsername it.id to it.normalizedUsername
@@ -50,36 +53,41 @@ class AdminAuditService(
) )
}, },
nextCursor = if (records.size > limit) { nextCursor = if (records.size > limit) {
pageRecords.lastOrNull()?.let(::encodeCursor) pageRecords.lastOrNull()?.let { encodeCursor(it, query.order) }
} else { } else {
null 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() if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException()
return runCatching { return runCatching {
val decoded = String( val decoded = String(
Base64.getUrlDecoder().decode(value), Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8, StandardCharsets.UTF_8,
) )
val parts = decoded.split(':', limit = 3) val parts = decoded.split(':', limit = 5)
require(parts.size == 3) require(parts.size == 5)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminAuditCursor( AdminAuditCursor(
occurredAt = Instant.ofEpochSecond( occurredAt = Instant.ofEpochSecond(
parts[0].toLong(), parts[2].toLong(),
parts[1].toLong(), parts[3].toLong(),
), ),
id = UUID.fromString(parts[2]), id = UUID.fromString(parts[4]),
) )
}.getOrElse { }.getOrElse {
throw AdminAuditCursorException() throw AdminAuditCursorException()
} }
} }
private fun encodeCursor(record: AdminAuditRecord): String { private fun encodeCursor(record: AdminAuditRecord, order: AdminSortOrder): String {
val payload = buildString { val payload = buildString {
append("v1:")
append(order.name)
append(':')
append(record.occurredAt.epochSecond) append(record.occurredAt.epochSecond)
append(':') append(':')
append(record.occurredAt.nano) append(record.occurredAt.nano)
@@ -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.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminOperatorMutationResult import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorCursor 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.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole 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.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository 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.AdminTotpProvisioning
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
import java.time.Clock
import java.time.Instant
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.Clock
import java.util.Base64 import java.util.Base64
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
@@ -73,17 +75,33 @@ class AdminOperatorService(
actor: AdminPrincipal, actor: AdminPrincipal,
cursor: String?, cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE, 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 { ): AdminOperatorPage {
requireSuperAdministrator(actor) requireSuperAdministrator(actor)
if (limit !in 1..MAX_PAGE_SIZE) { if (limit !in 1..MAX_PAGE_SIZE) {
throw AdminOperatorCursorException() throw AdminOperatorCursorException()
} }
val decodedCursor = cursor?.let(::decodeCursor) val decodedCursor = cursor?.let { decodeCursor(it, query) }
val records = repository.listOperatorsPage(limit + 1, decodedCursor) val records = repository.listOperatorsPage(limit + 1, decodedCursor, query)
val items = records.take(limit) val items = records.take(limit)
return AdminOperatorPage( return AdminOperatorPage(
items = items, 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_PASSWORD_CHARS = 1_024
const val MAX_AUDIT_TARGET_CHARS = 128 const val MAX_AUDIT_TARGET_CHARS = 128
const val OPERATOR_TARGET = "ADMIN_OPERATOR" 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() if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException()
return runCatching { return runCatching {
val decoded = String( val decoded = String(
Base64.getUrlDecoder().decode(value), Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8, StandardCharsets.UTF_8,
) )
val parts = decoded.split(':', limit = 3) val parts = decoded.split('|')
require(parts.size == 3) require(parts.size == 5)
require(parts[0] == "v1")
require(parts[1] == query.sort.name)
require(parts[2] == query.order.name)
AdminOperatorCursor( AdminOperatorCursor(
createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()), value = parts[3].takeUnless { it == NULL_CURSOR_VALUE },
id = UUID.fromString(parts[2]), id = UUID.fromString(parts[4]),
) )
}.getOrElse { }.getOrElse {
throw AdminOperatorCursorException() throw AdminOperatorCursorException()
} }
} }
private fun encodeCursor(record: AdminOperatorRecord): String { private fun encodeCursor(record: AdminOperatorRecord, query: AdminOperatorQuery): String {
val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}" 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( return Base64.getUrlEncoder().withoutPadding().encodeToString(
payload.toByteArray(StandardCharsets.UTF_8), payload.toByteArray(StandardCharsets.UTF_8),
) )
@@ -1,6 +1,8 @@
package com.osglab.account.features.admin.stats.services 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.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.AdminRegistrationPointDto
import com.osglab.account.features.admin.stats.models.AdminStatsDto import com.osglab.account.features.admin.stats.models.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto
@@ -10,6 +12,12 @@ import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneOffset import java.time.ZoneOffset
enum class AdminReferralSort {
INVITED,
QUALIFIED,
CREDITS_EARNED,
}
class AdminStatsService( class AdminStatsService(
private val repository: AdminStatsRepository, private val repository: AdminStatsRepository,
) { ) {
@@ -17,6 +25,8 @@ class AdminStatsService(
from: Instant, from: Instant,
until: Instant, until: Instant,
referralRankLimit: Int = 20, referralRankLimit: Int = 20,
referralSort: AdminReferralSort? = null,
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
): AdminStatsDto { ): AdminStatsDto {
require(from < until) { "Statistics range must be non-empty" } require(from < until) { "Statistics range must be non-empty" }
require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" } require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" }
@@ -40,12 +50,30 @@ class AdminStatsService(
) )
}, },
referralFunnel = snapshot.referralFunnel, referralFunnel = snapshot.referralFunnel,
referralRanking = snapshot.referralRanking.take(referralRankLimit), referralRanking = snapshot.referralRanking
.sortedForReferralRanking(referralSort, referralOrder)
.take(referralRankLimit),
usage = snapshot.usage, 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> { private fun utcDates(range: AdminStatsRange): List<LocalDate> {
val dates = mutableListOf<LocalDate>() val dates = mutableListOf<LocalDate>()
var date = range.from.atZone(ZoneOffset.UTC).toLocalDate() var date = range.from.atZone(ZoneOffset.UTC).toLocalDate()
@@ -1,6 +1,8 @@
package com.osglab.account.features.admin.users.repositories package com.osglab.account.features.admin.users.repositories
import com.osglab.account.config.DatabaseFactory 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.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto 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.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.Table
import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq 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.inList
import org.jetbrains.exposed.v1.core.less import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.like import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -33,10 +38,54 @@ data class AdminUserLedgerCursor(
val ledgerEntryId: UUID, val ledgerEntryId: UUID,
) )
interface AdminUsersRepository { enum class AdminUserStatus {
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto> 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 suspend fun exists(userId: UUID): Boolean
@@ -46,11 +95,13 @@ interface AdminUsersRepository {
userId: UUID, userId: UUID,
limit: Int, limit: Int,
cursor: AdminUserLedgerCursor?, cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> ): List<AdminUserLedgerEntryDto>
suspend fun listLatestLedger( suspend fun listLatestLedger(
limit: Int, limit: Int,
cursor: AdminUserLedgerCursor?, cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> ): List<AdminUserLedgerEntryDto>
} }
@@ -60,10 +111,30 @@ class ExposedAdminUsersRepository(
override suspend fun list( override suspend fun list(
limit: Int, limit: Int,
cursor: AdminUserCursor?, cursor: AdminUserCursor?,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> = databaseFactory.query { ): List<AdminUserSummaryDto> = databaseFactory.query {
val query = AdminUsersAccountsTable.selectAll() 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)
}
}
if (cursor != null) { if (cursor != null) {
query.where { 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 less cursor.createdAt) or
( (
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and (AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
@@ -71,10 +142,12 @@ class ExposedAdminUsersRepository(
) )
} }
} }
val accountRows = query }
val sortOrder = query.order.toExposedSortOrder()
val accountRows = statement
.orderBy( .orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC, AdminUsersAccountsTable.createdAt to sortOrder,
AdminUsersAccountsTable.id to SortOrder.DESC, AdminUsersAccountsTable.id to sortOrder,
) )
.limit(limit) .limit(limit)
.toList() .toList()
@@ -85,12 +158,27 @@ class ExposedAdminUsersRepository(
override suspend fun findByIdSuffix( override suspend fun findByIdSuffix(
suffix: String, suffix: String,
limit: Int, limit: Int,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> = databaseFactory.query { ): List<AdminUserSummaryDto> = databaseFactory.query {
val accountRows = AdminUsersAccountsTable.selectAll() val statement = AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id like "%$suffix" } .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( .orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC, AdminUsersAccountsTable.createdAt to sortOrder,
AdminUsersAccountsTable.id to SortOrder.DESC, AdminUsersAccountsTable.id to sortOrder,
) )
.limit(limit) .limit(limit)
.toList() .toList()
@@ -155,25 +243,41 @@ class ExposedAdminUsersRepository(
userId: UUID, userId: UUID,
limit: Int, limit: Int,
cursor: AdminUserLedgerCursor?, cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query { ): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll() if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
if (cursor == null) { val statement = AdminUsersCreditLedgerTable.selectAll()
query.where { AdminUsersCreditLedgerTable.userId eq userId.toString() } .where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
} else { query.time.from?.let { from ->
query.where { statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
(AdminUsersCreditLedgerTable.userId eq userId.toString()) and }
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 less cursor.createdAt) or
( (
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString()) (AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
) )
)
} }
} }
val ledger = query.orderBy( }
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC, val sortOrder = query.order.toExposedSortOrder()
AdminUsersCreditLedgerTable.id to SortOrder.DESC, val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
) )
.limit(limit) .limit(limit)
.map(ResultRow::toUserLedgerRow) .map(ResultRow::toUserLedgerRow)
@@ -184,10 +288,28 @@ class ExposedAdminUsersRepository(
override suspend fun listLatestLedger( override suspend fun listLatestLedger(
limit: Int, limit: Int,
cursor: AdminUserLedgerCursor?, cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query { ): 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) { if (cursor != null) {
query.where { 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 less cursor.createdAt) or
( (
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
@@ -195,9 +317,11 @@ class ExposedAdminUsersRepository(
) )
} }
} }
val ledger = query.orderBy( }
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC, val sortOrder = query.order.toExposedSortOrder()
AdminUsersCreditLedgerTable.id to SortOrder.DESC, val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
) )
.limit(limit) .limit(limit)
.map(ResultRow::toUserLedgerRow) .map(ResultRow::toUserLedgerRow)
@@ -413,3 +537,6 @@ private fun ResultRow.toUserReferralBindingRow() = UserReferralBindingRow(
private inline fun <T> Iterable<T>.exactSumOf(value: (T) -> Long): Long = private inline fun <T> Iterable<T>.exactSumOf(value: (T) -> Long): Long =
fold(0L) { total, item -> Math.addExact(total, value(item)) } fold(0L) { total, item -> Math.addExact(total, value(item)) }
private fun AdminSortOrder.toExposedSortOrder(): SortOrder =
if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC
@@ -1,11 +1,15 @@
package com.osglab.account.features.admin.users.services 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.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto 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.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.models.AdminUserPageDto 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.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.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.repositories.AdminUsersRepository
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.time.Instant import java.time.Instant
@@ -17,18 +21,26 @@ class AdminUserNotFoundException : RuntimeException("Admin user view does not ex
class AdminUsersService( class AdminUsersService(
private val repository: AdminUsersRepository, private val repository: AdminUsersRepository,
) { ) {
suspend fun searchByInternalId(query: String): AdminUserPageDto { suspend fun searchByInternalId(
query: String,
listQuery: AdminUserListQuery = AdminUserListQuery(),
): AdminUserPageDto {
val normalized = query.trim() val normalized = query.trim()
val userId = runCatching { UUID.fromString(normalized) }.getOrNull() val userId = runCatching { UUID.fromString(normalized) }.getOrNull()
if (userId != null) { if (userId != null) {
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
?.takeIf { it.matches(listQuery) }
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null) return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
} }
if (!SHORT_INTERNAL_ID.matches(normalized)) { if (!SHORT_INTERNAL_ID.matches(normalized)) {
return AdminUserPageDto(emptyList(), null) return AdminUserPageDto(emptyList(), null)
} }
return AdminUserPageDto( 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, nextCursor = null,
) )
} }
@@ -36,10 +48,11 @@ class AdminUsersService(
suspend fun list( suspend fun list(
limit: Int = 50, limit: Int = 50,
cursor: String? = null, cursor: String? = null,
query: AdminUserListQuery = AdminUserListQuery(),
): AdminUserPageDto { ): AdminUserPageDto {
require(limit in 1..100) { "User page limit must be between 1 and 100" } require(limit in 1..100) { "User page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserCursorCodec::decode) val decodedCursor = cursor?.let { AdminUserCursorCodec.decode(it, query.order) }
val results = repository.list(limit + 1, decodedCursor) val results = repository.list(limit + 1, decodedCursor, query)
val hasMore = results.size > limit val hasMore = results.size > limit
val items = results.take(limit) val items = results.take(limit)
val nextCursor = if (hasMore) { val nextCursor = if (hasMore) {
@@ -49,6 +62,7 @@ class AdminUsersService(
createdAt = Instant.parse(last.createdAt), createdAt = Instant.parse(last.createdAt),
userId = UUID.fromString(last.id), userId = UUID.fromString(last.id),
), ),
query.order,
) )
} else { } else {
null null
@@ -68,26 +82,31 @@ class AdminUsersService(
userId: UUID, userId: UUID,
limit: Int = 50, limit: Int = 50,
cursor: String? = null, cursor: String? = null,
query: AdminLedgerQuery = AdminLedgerQuery(),
): AdminUserLedgerPageDto { ): AdminUserLedgerPageDto {
return ledgerPage(limit, cursor) { pageSize, decodedCursor -> return ledgerPage(limit, cursor, query) { pageSize, decodedCursor ->
if (!repository.exists(userId)) throw AdminUserNotFoundException() if (!repository.exists(userId)) throw AdminUserNotFoundException()
repository.listLedger(userId, pageSize, decodedCursor) repository.listLedger(userId, pageSize, decodedCursor, query)
} }
} }
suspend fun latestLedger( suspend fun latestLedger(
limit: Int = 100, limit: Int = 100,
cursor: String? = null, cursor: String? = null,
query: AdminLedgerQuery = AdminLedgerQuery(),
): AdminUserLedgerPageDto = ): AdminUserLedgerPageDto =
ledgerPage(limit, cursor, repository::listLatestLedger) ledgerPage(limit, cursor, query) { pageSize, decodedCursor ->
repository.listLatestLedger(pageSize, decodedCursor, query)
}
private suspend fun ledgerPage( private suspend fun ledgerPage(
limit: Int, limit: Int,
cursor: String?, cursor: String?,
query: AdminLedgerQuery,
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>, load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
): AdminUserLedgerPageDto { ): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" } 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 results = load(limit + 1, decodedCursor)
val hasMore = results.size > limit val hasMore = results.size > limit
val items = results.take(limit) val items = results.take(limit)
@@ -98,6 +117,7 @@ class AdminUsersService(
createdAt = Instant.parse(last.createdAt), createdAt = Instant.parse(last.createdAt),
ledgerEntryId = UUID.fromString(last.id), ledgerEntryId = UUID.fromString(last.id),
), ),
query.order,
) )
} else { } else {
null null
@@ -109,14 +129,27 @@ class AdminUsersService(
private val SHORT_INTERNAL_ID = Regex("^[A-Fa-f0-9]{8}$") private val SHORT_INTERNAL_ID = Regex("^[A-Fa-f0-9]{8}$")
private const val MAX_SHORT_ID_MATCHES = 100 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 { internal object AdminUserCursorCodec {
fun encode(cursor: AdminUserCursor): String { fun encode(cursor: AdminUserCursor, order: AdminSortOrder): String {
val value = "${cursor.createdAt}|${cursor.userId}" val value = "v1|${order.name}|${cursor.createdAt}|${cursor.userId}"
return Base64.getUrlEncoder().withoutPadding() return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8)) .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" } require(value.length in 1..256) { "User cursor is invalid" }
return try { return try {
val decoded = String( val decoded = String(
@@ -124,10 +157,12 @@ internal object AdminUserCursorCodec {
StandardCharsets.UTF_8, StandardCharsets.UTF_8,
) )
val parts = decoded.split('|') val parts = decoded.split('|')
require(parts.size == 2) require(parts.size == 4)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminUserCursor( AdminUserCursor(
createdAt = Instant.parse(parts[0]), createdAt = Instant.parse(parts[2]),
userId = UUID.fromString(parts[1]), userId = UUID.fromString(parts[3]),
) )
} catch (failure: IllegalArgumentException) { } catch (failure: IllegalArgumentException) {
throw IllegalArgumentException("User cursor is invalid", failure) throw IllegalArgumentException("User cursor is invalid", failure)
@@ -138,13 +173,13 @@ internal object AdminUserCursorCodec {
internal object AdminUserLedgerCursorCodec { internal object AdminUserLedgerCursorCodec {
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid" private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
fun encode(cursor: AdminUserLedgerCursor): String { fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String {
val value = "${cursor.createdAt}|${cursor.ledgerEntryId}" val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}"
return Base64.getUrlEncoder().withoutPadding() return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8)) .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 } require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
return try { return try {
val decoded = String( val decoded = String(
@@ -152,10 +187,12 @@ internal object AdminUserLedgerCursorCodec {
StandardCharsets.UTF_8, StandardCharsets.UTF_8,
) )
val parts = decoded.split('|') val parts = decoded.split('|')
require(parts.size == 2) require(parts.size == 4)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminUserLedgerCursor( AdminUserLedgerCursor(
createdAt = Instant.parse(parts[0]), createdAt = Instant.parse(parts[2]),
ledgerEntryId = UUID.fromString(parts[1]), ledgerEntryId = UUID.fromString(parts[3]),
) )
} catch (failure: IllegalArgumentException) { } catch (failure: IllegalArgumentException) {
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure) 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 package com.osglab.account.features.admin
import com.osglab.account.features.admin.models.AdminAuditCursor 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.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminLockState import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor 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.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord 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.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession import com.osglab.account.features.admin.models.NewAdminSession
@@ -24,6 +28,7 @@ internal class InMemoryAdminRepository(
var passwordHash: String = "valid-password-hash", var passwordHash: String = "valid-password-hash",
var encryptedTotpSecret: String, var encryptedTotpSecret: String,
var role: AdminRole = AdminRole.SUPER_ADMIN, var role: AdminRole = AdminRole.SUPER_ADMIN,
var lastLoginAt: Instant? = null,
) : AdminRepository { ) : AdminRepository {
private val mutex = Mutex() private val mutex = Mutex()
var lockState = AdminLockState(0, null) var lockState = AdminLockState(0, null)
@@ -66,19 +71,22 @@ internal class InMemoryAdminRepository(
override suspend fun listOperatorsPage( override suspend fun listOperatorsPage(
limit: Int, limit: Int,
before: AdminOperatorCursor?, before: AdminOperatorCursor?,
query: AdminOperatorQuery,
): List<AdminOperatorRecord> = mutex.withLock { ): List<AdminOperatorRecord> = mutex.withLock {
require(limit in 1..101) require(limit in 1..101)
(listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord)) (listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord))
.asSequence() .asSequence()
.filter { .filter {
before == null || query.time.from?.let { from -> it.createdAt >= from } != false &&
it.createdAt.isAfter(before.createdAt) || query.time.until?.let { until -> it.createdAt < until } != false &&
(it.createdAt == before.createdAt && it.id.toString() > before.id.toString()) 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( .filter { before == null || operatorAfter(it, before, query) }
compareBy<AdminOperatorRecord> { it.createdAt } .sortedWith(operatorComparator(query))
.thenBy { it.id.toString() },
)
.take(limit) .take(limit)
.toList() .toList()
} }
@@ -307,20 +315,40 @@ internal class InMemoryAdminRepository(
override suspend fun listAudit( override suspend fun listAudit(
limit: Int, limit: Int,
before: AdminAuditCursor?, before: AdminAuditCursor?,
query: AdminAuditQuery,
): List<AdminAuditRecord> = ): List<AdminAuditRecord> =
mutex.withLock { mutex.withLock {
audits.asSequence() 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 { .filter {
before == null || before == null ||
it.occurredAt.isBefore(before.occurredAt) || 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.occurredAt == before.occurredAt &&
it.id.toString() < before.id.toString() it.id.toString() < before.id.toString()
) )
} }
}
.sortedWith( .sortedWith(
if (query.order == AdminSortOrder.ASC) {
compareBy<NewAdminAuditEvent> { it.occurredAt }
.thenBy { it.id.toString() }
} else {
compareByDescending<NewAdminAuditEvent> { it.occurredAt } compareByDescending<NewAdminAuditEvent> { it.occurredAt }
.thenByDescending { it.id.toString() }, .thenByDescending { it.id.toString() }
},
) )
.take(limit) .take(limit)
.map { .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( private fun authRecord() = AdminOperatorAuthRecord(
id = operatorId, id = operatorId,
normalizedUsername = username, normalizedUsername = username,
@@ -360,7 +409,7 @@ internal class InMemoryAdminRepository(
role = role, role = role,
lockState = lockState, lockState = lockState,
disabledAt = disabledAt, disabledAt = disabledAt,
lastLoginAt = null, lastLoginAt = lastLoginAt,
createdAt = Instant.EPOCH, createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH, updatedAt = Instant.EPOCH,
) )
@@ -391,6 +440,7 @@ internal class InMemoryAdminRepository(
var lockState: AdminLockState = AdminLockState(0, null), var lockState: AdminLockState = AdminLockState(0, null),
var lastTotpCounter: Long? = null, var lastTotpCounter: Long? = null,
var disabledAt: Instant? = null, var disabledAt: Instant? = null,
var lastLoginAt: Instant? = null,
var updatedAt: Instant = operator.createdAt, var updatedAt: Instant = operator.createdAt,
) { ) {
fun toAuthRecord() = AdminOperatorAuthRecord( fun toAuthRecord() = AdminOperatorAuthRecord(
@@ -409,7 +459,7 @@ internal class InMemoryAdminRepository(
role = operator.role, role = operator.role,
lockState = lockState, lockState = lockState,
disabledAt = disabledAt, disabledAt = disabledAt,
lastLoginAt = null, lastLoginAt = lastLoginAt,
createdAt = operator.createdAt, createdAt = operator.createdAt,
updatedAt = updatedAt, updatedAt = updatedAt,
) )
@@ -424,3 +474,64 @@ private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): N
com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED 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
@@ -4,12 +4,18 @@ import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditAction import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome 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.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminRole 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.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession import com.osglab.account.features.admin.models.NewAdminSession
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -175,6 +181,85 @@ class AdminOperatorRepositoryIntegrationTest : FunSpec({
(repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true (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( private suspend fun withAdminRepositories(
@@ -188,7 +188,7 @@ class AdminRoutesTest {
fun `operator list maps non-super authorization to stable forbidden response`() = testApplication { fun `operator list maps non-super authorization to stable forbidden response`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPPORT) val sessionService = sessionFixture(AdminRole.SUPPORT)
val operatorService = mockk<AdminOperatorService>() val operatorService = mockk<AdminOperatorService>()
coEvery { operatorService.listPage(any(), any(), any()) } throws coEvery { operatorService.listPage(any(), any(), any(), any()) } throws
AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION) AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
application { application {
installAdminTestRoutes( installAdminTestRoutes(
@@ -259,6 +259,34 @@ class AdminRoutesTest {
response.bodyAsText() shouldContain """"usageType":"hotword"""" 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 @Test
fun `operator creation maps normalized username conflict to 409`() = testApplication { fun `operator creation maps normalized username conflict to 409`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN) val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
@@ -1,10 +1,15 @@
package com.osglab.account.features.admin.services 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.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome 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.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.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole 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 com.osglab.account.features.admin.repositories.AdminRepository
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
@@ -66,6 +71,54 @@ class AdminAuditServiceTest : FunSpec({
service.list(support, null) service.list(support, null)
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION }.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( private fun auditRecord(occurredAt: String) = AdminAuditRecord(
@@ -79,6 +132,19 @@ private fun auditRecord(occurredAt: String) = AdminAuditRecord(
occurredAt = Instant.parse(occurredAt), 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( private fun superAdministrator() = AdminPrincipal(
operatorId = UUID.randomUUID(), operatorId = UUID.randomUUID(),
sessionId = UUID.randomUUID(), sessionId = UUID.randomUUID(),
@@ -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.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorRecord 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.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord 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.models.NewAdminOperator
import com.osglab.account.features.admin.security.AdminPasswordHasher import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning import com.osglab.account.features.admin.security.AdminTotpProvisioning
@@ -216,6 +220,104 @@ class AdminOperatorServiceTest : FunSpec({
fixture.service.listPage(fixture.owner, cursor = "not-a-cursor") 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( private data class OperatorFixture(
@@ -1,7 +1,9 @@
package com.osglab.account.features.admin.stats 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.AdminOverviewDto
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto 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.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange 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.assembleAdminStats
import com.osglab.account.features.admin.stats.repositories.toExactLong 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.AdminStatsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
@@ -159,4 +162,40 @@ class AdminStatsRepositoryTest : FunSpec({
BigDecimal("1.5").toExactLong() 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.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto 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.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.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.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService import com.osglab.account.features.admin.users.services.AdminUsersService
@@ -223,6 +229,112 @@ class AdminUsersServiceTest : FunSpec({
service.detail(UUID.randomUUID()) 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( private class PagingUsersRepository(
@@ -233,15 +345,24 @@ private class PagingUsersRepository(
override suspend fun list( override suspend fun list(
limit: Int, limit: Int,
cursor: AdminUserCursor?, cursor: AdminUserCursor?,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> = ): List<AdminUserSummaryDto> =
users.filter { users.asSequence()
cursor == null || .filter { it.matches(query) }
Instant.parse(it.createdAt) < cursor.createdAt || .filter {
( cursor == null || userAfter(it, cursor, query.order)
Instant.parse(it.createdAt) == cursor.createdAt && }
UUID.fromString(it.id).toString() < cursor.userId.toString() .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) .take(limit)
.toList()
override suspend fun findDetail( override suspend fun findDetail(
userId: UUID, userId: UUID,
@@ -251,8 +372,11 @@ private class PagingUsersRepository(
override suspend fun findByIdSuffix( override suspend fun findByIdSuffix(
suffix: String, suffix: String,
limit: Int, limit: Int,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> = ): 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 = override suspend fun exists(userId: UUID): Boolean =
userId in details || userId in ledger || users.any { it.id == userId.toString() } userId in details || userId in ledger || users.any { it.id == userId.toString() }
@@ -261,48 +385,108 @@ private class PagingUsersRepository(
userId: UUID, userId: UUID,
limit: Int, limit: Int,
cursor: AdminUserLedgerCursor?, cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = ): List<AdminUserLedgerEntryDto> =
ledger[userId].orEmpty() ledger[userId].orEmpty()
.filter { it.matches(query) }
.filter { .filter {
val createdAt = Instant.parse(it.createdAt) cursor == null || ledgerAfter(it, cursor, query.order)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
} }
.sortedWith( .sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) } ledgerComparator(query.order),
.thenByDescending(AdminUserLedgerEntryDto::id),
) )
.take(limit) .take(limit)
override suspend fun listLatestLedger( override suspend fun listLatestLedger(
limit: Int, limit: Int,
cursor: AdminUserLedgerCursor?, cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = ): List<AdminUserLedgerEntryDto> =
ledger.values.flatten() ledger.values.flatten()
.filter { it.matches(query) }
.filter { .filter {
val createdAt = Instant.parse(it.createdAt) cursor == null || ledgerAfter(it, cursor, query.order)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
} }
.sortedWith( .sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) } ledgerComparator(query.order),
.thenByDescending(AdminUserLedgerEntryDto::id),
) )
.take(limit) .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(), id = id.toString(),
createdAt = createdAt.toString(), createdAt = createdAt.toString(),
antiAbuseRestricted = false, antiAbuseRestricted = restricted,
creditBalance = 0, creditBalance = 0,
consumedCredits = 0, consumedCredits = 0,
manualGrantedCredits = 0, manualGrantedCredits = 0,
@@ -316,10 +500,11 @@ private fun ledgerEntry(
id: UUID, id: UUID,
createdAt: Instant, createdAt: Instant,
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"), userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
type: String = "MANUAL_GRANT",
) = AdminUserLedgerEntryDto( ) = AdminUserLedgerEntryDto(
id = id.toString(), id = id.toString(),
userId = userId.toString(), userId = userId.toString(),
type = "MANUAL_GRANT", type = type,
amountDelta = 10, amountDelta = 10,
balanceAfter = 10, balanceAfter = 10,
referenceId = null, referenceId = null,