diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index 511cc4d..a00919c 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -4,16 +4,21 @@ import type { AdminOperatorProvisioning, AdminSecuritySummary, AdminLoginResponse, + AuditQuery, AuditLogEntry, CreditGrantRequest, CreditGrantResponse, + LedgerQuery, LedgerEntry, + OperatorsQuery, Overview, PageResult, ProductAnalyticsOverview, + ReferralsQuery, ReferralOverview, SessionResponse, UserDetail, + UsersQuery, UserSummary, } from "./types"; @@ -140,15 +145,21 @@ async function request( } } -function query(params: Record): string { +export function encodeQuery(params: T): string { const search = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { - if (value) search.set(key, value); + if (value !== undefined && value !== "") search.set(key, String(value)); }); const result = search.toString(); return result ? `?${result}` : ""; } +function cursorQuery(value?: string | T): CursorQuery | T { + return typeof value === "string" ? { cursor: value } : (value ?? {}); +} + +type CursorQuery = { cursor?: string }; + export const adminApi = { session: () => request("/auth/session"), @@ -161,28 +172,35 @@ export const adminApi = { logout: () => request("/auth/logout", { method: "POST" }), overview: (range: string) => - request(`/overview${query({ range })}`), + request(`/overview${encodeQuery({ range })}`), - referrals: (range: string) => - request(`/referrals${query({ range })}`), + referrals: (value: string | ReferralsQuery) => { + const params = typeof value === "string" ? { range: value } : value; + return request(`/referrals${encodeQuery(params)}`); + }, productAnalytics: (range: string) => - request(`/analytics${query({ range })}`), + request(`/analytics${encodeQuery({ range })}`), - users: (search = "", cursor?: string) => - request>( - `/users${query({ q: search.trim(), cursor })}`, - ), + users: (value: string | UsersQuery = "", legacyCursor?: string) => { + const params = + typeof value === "string" + ? { q: value.trim(), cursor: legacyCursor } + : { ...value, q: value.q?.trim() }; + return request>(`/users${encodeQuery(params)}`); + }, user: (userId: string) => request(`/users/${encodeURIComponent(userId)}`), - latestLedger: (cursor?: string) => - request>(`/credits/ledger${query({ cursor })}`), - - ledger: (userId: string, cursor?: string) => + latestLedger: (value?: string | LedgerQuery) => request>( - `/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`, + `/credits/ledger${encodeQuery(cursorQuery(value))}`, + ), + + ledger: (userId: string, value?: string | LedgerQuery) => + request>( + `/users/${encodeURIComponent(userId)}/ledger${encodeQuery(cursorQuery(value))}`, ), grantCredits: (payload: CreditGrantRequest) => @@ -196,11 +214,15 @@ export const adminApi = { headers: { "Idempotency-Key": payload.idempotencyKey }, }), - auditLogs: (cursor?: string) => - request>(`/audit${query({ cursor })}`), + auditLogs: (value?: string | AuditQuery) => + request>( + `/audit${encodeQuery(cursorQuery(value))}`, + ), - operators: (cursor?: string) => - request>(`/operators${query({ cursor })}`), + operators: (value?: string | OperatorsQuery) => + request>( + `/operators${encodeQuery(cursorQuery(value))}`, + ), operatorSummary: () => request("/operators/summary"), diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index 44ddc54..b36e3ad 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -1,5 +1,55 @@ export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST"; +export type SortOrder = "asc" | "desc"; +export type AdminAuditAction = + | "LOGIN_SUCCEEDED" + | "LOGIN_FAILED" + | "SESSION_REVOKED" + | "OPERATOR_CREATED" + | "OPERATOR_ENABLED" + | "OPERATOR_DISABLED" + | "OPERATOR_UNLOCKED" + | "OPERATOR_CREDENTIALS_RESET" + | "OPERATOR_SESSIONS_REVOKED" + | "MANUAL_CREDIT_GRANTED"; + +export interface CursorPageQuery { + cursor?: string; + limit?: number; + from?: string; + until?: string; + sort?: "createdAt"; + order?: SortOrder; +} + +export interface UsersQuery extends CursorPageQuery { + q?: string; + status?: UserSummary["status"]; +} + +export interface LedgerQuery extends CursorPageQuery { + type?: LedgerEntryType; +} + +export interface AuditQuery extends CursorPageQuery { + action?: AdminAuditAction; + result?: AuditLogEntry["result"]; +} + +export interface OperatorsQuery extends Omit { + role?: AdminRole; + enabled?: boolean; + locked?: boolean; + sort?: "createdAt" | "username" | "lastLoginAt"; +} + +export interface ReferralsQuery { + range: string; + sort?: "invited" | "qualified" | "creditsEarned"; + order?: SortOrder; + limit?: number; +} + export type AuthState = | { status: "anonymous" } | { status: "authenticated"; operatorName: string; role: AdminRole }; diff --git a/admin-web/src/components/chart-toolbar.tsx b/admin-web/src/components/chart-toolbar.tsx new file mode 100644 index 0000000..fc1033f --- /dev/null +++ b/admin-web/src/components/chart-toolbar.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +export function ChartToolbar({ + children, + label, +}: { + children: ReactNode; + label: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/admin-web/src/components/charts/trend-chart.tsx b/admin-web/src/components/charts/trend-chart.tsx index 5d7e658..2a87b3e 100644 --- a/admin-web/src/components/charts/trend-chart.tsx +++ b/admin-web/src/components/charts/trend-chart.tsx @@ -1,10 +1,21 @@ import type { TrendPoint } from "../../api/types"; import { formatNumber } from "../../lib/format"; -export function TrendChart({ points }: { points: TrendPoint[] }) { +export function TrendChart({ + points, + showRegistrations = true, + showCredits = true, +}: { + points: TrendPoint[]; + showRegistrations?: boolean; + showCredits?: boolean; +}) { if (points.length === 0) { return
暂无趋势数据
; } + if (!showRegistrations && !showCredits) { + return
请选择至少一个趋势系列
; + } const width = 760; const height = 300; @@ -53,26 +64,38 @@ export function TrendChart({ points }: { points: TrendPoint[] }) { className="chart-grid-line" /> ))} - - {formatNumber(registrationMax)} 用户 - - - {formatNumber(creditMax)} 积分 - - - + {showRegistrations ? ( + + {formatNumber(registrationMax)} 用户 + + ) : null} + {showCredits ? ( + + {formatNumber(creditMax)} 积分 + + ) : null} + {showRegistrations ? ( + + ) : null} + {showCredits ? ( + + ) : null} {coordinates.map(({ point, x, registrationY, creditY }, index) => ( - - - {point.date}:新增 {formatNumber(point.registrations)} 位用户 - - - - - {point.date}:消耗 {formatNumber(point.creditsUsed)} 积分 - - + {showRegistrations ? ( + + + {point.date}:新增 {formatNumber(point.registrations)} 位用户 + + + ) : null} + {showCredits ? ( + + + {point.date}:消耗 {formatNumber(point.creditsUsed)} 积分 + + + ) : null} {index % labelEvery === 0 || index === points.length - 1 ? ( UTC 日期 - 新增用户 - 消耗积分 + {showRegistrations ? 新增用户 : null} + {showCredits ? 消耗积分 : null} {points.map((point) => ( {point.date} - {formatNumber(point.registrations)} - {formatNumber(point.creditsUsed)} + {showRegistrations ? {formatNumber(point.registrations)} : null} + {showCredits ? {formatNumber(point.creditsUsed)} : null} ))} diff --git a/admin-web/src/components/data-table.tsx b/admin-web/src/components/data-table.tsx index 9fc6de4..ed30062 100644 --- a/admin-web/src/components/data-table.tsx +++ b/admin-web/src/components/data-table.tsx @@ -5,7 +5,9 @@ import { type LegacyColumnDef, useLegacyTable, } from "@tanstack/react-table/legacy"; +import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import { type ReactNode } from "react"; +import type { SortOrder } from "../api/types"; import { cn } from "../lib/utils"; import { EmptyState } from "./primitives"; @@ -18,6 +20,9 @@ export function DataTable({ emptyTitle = "暂无数据", footer, className, + sort, + sortableColumns, + onSortChange, }: { data: T[]; columns: DataColumn[]; @@ -25,6 +30,9 @@ export function DataTable({ emptyTitle?: string; footer?: ReactNode; className?: string; + sort?: { key: string; order: SortOrder }; + sortableColumns?: Record; + onSortChange?: (key: string, order: SortOrder) => void; }) { const table = useLegacyTable({ data, @@ -42,16 +50,50 @@ export function DataTable({ {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} + {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 ( + + {header.isPlaceholder ? null : sortKey && onSortChange ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + + ); + })} ))} diff --git a/admin-web/src/components/date-range-control.tsx b/admin-web/src/components/date-range-control.tsx new file mode 100644 index 0000000..12db158 --- /dev/null +++ b/admin-web/src/components/date-range-control.tsx @@ -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 ( +
+ 日期范围 + + +
+ ); +} diff --git a/admin-web/src/components/filter-control.tsx b/admin-web/src/components/filter-control.tsx new file mode 100644 index 0000000..75f980e --- /dev/null +++ b/admin-web/src/components/filter-control.tsx @@ -0,0 +1,55 @@ +export interface FilterOption { + value: T; + label: string; +} + +export function FilterControl({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: readonly FilterOption[]; + onChange: (value: T) => void; +}) { + return ( + + ); +} + +export function ToggleFilter({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} diff --git a/admin-web/src/components/sort-control.tsx b/admin-web/src/components/sort-control.tsx new file mode 100644 index 0000000..8c6b59f --- /dev/null +++ b/admin-web/src/components/sort-control.tsx @@ -0,0 +1,57 @@ +import type { SortOrder } from "../api/types"; + +export interface SortOption { + value: T; + label: string; +} + +export function SortControl({ + value, + order, + options, + onChange, + label = "排序", +}: { + value: T; + order: SortOrder; + options: readonly SortOption[]; + onChange: (value: T, order: SortOrder) => void; + label?: string; +}) { + return ( +
+ {label} + +
+ {(["desc", "asc"] as const).map((direction) => ( + + ))} +
+
+ ); +} diff --git a/admin-web/src/components/table-toolbar.tsx b/admin-web/src/components/table-toolbar.tsx new file mode 100644 index 0000000..26878e5 --- /dev/null +++ b/admin-web/src/components/table-toolbar.tsx @@ -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 ( +
+ {children} + +
+ ); +} diff --git a/admin-web/src/features/analytics/analytics-page.tsx b/admin-web/src/features/analytics/analytics-page.tsx index e44995f..b9c88d6 100644 --- a/admin-web/src/features/analytics/analytics-page.tsx +++ b/admin-web/src/features/analytics/analytics-page.tsx @@ -9,27 +9,47 @@ import { Target, Users, } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { adminApi } from "../../api/client"; -import type { AnalyticsRate, ProductAnalyticsOverview } from "../../api/types"; +import type { + AnalyticsRate, + ProductAnalyticsOverview, + SortOrder, +} from "../../api/types"; +import { ChartToolbar } from "../../components/chart-toolbar"; import { CohortHeatmap } from "../../components/charts/cohort-heatmap"; import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart"; import { FunnelChart } from "../../components/charts/funnel-chart"; +import { FilterControl, ToggleFilter } from "../../components/filter-control"; import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives"; import { RangeControl } from "../../components/range-control"; +import { SortControl } from "../../components/sort-control"; import { formatNumber } from "../../lib/format"; +import { stableSort } from "../../lib/sort"; export function AnalyticsPage() { const [range, setRange] = useState("30d"); const [data, setData] = useState(); const [error, setError] = useState(); + const [executionMode, setExecutionMode] = useState(""); + const [aiSort, setAiSort] = useState<"successes" | "users">("successes"); + const [aiOrder, setAiOrder] = useState("desc"); + const [channelSort, setChannelSort] = useState<"installs" | "activated" | "rate">("installs"); + const [channelOrder, setChannelOrder] = useState("desc"); + const [hideUnknown, setHideUnknown] = useState(false); + const [cohortOrder, setCohortOrder] = useState("desc"); + const [minimumCohortSize, setMinimumCohortSize] = useState(0); + const [matureOnly, setMatureOnly] = useState(false); + const requestVersion = useRef(0); const load = useCallback(async () => { + const version = ++requestVersion.current; setError(undefined); try { - setData(await adminApi.productAnalytics(range)); + const response = await adminApi.productAnalytics(range); + if (requestVersion.current === version) setData(response); } catch (requestError) { - setError(requestError); + if (requestVersion.current === version) setError(requestError); } }, [range]); @@ -37,6 +57,47 @@ export function AnalyticsPage() { void load(); }, [load]); + const visibleAiFeatures = useMemo( + () => + stableSort( + (data?.aiFeatures ?? []).filter( + (item) => !executionMode || item.executionMode === executionMode, + ), + (item) => (aiSort === "successes" ? item.successes : item.users), + aiOrder, + ), + [aiOrder, aiSort, data?.aiFeatures, executionMode], + ); + const visibleChannels = useMemo( + () => + stableSort( + (data?.growth.channels ?? []).filter( + (channel) => !hideUnknown || channel.channel !== "UNKNOWN", + ), + (channel) => + channelSort === "installs" + ? channel.installations + : channelSort === "activated" + ? channel.activated + : channel.activationRate.percent ?? -1, + channelOrder, + ), + [channelOrder, channelSort, data?.growth.channels, hideUnknown], + ); + const visibleCohorts = useMemo( + () => + stableSort( + (data?.retention ?? []).filter( + (cohort) => + cohort.size >= minimumCohortSize && + (!matureOnly || cohort.d30?.percent != null), + ), + (cohort) => cohort.cohortDate, + cohortOrder, + ), + [cohortOrder, data?.retention, matureOnly, minimumCohortSize], + ); + if (error) return void load()} />; if (!data) return ; @@ -100,7 +161,28 @@ export function AnalyticsPage() { description="按首次 AI 成功日分组,未成熟窗口显示为 —" icon={ChartNoAxesCombined} /> - + + setCohortOrder(order)} + /> + setMinimumCohortSize(Number(value))} + /> + + + @@ -111,13 +193,36 @@ export function AnalyticsPage() { description="客户端功能与执行模式,仅包含白名单元数据" icon={BrainCircuit} /> - {data.aiFeatures.length === 0 ? ( -

等待客户端接入事件后显示

+ + + { + setAiSort(value); + setAiOrder(order); + }} + /> + + {visibleAiFeatures.length === 0 ? ( +

当前筛选条件下暂无 AI 使用数据

) : ( right.successes - left.successes) - .map((item) => ({ + items={visibleAiFeatures.map((item) => ({ id: `${item.feature}-${item.executionMode}`, label: featureLabel(item.feature), value: item.successes, @@ -227,9 +332,25 @@ export function AnalyticsPage() { + + { + setChannelSort(value); + setChannelOrder(order); + }} + /> + +
({ + items={visibleChannels.map((channel) => ({ label: channelLabel(channel.channel), value: channel.installations, secondaryValue: channel.activated, @@ -237,7 +358,7 @@ export function AnalyticsPage() { }))} primaryLabel="新增安装" secondaryLabel="24 小时激活" - emptyText="暂无渠道归因数据" + emptyText="当前筛选条件下暂无渠道归因数据" /> ([]); - const [nextCursor, setNextCursor] = useState(); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(); - - const load = useCallback(async () => { - setLoading(true); - setError(undefined); - try { - const page = await adminApi.auditLogs(); - setItems(page.items); - setNextCursor(page.nextCursor); - } catch (requestError) { - setError(requestError); - } finally { - setLoading(false); - } - }, []); + const [searchParams, setSearchParams] = useSearchParams(); + const [from, setFrom] = useState(searchParams.get("from") ?? ""); + const [until, setUntil] = useState(searchParams.get("until") ?? ""); + const [action, setAction] = useState<"" | AdminAuditAction>( + (searchParams.get("action") as AdminAuditAction | null) ?? "", + ); + const [result, setResult] = useState<"" | AuditLogEntry["result"]>( + (searchParams.get("result") as AuditLogEntry["result"] | null) ?? "", + ); + const [order, setOrder] = useState( + searchParams.get("order") === "asc" ? "asc" : "desc", + ); + const auditQuery = useMemo( + () => ({ + from: from || undefined, + until: until || undefined, + action: action || undefined, + result: result || undefined, + sort: "createdAt", + order, + limit: 50, + }), + [action, from, order, result, until], + ); + const fetchAudit = useCallback((value: AuditQuery) => adminApi.auditLogs(value), []); + const { + items, + nextCursor, + loading, + loadingMore, + error, + loadMore, + reload, + } = useCursorPage(auditQuery, fetchAudit); useEffect(() => { - void load(); - }, [load]); - - async function loadMore() { - if (!nextCursor) return; - setLoadingMore(true); - try { - const page = await adminApi.auditLogs(nextCursor); - setItems((current) => [...current, ...page.items]); - setNextCursor(page.nextCursor); - } catch (requestError) { - toast.error( - requestError instanceof ApiError ? requestError.message : "加载审计记录失败", - ); - } finally { - setLoadingMore(false); - } - } + const params = new URLSearchParams(); + if (from) params.set("from", from); + if (until) params.set("until", until); + if (action) params.set("action", action); + if (result) params.set("result", result); + params.set("sort", "createdAt"); + params.set("order", order); + setSearchParams(params, { replace: true }); + }, [action, from, order, result, setSearchParams, until]); const columns = useMemo[]>( () => [ @@ -140,9 +155,55 @@ export function AuditPage() { + { + setFrom(""); + setUntil(""); + setAction(""); + setResult(""); + setOrder("desc"); + }} + > + { + setFrom(value.from ?? ""); + setUntil(value.until ?? ""); + }} + /> + + + {error ? (
- void load()} /> +
) : loading ? ( @@ -151,7 +212,14 @@ export function AuditPage() { data={items} columns={columns} caption="管理员审计事件" - emptyTitle="暂无审计记录" + emptyTitle={ + from || until || action || result + ? "没有符合当前筛选条件的审计记录" + : "暂无审计记录" + } + sort={{ key: "createdAt", order }} + sortableColumns={{ createdAt: "createdAt" }} + onSortChange={(_, nextOrder) => setOrder(nextOrder)} footer={ nextCursor ? (
diff --git a/admin-web/src/features/credits/credits-page.tsx b/admin-web/src/features/credits/credits-page.tsx index 5eeaa83..7f40a25 100644 --- a/admin-web/src/features/credits/credits-page.tsx +++ b/admin-web/src/features/credits/credits-page.tsx @@ -1,8 +1,11 @@ import { ArrowDownUp, Coins, Search } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; -import { adminApi, ApiError } from "../../api/client"; -import type { LedgerEntry } from "../../api/types"; +import { useSearchParams } from "react-router-dom"; +import { adminApi } from "../../api/client"; +import type { LedgerEntry, LedgerEntryType, LedgerQuery, SortOrder } from "../../api/types"; import { DataTable, type DataColumn } from "../../components/data-table"; +import { DateRangeControl } from "../../components/date-range-control"; +import { FilterControl } from "../../components/filter-control"; import { Badge, Button, @@ -12,6 +15,8 @@ import { LoadingState, PageHeader, } from "../../components/primitives"; +import { TableToolbar } from "../../components/table-toolbar"; +import { useCursorPage } from "../../hooks/use-cursor-page"; import { formatDateTime, formatNumber, @@ -19,58 +24,63 @@ import { statusLabel, usageTypeLabel, } from "../../lib/format"; -import { toast } from "sonner"; export function CreditsPage() { - const [query, setQuery] = useState(""); - const [activeUserId, setActiveUserId] = useState(); - const [items, setItems] = useState([]); - const [nextCursor, setNextCursor] = useState(); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(); - - const load = useCallback(async (userId?: string) => { - setLoading(true); - setError(undefined); - try { - const page = userId - ? await adminApi.ledger(userId) - : await adminApi.latestLedger(); - setItems(page.items); - setNextCursor(page.nextCursor); - } catch (requestError) { - setError(requestError); - } finally { - setLoading(false); - } - }, []); + const [searchParams, setSearchParams] = useSearchParams(); + const initialUserId = searchParams.get("userId") ?? ""; + const [query, setQuery] = useState(initialUserId); + const [activeUserId, setActiveUserId] = useState(initialUserId); + const [from, setFrom] = useState(searchParams.get("from") ?? ""); + const [until, setUntil] = useState(searchParams.get("until") ?? ""); + const [type, setType] = useState<"" | LedgerEntryType>( + (searchParams.get("type") as LedgerEntryType | null) ?? "", + ); + const [order, setOrder] = useState( + searchParams.get("order") === "asc" ? "asc" : "desc", + ); + const ledgerQuery = useMemo( + () => ({ + from: from || undefined, + until: until || undefined, + type: type || undefined, + sort: "createdAt", + order, + limit: 50, + }), + [from, order, type, until], + ); + const fetchLedger = useCallback( + (value: LedgerQuery) => + activeUserId + ? adminApi.ledger(activeUserId, value) + : adminApi.latestLedger(value), + [activeUserId], + ); + const { + items, + nextCursor, + loading, + loadingMore, + error, + loadMore, + reload, + } = useCursorPage(ledgerQuery, fetchLedger); useEffect(() => { - void load(); - }, [load]); - - async function loadMore() { - if (!nextCursor) return; - setLoadingMore(true); - try { - const page = activeUserId - ? await adminApi.ledger(activeUserId, nextCursor) - : await adminApi.latestLedger(nextCursor); - setItems((current) => [...current, ...page.items]); - setNextCursor(page.nextCursor); - } catch (requestError) { - toast.error(requestError instanceof ApiError ? requestError.message : "加载流水失败"); - } finally { - setLoadingMore(false); - } - } + const params = new URLSearchParams(); + if (activeUserId) params.set("userId", activeUserId); + if (from) params.set("from", from); + if (until) params.set("until", until); + if (type) params.set("type", type); + params.set("sort", "createdAt"); + params.set("order", order); + setSearchParams(params, { replace: true }); + }, [activeUserId, from, order, setSearchParams, type, until]); function submit(event: FormEvent) { event.preventDefault(); const userId = query.trim() || undefined; - setActiveUserId(userId); - void load(userId); + setActiveUserId(userId ?? ""); } const columns = useMemo[]>( @@ -171,6 +181,38 @@ export function CreditsPage() {
+ { + setQuery(""); + setActiveUserId(""); + setFrom(""); + setUntil(""); + setType(""); + setOrder("desc"); + }} + > + { + setFrom(value.from ?? ""); + setUntil(value.until ?? ""); + }} + /> + +
@@ -186,7 +228,7 @@ export function CreditsPage() { {error ? (
- void load(activeUserId)} /> +
) : loading ? ( @@ -195,7 +237,14 @@ export function CreditsPage() { data={items} columns={columns} caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"} - emptyTitle={activeUserId ? "该用户暂无积分流水" : "暂无积分流水"} + emptyTitle={ + activeUserId || from || until || type + ? "没有符合当前筛选条件的流水" + : "暂无积分流水" + } + sort={{ key: "createdAt", order }} + sortableColumns={{ createdAt: "createdAt" }} + onSortChange={(_, nextOrder) => setOrder(nextOrder)} footer={ nextCursor ? (
diff --git a/admin-web/src/features/overview/overview-page.tsx b/admin-web/src/features/overview/overview-page.tsx index 6a4e8ff..7981a4c 100644 --- a/admin-web/src/features/overview/overview-page.tsx +++ b/admin-web/src/features/overview/overview-page.tsx @@ -4,28 +4,39 @@ import { CreditCard, Users, } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { adminApi } from "../../api/client"; -import type { Overview } from "../../api/types"; +import type { Overview, SortOrder } from "../../api/types"; +import { ChartToolbar } from "../../components/chart-toolbar"; import { ChartLegend } from "../../components/charts/chart-legend"; import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart"; import { RadialMetric } from "../../components/charts/radial-metric"; import { TrendChart } from "../../components/charts/trend-chart"; +import { ToggleFilter } from "../../components/filter-control"; import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives"; import { RangeControl } from "../../components/range-control"; +import { SortControl } from "../../components/sort-control"; import { formatNumber, usageTypeLabel } from "../../lib/format"; +import { stableSort } from "../../lib/sort"; export function OverviewPage() { const [range, setRange] = useState("30d"); const [data, setData] = useState(); const [error, setError] = useState(); + const [showRegistrations, setShowRegistrations] = useState(true); + const [showCredits, setShowCredits] = useState(true); + const [usageSort, setUsageSort] = useState<"credits" | "requests">("credits"); + const [usageOrder, setUsageOrder] = useState("desc"); + const requestVersion = useRef(0); const load = useCallback(async () => { + const version = ++requestVersion.current; setError(undefined); try { - setData(await adminApi.overview(range)); + const response = await adminApi.overview(range); + if (requestVersion.current === version) setData(response); } catch (requestError) { - setError(requestError); + if (requestVersion.current === version) setError(requestError); } }, [range]); @@ -33,6 +44,16 @@ export function OverviewPage() { void load(); }, [load]); + const sortedUsage = useMemo( + () => + stableSort( + data?.usage ?? [], + (item) => (usageSort === "credits" ? item.chargedCredits : item.requests), + usageOrder, + ), + [data?.usage, usageOrder, usageSort], + ); + if (error) return void load()} />; if (!data) return ; @@ -88,13 +109,29 @@ export function OverviewPage() {
+
+ + +
- +
@@ -129,13 +166,25 @@ export function OverviewPage() {

仅聚合计量数据,不包含任何用户内容

+ + { + setUsageSort(value); + setUsageOrder(order); + }} + /> + {data.usage.length === 0 ? (
当前周期暂无使用记录
) : ( right.chargedCredits - left.chargedCredits) - .map((item) => ({ + items={sortedUsage.map((item) => ({ label: usageTypeLabel(item.kind), value: item.chargedCredits, hint: `${formatNumber(item.requests)} 次请求`, diff --git a/admin-web/src/features/referrals/referrals-page.tsx b/admin-web/src/features/referrals/referrals-page.tsx index 86dae2f..b796e5d 100644 --- a/admin-web/src/features/referrals/referrals-page.tsx +++ b/admin-web/src/features/referrals/referrals-page.tsx @@ -1,11 +1,19 @@ import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { adminApi } from "../../api/client"; -import type { ReferralOverview, ReferralRankingItem } from "../../api/types"; +import type { + ReferralOverview, + ReferralRankingItem, + ReferralsQuery, + SortOrder, +} from "../../api/types"; +import { ChartToolbar } from "../../components/chart-toolbar"; import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart"; import { FunnelChart } from "../../components/charts/funnel-chart"; import { RadialMetric } from "../../components/charts/radial-metric"; import { DataTable, type DataColumn } from "../../components/data-table"; +import { FilterControl } from "../../components/filter-control"; import { Card, ErrorState, @@ -14,26 +22,51 @@ import { StatCard, } from "../../components/primitives"; import { RangeControl } from "../../components/range-control"; +import { SortControl } from "../../components/sort-control"; import { formatNumber } from "../../lib/format"; export function ReferralsPage() { - const [range, setRange] = useState("30d"); + const [searchParams, setSearchParams] = useSearchParams(); + const [range, setRange] = useState(searchParams.get("range") ?? "30d"); + const [sort, setSort] = useState>( + (searchParams.get("sort") as ReferralsQuery["sort"] | null) ?? "qualified", + ); + const [order, setOrder] = useState( + 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(); const [error, setError] = useState(); + const requestVersion = useRef(0); + const query = useMemo( + () => ({ range, sort, order, limit }), + [limit, order, range, sort], + ); const load = useCallback(async () => { + const version = ++requestVersion.current; setError(undefined); + setData(undefined); try { - setData(await adminApi.referrals(range)); + const response = await adminApi.referrals(query); + if (requestVersion.current === version) setData(response); } catch (requestError) { - setError(requestError); + if (requestVersion.current === version) setError(requestError); } - }, [range]); + }, [query]); useEffect(() => { void load(); }, [load]); + useEffect(() => { + const params = new URLSearchParams({ range, sort, order, limit: String(limit) }); + setSearchParams(params, { replace: true }); + }, [limit, order, range, setSearchParams, sort]); + const columns = useMemo[]>( () => [ { @@ -117,6 +150,35 @@ export function ReferralsPage() { /> + + + { + setSort(value); + setOrder(nextOrder); + }} + /> + setLimit(Number(value))} + /> + + +
@@ -180,6 +242,16 @@ export function ReferralsPage() { columns={columns} caption="有效邀请用户排行" emptyTitle="当前周期暂无排行数据" + sort={{ key: sort, order }} + sortableColumns={{ + invited: "invited", + qualified: "qualified", + creditsEarned: "creditsEarned", + }} + onSortChange={(key, nextOrder) => { + setSort(key as NonNullable); + setOrder(nextOrder); + }} /> diff --git a/admin-web/src/features/security/security-page.tsx b/admin-web/src/features/security/security-page.tsx index 306c5e9..c040900 100644 --- a/admin-web/src/features/security/security-page.tsx +++ b/admin-web/src/features/security/security-page.tsx @@ -18,6 +18,7 @@ import { useState, type FormEvent, } from "react"; +import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { adminApi, ApiError } from "../../api/client"; import type { @@ -25,8 +26,12 @@ import type { AdminOperatorProvisioning, AdminRole, AdminSecuritySummary, + OperatorsQuery, + SortOrder, } from "../../api/types"; import { DataTable, type DataColumn } from "../../components/data-table"; +import { DateRangeControl } from "../../components/date-range-control"; +import { FilterControl } from "../../components/filter-control"; import { Badge, Button, @@ -38,6 +43,8 @@ import { PageHeader, StatCard, } from "../../components/primitives"; +import { TableToolbar } from "../../components/table-toolbar"; +import { useCursorPage } from "../../hooks/use-cursor-page"; import { formatDateTime, formatNumber } from "../../lib/format"; import { useAuth } from "../auth/auth-context"; @@ -51,12 +58,22 @@ interface ConfirmationState { export function SecurityPage() { const { auth } = useAuth(); const currentUsername = auth.status === "authenticated" ? auth.operatorName : ""; - const [operators, setOperators] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); const [summary, setSummary] = useState(); - const [nextCursor, setNextCursor] = useState(); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(); + const [summaryError, setSummaryError] = useState(); + const [from, setFrom] = useState(searchParams.get("from") ?? ""); + const [until, setUntil] = useState(searchParams.get("until") ?? ""); + const [role, setRole] = useState<"" | AdminRole>( + (searchParams.get("role") as AdminRole | null) ?? "", + ); + const [enabled, setEnabled] = useState(searchParams.get("enabled") ?? ""); + const [locked, setLocked] = useState(searchParams.get("locked") ?? ""); + const [sort, setSort] = useState>( + (searchParams.get("sort") as OperatorsQuery["sort"] | null) ?? "createdAt", + ); + const [order, setOrder] = useState( + searchParams.get("order") === "asc" ? "asc" : "desc", + ); const [createOpen, setCreateOpen] = useState(false); const [resetOperator, setResetOperator] = useState(); const [confirmation, setConfirmation] = useState(); @@ -64,44 +81,60 @@ export function SecurityPage() { data: AdminOperatorProvisioning; title: string; }>(); - - const load = useCallback(async () => { - setLoading(true); - setError(undefined); + const operatorsQuery = useMemo( + () => ({ + from: from || undefined, + until: until || undefined, + role: role || undefined, + enabled: enabled === "" ? undefined : enabled === "true", + locked: locked === "" ? undefined : locked === "true", + sort, + order, + limit: 50, + }), + [enabled, from, locked, order, role, sort, until], + ); + const fetchOperators = useCallback( + (value: OperatorsQuery) => adminApi.operators(value), + [], + ); + const { + items: operators, + nextCursor, + loading, + loadingMore, + error, + loadMore, + reload, + } = useCursorPage(operatorsQuery, fetchOperators); + const loadSummary = useCallback(async () => { + setSummaryError(undefined); try { - const [page, securitySummary] = await Promise.all([ - adminApi.operators(), - adminApi.operatorSummary(), - ]); - setOperators(page.items); - setNextCursor(page.nextCursor); - setSummary(securitySummary); + setSummary(await adminApi.operatorSummary()); } catch (requestError) { - setError(requestError); - } finally { - setLoading(false); + setSummaryError(requestError); } }, []); + const load = useCallback(async () => { + reload(); + await loadSummary(); + }, [loadSummary, reload]); useEffect(() => { - void load(); - }, [load]); + void loadSummary(); + }, [loadSummary]); - async function loadMore() { - if (!nextCursor) return; - setLoadingMore(true); - try { - const page = await adminApi.operators(nextCursor); - setOperators((current) => [...current, ...page.items]); - setNextCursor(page.nextCursor); - } catch (requestError) { - toast.error( - requestError instanceof ApiError ? requestError.message : "加载管理员失败", - ); - } finally { - setLoadingMore(false); - } - } + useEffect(() => { + const params = new URLSearchParams(); + if (from) params.set("from", from); + if (until) params.set("until", until); + if (role) params.set("role", role); + if (enabled) params.set("enabled", enabled); + if (locked) params.set("locked", locked); + params.set("sort", sort); + params.set("order", order); + setSearchParams(params, { replace: true }); + }, [enabled, from, locked, order, role, setSearchParams, sort, until]); async function handleConfirmedAction() { if (!confirmation) return; @@ -129,7 +162,7 @@ export function SecurityPage() { const columns = useMemo[]>( () => [ { - id: "operator", + id: "username", header: "管理员", cell: ({ row }) => { const current = row.original.username === currentUsername; @@ -177,6 +210,15 @@ export function SecurityPage() { ), }, + { + accessorKey: "createdAt", + header: "创建时间", + cell: ({ getValue }) => ( + + {formatDateTime(String(getValue()))} + + ), + }, { id: "actions", header: "操作", @@ -193,7 +235,9 @@ export function SecurityPage() { [currentUsername], ); - if (error) return void load()} />; + if (error || summaryError) { + return void load()} />; + } if (loading || !summary) return ; return ( @@ -258,11 +302,84 @@ export function SecurityPage() {
+ { + setFrom(""); + setUntil(""); + setRole(""); + setEnabled(""); + setLocked(""); + setSort("createdAt"); + setOrder("desc"); + }} + > + { + setFrom(value.from ?? ""); + setUntil(value.until ?? ""); + }} + /> + + + + { + setSort(key as NonNullable); + setOrder(nextOrder); + }} footer={ nextCursor ? (
diff --git a/admin-web/src/features/users/users-page.tsx b/admin-web/src/features/users/users-page.tsx index 727b8c5..dd2e0fa 100644 --- a/admin-web/src/features/users/users-page.tsx +++ b/admin-web/src/features/users/users-page.tsx @@ -11,18 +11,26 @@ import { useCallback, useEffect, useMemo, + useRef, useState, type FormEvent, } from "react"; +import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { adminApi, ApiError } from "../../api/client"; import type { + LedgerEntryType, + LedgerQuery, LedgerEntry, + SortOrder, UserDetail, + UsersQuery, UserSummary, UserUsageAggregate, } from "../../api/types"; import { DataTable, type DataColumn } from "../../components/data-table"; +import { DateRangeControl } from "../../components/date-range-control"; +import { FilterControl } from "../../components/filter-control"; import { Badge, Button, @@ -34,6 +42,8 @@ import { PageHeader, Textarea, } from "../../components/primitives"; +import { TableToolbar } from "../../components/table-toolbar"; +import { useCursorPage } from "../../hooks/use-cursor-page"; import { createIdempotencyKey, formatDateTime, @@ -44,6 +54,15 @@ import { } from "../../lib/format"; import { useAuth } from "../auth/auth-context"; +const ledgerTypeOptions: Array<{ value: "" | LedgerEntryType; label: string }> = [ + { value: "", label: "全部类型" }, + { value: "grant", label: "赠送" }, + { value: "reserve", label: "预留" }, + { value: "settle", label: "结算" }, + { value: "refund", label: "退款" }, + { value: "adjustment", label: "调整" }, +]; + export function UsersPage() { const { auth } = useAuth(); const role = auth.status === "authenticated" ? auth.role : "ANALYST"; @@ -61,51 +80,56 @@ export function UsersPage() { } function UserList({ onSelect }: { onSelect: (userId: string) => void }) { - const [query, setQuery] = useState(""); - const [activeQuery, setActiveQuery] = useState(""); - const [items, setItems] = useState([]); - const [nextCursor, setNextCursor] = useState(); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(); - - const load = useCallback(async (search = "") => { - setLoading(true); - setError(undefined); - try { - const page = await adminApi.users(search); - setItems(page.items); - setNextCursor(page.nextCursor); - } catch (requestError) { - setError(requestError); - } finally { - setLoading(false); - } - }, []); + const [searchParams, setSearchParams] = useSearchParams(); + const initialQuery = searchParams.get("q") ?? ""; + const [query, setQuery] = useState(initialQuery); + const [activeQuery, setActiveQuery] = useState(initialQuery); + const [from, setFrom] = useState(searchParams.get("from") ?? ""); + const [until, setUntil] = useState(searchParams.get("until") ?? ""); + const [status, setStatus] = useState<"" | UserSummary["status"]>( + (searchParams.get("status") as UserSummary["status"] | null) ?? "", + ); + const [order, setOrder] = useState( + searchParams.get("order") === "asc" ? "asc" : "desc", + ); + const usersQuery = useMemo( + () => ({ + q: activeQuery || undefined, + from: from || undefined, + until: until || undefined, + status: status || undefined, + sort: "createdAt", + order, + limit: 50, + }), + [activeQuery, from, order, status, until], + ); + const fetchUsers = useCallback((value: UsersQuery) => adminApi.users(value), []); + const { + items, + nextCursor, + loading, + loadingMore, + error, + loadMore, + reload, + } = useCursorPage(usersQuery, fetchUsers); useEffect(() => { - void load(); - }, [load]); + const params = new URLSearchParams(); + if (activeQuery) params.set("q", activeQuery); + if (from) params.set("from", from); + if (until) params.set("until", until); + if (status) params.set("status", status); + params.set("sort", "createdAt"); + params.set("order", order); + setSearchParams(params, { replace: true }); + }, [activeQuery, from, order, setSearchParams, status, until]); function submit(event: FormEvent) { event.preventDefault(); const search = query.trim(); setActiveQuery(search); - void load(search); - } - - async function loadMore() { - if (!nextCursor) return; - setLoadingMore(true); - try { - const page = await adminApi.users(activeQuery, nextCursor); - setItems((current) => [...current, ...page.items]); - setNextCursor(page.nextCursor); - } catch (requestError) { - toast.error(requestError instanceof ApiError ? requestError.message : "加载用户失败"); - } finally { - setLoadingMore(false); - } } const columns = useMemo[]>( @@ -201,6 +225,35 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
+ { + setQuery(""); + setActiveQuery(""); + setFrom(""); + setUntil(""); + setStatus(""); + setOrder("desc"); + }} + > + { + setFrom(value.from ?? ""); + setUntil(value.until ?? ""); + }} + /> + +
{activeQuery ? "查询结果" : "全部用户"} @@ -209,7 +262,7 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
{error ? (
- void load(activeQuery)} /> +
) : loading ? ( @@ -218,7 +271,14 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) { data={items} columns={columns} caption={activeQuery ? "用户查询结果" : "全部用户"} - emptyTitle="未找到匹配用户" + emptyTitle={ + activeQuery || from || until || status + ? "没有符合当前筛选条件的用户" + : "暂无用户" + } + sort={{ key: "createdAt", order }} + sortableColumns={{ createdAt: "createdAt" }} + onSortChange={(_, nextOrder) => setOrder(nextOrder)} footer={ nextCursor ? (
@@ -244,6 +304,7 @@ function UserDetailView({ canGrant: boolean; onBack: () => void; }) { + const [searchParams, setSearchParams] = useSearchParams(); const [user, setUser] = useState(); const [ledger, setLedger] = useState([]); const [nextCursor, setNextCursor] = useState(); @@ -251,34 +312,75 @@ function UserDetailView({ const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(); 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( + searchParams.get("ledgerOrder") === "asc" ? "asc" : "desc", + ); + const requestVersion = useRef(0); + const ledgerQuery = useMemo( + () => ({ + from: from || undefined, + until: until || undefined, + type: type || undefined, + sort: "createdAt", + order, + limit: 50, + }), + [from, order, type, until], + ); const load = useCallback(async () => { + const version = ++requestVersion.current; + setLedger([]); + setNextCursor(undefined); setLoading(true); + setLoadingMore(false); setError(undefined); try { const [detail, page] = await Promise.all([ adminApi.user(userId), - adminApi.ledger(userId), + adminApi.ledger(userId, ledgerQuery), ]); + if (requestVersion.current !== version) return; setUser(detail); setLedger(page.items); setNextCursor(page.nextCursor); } catch (requestError) { - setError(requestError); + if (requestVersion.current === version) setError(requestError); } finally { - setLoading(false); + if (requestVersion.current === version) setLoading(false); } - }, [userId]); + }, [ledgerQuery, userId]); useEffect(() => { void load(); }, [load]); + useEffect(() => { + setSearchParams((current) => { + const params = new URLSearchParams(current); + ["ledgerFrom", "ledgerUntil", "ledgerType", "ledgerOrder"].forEach((key) => + params.delete(key), + ); + if (from) params.set("ledgerFrom", from); + if (until) params.set("ledgerUntil", until); + if (type) params.set("ledgerType", type); + params.set("ledgerOrder", order); + return params; + }, { replace: true }); + }, [from, order, setSearchParams, type, until]); + async function loadMoreLedger() { if (!nextCursor) return; + const version = requestVersion.current; setLoadingMore(true); try { - const page = await adminApi.ledger(userId, nextCursor); + const page = await adminApi.ledger(userId, { ...ledgerQuery, cursor: nextCursor }); + if (requestVersion.current !== version) return; setLedger((current) => [...current, ...page.items]); setNextCursor(page.nextCursor); } catch (requestError) { @@ -286,7 +388,7 @@ function UserDetailView({ requestError instanceof ApiError ? requestError.message : "加载积分流水失败", ); } finally { - setLoadingMore(false); + if (requestVersion.current === version) setLoadingMore(false); } } @@ -370,11 +472,37 @@ function UserDetailView({ title="积分流水" description="所有变动均来自不可变账本" /> + { + setFrom(""); + setUntil(""); + setType(""); + setOrder("desc"); + }} + > + { + setFrom(value.from ?? ""); + setUntil(value.until ?? ""); + }} + /> + + setOrder(nextOrder)} footer={ nextCursor ? (
diff --git a/admin-web/src/hooks/use-cursor-page.ts b/admin-web/src/hooks/use-cursor-page.ts new file mode 100644 index 0000000..8942a6a --- /dev/null +++ b/admin-web/src/hooks/use-cursor-page.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PageResult } from "../api/types"; + +export function useCursorPage( + query: Q, + fetchPage: (query: Q) => Promise>, +) { + const [items, setItems] = useState([]); + const [nextCursor, setNextCursor] = useState(); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(); + 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), []), + }; +} diff --git a/admin-web/src/lib/sort.ts b/admin-web/src/lib/sort.ts new file mode 100644 index 0000000..36bc544 --- /dev/null +++ b/admin-web/src/lib/sort.ts @@ -0,0 +1,21 @@ +import type { SortOrder } from "../api/types"; + +export function stableSort( + 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); +} diff --git a/admin-web/src/test/client.test.ts b/admin-web/src/test/client.test.ts index b487d0d..cc4a803 100644 --- a/admin-web/src/test/client.test.ts +++ b/admin-web/src/test/client.test.ts @@ -105,6 +105,42 @@ describe("adminApi", () => { ); }); + it("typed query 编码日期、筛选、排序与布尔值", async () => { + const fetchMock = vi.fn().mockImplementation(async () => + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await adminApi.users({ + q: "user + value", + from: "2026-08-01T00:00:00.000Z", + until: "2026-08-20T23:59:59.999Z", + status: "suspended", + sort: "createdAt", + order: "asc", + limit: 25, + }); + await adminApi.operators({ + enabled: false, + locked: true, + sort: "username", + order: "desc", + }); + + expect(fetchMock.mock.calls[0]?.[0]).toContain( + "q=user+%2B+value&from=2026-08-01T00%3A00%3A00.000Z", + ); + expect(fetchMock.mock.calls[0]?.[0]).toContain( + "status=suspended&sort=createdAt&order=asc&limit=25", + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + "/v1/admin/operators?enabled=false&locked=true&sort=username&order=desc", + ); + }); + it("缺少 CSRF 时在发送变更请求前失败", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); diff --git a/admin-web/src/test/filter-sort.test.tsx b/admin-web/src/test/filter-sort.test.tsx new file mode 100644 index 0000000..e261d20 --- /dev/null +++ b/admin-web/src/test/filter-sort.test.tsx @@ -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("desc"); + const columns: DataColumn<{ createdAt: string }>[] = [ + { accessorKey: "createdAt", header: "时间" }, + ]; + return ( + setOrder(nextOrder)} + /> + ); + } + + render(); + 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(); + + await userEvent.type(screen.getByLabelText("结束日期"), "2026-08-20"); + + expect(onChange).toHaveBeenLastCalledWith({ + until: "2026-08-21T00:00:00.000Z", + }); + }); +}); diff --git a/admin-web/src/test/pages.test.tsx b/admin-web/src/test/pages.test.tsx index 8aa5757..e369413 100644 --- a/admin-web/src/test/pages.test.tsx +++ b/admin-web/src/test/pages.test.tsx @@ -36,12 +36,49 @@ describe("React 管理页面", () => { await userEvent.click(screen.getByRole("button", { name: "加载更多用户" })); await waitFor(() => { - expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next"); + expect(adminApi.users).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cursor: "user-next", sort: "createdAt", order: "desc" }), + ); }); expect(await screen.findByText("第二位用户")).toBeTruthy(); expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull(); }); + it("用户排序变更会清空旧游标并重新请求", async () => { + mockSession("SUPPORT"); + vi.spyOn(adminApi, "users") + .mockResolvedValueOnce({ + items: [user("第一页", userId)], + nextCursor: "stale-cursor", + }) + .mockResolvedValueOnce({ + items: [user("旧游标结果", "22222222-2222-4222-8222-222222222222")], + }) + .mockResolvedValueOnce({ + items: [user("升序结果", "33333333-3333-4333-8333-333333333333")], + }); + window.location.hash = "#/users"; + + render(); + expect(await screen.findByText("第一页")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "加载更多用户" })); + expect(await screen.findByText("旧游标结果")).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: /注册时间/ })); + + await waitFor(() => { + expect(adminApi.users).toHaveBeenCalledTimes(3); + const refreshedQuery = vi.mocked(adminApi.users).mock.calls[2]?.[0]; + expect(refreshedQuery).toMatchObject({ order: "asc" }); + expect(refreshedQuery).not.toHaveProperty("cursor"); + }); + expect(await screen.findByText("升序结果")).toBeTruthy(); + expect(screen.queryByText("旧游标结果")).toBeNull(); + expect(window.location.hash).toContain("order=asc"); + expect(window.location.hash).not.toContain("cursor"); + }); + it("积分页自动显示最新不可变流水", async () => { mockSession("SUPPORT"); const entry: LedgerEntry = { @@ -81,7 +118,10 @@ describe("React 管理页面", () => { await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" })); await waitFor(() => { - expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next"); + expect(adminApi.operators).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cursor: "operator-next", sort: "createdAt", order: "desc" }), + ); }); expect(await screen.findByText("support")).toBeTruthy(); expect(screen.getByText("已加载 2 个账户")).toBeTruthy(); @@ -101,6 +141,32 @@ describe("React 管理页面", () => { expect(screen.getByText("文字润色")).toBeTruthy(); expect(screen.getByText("7 天免费转付费")).toBeTruthy(); }); + + it("产品图表可按执行模式筛选并按用户数稳定排序", async () => { + mockSession("SUPPORT"); + const overview = analyticsOverview(); + overview.aiFeatures = [ + { feature: "POLISH", executionMode: "MANAGED", users: 10, successes: 100 }, + { feature: "AI_ASSISTANT", executionMode: "MANAGED", users: 40, successes: 50 }, + { feature: "HOTWORD", executionMode: "LOCAL", users: 80, successes: 200 }, + ]; + vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(overview); + window.location.hash = "#/analytics"; + + render(); + 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") { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 131748e..2f18d43 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -593,13 +593,20 @@ paths: summary: Return referral funnel and ranking statistics parameters: - $ref: "#/components/parameters/AdminRange" + - name: sort + in: query + schema: { type: string, enum: [invited, qualified, creditsEarned] } + - $ref: "#/components/parameters/AdminSortOrder" + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } responses: "200": description: Referral statistics content: application/json: schema: { $ref: "#/components/schemas/AdminReferralOverview" } - "400": { description: Range is invalid } + "400": { description: Range, sort, order, or limit is invalid } "401": { description: Session is invalid } /v1/admin/analytics: get: @@ -631,13 +638,20 @@ paths: in: query schema: { type: string, maxLength: 256 } - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/AdminFrom" + - $ref: "#/components/parameters/AdminUntil" + - name: status + in: query + schema: { type: string, enum: [active, suspended] } + - $ref: "#/components/parameters/AdminCreatedAtSort" + - $ref: "#/components/parameters/AdminSortOrder" responses: "200": description: Privacy-minimized user summaries content: application/json: schema: { $ref: "#/components/schemas/AdminUserPage" } - "400": { description: Query or cursor is malformed } + "400": { description: Query, filter, sort, limit, or cursor is malformed } "401": { description: Session is invalid } "403": { description: ANALYST role cannot access user records } /v1/admin/users/{userId}: @@ -667,14 +681,19 @@ paths: - name: cursor in: query schema: { type: string, maxLength: 256 } - - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/AdminLedgerLimit" + - $ref: "#/components/parameters/AdminFrom" + - $ref: "#/components/parameters/AdminUntil" + - $ref: "#/components/parameters/AdminLedgerType" + - $ref: "#/components/parameters/AdminCreatedAtSort" + - $ref: "#/components/parameters/AdminSortOrder" responses: "200": description: Credit ledger entries ordered by creation time and entry ID content: application/json: schema: { $ref: "#/components/schemas/AdminLedgerPage" } - "400": { description: Cursor is malformed } + "400": { description: Filter, sort, limit, or cursor is malformed } "403": { description: ANALYST role cannot access credit ledger records } "404": { description: User was not found } /v1/admin/credits/ledger: @@ -687,14 +706,19 @@ paths: - name: cursor in: query schema: { type: string, maxLength: 256 } - - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/AdminLedgerLimit" + - $ref: "#/components/parameters/AdminFrom" + - $ref: "#/components/parameters/AdminUntil" + - $ref: "#/components/parameters/AdminLedgerType" + - $ref: "#/components/parameters/AdminCreatedAtSort" + - $ref: "#/components/parameters/AdminSortOrder" responses: "200": description: Latest credit ledger entries ordered by creation time and entry ID content: application/json: schema: { $ref: "#/components/schemas/AdminLedgerPage" } - "400": { description: Cursor is malformed } + "400": { description: Filter, sort, limit, or cursor is malformed } "403": { description: ANALYST role cannot access credit ledger records } /v1/admin/credits/grants: post: @@ -758,14 +782,33 @@ paths: in: query schema: { type: string, maxLength: 256 } - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/AdminFrom" + - $ref: "#/components/parameters/AdminUntil" + - name: role + in: query + schema: { type: string, enum: [SUPER_ADMIN, SUPPORT, ANALYST] } + - name: enabled + in: query + schema: { type: boolean } + - name: locked + in: query + description: Whether locked_until is later than the request time. + schema: { type: boolean } + - name: sort + in: query + schema: + type: string + enum: [createdAt, username, lastLoginAt] + default: createdAt + - $ref: "#/components/parameters/AdminSortOrder" responses: "200": - description: Operators ordered by creation time + description: Operators ordered by the requested stable sort and operator ID; null lastLoginAt values are last content: application/json: schema: { $ref: "#/components/schemas/AdminOperatorPage" } "403": { description: INSUFFICIENT_PERMISSION; SUPER_ADMIN is required } - "400": { description: Cursor or limit is malformed } + "400": { description: Filter, sort, limit, or cursor is malformed } post: security: - adminMtls: [] @@ -874,13 +917,35 @@ paths: in: query schema: { type: string, maxLength: 256 } - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/AdminFrom" + - $ref: "#/components/parameters/AdminUntil" + - name: action + in: query + schema: + type: string + enum: + - LOGIN_SUCCEEDED + - LOGIN_FAILED + - SESSION_REVOKED + - OPERATOR_CREATED + - OPERATOR_ENABLED + - OPERATOR_DISABLED + - OPERATOR_UNLOCKED + - OPERATOR_CREDENTIALS_RESET + - OPERATOR_SESSIONS_REVOKED + - MANUAL_CREDIT_GRANTED + - name: result + in: query + schema: { type: string, enum: [success, rejected] } + - $ref: "#/components/parameters/AdminCreatedAtSort" + - $ref: "#/components/parameters/AdminSortOrder" responses: "200": description: Recent audit events content: application/json: schema: { $ref: "#/components/schemas/AdminAuditPage" } - "400": { description: Cursor is malformed } + "400": { description: Filter, sort, limit, or cursor is malformed } "403": { description: Super-administrator role is required } components: securitySchemes: @@ -919,6 +984,34 @@ components: name: range in: query schema: { type: string, enum: [7d, 30d, 90d], default: 30d } + AdminFrom: + name: from + in: query + description: Inclusive UTC lower bound. When both bounds are present, from must be earlier than until. + schema: { type: string, format: date-time } + AdminUntil: + name: until + in: query + description: Exclusive UTC upper bound. + schema: { type: string, format: date-time } + AdminCreatedAtSort: + name: sort + in: query + schema: { type: string, enum: [createdAt], default: createdAt } + AdminSortOrder: + name: order + in: query + schema: { type: string, enum: [asc, desc] } + AdminLedgerType: + name: type + in: query + schema: + type: string + enum: [grant, reserve, settle, refund, adjustment] + AdminLedgerLimit: + name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 100 } AdminUserId: name: userId in: path diff --git a/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt b/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt index 6144c38..8b8dcbf 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/models/AdminModels.kt @@ -20,11 +20,6 @@ data class AdminOperatorRecord( val updatedAt: Instant, ) -data class AdminOperatorCursor( - val createdAt: Instant, - val id: UUID, -) - data class NewAdminOperator( val id: UUID, val normalizedUsername: String, diff --git a/src/main/kotlin/com/osglab/account/features/admin/models/AdminQueryModels.kt b/src/main/kotlin/com/osglab/account/features/admin/models/AdminQueryModels.kt new file mode 100644 index 0000000..9e9eda6 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/admin/models/AdminQueryModels.kt @@ -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, +) diff --git a/src/main/kotlin/com/osglab/account/features/admin/repositories/AdminRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/repositories/AdminRepository.kt index 8d9c68b..b52f3e5 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/repositories/AdminRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/repositories/AdminRepository.kt @@ -1,6 +1,7 @@ package com.osglab.account.features.admin.repositories import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.models.AdminAuditQuery import com.osglab.account.features.admin.models.AdminAuditCursor import com.osglab.account.features.admin.models.AdminLockState import com.osglab.account.features.admin.models.AdminAuditAction @@ -8,10 +9,13 @@ import com.osglab.account.features.admin.models.AdminAuditOutcome import com.osglab.account.features.admin.models.AdminAuditRecord import com.osglab.account.features.admin.models.AdminOperatorAuthRecord import com.osglab.account.features.admin.models.AdminOperatorCursor +import com.osglab.account.features.admin.models.AdminOperatorQuery +import com.osglab.account.features.admin.models.AdminOperatorSort import com.osglab.account.features.admin.models.AdminOperatorMutationResult import com.osglab.account.features.admin.models.AdminOperatorRecord import com.osglab.account.features.admin.models.AdminRole import com.osglab.account.features.admin.models.AdminSessionRecord +import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.models.NewAdminAuditEvent import com.osglab.account.features.admin.models.NewAdminOperator import com.osglab.account.features.admin.models.NewAdminSession @@ -21,6 +25,7 @@ import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.core.greaterEq import org.jetbrains.exposed.v1.core.inList import org.jetbrains.exposed.v1.core.isNotNull import org.jetbrains.exposed.v1.core.isNull @@ -86,6 +91,7 @@ interface AdminRepository { suspend fun listOperatorsPage( limit: Int, before: AdminOperatorCursor? = null, + query: AdminOperatorQuery, ): List suspend fun countActiveSessions(now: Instant): Long suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? @@ -148,6 +154,7 @@ interface AdminRepository { suspend fun listAudit( limit: Int, before: AdminAuditCursor? = null, + query: AdminAuditQuery = AdminAuditQuery(), ): List } @@ -185,24 +192,119 @@ class ExposedAdminRepository( override suspend fun listOperatorsPage( limit: Int, before: AdminOperatorCursor?, + query: AdminOperatorQuery, ): List = databaseFactory.query { require(limit in 1..101) - val query = AdminOperatorsTable.selectAll() - if (before != null) { - query.andWhere { - (AdminOperatorsTable.createdAt greater before.createdAt) or - ( - (AdminOperatorsTable.createdAt eq before.createdAt) and - (AdminOperatorsTable.id greater before.id.toString()) - ) + val statement = AdminOperatorsTable.selectAll() + query.time.from?.let { from -> + statement.andWhere { AdminOperatorsTable.createdAt greaterEq from } + } + query.time.until?.let { until -> + statement.andWhere { AdminOperatorsTable.createdAt less until } + } + query.role?.let { role -> + statement.andWhere { AdminOperatorsTable.role eq role.name } + } + query.enabled?.let { enabled -> + statement.andWhere { + if (enabled) AdminOperatorsTable.disabledAt.isNull() + else AdminOperatorsTable.disabledAt.isNotNull() } } - query - .orderBy( - AdminOperatorsTable.createdAt to SortOrder.ASC, - AdminOperatorsTable.id to SortOrder.ASC, + query.locked?.let { locked -> + statement.andWhere { + if (locked) { + AdminOperatorsTable.lockedUntil.isNotNull() and + (AdminOperatorsTable.lockedUntil greater query.now) + } else { + AdminOperatorsTable.lockedUntil.isNull() or + (AdminOperatorsTable.lockedUntil lessEq query.now) + } + } + } + if (before != null) { + statement.andWhere { + val ascending = query.order == AdminSortOrder.ASC + when (query.sort) { + AdminOperatorSort.CREATED_AT -> { + val value = Instant.parse(requireNotNull(before.value)) + if (ascending) { + (AdminOperatorsTable.createdAt greater value) or + ( + (AdminOperatorsTable.createdAt eq value) and + (AdminOperatorsTable.id greater before.id.toString()) + ) + } else { + (AdminOperatorsTable.createdAt less value) or + ( + (AdminOperatorsTable.createdAt eq value) and + (AdminOperatorsTable.id less before.id.toString()) + ) + } + } + AdminOperatorSort.USERNAME -> { + val value = requireNotNull(before.value) + if (ascending) { + (AdminOperatorsTable.username greater value) or + ( + (AdminOperatorsTable.username eq value) and + (AdminOperatorsTable.id greater before.id.toString()) + ) + } else { + (AdminOperatorsTable.username less value) or + ( + (AdminOperatorsTable.username eq value) and + (AdminOperatorsTable.id less before.id.toString()) + ) + } + } + AdminOperatorSort.LAST_LOGIN_AT -> { + val value = before.value?.let(Instant::parse) + if (value == null) { + AdminOperatorsTable.lastLoginAt.isNull() and + if (ascending) { + AdminOperatorsTable.id greater before.id.toString() + } else { + AdminOperatorsTable.id less before.id.toString() + } + } else { + val nonNullAfter = if (ascending) { + (AdminOperatorsTable.lastLoginAt greater value) or + ( + (AdminOperatorsTable.lastLoginAt eq value) and + (AdminOperatorsTable.id greater before.id.toString()) + ) + } else { + (AdminOperatorsTable.lastLoginAt less value) or + ( + (AdminOperatorsTable.lastLoginAt eq value) and + (AdminOperatorsTable.id less before.id.toString()) + ) + } + nonNullAfter or AdminOperatorsTable.lastLoginAt.isNull() + } + } + } + } + } + val sortOrder = query.order.toExposedSortOrder() + when (query.sort) { + AdminOperatorSort.CREATED_AT -> statement.orderBy( + AdminOperatorsTable.createdAt to sortOrder, + AdminOperatorsTable.id to sortOrder, ) + AdminOperatorSort.USERNAME -> statement.orderBy( + AdminOperatorsTable.username to sortOrder, + AdminOperatorsTable.id to sortOrder, + ) + AdminOperatorSort.LAST_LOGIN_AT -> statement.orderBy( + AdminOperatorsTable.lastLoginAt.isNull() to SortOrder.ASC, + AdminOperatorsTable.lastLoginAt to sortOrder, + AdminOperatorsTable.id to sortOrder, + ) + } + statement .limit(limit) .map(ResultRow::toOperatorRecord) } @@ -495,23 +597,45 @@ class ExposedAdminRepository( override suspend fun listAudit( limit: Int, before: AdminAuditCursor?, + query: AdminAuditQuery, ): List = databaseFactory.query { require(limit in 1..101) - val query = AdminAuditLogTable.selectAll() + val statement = AdminAuditLogTable.selectAll() + query.time.from?.let { from -> + statement.andWhere { AdminAuditLogTable.occurredAt greaterEq from } + } + query.time.until?.let { until -> + statement.andWhere { AdminAuditLogTable.occurredAt less until } + } + query.action?.let { action -> + statement.andWhere { AdminAuditLogTable.action eq action.name } + } + query.outcome?.let { outcome -> + statement.andWhere { AdminAuditLogTable.outcome eq outcome.name } + } if (before != null) { - query.andWhere { - (AdminAuditLogTable.occurredAt less before.occurredAt) or - ( - (AdminAuditLogTable.occurredAt eq before.occurredAt) and - (AdminAuditLogTable.id less before.id.toString()) - ) + statement.andWhere { + if (query.order == AdminSortOrder.ASC) { + (AdminAuditLogTable.occurredAt greater before.occurredAt) or + ( + (AdminAuditLogTable.occurredAt eq before.occurredAt) and + (AdminAuditLogTable.id greater before.id.toString()) + ) + } else { + (AdminAuditLogTable.occurredAt less before.occurredAt) or + ( + (AdminAuditLogTable.occurredAt eq before.occurredAt) and + (AdminAuditLogTable.id less before.id.toString()) + ) + } } } - query + val sortOrder = query.order.toExposedSortOrder() + statement .orderBy( - AdminAuditLogTable.occurredAt to SortOrder.DESC, - AdminAuditLogTable.id to SortOrder.DESC, + AdminAuditLogTable.occurredAt to sortOrder, + AdminAuditLogTable.id to sortOrder, ) .limit(limit) .map { @@ -528,6 +652,9 @@ class ExposedAdminRepository( } } + private fun AdminSortOrder.toExposedSortOrder(): SortOrder = + if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC + private fun insertAudit(event: NewAdminAuditEvent) { AdminAuditLogTable.insert { it[id] = event.id.toString() diff --git a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt index 151d6bc..d02ce0f 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt @@ -3,10 +3,17 @@ package com.osglab.account.features.admin.routes import com.osglab.account.config.AppConfig import com.osglab.account.features.admin.grants.models.ManualGrantCommand import com.osglab.account.features.admin.grants.services.AdminGrantService +import com.osglab.account.features.admin.models.AdminAuditAction +import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminAuditQuery import com.osglab.account.features.admin.models.AdminLoginResult +import com.osglab.account.features.admin.models.AdminOperatorQuery +import com.osglab.account.features.admin.models.AdminOperatorRecord +import com.osglab.account.features.admin.models.AdminOperatorSort import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminRole -import com.osglab.account.features.admin.models.AdminOperatorRecord +import com.osglab.account.features.admin.models.AdminSortOrder +import com.osglab.account.features.admin.models.AdminTimeFilter import com.osglab.account.features.admin.services.AdminAuditCursorException import com.osglab.account.features.admin.services.AdminAuditService import com.osglab.account.features.admin.services.AdminAuthService @@ -19,11 +26,16 @@ import com.osglab.account.features.admin.services.AdminSessionService import com.osglab.account.features.admin.stats.models.AdminStatsDto import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService +import com.osglab.account.features.admin.stats.services.AdminReferralSort import com.osglab.account.features.admin.stats.services.AdminStatsService import com.osglab.account.features.admin.users.models.AdminUserDetailDto import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto import com.osglab.account.features.admin.users.models.AdminUserReferralDto import com.osglab.account.features.admin.users.models.AdminUserSummaryDto +import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery +import com.osglab.account.features.admin.users.repositories.AdminLedgerType +import com.osglab.account.features.admin.users.repositories.AdminUserListQuery +import com.osglab.account.features.admin.users.repositories.AdminUserStatus import com.osglab.account.features.admin.users.services.AdminUserNotFoundException import com.osglab.account.features.admin.users.services.AdminUsersService import com.osglab.account.features.credits.domain.CreditConflict @@ -51,6 +63,9 @@ import io.ktor.server.routing.route import kotlinx.serialization.Serializable import java.time.Clock import java.time.Duration +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID fun Route.adminWebRoutes(config: AppConfig) { @@ -159,7 +174,18 @@ fun Route.adminApiRoutes( get("/referrals") { if (call.requirePrincipal(config, sessionService) == null) return@get - val stats = statsService.getRange(call.request.queryParameters["range"], clock) + val options = runCatching { call.adminReferralQuery() }.getOrNull() + ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + val stats = statsService.getRange( + range = call.request.queryParameters["range"], + clock = clock, + referralRankLimit = options.limit, + referralSort = options.sort, + referralOrder = options.order, + ) ?: run { call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) return@get @@ -185,6 +211,11 @@ fun Route.adminApiRoutes( setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT), ) == null ) return@get + val listQuery = runCatching { call.adminUserListQuery() }.getOrNull() + ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } val limit = call.pageLimit(maximum = 100) ?: run { call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) return@get @@ -195,9 +226,10 @@ fun Route.adminApiRoutes( usersService.list( limit = limit, cursor = call.request.queryParameters["cursor"], + query = listQuery, ) } else { - usersService.searchByInternalId(query) + usersService.searchByInternalId(query, listQuery) } } catch (_: IllegalArgumentException) { call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) @@ -236,6 +268,11 @@ fun Route.adminApiRoutes( ) == null ) return@get val userId = call.uuidPathParameter("userId") ?: return@get + val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull() + ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } val limit = call.pageLimit(maximum = 100, default = 100) ?: run { call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) return@get @@ -245,6 +282,7 @@ fun Route.adminApiRoutes( userId = userId, limit = limit, cursor = call.request.queryParameters["cursor"], + query = ledgerQuery, ) call.respond( PageResponse( @@ -267,6 +305,11 @@ fun Route.adminApiRoutes( setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT), ) == null ) return@get + val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull() + ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } val limit = call.pageLimit(maximum = 100, default = 100) ?: run { call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) return@get @@ -275,6 +318,7 @@ fun Route.adminApiRoutes( val page = usersService.latestLedger( limit = limit, cursor = call.request.queryParameters["cursor"], + query = ledgerQuery, ) call.respond( PageResponse( @@ -353,6 +397,11 @@ fun Route.adminApiRoutes( get("/operators") { val principal = call.requirePrincipal(config, sessionService) ?: return@get + val operatorQuery = runCatching { call.adminOperatorQuery(clock.instant()) }.getOrNull() + ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } val limit = call.pageLimit(maximum = 100) ?: run { call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) return@get @@ -362,6 +411,7 @@ fun Route.adminApiRoutes( actor = principal, cursor = call.request.queryParameters["cursor"], limit = limit, + query = operatorQuery, ) call.respond( PageResponse( @@ -480,6 +530,11 @@ fun Route.adminApiRoutes( get("/audit") { val principal = call.requirePrincipal(config, sessionService) ?: return@get + val auditQuery = runCatching { call.adminAuditQuery() }.getOrNull() + ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } val limit = call.pageLimit(maximum = 100) ?: run { call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) return@get @@ -489,6 +544,7 @@ fun Route.adminApiRoutes( actor = principal, cursor = call.request.queryParameters["cursor"], limit = limit, + query = auditQuery, ) } catch (exception: AdminOperatorException) { call.respondOperatorError(exception) @@ -522,9 +578,18 @@ fun Route.adminApiRoutes( private suspend fun AdminStatsService.getRange( range: String?, clock: Clock, + referralRankLimit: Int = 20, + referralSort: AdminReferralSort? = null, + referralOrder: AdminSortOrder = AdminSortOrder.DESC, ): AdminStatsDto? { val window = parseAdminStatsRange(range, clock) ?: return null - return get(window.first, window.second) + return get( + from = window.first, + until = window.second, + referralRankLimit = referralRankLimit, + referralSort = referralSort, + referralOrder = referralOrder, + ) } private fun parseAdminStatsRange(range: String?, clock: Clock): Pair? { @@ -538,6 +603,161 @@ private fun parseAdminStatsRange(range: String?, clock: Clock): Pair 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) { + val parameters = request.queryParameters + require(parameters.names().all { it in allowed }) + require(parameters.names().all { parameters.getAll(it).orEmpty().size == 1 }) +} + +private fun parseSortOrder(value: String?, default: AdminSortOrder): AdminSortOrder = + when (value) { + null -> default + "asc" -> AdminSortOrder.ASC + "desc" -> AdminSortOrder.DESC + else -> throw IllegalArgumentException("Invalid sort order") + } + +private fun parseStrictBoolean(value: String): Boolean = + when (value) { + "true" -> true + "false" -> false + else -> throw IllegalArgumentException("Invalid boolean filter") + } + private suspend fun ApplicationCall.requirePrincipal( config: AppConfig, sessions: AdminSessionService, diff --git a/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuditService.kt b/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuditService.kt index 316e4e5..9589d01 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuditService.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/services/AdminAuditService.kt @@ -1,9 +1,11 @@ package com.osglab.account.features.admin.services import com.osglab.account.features.admin.models.AdminAuditCursor +import com.osglab.account.features.admin.models.AdminAuditQuery import com.osglab.account.features.admin.models.AdminAuditRecord import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.repositories.AdminRepository import java.nio.charset.StandardCharsets import java.time.Instant @@ -29,13 +31,14 @@ class AdminAuditService( actor: AdminPrincipal, cursor: String?, limit: Int = DEFAULT_PAGE_SIZE, + query: AdminAuditQuery = AdminAuditQuery(), ): AdminAuditPage { if (actor.role != AdminRole.SUPER_ADMIN) { throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION) } require(limit in 1..MAX_PAGE_SIZE) - val decodedCursor = cursor?.let(::decodeCursor) - val records = repository.listAudit(limit + 1, decodedCursor) + val decodedCursor = cursor?.let { decodeCursor(it, query.order) } + val records = repository.listAudit(limit + 1, decodedCursor, query) val pageRecords = records.take(limit) val operatorNames = repository.listOperators().associate { it.id to it.normalizedUsername @@ -50,36 +53,41 @@ class AdminAuditService( ) }, nextCursor = if (records.size > limit) { - pageRecords.lastOrNull()?.let(::encodeCursor) + pageRecords.lastOrNull()?.let { encodeCursor(it, query.order) } } else { null }, ) } - private fun decodeCursor(value: String): AdminAuditCursor { + private fun decodeCursor(value: String, expectedOrder: AdminSortOrder): AdminAuditCursor { if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException() return runCatching { val decoded = String( Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8, ) - val parts = decoded.split(':', limit = 3) - require(parts.size == 3) + val parts = decoded.split(':', limit = 5) + require(parts.size == 5) + require(parts[0] == "v1") + require(parts[1] == expectedOrder.name) AdminAuditCursor( occurredAt = Instant.ofEpochSecond( - parts[0].toLong(), - parts[1].toLong(), + parts[2].toLong(), + parts[3].toLong(), ), - id = UUID.fromString(parts[2]), + id = UUID.fromString(parts[4]), ) }.getOrElse { throw AdminAuditCursorException() } } - private fun encodeCursor(record: AdminAuditRecord): String { + private fun encodeCursor(record: AdminAuditRecord, order: AdminSortOrder): String { val payload = buildString { + append("v1:") + append(order.name) + append(':') append(record.occurredAt.epochSecond) append(':') append(record.occurredAt.nano) diff --git a/src/main/kotlin/com/osglab/account/features/admin/services/AdminOperatorService.kt b/src/main/kotlin/com/osglab/account/features/admin/services/AdminOperatorService.kt index c6a1073..95bf5da 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/services/AdminOperatorService.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/services/AdminOperatorService.kt @@ -5,9 +5,12 @@ import com.osglab.account.features.admin.models.AdminAuditAction import com.osglab.account.features.admin.models.AdminAuditOutcome import com.osglab.account.features.admin.models.AdminOperatorMutationResult import com.osglab.account.features.admin.models.AdminOperatorCursor +import com.osglab.account.features.admin.models.AdminOperatorQuery import com.osglab.account.features.admin.models.AdminOperatorRecord +import com.osglab.account.features.admin.models.AdminOperatorSort import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.models.NewAdminAuditEvent import com.osglab.account.features.admin.models.NewAdminOperator import com.osglab.account.features.admin.repositories.AdminRepository @@ -15,9 +18,8 @@ import com.osglab.account.features.admin.security.AdminPasswordHasher import com.osglab.account.features.admin.security.AdminTotpProvisioning import com.osglab.account.features.admin.security.AdminTotpSecretGenerator import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator -import java.time.Clock -import java.time.Instant import java.nio.charset.StandardCharsets +import java.time.Clock import java.util.Base64 import java.util.Locale import java.util.UUID @@ -73,17 +75,33 @@ class AdminOperatorService( actor: AdminPrincipal, cursor: String?, limit: Int = DEFAULT_PAGE_SIZE, + ): AdminOperatorPage = listPage( + actor = actor, + cursor = cursor, + limit = limit, + query = AdminOperatorQuery(now = clock.instant()), + ) + + suspend fun listPage( + actor: AdminPrincipal, + cursor: String?, + limit: Int = DEFAULT_PAGE_SIZE, + query: AdminOperatorQuery, ): AdminOperatorPage { requireSuperAdministrator(actor) if (limit !in 1..MAX_PAGE_SIZE) { throw AdminOperatorCursorException() } - val decodedCursor = cursor?.let(::decodeCursor) - val records = repository.listOperatorsPage(limit + 1, decodedCursor) + val decodedCursor = cursor?.let { decodeCursor(it, query) } + val records = repository.listOperatorsPage(limit + 1, decodedCursor, query) val items = records.take(limit) return AdminOperatorPage( items = items, - nextCursor = if (records.size > limit) items.lastOrNull()?.let(::encodeCursor) else null, + nextCursor = if (records.size > limit) { + items.lastOrNull()?.let { encodeCursor(it, query) } + } else { + null + }, ) } @@ -370,28 +388,37 @@ class AdminOperatorService( const val MAX_PASSWORD_CHARS = 1_024 const val MAX_AUDIT_TARGET_CHARS = 128 const val OPERATOR_TARGET = "ADMIN_OPERATOR" + const val NULL_CURSOR_VALUE = "~" } - private fun decodeCursor(value: String): AdminOperatorCursor { + private fun decodeCursor(value: String, query: AdminOperatorQuery): AdminOperatorCursor { if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException() return runCatching { val decoded = String( Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8, ) - val parts = decoded.split(':', limit = 3) - require(parts.size == 3) + val parts = decoded.split('|') + require(parts.size == 5) + require(parts[0] == "v1") + require(parts[1] == query.sort.name) + require(parts[2] == query.order.name) AdminOperatorCursor( - createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()), - id = UUID.fromString(parts[2]), + value = parts[3].takeUnless { it == NULL_CURSOR_VALUE }, + id = UUID.fromString(parts[4]), ) }.getOrElse { throw AdminOperatorCursorException() } } - private fun encodeCursor(record: AdminOperatorRecord): String { - val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}" + private fun encodeCursor(record: AdminOperatorRecord, query: AdminOperatorQuery): String { + val value = when (query.sort) { + AdminOperatorSort.CREATED_AT -> record.createdAt.toString() + AdminOperatorSort.USERNAME -> record.normalizedUsername + AdminOperatorSort.LAST_LOGIN_AT -> record.lastLoginAt?.toString() ?: NULL_CURSOR_VALUE + } + val payload = "v1|${query.sort.name}|${query.order.name}|$value|${record.id}" return Base64.getUrlEncoder().withoutPadding().encodeToString( payload.toByteArray(StandardCharsets.UTF_8), ) diff --git a/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminStatsService.kt b/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminStatsService.kt index 7db2fd7..5638f78 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminStatsService.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/stats/services/AdminStatsService.kt @@ -1,6 +1,8 @@ package com.osglab.account.features.admin.stats.services +import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.stats.models.AdminCreditFlowPointDto +import com.osglab.account.features.admin.stats.models.AdminReferralRankDto import com.osglab.account.features.admin.stats.models.AdminRegistrationPointDto import com.osglab.account.features.admin.stats.models.AdminStatsDto import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto @@ -10,6 +12,12 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneOffset +enum class AdminReferralSort { + INVITED, + QUALIFIED, + CREDITS_EARNED, +} + class AdminStatsService( private val repository: AdminStatsRepository, ) { @@ -17,6 +25,8 @@ class AdminStatsService( from: Instant, until: Instant, referralRankLimit: Int = 20, + referralSort: AdminReferralSort? = null, + referralOrder: AdminSortOrder = AdminSortOrder.DESC, ): AdminStatsDto { require(from < until) { "Statistics range must be non-empty" } require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" } @@ -40,12 +50,30 @@ class AdminStatsService( ) }, referralFunnel = snapshot.referralFunnel, - referralRanking = snapshot.referralRanking.take(referralRankLimit), + referralRanking = snapshot.referralRanking + .sortedForReferralRanking(referralSort, referralOrder) + .take(referralRankLimit), usage = snapshot.usage, ) } } +private fun List.sortedForReferralRanking( + sort: AdminReferralSort?, + order: AdminSortOrder, +): List { + 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 { val dates = mutableListOf() var date = range.from.atZone(ZoneOffset.UTC).toLocalDate() diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt index 34c2268..6e49764 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt @@ -1,6 +1,8 @@ package com.osglab.account.features.admin.users.repositories import com.osglab.account.config.DatabaseFactory +import com.osglab.account.features.admin.models.AdminSortOrder +import com.osglab.account.features.admin.models.AdminTimeFilter import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto import com.osglab.account.features.admin.users.models.AdminUserDetailDto import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto @@ -14,11 +16,14 @@ import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.core.greaterEq import org.jetbrains.exposed.v1.core.inList import org.jetbrains.exposed.v1.core.less import org.jetbrains.exposed.v1.core.like import org.jetbrains.exposed.v1.core.or import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.andWhere import org.jetbrains.exposed.v1.jdbc.selectAll import java.time.Instant import java.util.UUID @@ -33,10 +38,54 @@ data class AdminUserLedgerCursor( val ledgerEntryId: UUID, ) -interface AdminUsersRepository { - suspend fun list(limit: Int, cursor: AdminUserCursor?): List +enum class AdminUserStatus { + ACTIVE, + SUSPENDED, +} - suspend fun findByIdSuffix(suffix: String, limit: Int): List +data class AdminUserListQuery( + val time: AdminTimeFilter = AdminTimeFilter(), + val status: AdminUserStatus? = null, + val order: AdminSortOrder = AdminSortOrder.DESC, +) + +enum class AdminLedgerType( + internal val entryTypes: Set, +) { + 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 + + suspend fun findByIdSuffix( + suffix: String, + limit: Int, + query: AdminUserListQuery, + ): List suspend fun exists(userId: UUID): Boolean @@ -46,11 +95,13 @@ interface AdminUsersRepository { userId: UUID, limit: Int, cursor: AdminUserLedgerCursor?, + query: AdminLedgerQuery, ): List suspend fun listLatestLedger( limit: Int, cursor: AdminUserLedgerCursor?, + query: AdminLedgerQuery, ): List } @@ -60,21 +111,43 @@ class ExposedAdminUsersRepository( override suspend fun list( limit: Int, cursor: AdminUserCursor?, + query: AdminUserListQuery, ): List = databaseFactory.query { - val query = AdminUsersAccountsTable.selectAll() - if (cursor != null) { - query.where { - (AdminUsersAccountsTable.createdAt less cursor.createdAt) or - ( - (AdminUsersAccountsTable.createdAt eq cursor.createdAt) and - (AdminUsersAccountsTable.id less cursor.userId.toString()) - ) + val statement = AdminUsersAccountsTable.selectAll() + query.time.from?.let { from -> + statement.andWhere { AdminUsersAccountsTable.createdAt greaterEq from } + } + query.time.until?.let { until -> + statement.andWhere { AdminUsersAccountsTable.createdAt less until } + } + query.status?.let { status -> + statement.andWhere { + AdminUsersAccountsTable.antiAbuseRestricted eq + (status == AdminUserStatus.SUSPENDED) } } - val accountRows = query + if (cursor != null) { + statement.andWhere { + if (query.order == AdminSortOrder.ASC) { + (AdminUsersAccountsTable.createdAt greater cursor.createdAt) or + ( + (AdminUsersAccountsTable.createdAt eq cursor.createdAt) and + (AdminUsersAccountsTable.id greater cursor.userId.toString()) + ) + } else { + (AdminUsersAccountsTable.createdAt less cursor.createdAt) or + ( + (AdminUsersAccountsTable.createdAt eq cursor.createdAt) and + (AdminUsersAccountsTable.id less cursor.userId.toString()) + ) + } + } + } + val sortOrder = query.order.toExposedSortOrder() + val accountRows = statement .orderBy( - AdminUsersAccountsTable.createdAt to SortOrder.DESC, - AdminUsersAccountsTable.id to SortOrder.DESC, + AdminUsersAccountsTable.createdAt to sortOrder, + AdminUsersAccountsTable.id to sortOrder, ) .limit(limit) .toList() @@ -85,12 +158,27 @@ class ExposedAdminUsersRepository( override suspend fun findByIdSuffix( suffix: String, limit: Int, + query: AdminUserListQuery, ): List = databaseFactory.query { - val accountRows = AdminUsersAccountsTable.selectAll() + val statement = AdminUsersAccountsTable.selectAll() .where { AdminUsersAccountsTable.id like "%$suffix" } + query.time.from?.let { from -> + statement.andWhere { AdminUsersAccountsTable.createdAt greaterEq from } + } + query.time.until?.let { until -> + statement.andWhere { AdminUsersAccountsTable.createdAt less until } + } + query.status?.let { status -> + statement.andWhere { + AdminUsersAccountsTable.antiAbuseRestricted eq + (status == AdminUserStatus.SUSPENDED) + } + } + val sortOrder = query.order.toExposedSortOrder() + val accountRows = statement .orderBy( - AdminUsersAccountsTable.createdAt to SortOrder.DESC, - AdminUsersAccountsTable.id to SortOrder.DESC, + AdminUsersAccountsTable.createdAt to sortOrder, + AdminUsersAccountsTable.id to sortOrder, ) .limit(limit) .toList() @@ -155,25 +243,41 @@ class ExposedAdminUsersRepository( userId: UUID, limit: Int, cursor: AdminUserLedgerCursor?, + query: AdminLedgerQuery, ): List = databaseFactory.query { - val query = AdminUsersCreditLedgerTable.selectAll() - if (cursor == null) { - query.where { AdminUsersCreditLedgerTable.userId eq userId.toString() } - } else { - query.where { - (AdminUsersCreditLedgerTable.userId eq userId.toString()) and - ( - (AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or - ( - (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and - (AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString()) - ) - ) + if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList() + val statement = AdminUsersCreditLedgerTable.selectAll() + .where { AdminUsersCreditLedgerTable.userId eq userId.toString() } + query.time.from?.let { from -> + statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from } + } + query.time.until?.let { until -> + statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until } + } + query.type?.let { type -> + statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes } + } + if (cursor != null) { + statement.andWhere { + if (query.order == AdminSortOrder.ASC) { + (AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or + ( + (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and + (AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString()) + ) + } else { + (AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or + ( + (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and + (AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString()) + ) + } } } - val ledger = query.orderBy( - AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC, - AdminUsersCreditLedgerTable.id to SortOrder.DESC, + val sortOrder = query.order.toExposedSortOrder() + val ledger = statement.orderBy( + AdminUsersCreditLedgerTable.createdAt to sortOrder, + AdminUsersCreditLedgerTable.id to sortOrder, ) .limit(limit) .map(ResultRow::toUserLedgerRow) @@ -184,20 +288,40 @@ class ExposedAdminUsersRepository( override suspend fun listLatestLedger( limit: Int, cursor: AdminUserLedgerCursor?, + query: AdminLedgerQuery, ): List = databaseFactory.query { - val query = AdminUsersCreditLedgerTable.selectAll() + if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList() + val statement = AdminUsersCreditLedgerTable.selectAll() + query.time.from?.let { from -> + statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from } + } + query.time.until?.let { until -> + statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until } + } + query.type?.let { type -> + statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes } + } if (cursor != null) { - query.where { - (AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or - ( - (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and - (AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString()) - ) + statement.andWhere { + if (query.order == AdminSortOrder.ASC) { + (AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or + ( + (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and + (AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString()) + ) + } else { + (AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or + ( + (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and + (AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString()) + ) + } } } - val ledger = query.orderBy( - AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC, - AdminUsersCreditLedgerTable.id to SortOrder.DESC, + val sortOrder = query.order.toExposedSortOrder() + val ledger = statement.orderBy( + AdminUsersCreditLedgerTable.createdAt to sortOrder, + AdminUsersCreditLedgerTable.id to sortOrder, ) .limit(limit) .map(ResultRow::toUserLedgerRow) @@ -413,3 +537,6 @@ private fun ResultRow.toUserReferralBindingRow() = UserReferralBindingRow( private inline fun Iterable.exactSumOf(value: (T) -> Long): Long = fold(0L) { total, item -> Math.addExact(total, value(item)) } + +private fun AdminSortOrder.toExposedSortOrder(): SortOrder = + if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt index 55d5c6e..d7b5484 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt @@ -1,11 +1,15 @@ package com.osglab.account.features.admin.users.services +import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.users.models.AdminUserDetailDto import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto import com.osglab.account.features.admin.users.models.AdminUserPageDto import com.osglab.account.features.admin.users.repositories.AdminUserCursor +import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor +import com.osglab.account.features.admin.users.repositories.AdminUserListQuery +import com.osglab.account.features.admin.users.repositories.AdminUserStatus import com.osglab.account.features.admin.users.repositories.AdminUsersRepository import java.nio.charset.StandardCharsets import java.time.Instant @@ -17,18 +21,26 @@ class AdminUserNotFoundException : RuntimeException("Admin user view does not ex class AdminUsersService( private val repository: AdminUsersRepository, ) { - suspend fun searchByInternalId(query: String): AdminUserPageDto { + suspend fun searchByInternalId( + query: String, + listQuery: AdminUserListQuery = AdminUserListQuery(), + ): AdminUserPageDto { val normalized = query.trim() val userId = runCatching { UUID.fromString(normalized) }.getOrNull() if (userId != null) { val user = repository.findDetail(userId, ledgerLimit = 1)?.summary + ?.takeIf { it.matches(listQuery) } return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null) } if (!SHORT_INTERNAL_ID.matches(normalized)) { return AdminUserPageDto(emptyList(), null) } return AdminUserPageDto( - items = repository.findByIdSuffix(normalized.lowercase(), limit = MAX_SHORT_ID_MATCHES), + items = repository.findByIdSuffix( + normalized.lowercase(), + limit = MAX_SHORT_ID_MATCHES, + query = listQuery, + ), nextCursor = null, ) } @@ -36,10 +48,11 @@ class AdminUsersService( suspend fun list( limit: Int = 50, cursor: String? = null, + query: AdminUserListQuery = AdminUserListQuery(), ): AdminUserPageDto { require(limit in 1..100) { "User page limit must be between 1 and 100" } - val decodedCursor = cursor?.let(AdminUserCursorCodec::decode) - val results = repository.list(limit + 1, decodedCursor) + val decodedCursor = cursor?.let { AdminUserCursorCodec.decode(it, query.order) } + val results = repository.list(limit + 1, decodedCursor, query) val hasMore = results.size > limit val items = results.take(limit) val nextCursor = if (hasMore) { @@ -49,6 +62,7 @@ class AdminUsersService( createdAt = Instant.parse(last.createdAt), userId = UUID.fromString(last.id), ), + query.order, ) } else { null @@ -68,26 +82,31 @@ class AdminUsersService( userId: UUID, limit: Int = 50, cursor: String? = null, + query: AdminLedgerQuery = AdminLedgerQuery(), ): AdminUserLedgerPageDto { - return ledgerPage(limit, cursor) { pageSize, decodedCursor -> + return ledgerPage(limit, cursor, query) { pageSize, decodedCursor -> if (!repository.exists(userId)) throw AdminUserNotFoundException() - repository.listLedger(userId, pageSize, decodedCursor) + repository.listLedger(userId, pageSize, decodedCursor, query) } } suspend fun latestLedger( limit: Int = 100, cursor: String? = null, + query: AdminLedgerQuery = AdminLedgerQuery(), ): AdminUserLedgerPageDto = - ledgerPage(limit, cursor, repository::listLatestLedger) + ledgerPage(limit, cursor, query) { pageSize, decodedCursor -> + repository.listLatestLedger(pageSize, decodedCursor, query) + } private suspend fun ledgerPage( limit: Int, cursor: String?, + query: AdminLedgerQuery, load: suspend (Int, AdminUserLedgerCursor?) -> List, ): AdminUserLedgerPageDto { require(limit in 1..100) { "Ledger page limit must be between 1 and 100" } - val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode) + val decodedCursor = cursor?.let { AdminUserLedgerCursorCodec.decode(it, query.order) } val results = load(limit + 1, decodedCursor) val hasMore = results.size > limit val items = results.take(limit) @@ -98,6 +117,7 @@ class AdminUsersService( createdAt = Instant.parse(last.createdAt), ledgerEntryId = UUID.fromString(last.id), ), + query.order, ) } else { null @@ -109,14 +129,27 @@ class AdminUsersService( private val SHORT_INTERNAL_ID = Regex("^[A-Fa-f0-9]{8}$") private const val MAX_SHORT_ID_MATCHES = 100 +private fun com.osglab.account.features.admin.users.models.AdminUserSummaryDto.matches( + query: AdminUserListQuery, +): Boolean { + val createdAt = Instant.parse(createdAt) + return query.time.from?.let { createdAt >= it } != false && + query.time.until?.let { createdAt < it } != false && + when (query.status) { + AdminUserStatus.ACTIVE -> !antiAbuseRestricted + AdminUserStatus.SUSPENDED -> antiAbuseRestricted + null -> true + } +} + internal object AdminUserCursorCodec { - fun encode(cursor: AdminUserCursor): String { - val value = "${cursor.createdAt}|${cursor.userId}" + fun encode(cursor: AdminUserCursor, order: AdminSortOrder): String { + val value = "v1|${order.name}|${cursor.createdAt}|${cursor.userId}" return Base64.getUrlEncoder().withoutPadding() .encodeToString(value.toByteArray(StandardCharsets.UTF_8)) } - fun decode(value: String): AdminUserCursor { + fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserCursor { require(value.length in 1..256) { "User cursor is invalid" } return try { val decoded = String( @@ -124,10 +157,12 @@ internal object AdminUserCursorCodec { StandardCharsets.UTF_8, ) val parts = decoded.split('|') - require(parts.size == 2) + require(parts.size == 4) + require(parts[0] == "v1") + require(parts[1] == expectedOrder.name) AdminUserCursor( - createdAt = Instant.parse(parts[0]), - userId = UUID.fromString(parts[1]), + createdAt = Instant.parse(parts[2]), + userId = UUID.fromString(parts[3]), ) } catch (failure: IllegalArgumentException) { throw IllegalArgumentException("User cursor is invalid", failure) @@ -138,13 +173,13 @@ internal object AdminUserCursorCodec { internal object AdminUserLedgerCursorCodec { private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid" - fun encode(cursor: AdminUserLedgerCursor): String { - val value = "${cursor.createdAt}|${cursor.ledgerEntryId}" + fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String { + val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}" return Base64.getUrlEncoder().withoutPadding() .encodeToString(value.toByteArray(StandardCharsets.UTF_8)) } - fun decode(value: String): AdminUserLedgerCursor { + fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserLedgerCursor { require(value.length in 1..256) { INVALID_CURSOR_MESSAGE } return try { val decoded = String( @@ -152,10 +187,12 @@ internal object AdminUserLedgerCursorCodec { StandardCharsets.UTF_8, ) val parts = decoded.split('|') - require(parts.size == 2) + require(parts.size == 4) + require(parts[0] == "v1") + require(parts[1] == expectedOrder.name) AdminUserLedgerCursor( - createdAt = Instant.parse(parts[0]), - ledgerEntryId = UUID.fromString(parts[1]), + createdAt = Instant.parse(parts[2]), + ledgerEntryId = UUID.fromString(parts[3]), ) } catch (failure: IllegalArgumentException) { throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure) diff --git a/src/main/resources/db/migration/V18__admin_filter_sort_indexes.sql b/src/main/resources/db/migration/V18__admin_filter_sort_indexes.sql new file mode 100644 index 0000000..4e5e7e8 --- /dev/null +++ b/src/main/resources/db/migration/V18__admin_filter_sort_indexes.sql @@ -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); diff --git a/src/test/kotlin/com/osglab/account/features/admin/InMemoryAdminRepository.kt b/src/test/kotlin/com/osglab/account/features/admin/InMemoryAdminRepository.kt index a4f685f..8db8bcc 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/InMemoryAdminRepository.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/InMemoryAdminRepository.kt @@ -1,14 +1,18 @@ package com.osglab.account.features.admin import com.osglab.account.features.admin.models.AdminAuditCursor +import com.osglab.account.features.admin.models.AdminAuditQuery import com.osglab.account.features.admin.models.AdminAuditRecord import com.osglab.account.features.admin.models.AdminLockState import com.osglab.account.features.admin.models.AdminOperatorAuthRecord import com.osglab.account.features.admin.models.AdminOperatorCursor +import com.osglab.account.features.admin.models.AdminOperatorQuery +import com.osglab.account.features.admin.models.AdminOperatorSort import com.osglab.account.features.admin.models.AdminOperatorMutationResult import com.osglab.account.features.admin.models.AdminOperatorRecord import com.osglab.account.features.admin.models.AdminRole import com.osglab.account.features.admin.models.AdminSessionRecord +import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.models.NewAdminAuditEvent import com.osglab.account.features.admin.models.NewAdminOperator import com.osglab.account.features.admin.models.NewAdminSession @@ -24,6 +28,7 @@ internal class InMemoryAdminRepository( var passwordHash: String = "valid-password-hash", var encryptedTotpSecret: String, var role: AdminRole = AdminRole.SUPER_ADMIN, + var lastLoginAt: Instant? = null, ) : AdminRepository { private val mutex = Mutex() var lockState = AdminLockState(0, null) @@ -66,19 +71,22 @@ internal class InMemoryAdminRepository( override suspend fun listOperatorsPage( limit: Int, before: AdminOperatorCursor?, + query: AdminOperatorQuery, ): List = mutex.withLock { require(limit in 1..101) (listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord)) .asSequence() .filter { - before == null || - it.createdAt.isAfter(before.createdAt) || - (it.createdAt == before.createdAt && it.id.toString() > before.id.toString()) + query.time.from?.let { from -> it.createdAt >= from } != false && + query.time.until?.let { until -> it.createdAt < until } != false && + query.role?.let { role -> it.role == role } != false && + query.enabled?.let { enabled -> (it.disabledAt == null) == enabled } != false && + query.locked?.let { locked -> + (it.lockState.lockedUntil?.isAfter(query.now) == true) == locked + } != false } - .sortedWith( - compareBy { it.createdAt } - .thenBy { it.id.toString() }, - ) + .filter { before == null || operatorAfter(it, before, query) } + .sortedWith(operatorComparator(query)) .take(limit) .toList() } @@ -307,20 +315,40 @@ internal class InMemoryAdminRepository( override suspend fun listAudit( limit: Int, before: AdminAuditCursor?, + query: AdminAuditQuery, ): List = mutex.withLock { audits.asSequence() + .filter { + query.time.from?.let { from -> it.occurredAt >= from } != false && + query.time.until?.let { until -> it.occurredAt < until } != false && + query.action?.let { action -> it.action == action } != false && + query.outcome?.let { outcome -> it.outcome == outcome } != false + } .filter { before == null || - it.occurredAt.isBefore(before.occurredAt) || - ( - it.occurredAt == before.occurredAt && - it.id.toString() < before.id.toString() - ) + if (query.order == AdminSortOrder.ASC) { + it.occurredAt > before.occurredAt || + ( + it.occurredAt == before.occurredAt && + it.id.toString() > before.id.toString() + ) + } else { + it.occurredAt < before.occurredAt || + ( + it.occurredAt == before.occurredAt && + it.id.toString() < before.id.toString() + ) + } } .sortedWith( - compareByDescending { it.occurredAt } - .thenByDescending { it.id.toString() }, + if (query.order == AdminSortOrder.ASC) { + compareBy { it.occurredAt } + .thenBy { it.id.toString() } + } else { + compareByDescending { it.occurredAt } + .thenByDescending { it.id.toString() } + }, ) .take(limit) .map { @@ -344,6 +372,27 @@ internal class InMemoryAdminRepository( } } + suspend fun configureOperatorListState( + targetId: UUID, + disabledAt: Instant? = null, + lockState: AdminLockState = AdminLockState(0, null), + lastLoginAt: Instant? = null, + ) { + mutex.withLock { + if (targetId == operatorId) { + this.disabledAt = disabledAt + this.lockState = lockState + this.lastLoginAt = lastLoginAt + } else { + additionalOperators.getValue(targetId).apply { + this.disabledAt = disabledAt + this.lockState = lockState + this.lastLoginAt = lastLoginAt + } + } + } + } + private fun authRecord() = AdminOperatorAuthRecord( id = operatorId, normalizedUsername = username, @@ -360,7 +409,7 @@ internal class InMemoryAdminRepository( role = role, lockState = lockState, disabledAt = disabledAt, - lastLoginAt = null, + lastLoginAt = lastLoginAt, createdAt = Instant.EPOCH, updatedAt = Instant.EPOCH, ) @@ -391,6 +440,7 @@ internal class InMemoryAdminRepository( var lockState: AdminLockState = AdminLockState(0, null), var lastTotpCounter: Long? = null, var disabledAt: Instant? = null, + var lastLoginAt: Instant? = null, var updatedAt: Instant = operator.createdAt, ) { fun toAuthRecord() = AdminOperatorAuthRecord( @@ -409,7 +459,7 @@ internal class InMemoryAdminRepository( role = operator.role, lockState = lockState, disabledAt = disabledAt, - lastLoginAt = null, + lastLoginAt = lastLoginAt, createdAt = operator.createdAt, updatedAt = updatedAt, ) @@ -424,3 +474,64 @@ private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): N com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED }, ) + +private fun operatorComparator(query: AdminOperatorQuery): Comparator = + 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 > 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 diff --git a/src/test/kotlin/com/osglab/account/features/admin/repositories/AdminOperatorRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/admin/repositories/AdminOperatorRepositoryIntegrationTest.kt index 797723e..09e307b 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/repositories/AdminOperatorRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/repositories/AdminOperatorRepositoryIntegrationTest.kt @@ -4,12 +4,18 @@ import com.osglab.account.config.DatabaseConfig import com.osglab.account.config.DatabaseFactory import com.osglab.account.features.admin.models.AdminAuditAction import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminAuditQuery +import com.osglab.account.features.admin.models.AdminOperatorQuery +import com.osglab.account.features.admin.models.AdminOperatorSort import com.osglab.account.features.admin.models.AdminOperatorMutationResult import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.AdminSortOrder +import com.osglab.account.features.admin.models.AdminTimeFilter import com.osglab.account.features.admin.models.NewAdminAuditEvent import com.osglab.account.features.admin.models.NewAdminOperator import com.osglab.account.features.admin.models.NewAdminSession import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.shouldBe import kotlinx.coroutines.async @@ -175,6 +181,85 @@ class AdminOperatorRepositoryIntegrationTest : FunSpec({ (repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true } } + + test("MySQL list queries preserve filters keyset order and null-last semantics") { + withAdminRepositories { repository, _ -> + val from = Instant.parse("2026-08-17T00:00:00Z") + val until = from.plusSeconds(120) + val neverLoggedIn = UUID.fromString("10000000-0000-0000-0000-000000000001") + val earlier = UUID.fromString("20000000-0000-0000-0000-000000000002") + val later = UUID.fromString("30000000-0000-0000-0000-000000000003") + listOf(neverLoggedIn, earlier, later).forEachIndexed { index, id -> + repository.createOperatorIfAbsent( + newOperator(id, "query-operator-$index", AdminRole.SUPPORT, from), + ) + } + listOf(earlier to from.plusSeconds(10), later to from.plusSeconds(20)).forEach { + (operatorId, loginAt) -> + repository.createSessionIfTotpCounterFresh( + session = NewAdminSession( + id = UUID.randomUUID(), + operatorId = operatorId, + tokenHash = operatorId.toString().replace("-", "").padEnd(64, '0'), + csrfTokenHash = operatorId.toString().replace("-", "").padEnd(64, 'f'), + createdAt = loginAt, + expiresAt = loginAt.plusSeconds(60), + ), + totpCounter = 1, + now = loginAt, + auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, loginAt), + ) + } + + val operators = repository.listOperatorsPage( + limit = 10, + query = AdminOperatorQuery( + time = AdminTimeFilter(from, until), + role = AdminRole.SUPPORT, + sort = AdminOperatorSort.LAST_LOGIN_AT, + order = AdminSortOrder.DESC, + now = until, + ), + ) + operators.map { it.id } shouldContainExactly listOf(later, earlier, neverLoggedIn) + + val lowerAuditId = UUID.fromString("40000000-0000-0000-0000-000000000004") + val higherAuditId = UUID.fromString("50000000-0000-0000-0000-000000000005") + listOf( + NewAdminAuditEvent( + id = higherAuditId, + actorOperatorId = null, + action = AdminAuditAction.LOGIN_FAILED, + outcome = AdminAuditOutcome.DENIED, + occurredAt = from, + ), + NewAdminAuditEvent( + id = lowerAuditId, + actorOperatorId = null, + action = AdminAuditAction.LOGIN_FAILED, + outcome = AdminAuditOutcome.DENIED, + occurredAt = from, + ), + NewAdminAuditEvent( + actorOperatorId = null, + action = AdminAuditAction.LOGIN_FAILED, + outcome = AdminAuditOutcome.DENIED, + occurredAt = until, + ), + ).forEach { repository.appendAudit(it) } + val audits = repository.listAudit( + limit = 10, + query = AdminAuditQuery( + time = AdminTimeFilter(from, until), + action = AdminAuditAction.LOGIN_FAILED, + outcome = AdminAuditOutcome.DENIED, + order = AdminSortOrder.ASC, + ), + ) + + audits.map { it.id } shouldContainExactly listOf(lowerAuditId, higherAuditId) + } + } }) private suspend fun withAdminRepositories( diff --git a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt index a2331e2..e7645d7 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt @@ -188,7 +188,7 @@ class AdminRoutesTest { fun `operator list maps non-super authorization to stable forbidden response`() = testApplication { val sessionService = sessionFixture(AdminRole.SUPPORT) val operatorService = mockk() - coEvery { operatorService.listPage(any(), any(), any()) } throws + coEvery { operatorService.listPage(any(), any(), any(), any()) } throws AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION) application { installAdminTestRoutes( @@ -259,6 +259,34 @@ class AdminRoutesTest { response.bodyAsText() shouldContain """"usageType":"hotword"""" } + @Test + fun `admin list routes reject non-whitelisted filters and invalid ranges`() = testApplication { + application { + installAdminTestRoutes( + sessionService = sessionFixture(AdminRole.SUPER_ADMIN), + ) + } + val invalidPaths = listOf( + "/v1/admin/users?from=2026-08-20T00:00:00Z&until=2026-08-20T00:00:00Z", + "/v1/admin/credits/ledger?type=unknown", + "/v1/admin/operators?enabled=1", + "/v1/admin/audit?action=NOT_AN_ACTION", + "/v1/admin/referrals?range=30d&limit=101", + "/v1/admin/users?unexpected=value", + "/v1/admin/users?from=2026-08-20T08:00:00%2B08:00", + ) + + invalidPaths.forEach { path -> + val response = client.get(path) { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + } + + assertEquals(HttpStatusCode.BadRequest, response.status, path) + response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR"""" + } + } + @Test fun `operator creation maps normalized username conflict to 409`() = testApplication { val sessionService = sessionFixture(AdminRole.SUPER_ADMIN) diff --git a/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuditServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuditServiceTest.kt index 99ae823..0ad3492 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuditServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/services/AdminAuditServiceTest.kt @@ -1,10 +1,15 @@ package com.osglab.account.features.admin.services +import com.osglab.account.features.admin.InMemoryAdminRepository import com.osglab.account.features.admin.models.AdminAuditAction import com.osglab.account.features.admin.models.AdminAuditOutcome +import com.osglab.account.features.admin.models.AdminAuditQuery import com.osglab.account.features.admin.models.AdminAuditRecord +import com.osglab.account.features.admin.models.AdminSortOrder +import com.osglab.account.features.admin.models.AdminTimeFilter import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminRole +import com.osglab.account.features.admin.models.NewAdminAuditEvent import com.osglab.account.features.admin.repositories.AdminRepository import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec @@ -66,6 +71,54 @@ class AdminAuditServiceTest : FunSpec({ service.list(support, null) }.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION } + + test("audit filters use half-open boundaries and ascending ID tie-break") { + val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted") + val actor = superAdministrator().copy(operatorId = repository.operatorId) + val from = Instant.parse("2026-08-17T00:00:00Z") + val until = from.plusSeconds(60) + val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222") + listOf( + auditEvent(lowerId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED), + auditEvent(higherId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED), + auditEvent(UUID.randomUUID(), from.minusNanos(1), AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED), + auditEvent(UUID.randomUUID(), until, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED), + auditEvent(UUID.randomUUID(), from.plusSeconds(1), AdminAuditAction.LOGIN_SUCCEEDED, AdminAuditOutcome.SUCCESS), + ).forEach { repository.appendAudit(it) } + + val page = AdminAuditService(repository).list( + actor = actor, + cursor = null, + query = AdminAuditQuery( + time = AdminTimeFilter(from, until), + action = AdminAuditAction.LOGIN_FAILED, + outcome = AdminAuditOutcome.DENIED, + order = AdminSortOrder.ASC, + ), + ) + + page.items.map { it.record.id } shouldBe listOf(lowerId, higherId) + } + + test("audit cursor rejects the opposite order") { + val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted") + val actor = superAdministrator().copy(operatorId = repository.operatorId) + listOf( + auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:02Z")), + auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:01Z")), + ).forEach { repository.appendAudit(it) } + val service = AdminAuditService(repository) + val first = service.list(actor, null, limit = 1) + + shouldThrow { + service.list( + actor, + first.nextCursor, + query = AdminAuditQuery(order = AdminSortOrder.ASC), + ) + } + } }) private fun auditRecord(occurredAt: String) = AdminAuditRecord( @@ -79,6 +132,19 @@ private fun auditRecord(occurredAt: String) = AdminAuditRecord( occurredAt = Instant.parse(occurredAt), ) +private fun auditEvent( + id: UUID, + occurredAt: Instant, + action: AdminAuditAction = AdminAuditAction.LOGIN_SUCCEEDED, + outcome: AdminAuditOutcome = AdminAuditOutcome.SUCCESS, +) = NewAdminAuditEvent( + id = id, + actorOperatorId = null, + action = action, + outcome = outcome, + occurredAt = occurredAt, +) + private fun superAdministrator() = AdminPrincipal( operatorId = UUID.randomUUID(), sessionId = UUID.randomUUID(), diff --git a/src/test/kotlin/com/osglab/account/features/admin/services/AdminOperatorServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/services/AdminOperatorServiceTest.kt index c5fe29f..e760243 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/services/AdminOperatorServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/services/AdminOperatorServiceTest.kt @@ -6,9 +6,13 @@ import com.osglab.account.features.admin.models.AdminAuditAction import com.osglab.account.features.admin.models.AdminAuditOutcome import com.osglab.account.features.admin.models.AdminLockState import com.osglab.account.features.admin.models.AdminOperatorRecord +import com.osglab.account.features.admin.models.AdminOperatorQuery +import com.osglab.account.features.admin.models.AdminOperatorSort import com.osglab.account.features.admin.models.AdminPrincipal import com.osglab.account.features.admin.models.AdminRole import com.osglab.account.features.admin.models.AdminSessionRecord +import com.osglab.account.features.admin.models.AdminSortOrder +import com.osglab.account.features.admin.models.AdminTimeFilter import com.osglab.account.features.admin.models.NewAdminOperator import com.osglab.account.features.admin.security.AdminPasswordHasher import com.osglab.account.features.admin.security.AdminTotpProvisioning @@ -216,6 +220,104 @@ class AdminOperatorServiceTest : FunSpec({ fixture.service.listPage(fixture.owner, cursor = "not-a-cursor") } } + + test("operator filters apply half-open time role enabled and current lock state") { + val fixture = operatorFixture() + val matching = fixture.seedOperator("locked-support", AdminRole.SUPPORT) + val expired = fixture.seedOperator("expired-support", AdminRole.SUPPORT) + val disabled = fixture.seedOperator("disabled-support", AdminRole.SUPPORT) + fixture.repository.configureOperatorListState( + matching, + lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)), + ) + fixture.repository.configureOperatorListState( + expired, + lockState = AdminLockState(2, fixture.clock.instant()), + ) + fixture.repository.configureOperatorListState( + disabled, + disabledAt = fixture.clock.instant(), + lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)), + ) + + val page = fixture.service.listPage( + actor = fixture.owner, + cursor = null, + query = AdminOperatorQuery( + time = AdminTimeFilter( + fixture.clock.instant(), + fixture.clock.instant().plusNanos(1), + ), + role = AdminRole.SUPPORT, + enabled = true, + locked = true, + now = fixture.clock.instant(), + ), + ) + + page.items.map(AdminOperatorRecord::id) shouldBe listOf(matching) + } + + test("last login sorting keeps null values last in both directions") { + val fixture = operatorFixture() + val earlier = fixture.seedOperator("earlier-login", AdminRole.SUPPORT) + val later = fixture.seedOperator("later-login", AdminRole.SUPPORT) + fixture.repository.configureOperatorListState( + earlier, + lastLoginAt = fixture.clock.instant().minusSeconds(60), + ) + fixture.repository.configureOperatorListState( + later, + lastLoginAt = fixture.clock.instant(), + ) + + val ascending = fixture.service.listPage( + fixture.owner, + cursor = null, + query = AdminOperatorQuery( + sort = AdminOperatorSort.LAST_LOGIN_AT, + order = AdminSortOrder.ASC, + now = fixture.clock.instant(), + ), + ) + val descending = fixture.service.listPage( + fixture.owner, + cursor = null, + query = AdminOperatorQuery( + sort = AdminOperatorSort.LAST_LOGIN_AT, + order = AdminSortOrder.DESC, + now = fixture.clock.instant(), + ), + ) + + ascending.items.map(AdminOperatorRecord::id) shouldBe + listOf(earlier, later, fixture.owner.operatorId) + descending.items.map(AdminOperatorRecord::id) shouldBe + listOf(later, earlier, fixture.owner.operatorId) + } + + test("operator cursor rejects a different sort contract") { + val fixture = operatorFixture() + fixture.seedOperator("cursor-a", AdminRole.SUPPORT) + fixture.seedOperator("cursor-b", AdminRole.SUPPORT) + val first = fixture.service.listPage( + fixture.owner, + cursor = null, + limit = 1, + query = AdminOperatorQuery(now = fixture.clock.instant()), + ) + + shouldThrow { + fixture.service.listPage( + fixture.owner, + cursor = first.nextCursor, + query = AdminOperatorQuery( + sort = AdminOperatorSort.USERNAME, + now = fixture.clock.instant(), + ), + ) + } + } }) private data class OperatorFixture( diff --git a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt index 79ef488..0dd53ee 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/stats/AdminStatsRepositoryTest.kt @@ -1,7 +1,9 @@ package com.osglab.account.features.admin.stats +import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.stats.models.AdminOverviewDto import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto +import com.osglab.account.features.admin.stats.models.AdminReferralRankDto import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates import com.osglab.account.features.admin.stats.repositories.AdminStatsRange @@ -11,6 +13,7 @@ import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggre import com.osglab.account.features.admin.stats.repositories.assembleAdminStats import com.osglab.account.features.admin.stats.repositories.toExactLong import com.osglab.account.features.admin.stats.services.AdminStatsService +import com.osglab.account.features.admin.stats.services.AdminReferralSort import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize @@ -159,4 +162,40 @@ class AdminStatsRepositoryTest : FunSpec({ BigDecimal("1.5").toExactLong() } } + + test("referral ranking supports explicit metric order stable ID tie-break and limit") { + val snapshot = AdminStatsSnapshot( + overview = AdminOverviewDto(0, 0, 0, 0, 0, 0), + registrationsByDate = emptyMap(), + issuedCreditsByDate = emptyMap(), + consumedCreditsByDate = emptyMap(), + referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0), + referralRanking = listOf( + AdminReferralRankDto("user-c", 2, 1, 20), + AdminReferralRankDto("user-a", 3, 1, 20), + AdminReferralRankDto("user-b", 1, 2, 10), + ), + usage = emptyList(), + ) + val service = AdminStatsService(AdminStatsRepository { snapshot }) + + val descending = service.get( + from, + until, + referralRankLimit = 2, + referralSort = AdminReferralSort.CREDITS_EARNED, + referralOrder = AdminSortOrder.DESC, + ) + val ascending = service.get( + from, + until, + referralSort = AdminReferralSort.QUALIFIED, + referralOrder = AdminSortOrder.ASC, + ) + + descending.referralRanking.map(AdminReferralRankDto::userId) shouldBe + listOf("user-c", "user-a") + ascending.referralRanking.map(AdminReferralRankDto::userId) shouldBe + listOf("user-a", "user-c", "user-b") + } }) diff --git a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt index 04d5060..b0d0320 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt @@ -5,8 +5,14 @@ import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto import com.osglab.account.features.admin.users.models.AdminUserReferralDto import com.osglab.account.features.admin.users.models.AdminUserSummaryDto import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto +import com.osglab.account.features.admin.models.AdminSortOrder +import com.osglab.account.features.admin.models.AdminTimeFilter +import com.osglab.account.features.admin.users.repositories.AdminLedgerType import com.osglab.account.features.admin.users.repositories.AdminUserCursor +import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor +import com.osglab.account.features.admin.users.repositories.AdminUserListQuery +import com.osglab.account.features.admin.users.repositories.AdminUserStatus import com.osglab.account.features.admin.users.repositories.AdminUsersRepository import com.osglab.account.features.admin.users.services.AdminUserNotFoundException import com.osglab.account.features.admin.users.services.AdminUsersService @@ -223,6 +229,112 @@ class AdminUsersServiceTest : FunSpec({ service.detail(UUID.randomUUID()) } } + + test("user filters use a UTC half-open range and ascending ID tie-break") { + val boundary = Instant.parse("2026-08-15T00:00:00Z") + val until = Instant.parse("2026-08-16T00:00:00Z") + val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val service = AdminUsersService( + PagingUsersRepository( + listOf( + summary(higherId, boundary, restricted = true), + summary(lowerId, boundary, restricted = true), + summary(UUID.randomUUID(), boundary.minusNanos(1), restricted = true), + summary(UUID.randomUUID(), until, restricted = true), + summary(UUID.randomUUID(), boundary.plusSeconds(1), restricted = false), + ), + ), + ) + + val page = service.list( + query = AdminUserListQuery( + time = AdminTimeFilter(boundary, until), + status = AdminUserStatus.SUSPENDED, + order = AdminSortOrder.ASC, + ), + ) + + page.items.map(AdminUserSummaryDto::id) shouldBe + listOf(lowerId.toString(), higherId.toString()) + } + + test("ledger filters type and half-open range in ascending stable order") { + val userId = UUID.randomUUID() + val from = Instant.parse("2026-08-15T00:00:00Z") + val until = from.plusSeconds(60) + val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val entries = listOf( + ledgerEntry(higherId, from, userId), + ledgerEntry(lowerId, from, userId), + ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId), + ledgerEntry(UUID.randomUUID(), until, userId), + ledgerEntry(UUID.randomUUID(), from.plusSeconds(1), userId, type = "USAGE_SETTLE"), + ) + val service = AdminUsersService( + PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)), + ) + + val page = service.ledger( + userId = userId, + query = AdminLedgerQuery( + time = AdminTimeFilter(from, until), + type = AdminLedgerType.GRANT, + order = AdminSortOrder.ASC, + ), + ) + + page.items.map(AdminUserLedgerEntryDto::id) shouldBe + listOf(lowerId.toString(), higherId.toString()) + } + + test("versioned user cursor rejects the opposite sort direction") { + val service = AdminUsersService( + PagingUsersRepository( + listOf( + summary(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")), + summary(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")), + ), + ), + ) + val first = service.list(limit = 1, query = AdminUserListQuery(order = AdminSortOrder.DESC)) + + shouldThrow { + 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 { + service.ledger( + userId, + cursor = first.nextCursor.shouldNotBeNull(), + query = AdminLedgerQuery(order = AdminSortOrder.ASC), + ) + } + } }) private class PagingUsersRepository( @@ -233,15 +345,24 @@ private class PagingUsersRepository( override suspend fun list( limit: Int, cursor: AdminUserCursor?, + query: AdminUserListQuery, ): List = - users.filter { - cursor == null || - Instant.parse(it.createdAt) < cursor.createdAt || - ( - Instant.parse(it.createdAt) == cursor.createdAt && - UUID.fromString(it.id).toString() < cursor.userId.toString() - ) - }.take(limit) + users.asSequence() + .filter { it.matches(query) } + .filter { + cursor == null || userAfter(it, cursor, query.order) + } + .sortedWith( + if (query.order == AdminSortOrder.ASC) { + compareBy { Instant.parse(it.createdAt) } + .thenBy(AdminUserSummaryDto::id) + } else { + compareByDescending { Instant.parse(it.createdAt) } + .thenByDescending(AdminUserSummaryDto::id) + }, + ) + .take(limit) + .toList() override suspend fun findDetail( userId: UUID, @@ -251,8 +372,11 @@ private class PagingUsersRepository( override suspend fun findByIdSuffix( suffix: String, limit: Int, + query: AdminUserListQuery, ): List = - users.filter { it.id.endsWith(suffix, ignoreCase = true) }.take(limit) + users.filter { + it.id.endsWith(suffix, ignoreCase = true) && it.matches(query) + }.take(limit) override suspend fun exists(userId: UUID): Boolean = userId in details || userId in ledger || users.any { it.id == userId.toString() } @@ -261,48 +385,108 @@ private class PagingUsersRepository( userId: UUID, limit: Int, cursor: AdminUserLedgerCursor?, + query: AdminLedgerQuery, ): List = ledger[userId].orEmpty() + .filter { it.matches(query) } .filter { - val createdAt = Instant.parse(it.createdAt) - cursor == null || - createdAt < cursor.createdAt || - ( - createdAt == cursor.createdAt && - it.id < cursor.ledgerEntryId.toString() - ) + cursor == null || ledgerAfter(it, cursor, query.order) } .sortedWith( - compareByDescending { Instant.parse(it.createdAt) } - .thenByDescending(AdminUserLedgerEntryDto::id), + ledgerComparator(query.order), ) .take(limit) override suspend fun listLatestLedger( limit: Int, cursor: AdminUserLedgerCursor?, + query: AdminLedgerQuery, ): List = ledger.values.flatten() + .filter { it.matches(query) } .filter { - val createdAt = Instant.parse(it.createdAt) - cursor == null || - createdAt < cursor.createdAt || - ( - createdAt == cursor.createdAt && - it.id < cursor.ledgerEntryId.toString() - ) + cursor == null || ledgerAfter(it, cursor, query.order) } .sortedWith( - compareByDescending { Instant.parse(it.createdAt) } - .thenByDescending(AdminUserLedgerEntryDto::id), + ledgerComparator(query.order), ) .take(limit) } -private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto( +private fun AdminUserSummaryDto.matches(query: AdminUserListQuery): Boolean { + val instant = Instant.parse(createdAt) + return query.time.from?.let { instant >= it } != false && + query.time.until?.let { instant < it } != false && + when (query.status) { + AdminUserStatus.ACTIVE -> !antiAbuseRestricted + AdminUserStatus.SUSPENDED -> antiAbuseRestricted + null -> true + } +} + +private fun userAfter( + item: AdminUserSummaryDto, + cursor: AdminUserCursor, + order: AdminSortOrder, +): Boolean { + val createdAt = Instant.parse(item.createdAt) + return if (order == AdminSortOrder.ASC) { + createdAt > cursor.createdAt || + (createdAt == cursor.createdAt && item.id > cursor.userId.toString()) + } else { + createdAt < cursor.createdAt || + (createdAt == cursor.createdAt && item.id < cursor.userId.toString()) + } +} + +private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean { + val instant = Instant.parse(createdAt) + val category = when (type) { + "USAGE_RESERVE" -> AdminLedgerType.RESERVE + "USAGE_SETTLE" -> AdminLedgerType.SETTLE + "USAGE_RELEASE", "USAGE_REFUND" -> AdminLedgerType.REFUND + "SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE", + "STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT", + -> AdminLedgerType.GRANT + else -> AdminLedgerType.ADJUSTMENT + } + return query.time.from?.let { instant >= it } != false && + query.time.until?.let { instant < it } != false && + (query.type == null || query.type == category) +} + +private fun ledgerAfter( + item: AdminUserLedgerEntryDto, + cursor: AdminUserLedgerCursor, + order: AdminSortOrder, +): Boolean { + val createdAt = Instant.parse(item.createdAt) + return if (order == AdminSortOrder.ASC) { + createdAt > cursor.createdAt || + (createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString()) + } else { + createdAt < cursor.createdAt || + (createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString()) + } +} + +private fun ledgerComparator(order: AdminSortOrder): Comparator = + if (order == AdminSortOrder.ASC) { + compareBy { Instant.parse(it.createdAt) } + .thenBy(AdminUserLedgerEntryDto::id) + } else { + compareByDescending { Instant.parse(it.createdAt) } + .thenByDescending(AdminUserLedgerEntryDto::id) + } + +private fun summary( + id: UUID, + createdAt: Instant, + restricted: Boolean = false, +) = AdminUserSummaryDto( id = id.toString(), createdAt = createdAt.toString(), - antiAbuseRestricted = false, + antiAbuseRestricted = restricted, creditBalance = 0, consumedCredits = 0, manualGrantedCredits = 0, @@ -316,10 +500,11 @@ private fun ledgerEntry( id: UUID, createdAt: Instant, userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"), + type: String = "MANUAL_GRANT", ) = AdminUserLedgerEntryDto( id = id.toString(), userId = userId.toString(), - type = "MANUAL_GRANT", + type = type, amountDelta = 10, balanceAfter = 10, referenceId = null,