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