Enhance ledger operations and referral lifecycle
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
Rocky
2026-08-20 22:14:22 +08:00
parent 74c3fcd45f
commit b5212dcdc2
38 changed files with 3080 additions and 620 deletions
+7 -2
View File
@@ -160,6 +160,11 @@ function cursorQuery<T extends CursorQuery>(value?: string | T): CursorQuery | T
type CursorQuery = { cursor?: string };
function ledgerQuery(value?: string | LedgerQuery): CursorQuery | LedgerQuery {
if (typeof value === "string") return { cursor: value };
return value ? { ...value, referenceId: value.referenceId?.trim() } : {};
}
export const adminApi = {
session: () => request<SessionResponse>("/auth/session"),
@@ -195,12 +200,12 @@ export const adminApi = {
latestLedger: (value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>(
`/credits/ledger${encodeQuery(cursorQuery(value))}`,
`/credits/ledger${encodeQuery(ledgerQuery(value))}`,
),
ledger: (userId: string, value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(cursorQuery(value))}`,
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(ledgerQuery(value))}`,
),
grantCredits: (payload: CreditGrantRequest) =>
+48 -6
View File
@@ -27,8 +27,12 @@ export interface UsersQuery extends CursorPageQuery {
status?: UserSummary["status"];
}
export interface LedgerQuery extends CursorPageQuery {
type?: LedgerEntryType;
export interface LedgerQuery extends Omit<CursorPageQuery, "sort"> {
type?: LedgerAction;
entryType?: LedgerEntryType;
usageType?: UsageType;
referenceId?: string;
sort?: "createdAt" | "amount";
}
export interface AuditQuery extends CursorPageQuery {
@@ -219,23 +223,61 @@ export interface PageResult<T> {
nextCursor?: string;
}
export type LedgerEntryType =
export type LedgerAction =
| "grant"
| "reserve"
| "settle"
| "refund"
| "adjustment";
| "refund";
export type LedgerEntryType =
| "SIGNUP_TRIAL"
| "MANUAL_GRANT"
| "USAGE_RESERVE"
| "USAGE_SETTLE"
| "USAGE_RELEASE"
| "USAGE_REFUND"
| "REFERRAL_INVITER"
| "REFERRAL_INVITEE"
| "STOREKIT_PURCHASE"
| "SUBSCRIPTION_GRANT";
export type UsageType = "polish" | "asr" | "ai" | "agent" | "hotword";
export type LedgerEntryDetails =
| {
kind: "manualGrant";
reason?: string;
operatorName?: string;
}
| {
kind: "storeKit";
productId?: string;
transactionId?: string;
originalTransactionId?: string;
environment?: string;
purchasedAt?: string;
}
| {
kind: "referral";
role?: string;
relatedUserId?: string;
}
| {
kind: "usage";
reservationId?: string;
};
export interface LedgerEntry {
entryId: string;
userId: string;
type: LedgerEntryType;
type: LedgerAction;
entryType: LedgerEntryType;
amount: number;
balanceAfter: number;
reasonCode: string;
usageType?: UsageType;
referenceId?: string;
details?: LedgerEntryDetails;
createdAt: string;
}
+68 -8
View File
@@ -5,8 +5,14 @@ import {
type LegacyColumnDef,
useLegacyTable,
} from "@tanstack/react-table/legacy";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { type ReactNode } from "react";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { Fragment, type ReactNode, useState } from "react";
import type { SortOrder } from "../api/types";
import { cn } from "../lib/utils";
import { EmptyState } from "./primitives";
@@ -23,6 +29,9 @@ export function DataTable<T extends RowData>({
sort,
sortableColumns,
onSortChange,
renderExpandedRow,
getRowId,
expandLabel = "详情",
}: {
data: T[];
columns: DataColumn<T>[];
@@ -33,7 +42,11 @@ export function DataTable<T extends RowData>({
sort?: { key: string; order: SortOrder };
sortableColumns?: Record<string, string>;
onSortChange?: (key: string, order: SortOrder) => void;
renderExpandedRow?: (row: T) => ReactNode;
getRowId?: (row: T) => string;
expandLabel?: string;
}) {
const [expandedRows, setExpandedRows] = useState<Set<string>>(() => new Set());
const table = useLegacyTable({
data,
columns,
@@ -94,15 +107,25 @@ export function DataTable<T extends RowData>({
</th>
);
})}
{renderExpandedRow ? (
<th
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"
scope="col"
>
{expandLabel}
</th>
) : null}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="group transition-colors hover:bg-surface-muted/70"
>
{table.getRowModel().rows.map((row) => {
const expansionKey = getRowId?.(row.original) ?? row.id;
const expanded = expandedRows.has(expansionKey);
const panelId = `expanded-row-${expansionKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
return (
<Fragment key={row.id}>
<tr className="group transition-colors hover:bg-surface-muted/70">
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
@@ -111,8 +134,45 @@ export function DataTable<T extends RowData>({
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{renderExpandedRow ? (
<td className="border-b border-border/70 px-5 py-4 text-right align-middle">
<button
className="inline-flex min-h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-semibold text-primary transition hover:bg-primary-soft focus-visible:ring-4 focus-visible:ring-primary/15"
type="button"
aria-expanded={expanded}
aria-controls={panelId}
onClick={() =>
setExpandedRows((current) => {
const next = new Set(current);
if (next.has(expansionKey)) next.delete(expansionKey);
else next.add(expansionKey);
return next;
})
}
>
{expanded ? (
<ChevronDown className="size-3.5" aria-hidden />
) : (
<ChevronRight className="size-3.5" aria-hidden />
)}
{expanded ? "收起" : "展开"}
</button>
</td>
) : null}
</tr>
))}
{renderExpandedRow && expanded ? (
<tr id={panelId}>
<td
className="border-b border-border bg-surface-muted/45 px-5 py-4"
colSpan={row.getVisibleCells().length + 1}
>
{renderExpandedRow(row.original)}
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
+3 -1
View File
@@ -5,10 +5,12 @@ export function TableToolbar({
children,
active,
onClear,
clearLabel = "清除筛选",
}: {
children: ReactNode;
active: boolean;
onClear: () => void;
clearLabel?: string;
}) {
return (
<div
@@ -24,7 +26,7 @@ export function TableToolbar({
disabled={!active}
onClick={onClear}
>
{clearLabel}
</Button>
</div>
);
+39 -153
View File
@@ -1,13 +1,9 @@
import { ArrowDownUp, Coins, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { useCallback, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { adminApi } from "../../api/client";
import type { LedgerEntry, LedgerEntryType, LedgerQuery, SortOrder } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import type { LedgerQuery } from "../../api/types";
import {
Badge,
Button,
Card,
ErrorState,
@@ -15,45 +11,21 @@ import {
LoadingState,
PageHeader,
} from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import {
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { formatNumber } from "../../lib/format";
import { LedgerFilters } from "./ledger-filters";
import { LedgerTable } from "./ledger-table";
import { useLedgerFilters } from "./use-ledger-filters";
export function CreditsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialUserId = searchParams.get("userId") ?? "";
const [query, setQuery] = useState(initialUserId);
const [activeUserId, setActiveUserId] = useState(initialUserId);
const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [type, setType] = useState<"" | LedgerEntryType>(
(searchParams.get("type") as LedgerEntryType | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("order") === "asc" ? "asc" : "desc",
);
const ledgerQuery = useMemo<LedgerQuery>(
() => ({
from: from || undefined,
until: until || undefined,
type: type || undefined,
sort: "createdAt",
order,
limit: 50,
}),
[from, order, type, until],
);
const filters = useLedgerFilters();
const fetchLedger = useCallback(
(value: LedgerQuery) =>
activeUserId
? adminApi.ledger(activeUserId, value)
: adminApi.latestLedger(value),
activeUserId ? adminApi.ledger(activeUserId, value) : adminApi.latestLedger(value),
[activeUserId],
);
const {
@@ -64,88 +36,27 @@ export function CreditsPage() {
error,
loadMore,
reload,
} = useCursorPage(ledgerQuery, fetchLedger);
useEffect(() => {
const params = new URLSearchParams();
if (activeUserId) params.set("userId", activeUserId);
if (from) params.set("from", from);
if (until) params.set("until", until);
if (type) params.set("type", type);
params.set("sort", "createdAt");
params.set("order", order);
setSearchParams(params, { replace: true });
}, [activeUserId, from, order, setSearchParams, type, until]);
} = useCursorPage(filters.query, fetchLedger);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const userId = query.trim() || undefined;
setActiveUserId(userId ?? "");
const userId = query.trim();
setActiveUserId(userId);
setSearchParams((current) => {
const params = new URLSearchParams(current);
if (userId) params.set("userId", userId);
else params.delete("userId");
return params;
}, { replace: true });
}
const columns = useMemo<DataColumn<LedgerEntry>[]>(
() => [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
accessorKey: "userId",
header: "用户 ID",
cell: ({ getValue }) => (
<span className="block max-w-48 truncate font-mono text-xs" title={String(getValue())}>
{String(getValue())}
</span>
),
},
{
accessorKey: "type",
header: "类型",
cell: ({ getValue }) => <Badge>{statusLabel(String(getValue()))}</Badge>,
},
{
accessorKey: "usageType",
header: "消费类型",
cell: ({ getValue }) =>
getValue() ? <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge> : "—",
},
{
accessorKey: "amount",
header: "变动",
cell: ({ getValue }) => {
const value = Number(getValue());
return (
<span
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
>
{formatSignedCredits(value)}
</span>
);
},
},
{
accessorKey: "balanceAfter",
header: "结余",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "reasonCode",
header: "原因",
cell: ({ getValue }) => (
<span className="rounded-lg bg-surface-muted px-2 py-1 font-mono text-[11px] text-muted">
{String(getValue())}
</span>
),
},
],
[],
const hasBusinessFilter = Boolean(
filters.state.from ||
filters.state.until ||
filters.state.type ||
filters.state.entryType ||
filters.state.usageType ||
filters.state.referenceId,
);
return (
@@ -153,7 +64,7 @@ export function CreditsPage() {
<PageHeader
eyebrow="积分账本"
title="积分流水"
description="查看不可变积分账本,可按完整内部用户 ID 精确查询。"
description="从账务动作、业务来源和消费能力三个维度查看不可变积分账本。"
/>
<Card className="overflow-hidden">
@@ -181,38 +92,12 @@ export function CreditsPage() {
</Button>
</form>
</div>
<TableToolbar
active={Boolean(activeUserId || from || until || type || order !== "desc")}
onClear={() => {
setQuery("");
setActiveUserId("");
setFrom("");
setUntil("");
setType("");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
<LedgerFilters
value={filters.state}
onChange={filters.update}
onClear={filters.clear}
/>
<FilterControl
label="流水类型"
value={type}
options={[
{ value: "", label: "全部类型" },
{ value: "grant", label: "赠送" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退款" },
{ value: "adjustment", label: "调整" },
]}
onChange={setType}
/>
</TableToolbar>
<div className="flex items-center gap-3 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
<Coins className="size-4 text-primary" aria-hidden />
@@ -233,18 +118,19 @@ export function CreditsPage() {
) : loading ? (
<LoadingState label="加载积分流水" />
) : (
<DataTable
data={items}
columns={columns}
<LedgerTable
entries={items}
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
emptyTitle={
activeUserId || from || until || type
? "没有符合当前筛选条件的流水"
activeUserId || hasBusinessFilter
? "没有符合当前用户和筛选条件的流水"
: "暂无积分流水"
}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
showUser={!activeUserId}
sort={filters.state.sort}
order={filters.state.order}
onSortChange={(sort, order) => filters.update({ sort, order })}
onReferenceFilter={(referenceId) => filters.update({ referenceId })}
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
@@ -0,0 +1,170 @@
import { CalendarRange, Link2 } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import { Button, Input } from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import {
ledgerActionLabel,
ledgerActionOptions,
ledgerEntryTypeLabel,
ledgerEntryTypeOptions,
ledgerUsageLabel,
ledgerUsageOptions,
} from "./ledger-format";
import {
ledgerDatePreset,
type LedgerDatePreset,
type LedgerFilterState,
} from "./use-ledger-filters";
export const ledgerDatePresetOptions: ReadonlyArray<{
value: LedgerDatePreset;
label: string;
}> = [
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "7d", label: "最近 7 天" },
{ value: "30d", label: "最近 30 天" },
{ value: "90d", label: "最近 90 天" },
{ value: "custom", label: "自定义" },
];
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function LedgerFilters({
value,
onChange,
onClear,
}: {
value: LedgerFilterState;
onChange: (patch: Partial<LedgerFilterState>) => void;
onClear: () => void;
}) {
const [referenceDraft, setReferenceDraft] = useState(value.referenceId);
const normalizedReference = referenceDraft.trim();
const referenceValid =
normalizedReference.length === 0 || UUID_PATTERN.test(normalizedReference);
useEffect(() => {
setReferenceDraft(value.referenceId);
}, [value.referenceId]);
function applyReference(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (referenceValid) onChange({ referenceId: normalizedReference });
}
const active =
value.range !== "all" ||
Boolean(value.type || value.entryType || value.usageType || value.referenceId) ||
value.sort !== "createdAt" ||
value.order !== "desc";
const summary = [
value.type ? `账务动作:${ledgerActionLabel(value.type)}` : "",
value.entryType ? `业务来源:${ledgerEntryTypeLabel(value.entryType)}` : "",
value.usageType ? `消费能力:${ledgerUsageLabel(value.usageType)}` : "",
value.referenceId ? `关联 ID${value.referenceId}` : "",
value.range !== "all"
? `时间:${ledgerDatePresetOptions.find((option) => option.value === value.range)?.label}`
: "",
].filter(Boolean);
return (
<>
<TableToolbar
active={active}
onClear={onClear}
clearLabel="清除全部"
>
<FilterControl
label="时间范围"
value={value.range}
options={ledgerDatePresetOptions}
onChange={(range) => {
if (range === "custom") onChange({ range });
else onChange(ledgerDatePreset(range));
}}
/>
{value.range === "custom" ? (
<DateRangeControl
value={{ from: value.from || undefined, until: value.until || undefined }}
onChange={(range) =>
onChange({
from: range.from ?? "",
until: range.until ?? "",
range: "custom",
})
}
/>
) : null}
<FilterControl
label="账务动作"
value={value.type}
options={ledgerActionOptions}
onChange={(type) => onChange({ type })}
/>
<FilterControl
label="业务来源"
value={value.entryType}
options={ledgerEntryTypeOptions}
onChange={(entryType) => onChange({ entryType })}
/>
<FilterControl
label="消费能力"
value={value.usageType}
options={ledgerUsageOptions}
onChange={(usageType) => onChange({ usageType })}
/>
<form className="grid min-w-64 flex-1 gap-1" onSubmit={applyReference}>
<label className="text-xs font-semibold text-muted" htmlFor="ledger-reference-id">
ID
</label>
<span className="flex items-center gap-2">
<span className="relative flex-1">
<Link2
className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted"
aria-hidden
/>
<Input
id="ledger-reference-id"
className="h-9 rounded-lg pl-9 font-mono text-xs"
value={referenceDraft}
onChange={(event) => setReferenceDraft(event.target.value)}
placeholder="输入 referenceId"
maxLength={36}
autoComplete="off"
aria-invalid={!referenceValid}
aria-describedby={!referenceValid ? "ledger-reference-error" : undefined}
/>
</span>
<Button
size="sm"
variant="secondary"
type="submit"
disabled={!referenceValid || normalizedReference === value.referenceId}
>
</Button>
</span>
{!referenceValid ? (
<span id="ledger-reference-error" className="text-xs font-medium text-danger">
UUID
</span>
) : null}
</form>
</TableToolbar>
<div
className="flex items-start gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6"
aria-live="polite"
>
<CalendarRange className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden />
<span className="font-semibold text-foreground"></span>
<span className="min-w-0 break-all">
{summary.length > 0 ? summary.join(" · ") : "全部流水"}
</span>
</div>
</>
);
}
@@ -0,0 +1,96 @@
import type {
LedgerAction,
LedgerEntryType,
UsageType,
} from "../../api/types";
export type LedgerTone = "neutral" | "success" | "danger" | "warning" | "info" | "violet";
export const ledgerActionOptions: ReadonlyArray<{
value: "" | LedgerAction;
label: string;
}> = [
{ value: "", label: "全部账务动作" },
{ value: "grant", label: "入账" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退还" },
];
export const ledgerEntryTypeOptions: ReadonlyArray<{
value: "" | LedgerEntryType;
label: string;
}> = [
{ value: "", label: "全部业务来源" },
{ value: "SIGNUP_TRIAL", label: "注册试用赠送" },
{ value: "MANUAL_GRANT", label: "人工赠送" },
{ value: "USAGE_RESERVE", label: "消费预留" },
{ value: "USAGE_SETTLE", label: "消费结算" },
{ value: "USAGE_RELEASE", label: "消费释放" },
{ value: "USAGE_REFUND", label: "消费退款" },
{ value: "REFERRAL_INVITER", label: "邀请人奖励" },
{ value: "REFERRAL_INVITEE", label: "受邀人奖励" },
{ value: "STOREKIT_PURCHASE", label: "App Store 购买" },
{ value: "SUBSCRIPTION_GRANT", label: "订阅赠送" },
];
export const ledgerUsageOptions: ReadonlyArray<{
value: "" | UsageType;
label: string;
}> = [
{ value: "", label: "全部消费能力" },
{ value: "polish", label: "文字润色" },
{ value: "asr", label: "语音转写" },
{ value: "ai", label: "AI 助手" },
{ value: "agent", label: "智能代理" },
{ value: "hotword", label: "热词" },
];
const entryTypeLabels = Object.fromEntries(
ledgerEntryTypeOptions
.filter((option) => option.value)
.map((option) => [option.value, option.label]),
) as Record<LedgerEntryType, string>;
const usageLabels = Object.fromEntries(
ledgerUsageOptions
.filter((option) => option.value)
.map((option) => [option.value, option.label]),
) as Record<UsageType, string>;
const actionPresentation: Record<LedgerAction, { label: string; tone: LedgerTone }> = {
grant: { label: "入账", tone: "success" },
reserve: { label: "预留", tone: "warning" },
settle: { label: "结算", tone: "info" },
refund: { label: "退还", tone: "violet" },
};
const usageClasses: Record<UsageType, string> = {
polish: "bg-violet-soft text-violet",
asr: "bg-primary-soft text-primary",
ai: "bg-success-soft text-success",
agent: "bg-warning-soft text-warning",
hotword: "bg-pink-500/10 text-pink-700 dark:text-pink-300",
};
export function ledgerEntryTypeLabel(value?: string): string {
if (!value) return "未知类型(空值)";
return entryTypeLabels[value as LedgerEntryType] ?? `未知类型(${value}`;
}
export function ledgerActionLabel(value?: string): string {
return actionPresentation[value as LedgerAction]?.label ?? `未知动作(${value || "空值"}`;
}
export function ledgerActionTone(value?: string): LedgerTone {
return actionPresentation[value as LedgerAction]?.tone ?? "neutral";
}
export function ledgerUsageLabel(value?: string): string {
if (!value) return "—";
return usageLabels[value as UsageType] ?? `未知能力(${value}`;
}
export function ledgerUsageClass(value?: string): string {
return usageClasses[value as UsageType] ?? "bg-surface-muted text-muted";
}
@@ -0,0 +1,264 @@
import { Clipboard, Filter, UserRound } from "lucide-react";
import { type ReactNode, useMemo } from "react";
import { toast } from "sonner";
import type { LedgerEntry, LedgerQuery, SortOrder } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import { Badge, Button } from "../../components/primitives";
import { formatDateTime, formatNumber, formatSignedCredits } from "../../lib/format";
import {
ledgerActionLabel,
ledgerActionTone,
ledgerEntryTypeLabel,
ledgerUsageClass,
ledgerUsageLabel,
} from "./ledger-format";
export function LedgerTable({
entries,
caption,
emptyTitle,
showUser = false,
sort,
order,
onSortChange,
onReferenceFilter,
footer,
}: {
entries: LedgerEntry[];
caption: string;
emptyTitle: string;
showUser?: boolean;
sort: NonNullable<LedgerQuery["sort"]>;
order: SortOrder;
onSortChange: (sort: NonNullable<LedgerQuery["sort"]>, order: SortOrder) => void;
onReferenceFilter: (referenceId: string) => void;
footer?: ReactNode;
}) {
const columns = useMemo<DataColumn<LedgerEntry>[]>(() => {
const result: DataColumn<LedgerEntry>[] = [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
];
if (showUser) {
result.push({
accessorKey: "userId",
header: "用户",
cell: ({ getValue }) => {
const userId = String(getValue());
return (
<a
className="group/user inline-flex max-w-52 items-center gap-2 text-left text-primary"
href={`#/users?userId=${encodeURIComponent(userId)}`}
title={userId}
>
<UserRound className="size-3.5 shrink-0" aria-hidden />
<span className="truncate font-mono text-xs underline-offset-4 group-hover/user:underline">
{userId}
</span>
</a>
);
},
});
}
result.push(
{
accessorKey: "type",
header: "账务动作",
cell: ({ getValue }) => (
<Badge tone={ledgerActionTone(String(getValue()))}>
{ledgerActionLabel(String(getValue()))}
</Badge>
),
},
{
accessorKey: "entryType",
header: "业务来源",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs font-semibold">
{ledgerEntryTypeLabel(String(getValue() ?? ""))}
</span>
),
},
{
accessorKey: "usageType",
header: "消费能力",
cell: ({ getValue }) => {
const usageType = getValue() ? String(getValue()) : undefined;
return usageType ? (
<Badge className={ledgerUsageClass(usageType)}>
{ledgerUsageLabel(usageType)}
</Badge>
) : (
<span className="text-muted"></span>
);
},
},
{
accessorKey: "amount",
header: "变动",
cell: ({ getValue }) => {
const value = Number(getValue());
return (
<span
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
>
{formatSignedCredits(value)}
</span>
);
},
},
{
accessorKey: "balanceAfter",
header: "结余",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
);
return result;
}, [showUser]);
return (
<DataTable
data={entries}
columns={columns}
caption={caption}
emptyTitle={emptyTitle}
sort={{ key: sort, order }}
sortableColumns={{ createdAt: "createdAt", amount: "amount" }}
onSortChange={(nextSort, nextOrder) =>
onSortChange(nextSort as NonNullable<LedgerQuery["sort"]>, nextOrder)
}
renderExpandedRow={(entry) => (
<LedgerDetails entry={entry} onReferenceFilter={onReferenceFilter} />
)}
getRowId={(entry) => entry.entryId}
footer={footer}
/>
);
}
function LedgerDetails({
entry,
onReferenceFilter,
}: {
entry: LedgerEntry;
onReferenceFilter: (referenceId: string) => void;
}) {
const details = entry.details;
return (
<section className="space-y-4" aria-label="流水安全详情">
<dl className="grid gap-x-8 gap-y-4 sm:grid-cols-2 xl:grid-cols-4">
<DetailItem label="流水 ID">
<Identifier value={entry.entryId} />
</DetailItem>
<DetailItem label="关联 ID">
{entry.referenceId ? (
<div className="flex flex-wrap items-center gap-2">
<Identifier value={entry.referenceId} />
<Button
size="sm"
variant="ghost"
onClick={() => onReferenceFilter(entry.referenceId!)}
>
<Filter className="size-3.5" aria-hidden />
</Button>
</div>
) : (
"—"
)}
</DetailItem>
<DetailItem label="原因代码">
<span className="font-mono text-[11px] text-muted">{entry.reasonCode || "—"}</span>
</DetailItem>
{details?.kind === "manualGrant" ? (
<>
<DetailItem label="人工原因">{details.reason || "—"}</DetailItem>
<DetailItem label="操作员">{details.operatorName || "—"}</DetailItem>
</>
) : null}
{details?.kind === "storeKit" ? (
<>
<DetailItem label="商品">{details.productId || "—"}</DetailItem>
<DetailItem label="交易 ID">
<Identifier value={details.transactionId} />
</DetailItem>
<DetailItem label="原始交易 ID">
<Identifier value={details.originalTransactionId} />
</DetailItem>
<DetailItem label="环境">{details.environment || "—"}</DetailItem>
<DetailItem label="购买时间">{formatDateTime(details.purchasedAt)}</DetailItem>
</>
) : null}
{details?.kind === "referral" ? (
<>
<DetailItem label="裂变角色">{referralRoleLabel(details.role)}</DetailItem>
<DetailItem label="关联用户">
<Identifier value={details.relatedUserId} />
</DetailItem>
</>
) : null}
{details?.kind === "usage" ? (
<DetailItem label="预留 ID">
<Identifier value={details.reservationId} />
</DetailItem>
) : null}
</dl>
</section>
);
}
function DetailItem({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-[11px] font-bold uppercase tracking-wide text-muted">{label}</dt>
<dd className="mt-1.5 break-words text-xs font-medium text-foreground">{children}</dd>
</div>
);
}
function Identifier({ value }: { value?: string }) {
if (!value) return <span className="text-muted"></span>;
return (
<span className="inline-flex max-w-full items-center gap-1.5">
<code className="break-all text-[11px]">{value}</code>
<button
className="grid size-7 shrink-0 place-items-center rounded-lg text-muted transition hover:bg-surface hover:text-primary focus-visible:ring-4 focus-visible:ring-primary/15"
type="button"
title="复制 ID"
aria-label={`复制 ID ${value}`}
onClick={() => void copyIdentifier(value)}
>
<Clipboard className="size-3.5" aria-hidden />
</button>
</span>
);
}
async function copyIdentifier(value: string) {
try {
await navigator.clipboard.writeText(value);
toast.success("ID 已复制");
} catch {
toast.error("无法复制,请手动选择 ID");
}
}
function referralRoleLabel(role?: string): string {
if (!role) return "—";
const labels: Record<string, string> = {
inviter: "邀请人",
invitee: "受邀人",
INVITER: "邀请人",
INVITEE: "受邀人",
};
return labels[role] ?? role;
}
@@ -0,0 +1,156 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type {
LedgerAction,
LedgerEntryType,
LedgerQuery,
SortOrder,
UsageType,
} from "../../api/types";
import {
ledgerActionOptions,
ledgerEntryTypeOptions,
ledgerUsageOptions,
} from "./ledger-format";
export type LedgerDatePreset = "all" | "today" | "7d" | "30d" | "90d" | "custom";
export interface LedgerFilterState {
from: string;
until: string;
type: "" | LedgerAction;
entryType: "" | LedgerEntryType;
usageType: "" | UsageType;
referenceId: string;
range: LedgerDatePreset;
sort: NonNullable<LedgerQuery["sort"]>;
order: SortOrder;
}
const defaultState: LedgerFilterState = {
from: "",
until: "",
type: "",
entryType: "",
usageType: "",
referenceId: "",
range: "all",
sort: "createdAt",
order: "desc",
};
const ranges = new Set<LedgerDatePreset>(["all", "today", "7d", "30d", "90d", "custom"]);
const sorts = new Set<LedgerFilterState["sort"]>(["createdAt", "amount"]);
const orders = new Set<SortOrder>(["asc", "desc"]);
function parameterName(prefix: string, name: string): string {
if (!prefix) return name;
return `${prefix}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
}
function validOption<T extends string>(
value: string | null,
options: ReadonlyArray<{ value: "" | T }>,
): "" | T {
return options.some((option) => option.value === value) ? (value as T) : "";
}
export function readLedgerFilterState(
searchParams: URLSearchParams,
prefix = "",
): LedgerFilterState {
const read = (name: string) => searchParams.get(parameterName(prefix, name));
const from = read("from") ?? "";
const until = read("until") ?? "";
const storedRange = read("range") as LedgerDatePreset | null;
return {
from,
until,
type: validOption(read("type"), ledgerActionOptions),
entryType: validOption(read("entryType"), ledgerEntryTypeOptions),
usageType: validOption(read("usageType"), ledgerUsageOptions),
referenceId: read("referenceId") ?? "",
range: storedRange && ranges.has(storedRange) ? storedRange : from || until ? "custom" : "all",
sort: sorts.has(read("sort") as LedgerFilterState["sort"])
? (read("sort") as LedgerFilterState["sort"])
: "createdAt",
order: orders.has(read("order") as SortOrder) ? (read("order") as SortOrder) : "desc",
};
}
export function ledgerDatePreset(
preset: Exclude<LedgerDatePreset, "custom">,
now = new Date(),
): Pick<LedgerFilterState, "from" | "until" | "range"> {
if (preset === "all") return { from: "", until: "", range: "all" };
const today = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
const until = new Date(today);
until.setUTCDate(until.getUTCDate() + 1);
const from = new Date(today);
const days = preset === "today" ? 1 : Number.parseInt(preset, 10);
from.setUTCDate(from.getUTCDate() - (days - 1));
return { from: from.toISOString(), until: until.toISOString(), range: preset };
}
export function useLedgerFilters(prefix = "") {
const [searchParams, setSearchParams] = useSearchParams();
const [state, setState] = useState<LedgerFilterState>(() =>
readLedgerFilterState(searchParams, prefix),
);
useEffect(() => {
setSearchParams((current) => {
const params = new URLSearchParams(current);
const keys: Array<keyof LedgerFilterState> = [
"from",
"until",
"type",
"entryType",
"usageType",
"referenceId",
"range",
"sort",
"order",
];
keys.forEach((key) => params.delete(parameterName(prefix, key)));
if (state.from) params.set(parameterName(prefix, "from"), state.from);
if (state.until) params.set(parameterName(prefix, "until"), state.until);
if (state.type) params.set(parameterName(prefix, "type"), state.type);
if (state.entryType) params.set(parameterName(prefix, "entryType"), state.entryType);
if (state.usageType) params.set(parameterName(prefix, "usageType"), state.usageType);
if (state.referenceId) {
params.set(parameterName(prefix, "referenceId"), state.referenceId);
}
if (state.range !== "all") params.set(parameterName(prefix, "range"), state.range);
if (state.sort !== "createdAt") params.set(parameterName(prefix, "sort"), state.sort);
if (state.order !== "desc") params.set(parameterName(prefix, "order"), state.order);
return params;
}, { replace: true });
}, [prefix, setSearchParams, state]);
const query = useMemo<LedgerQuery>(
() => ({
from: state.from || undefined,
until: state.until || undefined,
type: state.type || undefined,
entryType: state.entryType || undefined,
usageType: state.usageType || undefined,
referenceId: state.referenceId.trim() || undefined,
sort: state.sort,
order: state.order,
limit: 50,
}),
[state],
);
return {
state,
query,
setState,
update: (patch: Partial<LedgerFilterState>) =>
setState((current) => ({ ...current, ...patch })),
clear: () => setState(defaultState),
};
}
+60 -170
View File
@@ -19,9 +19,7 @@ import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
LedgerEntryType,
LedgerQuery,
LedgerEntry,
SortOrder,
UserDetail,
UsersQuery,
@@ -48,34 +46,37 @@ import {
createIdempotencyKey,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { useAuth } from "../auth/auth-context";
const ledgerTypeOptions: Array<{ value: "" | LedgerEntryType; label: string }> = [
{ value: "", label: "全部类型" },
{ value: "grant", label: "赠送" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退款" },
{ value: "adjustment", label: "调整" },
];
import { LedgerFilters } from "../credits/ledger-filters";
import { LedgerTable } from "../credits/ledger-table";
import { useLedgerFilters } from "../credits/use-ledger-filters";
export function UsersPage() {
const { auth } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
const [selectedUserId, setSelectedUserId] = useState<string>();
const selectedUserId = searchParams.get("userId") ?? undefined;
function selectUser(userId?: string) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
if (userId) params.set("userId", userId);
else params.delete("userId");
return params;
});
}
return selectedUserId ? (
<UserDetailView
userId={selectedUserId}
canGrant={role === "SUPER_ADMIN"}
onBack={() => setSelectedUserId(undefined)}
onBack={() => selectUser()}
/>
) : (
<UserList onSelect={setSelectedUserId} />
<UserList onSelect={selectUser} />
);
}
@@ -304,98 +305,49 @@ function UserDetailView({
canGrant: boolean;
onBack: () => void;
}) {
const [searchParams, setSearchParams] = useSearchParams();
const [user, setUser] = useState<UserDetail>();
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const [grantOpen, setGrantOpen] = useState(false);
const [from, setFrom] = useState(searchParams.get("ledgerFrom") ?? "");
const [until, setUntil] = useState(searchParams.get("ledgerUntil") ?? "");
const [type, setType] = useState<"" | LedgerEntryType>(
(searchParams.get("ledgerType") as LedgerEntryType | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("ledgerOrder") === "asc" ? "asc" : "desc",
);
const filters = useLedgerFilters("ledger");
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 fetchLedger = useCallback(
(value: LedgerQuery) => adminApi.ledger(userId, value),
[userId],
);
const ledgerPage = useCursorPage(filters.query, fetchLedger);
const load = useCallback(async () => {
const version = ++requestVersion.current;
setLedger([]);
setNextCursor(undefined);
setLoading(true);
setLoadingMore(false);
setError(undefined);
try {
const [detail, page] = await Promise.all([
adminApi.user(userId),
adminApi.ledger(userId, ledgerQuery),
]);
const detail = await adminApi.user(userId);
if (requestVersion.current !== version) return;
setUser(detail);
setLedger(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
if (requestVersion.current === version) setError(requestError);
} finally {
if (requestVersion.current === version) setLoading(false);
}
}, [ledgerQuery, userId]);
}, [userId]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
setSearchParams((current) => {
const params = new URLSearchParams(current);
["ledgerFrom", "ledgerUntil", "ledgerType", "ledgerOrder"].forEach((key) =>
params.delete(key),
if (error || ledgerPage.error) {
return (
<ErrorState
error={error ?? ledgerPage.error}
retry={() => {
void load();
ledgerPage.reload();
}}
/>
);
if (from) params.set("ledgerFrom", from);
if (until) params.set("ledgerUntil", until);
if (type) params.set("ledgerType", type);
params.set("ledgerOrder", order);
return params;
}, { replace: true });
}, [from, order, setSearchParams, type, until]);
async function loadMoreLedger() {
if (!nextCursor) return;
const version = requestVersion.current;
setLoadingMore(true);
try {
const page = await adminApi.ledger(userId, { ...ledgerQuery, cursor: nextCursor });
if (requestVersion.current !== version) return;
setLedger((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载积分流水失败",
);
} finally {
if (requestVersion.current === version) setLoadingMore(false);
}
}
const ledgerColumns = useLedgerColumns();
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (loading || !user) return <LoadingState label="加载用户详情" />;
if (loading || ledgerPage.loading || !user) return <LoadingState label="加载用户详情" />;
const usage = user.usage ?? [];
const requests = usage.reduce((total, item) => total + item.requests, 0);
@@ -472,44 +424,35 @@ function UserDetailView({
title="积分流水"
description="所有变动均来自不可变账本"
/>
<TableToolbar
active={Boolean(from || until || type || order !== "desc")}
onClear={() => {
setFrom("");
setUntil("");
setType("");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
<LedgerFilters
value={filters.state}
onChange={filters.update}
onClear={filters.clear}
/>
<FilterControl
label="流水类型"
value={type}
options={ledgerTypeOptions}
onChange={setType}
/>
</TableToolbar>
<DataTable
data={ledger}
columns={ledgerColumns}
<LedgerTable
entries={ledgerPage.items}
caption={`${user.displayName || user.userId} 的积分流水`}
emptyTitle={from || until || type ? "没有符合当前筛选条件的流水" : "暂无积分流水"}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
emptyTitle={
filters.state.from ||
filters.state.until ||
filters.state.type ||
filters.state.entryType ||
filters.state.usageType ||
filters.state.referenceId
? "没有符合当前筛选条件的流水"
: "暂无积分流水"
}
sort={filters.state.sort}
order={filters.state.order}
onSortChange={(sort, order) => filters.update({ sort, order })}
onReferenceFilter={(referenceId) => filters.update({ referenceId })}
footer={
nextCursor ? (
ledgerPage.nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button
variant="secondary"
onClick={() => void loadMoreLedger()}
loading={loadingMore}
onClick={() => void ledgerPage.loadMore()}
loading={ledgerPage.loadingMore}
>
</Button>
@@ -528,7 +471,10 @@ function UserDetailView({
open={grantOpen}
user={user}
onOpenChange={setGrantOpen}
onSuccess={load}
onSuccess={async () => {
await load();
ledgerPage.reload();
}}
/>
) : null}
</div>
@@ -694,62 +640,6 @@ export function GrantDialog({
);
}
function useLedgerColumns(): DataColumn<LedgerEntry>[] {
return useMemo(
() => [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
accessorKey: "type",
header: "类型",
cell: ({ getValue }) => <Badge>{statusLabel(String(getValue()))}</Badge>,
},
{
accessorKey: "usageType",
header: "消费类型",
cell: ({ getValue }) =>
getValue() ? <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge> : "—",
},
{
accessorKey: "amount",
header: "变动",
cell: ({ getValue }) => {
const value = Number(getValue());
return (
<span
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
>
{formatSignedCredits(value)}
</span>
);
},
},
{
accessorKey: "balanceAfter",
header: "结余",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "reasonCode",
header: "原因",
cell: ({ getValue }) => (
<span className="font-mono text-[11px] text-muted">{String(getValue())}</span>
),
},
],
[],
);
}
function UsagePanel({ usage }: { usage: UserUsageAggregate[] }) {
const columns = useMemo<DataColumn<UserUsageAggregate>[]>(
() => [
-1
View File
@@ -43,7 +43,6 @@ export function statusLabel(status: string): string {
reserve: "预留",
settle: "结算",
refund: "退还",
adjustment: "调整",
};
return labels[status] ?? status;
}
+30
View File
@@ -141,6 +141,36 @@ describe("adminApi", () => {
);
});
it("流水查询编码三维筛选、关联 ID 与金额排序", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await adminApi.latestLedger({
cursor: "next+/=",
limit: 50,
from: "2026-08-01T00:00:00.000Z",
until: "2026-08-21T00:00:00.000Z",
type: "settle",
entryType: "USAGE_SETTLE",
usageType: "hotword",
referenceId: "11111111-1111-4111-8111-111111111111",
sort: "amount",
order: "asc",
});
const url = String(fetchMock.mock.calls[0]?.[0]);
expect(url).toContain("cursor=next%2B%2F%3D&limit=50");
expect(url).toContain("type=settle&entryType=USAGE_SETTLE&usageType=hotword");
expect(url).toContain(
"referenceId=11111111-1111-4111-8111-111111111111&sort=amount&order=asc",
);
});
it("缺少 CSRF 时在发送变更请求前失败", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
+52
View File
@@ -6,6 +6,11 @@ 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";
import { LedgerFilters } from "../features/credits/ledger-filters";
import {
ledgerDatePreset,
readLedgerFilterState,
} from "../features/credits/use-ledger-filters";
afterEach(cleanup);
@@ -60,4 +65,51 @@ describe("筛选与排序基础能力", () => {
until: "2026-08-21T00:00:00.000Z",
});
});
it("流水时间预设生成 UTC 半开区间", () => {
const now = new Date("2026-08-20T21:30:00+08:00");
expect(ledgerDatePreset("today", now)).toEqual({
from: "2026-08-20T00:00:00.000Z",
until: "2026-08-21T00:00:00.000Z",
range: "today",
});
expect(ledgerDatePreset("7d", now)).toEqual({
from: "2026-08-14T00:00:00.000Z",
until: "2026-08-21T00:00:00.000Z",
range: "7d",
});
expect(ledgerDatePreset("all", now)).toEqual({
from: "",
until: "",
range: "all",
});
});
it("关联 ID 完整且合法后才应用筛选", async () => {
const onChange = vi.fn();
render(
<LedgerFilters
value={readLedgerFilterState(new URLSearchParams())}
onChange={onChange}
onClear={vi.fn()}
/>,
);
const input = screen.getByLabelText("关联 ID");
await userEvent.type(input, "invalid");
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(screen.getByText("请输入完整 UUID")).toBeTruthy();
expect(screen.getByRole("button", { name: "应用" }).hasAttribute("disabled")).toBe(true);
expect(onChange).not.toHaveBeenCalled();
await userEvent.clear(input);
await userEvent.type(input, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
await userEvent.click(screen.getByRole("button", { name: "应用" }));
expect(onChange).toHaveBeenLastCalledWith({
referenceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
});
});
});
+39
View File
@@ -6,6 +6,10 @@ import {
statusLabel,
usageTypeLabel,
} from "../lib/format";
import {
ledgerEntryTypeLabel,
ledgerUsageClass,
} from "../features/credits/ledger-format";
describe("format helpers", () => {
it("转义服务端文本以阻止 HTML 注入", () => {
@@ -33,4 +37,39 @@ describe("format helpers", () => {
expect(usageTypeLabel("hotword")).toBe("热词");
expect(usageTypeLabel()).toBe("—");
});
it("十种流水业务来源使用稳定中文标签,未知值保留原值", () => {
expect([
"SIGNUP_TRIAL",
"MANUAL_GRANT",
"USAGE_RESERVE",
"USAGE_SETTLE",
"USAGE_RELEASE",
"USAGE_REFUND",
"REFERRAL_INVITER",
"REFERRAL_INVITEE",
"STOREKIT_PURCHASE",
"SUBSCRIPTION_GRANT",
].map(ledgerEntryTypeLabel)).toEqual([
"注册试用赠送",
"人工赠送",
"消费预留",
"消费结算",
"消费释放",
"消费退款",
"邀请人奖励",
"受邀人奖励",
"App Store 购买",
"订阅赠送",
]);
expect(ledgerEntryTypeLabel("FUTURE_TYPE")).toBe("未知类型(FUTURE_TYPE");
});
it("五种消费能力使用固定且可读的类别色", () => {
expect(ledgerUsageClass("polish")).toContain("text-violet");
expect(ledgerUsageClass("asr")).toContain("text-primary");
expect(ledgerUsageClass("ai")).toContain("text-success");
expect(ledgerUsageClass("agent")).toContain("text-warning");
expect(ledgerUsageClass("hotword")).toContain("text-pink");
});
});
+92 -2
View File
@@ -85,6 +85,7 @@ describe("React 管理页面", () => {
entryId: "ledger-1",
userId,
type: "settle",
entryType: "USAGE_SETTLE",
amount: -18,
balanceAfter: 102,
reasonCode: "USAGE_SETTLE",
@@ -97,11 +98,100 @@ describe("React 管理页面", () => {
render(<App />);
expect(await screen.findByRole("heading", { name: "积分流水" })).toBeTruthy();
expect(await screen.findByText("USAGE_SETTLE")).toBeTruthy();
expect(screen.getByText("热词")).toBeTruthy();
expect(await screen.findByText("消费结算")).toBeTruthy();
expect(screen.getAllByText("热词")).toHaveLength(2);
expect(screen.getByText("-18")).toBeTruthy();
});
it("积分流水支持金额排序、展开安全详情、关联筛选与用户跳转", async () => {
mockSession("SUPPORT");
const referenceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const entry: LedgerEntry = {
entryId: "ledger-manual-1",
userId,
type: "grant",
entryType: "MANUAL_GRANT",
amount: 50,
balanceAfter: 170,
reasonCode: "MANUAL_CREDIT_GRANT",
referenceId,
details: {
kind: "manualGrant",
reason: "客服补偿",
operatorName: "support",
},
createdAt: "2026-08-20T09:00:00Z",
};
vi.spyOn(adminApi, "latestLedger").mockResolvedValue({ items: [entry] });
window.location.hash = "#/credits";
render(<App />);
const userLink = await screen.findByRole("link", { name: userId });
expect(screen.getAllByText("人工赠送")).toHaveLength(2);
expect(screen.queryByText("MANUAL_CREDIT_GRANT")).toBeNull();
expect(userLink.getAttribute("href")).toBe(`#/users?userId=${userId}`);
await userEvent.click(screen.getByRole("button", { name: /变动/ }));
await waitFor(() => {
expect(adminApi.latestLedger).toHaveBeenLastCalledWith(
expect.objectContaining({ sort: "amount", order: "desc" }),
);
});
expect(window.location.hash).toContain("sort=amount");
await userEvent.click(screen.getByRole("button", { name: "展开" }));
expect(screen.getByText("客服补偿")).toBeTruthy();
expect(screen.getByText("support")).toBeTruthy();
expect(screen.getByText("MANUAL_CREDIT_GRANT")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "筛选同笔生命周期" }));
await waitFor(() => {
expect(adminApi.latestLedger).toHaveBeenLastCalledWith(
expect.objectContaining({ referenceId }),
);
});
expect(window.location.hash).toContain(`referenceId=${referenceId}`);
});
it("用户 URL 可直接打开详情,ledger 前缀不覆盖列表筛选", async () => {
mockSession("SUPPORT");
vi.spyOn(adminApi, "user").mockResolvedValue({
...user("链接用户", userId),
qualifiedUsage: false,
});
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
vi.spyOn(adminApi, "users").mockResolvedValue({ items: [] });
window.location.hash =
`#/users?q=11111111&status=active&order=asc&userId=${userId}` +
"&ledgerType=reserve&ledgerEntryType=USAGE_RESERVE&ledgerUsageType=ai";
render(<App />);
expect(await screen.findByRole("heading", { name: "链接用户" })).toBeTruthy();
await waitFor(() => {
expect(adminApi.ledger).toHaveBeenCalledWith(
userId,
expect.objectContaining({
type: "reserve",
entryType: "USAGE_RESERVE",
usageType: "ai",
}),
);
});
expect(window.location.hash).toContain("q=11111111");
expect(window.location.hash).toContain("ledgerType=reserve");
await userEvent.click(screen.getByRole("button", { name: "返回用户列表" }));
await waitFor(() => {
expect(window.location.hash).not.toContain("userId=");
expect(window.location.hash).toContain("q=11111111");
expect(window.location.hash).toContain("status=active");
expect(window.location.hash).toContain("order=asc");
});
});
it("安全中心保留管理员游标分页", async () => {
mockSession("SUPER_ADMIN");
const first = operator("operator-1", "owner");
+98 -13
View File
@@ -263,17 +263,18 @@ paths:
default: { $ref: "#/components/responses/Error" }
/v1/referrals/me:
get:
summary: Return the referral profile and idempotently provision its invite code
summary: Return the referral profile and provision its permanent account invite code
responses:
"200":
description: Referral profile
content:
application/json:
schema: { type: object, additionalProperties: true }
schema: { $ref: "#/components/schemas/ReferralProfile" }
default: { $ref: "#/components/responses/Error" }
/v1/referrals/code:
post:
summary: Idempotently create the current campaign invite code
deprecated: true
summary: Compatibility endpoint for the permanent account invite code
responses:
"200":
description: Invite code
@@ -685,11 +686,14 @@ paths:
- $ref: "#/components/parameters/AdminFrom"
- $ref: "#/components/parameters/AdminUntil"
- $ref: "#/components/parameters/AdminLedgerType"
- $ref: "#/components/parameters/AdminCreatedAtSort"
- $ref: "#/components/parameters/AdminLedgerEntryType"
- $ref: "#/components/parameters/AdminLedgerUsageType"
- $ref: "#/components/parameters/AdminLedgerReferenceId"
- $ref: "#/components/parameters/AdminLedgerSort"
- $ref: "#/components/parameters/AdminSortOrder"
responses:
"200":
description: Credit ledger entries ordered by creation time and entry ID
description: Credit ledger entries ordered by the requested field and unique entry ID
content:
application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" }
@@ -710,11 +714,14 @@ paths:
- $ref: "#/components/parameters/AdminFrom"
- $ref: "#/components/parameters/AdminUntil"
- $ref: "#/components/parameters/AdminLedgerType"
- $ref: "#/components/parameters/AdminCreatedAtSort"
- $ref: "#/components/parameters/AdminLedgerEntryType"
- $ref: "#/components/parameters/AdminLedgerUsageType"
- $ref: "#/components/parameters/AdminLedgerReferenceId"
- $ref: "#/components/parameters/AdminLedgerSort"
- $ref: "#/components/parameters/AdminSortOrder"
responses:
"200":
description: Latest credit ledger entries ordered by creation time and entry ID
description: Latest credit ledger entries ordered by the requested field and unique entry ID
content:
application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" }
@@ -1007,7 +1014,26 @@ components:
in: query
schema:
type: string
enum: [grant, reserve, settle, refund, adjustment]
enum: [grant, reserve, settle, refund]
AdminLedgerEntryType:
name: entryType
in: query
description: Exact immutable ledger entry type. This filter is ANDed with all other filters.
schema:
$ref: "#/components/schemas/LedgerEntryType"
AdminLedgerUsageType:
name: usageType
in: query
description: Product usage associated with the reservation. HOTWORD request source takes precedence over capability.
schema: { type: string, enum: [polish, asr, ai, agent, hotword] }
AdminLedgerReferenceId:
name: referenceId
in: query
schema: { type: string, format: uuid }
AdminLedgerSort:
name: sort
in: query
schema: { type: string, enum: [createdAt, amount], default: createdAt }
AdminLedgerLimit:
name: limit
in: query
@@ -1411,21 +1437,58 @@ components:
type: array
items: { $ref: "#/components/schemas/AdminUsageAggregate" }
referral: { $ref: "#/components/schemas/AdminUserReferral" }
LedgerEntryType:
type: string
enum:
- SIGNUP_TRIAL
- MANUAL_GRANT
- USAGE_RESERVE
- USAGE_SETTLE
- USAGE_RELEASE
- USAGE_REFUND
- REFERRAL_INVITER
- REFERRAL_INVITEE
- STOREKIT_PURCHASE
- SUBSCRIPTION_GRANT
AdminLedgerDetails:
type: object
additionalProperties: false
required: [kind]
description: Privacy-minimized source metadata. Missing source associations omit the details object.
properties:
kind: { type: string, enum: [manualGrant, storeKit, referral, usage] }
reason: { type: string, description: Present for manualGrant details }
operatorName: { type: string, description: Present for manualGrant details }
productId: { type: string, description: Present for storeKit details }
transactionId: { type: string, description: Present for storeKit details }
originalTransactionId: { type: string, description: Present for storeKit details }
environment: { type: string, enum: [SANDBOX, PRODUCTION], description: Present for storeKit details }
purchasedAt: { type: string, format: date-time, description: Present for storeKit details }
role: { type: string, enum: [inviter, invitee], description: Present for referral details }
relatedUserId: { type: string, format: uuid, description: Present for referral details }
reservationId: { type: string, format: uuid, description: Present for usage details }
AdminLedgerEntry:
type: object
additionalProperties: false
required: [entryId, userId, type, amount, balanceAfter, reasonCode, createdAt]
required: [entryId, userId, type, entryType, amount, balanceAfter, reasonCode, createdAt]
properties:
entryId: { type: string, format: uuid }
userId: { type: string, format: uuid }
type: { type: string, enum: [grant, reserve, settle, refund, adjustment] }
type: { type: string, enum: [grant, reserve, settle, refund] }
entryType: { $ref: "#/components/schemas/LedgerEntryType" }
amount: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
reasonCode: { type: string }
reasonCode:
allOf:
- $ref: "#/components/schemas/LedgerEntryType"
deprecated: true
description: Compatibility alias for entryType
referenceId: { type: ["string", "null"], format: uuid }
usageType:
type: ["string", "null"]
enum: [polish, asr, ai, agent, hotword, null]
description: Product usage associated with this ledger operation
details: { $ref: "#/components/schemas/AdminLedgerDetails" }
createdAt: { type: string, format: date-time }
AdminLedgerPage:
type: object
@@ -1728,9 +1791,31 @@ components:
items: { type: object, additionalProperties: true }
ReferralCode:
type: object
additionalProperties: true
additionalProperties: false
required: [code, inviteUrl, createdAt]
properties:
code: { type: string, pattern: "^[A-Za-z0-9_-]{22}$" }
code:
type: string
pattern: "^[A-Za-z0-9_-]{22}$"
description: Permanent opaque identifier assigned once to the account.
inviteUrl:
type: string
format: uri
description: Stable first-party invitation URL containing the permanent code.
campaignId:
type: ["string", "null"]
format: uuid
description: Legacy creation metadata; it does not limit the invitation lifetime.
createdAt: { type: string, format: date-time }
ReferralProfile:
type: object
additionalProperties: false
required: [code]
properties:
code: { $ref: "#/components/schemas/ReferralCode" }
binding:
type: ["object", "null"]
additionalProperties: true
RedeemReferralRequest:
type: object
additionalProperties: false
@@ -115,7 +115,6 @@ import com.osglab.account.features.inviteweb.InviteWebConfig
import com.osglab.account.features.inviteweb.InviteOpenRecorder
import com.osglab.account.features.inviteweb.ReferralLookupPort
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
import com.osglab.account.features.referrals.domain.ReferralException
import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralService
@@ -312,7 +311,7 @@ fun Application.module() {
rateLimit(ACCOUNT_RATE_LIMIT) {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), appConfig.inviteBaseUrl, koin.get())
storeKitRoutes(koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
@@ -519,13 +518,8 @@ fun accountServerModule(config: AppConfig): Module = module {
)
}
get<AccountService>().seedDisplayName(accountId, displayName)
try {
get<ReferralOperations>().getOrCreateCode(accountId)
} catch (exception: CancellationException) {
throw exception
} catch (_: ReferralException) {
// Referral eligibility must not make account sign-in unavailable.
}
// Referral provisioning is intentionally handled by /v1/referrals/me
// after authentication so referral storage can never block sign-in.
}
}
single {
@@ -585,10 +579,9 @@ fun accountServerModule(config: AppConfig): Module = module {
val transactions = get<BillingTransactionRunner>()
ReferralLookupPort { code ->
transactions.inTransaction { unit ->
val referralCode = unit.referrals.findCode(code)
referralCode?.campaignId
?.let(unit.referrals::findCampaign)
?.isActive(Instant.now()) == true
// Invitation codes are permanent account identifiers. Campaign
// availability is evaluated only when an invitee redeems one.
unit.referrals.findCode(code) != null
}
}
}
@@ -28,12 +28,15 @@ import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
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
@@ -41,6 +44,7 @@ import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.ktor.http.Cookie
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -651,21 +655,61 @@ private fun ApplicationCall.adminUserListQuery(): AdminUserListQuery {
}
private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery {
requireQueryParameters(setOf("cursor", "limit", "from", "until", "type", "sort", "order"))
requireCreatedAtSort()
requireQueryParameters(
setOf(
"cursor",
"limit",
"from",
"until",
"type",
"entryType",
"usageType",
"referenceId",
"sort",
"order",
),
)
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")
}
}
val entryType = request.queryParameters["entryType"]?.let {
runCatching { LedgerEntryType.valueOf(it) }.getOrNull()
?: throw IllegalArgumentException("Invalid ledger entry type")
}
val usageType = request.queryParameters["usageType"]?.let {
when (it) {
"polish" -> AdminUsageType.POLISH
"asr" -> AdminUsageType.ASR
"ai" -> AdminUsageType.AI
"agent" -> AdminUsageType.AGENT
"hotword" -> AdminUsageType.HOTWORD
else -> throw IllegalArgumentException("Invalid ledger usage type")
}
}
val referenceId = request.queryParameters["referenceId"]?.let {
runCatching { UUID.fromString(it) }.getOrNull()
?: throw IllegalArgumentException("Invalid ledger reference ID")
}
val sort = request.queryParameters["sort"]?.let {
when (it) {
"createdAt" -> AdminLedgerSort.CREATED_AT
"amount" -> AdminLedgerSort.AMOUNT
else -> throw IllegalArgumentException("Invalid ledger sort")
}
} ?: AdminLedgerSort.CREATED_AT
return AdminLedgerQuery(
time = adminTimeFilter(),
type = type,
entryType = entryType,
usageType = usageType,
referenceId = referenceId,
sort = sort,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
)
}
@@ -962,21 +1006,34 @@ private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
AdminLedgerResponse(
entryId = id,
userId = userId,
type = when (type) {
"USAGE_RESERVE" -> "reserve"
"USAGE_SETTLE" -> "settle"
"USAGE_RELEASE", "USAGE_REFUND" -> "refund"
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant"
else -> "adjustment"
},
type = entryType.toAdminLedgerType(),
entryType = entryType.name,
amount = amountDelta,
balanceAfter = balanceAfter,
reasonCode = type,
reasonCode = entryType.name,
referenceId = referenceId,
usageType = usageType,
details = details,
createdAt = createdAt,
)
private fun LedgerEntryType.toAdminLedgerType(): String =
when (this) {
LedgerEntryType.USAGE_RESERVE -> "reserve"
LedgerEntryType.USAGE_SETTLE -> "settle"
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> "refund"
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.STOREKIT_PURCHASE,
LedgerEntryType.SUBSCRIPTION_GRANT,
-> "grant"
}
private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse =
AdminOperatorResponse(
operatorId = id.toString(),
@@ -1162,10 +1219,13 @@ private data class AdminLedgerResponse(
val entryId: String,
val userId: String,
val type: String,
val entryType: String,
val amount: Long,
val balanceAfter: Long,
val reasonCode: String,
val referenceId: String?,
val usageType: String?,
val details: AdminLedgerDetailsDto?,
val createdAt: String,
)
@@ -1,6 +1,7 @@
package com.osglab.account.features.admin.users.models
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.credits.domain.LedgerEntryType
import kotlinx.serialization.Serializable
@Serializable
@@ -27,12 +28,28 @@ data class AdminUserPageDto(
data class AdminUserLedgerEntryDto(
val id: String,
val userId: String,
val type: String,
val entryType: LedgerEntryType,
val amountDelta: Long,
val balanceAfter: Long,
val referenceId: String?,
val createdAt: String,
val usageType: String? = null,
val details: AdminLedgerDetailsDto? = null,
)
@Serializable
data class AdminLedgerDetailsDto(
val kind: String,
val reason: String? = null,
val operatorName: String? = null,
val productId: String? = null,
val transactionId: String? = null,
val originalTransactionId: String? = null,
val environment: String? = null,
val purchasedAt: String? = null,
val role: String? = null,
val relatedUserId: String? = null,
val reservationId: String? = null,
)
@Serializable
@@ -4,6 +4,7 @@ import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
@@ -19,11 +20,15 @@ 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.innerJoin
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.neq
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
import java.time.Instant
import java.util.UUID
@@ -34,9 +39,18 @@ data class AdminUserCursor(
)
data class AdminUserLedgerCursor(
val createdAt: Instant,
val sort: AdminLedgerSort,
val createdAt: Instant? = null,
val amount: Long? = null,
val ledgerEntryId: UUID,
)
) {
init {
require(
(sort == AdminLedgerSort.CREATED_AT && createdAt != null && amount == null) ||
(sort == AdminLedgerSort.AMOUNT && amount != null && createdAt == null),
) { "Ledger cursor value does not match its sort" }
}
}
enum class AdminUserStatus {
ACTIVE,
@@ -65,12 +79,30 @@ enum class AdminLedgerType(
LedgerEntryType.SUBSCRIPTION_GRANT,
),
),
ADJUSTMENT(emptySet()),
}
enum class AdminLedgerSort {
CREATED_AT,
AMOUNT,
}
enum class AdminUsageType(
internal val databaseValue: String,
) {
POLISH("POLISH"),
ASR("ASR"),
AI("AI"),
AGENT("AGENT"),
HOTWORD("HOTWORD"),
}
data class AdminLedgerQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val type: AdminLedgerType? = null,
val entryType: LedgerEntryType? = null,
val usageType: AdminUsageType? = null,
val referenceId: UUID? = null,
val sort: AdminLedgerSort = AdminLedgerSort.CREATED_AT,
val order: AdminSortOrder = AdminSortOrder.DESC,
)
@@ -217,13 +249,19 @@ class ExposedAdminUsersRepository(
)
}
.sortedBy(AdminUsageAggregateDto::kind)
val recentLedger = support.ledger.filter { it.userId == userId }
val recentLedgerRows = support.ledger.filter { it.userId == userId }
.sortedWith(
compareByDescending<UserLedgerRow>(UserLedgerRow::createdAt)
.thenByDescending { it.id.toString() },
)
.take(ledgerLimit)
.map { it.toDto(support.ledgerUsageTypes[it.referenceId]) }
val recentTrace = loadLedgerTrace(recentLedgerRows)
val recentLedger = recentLedgerRows.map { row ->
row.toDto(
usageType = support.ledgerUsageTypes[row.referenceId],
details = recentTrace.detailsFor(row),
)
}
AdminUserDetailDto(
summary = account.toSummary(support),
referralCode = findReferralCode(userId),
@@ -245,44 +283,7 @@ class ExposedAdminUsersRepository(
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
val statement = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
}
query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
}
if (cursor != null) {
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
}
val sortOrder = query.order.toExposedSortOrder()
val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
)
.limit(limit)
.map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
loadLedgerEntries(userId, limit, cursor, query)
}
override suspend fun listLatestLedger(
@@ -290,8 +291,30 @@ class ExposedAdminUsersRepository(
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
val statement = AdminUsersCreditLedgerTable.selectAll()
loadLedgerEntries(null, limit, cursor, query)
}
}
private fun loadLedgerEntries(
userId: UUID?,
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> {
val statement = if (query.usageType == null) {
AdminUsersCreditLedgerTable.selectAll()
} else {
AdminUsersCreditLedgerTable
.innerJoin(
otherTable = AdminUsersProviderRequestsTable,
onColumn = { referenceId },
otherColumn = { reservationId },
)
.select(AdminUsersCreditLedgerTable.columns)
}
userId?.let { id ->
statement.andWhere { AdminUsersCreditLedgerTable.userId eq id.toString() }
}
query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
}
@@ -301,32 +324,87 @@ class ExposedAdminUsersRepository(
query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
}
if (cursor != null) {
query.entryType?.let { entryType ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType eq entryType }
}
query.referenceId?.let { referenceId ->
statement.andWhere { AdminUsersCreditLedgerTable.referenceId eq referenceId.toString() }
}
query.usageType?.let { usageType ->
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())
)
if (usageType == AdminUsageType.HOTWORD) {
AdminUsersProviderRequestsTable.requestSource eq usageType.databaseValue
} else {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
AdminUsersProviderRequestsTable.requestSource.isNull() or
(AdminUsersProviderRequestsTable.requestSource neq AdminUsageType.HOTWORD.databaseValue)
) and
(AdminUsersProviderRequestsTable.capability eq usageType.databaseValue)
}
}
}
cursor?.let {
require(it.sort == query.sort) { "Ledger cursor sort does not match query" }
statement.andWhere { ledgerAfterCursor(it, query) }
}
val sortOrder = query.order.toExposedSortOrder()
val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
val sortColumn = when (query.sort) {
AdminLedgerSort.CREATED_AT -> AdminUsersCreditLedgerTable.createdAt
AdminLedgerSort.AMOUNT -> AdminUsersCreditLedgerTable.amountDelta
}
val ledger = statement
.orderBy(
sortColumn to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
)
.limit(limit)
.map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
val trace = loadLedgerTrace(ledger)
return ledger.map { row ->
row.toDto(
usageType = usageTypes[row.referenceId],
details = trace.detailsFor(row),
)
}
}
private fun ledgerAfterCursor(
cursor: AdminUserLedgerCursor,
query: AdminLedgerQuery,
) = when (query.sort) {
AdminLedgerSort.CREATED_AT -> {
val value = requireNotNull(cursor.createdAt)
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.createdAt greater value) or
(
(AdminUsersCreditLedgerTable.createdAt eq value) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.createdAt less value) or
(
(AdminUsersCreditLedgerTable.createdAt eq value) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
AdminLedgerSort.AMOUNT -> {
val value = requireNotNull(cursor.amount)
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.amountDelta greater value) or
(
(AdminUsersCreditLedgerTable.amountDelta eq value) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.amountDelta less value) or
(
(AdminUsersCreditLedgerTable.amountDelta eq value) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
}
@@ -401,12 +479,164 @@ private fun loadLedgerUsageTypes(ledger: List<UserLedgerRow>): Map<UUID, String>
}
.associate { row ->
UUID.fromString(requireNotNull(row[AdminUsersProviderRequestsTable.reservationId])) to
(
row[AdminUsersProviderRequestsTable.requestSource]
?: row[AdminUsersProviderRequestsTable.capability]
).lowercase()
if (
row[AdminUsersProviderRequestsTable.requestSource] ==
AdminUsageType.HOTWORD.databaseValue
) {
AdminUsageType.HOTWORD.name.lowercase()
} else {
row[AdminUsersProviderRequestsTable.capability].lowercase()
}
}
}
private data class ManualGrantTrace(
val reason: String,
val operatorName: String,
)
private data class StoreKitTrace(
val productId: String,
val transactionId: String,
val originalTransactionId: String,
val environment: String,
val purchasedAt: Instant,
)
private data class ReferralTrace(
val inviterUserId: UUID,
val inviteeUserId: UUID,
)
private data class LedgerTrace(
val manualGrants: Map<UUID, ManualGrantTrace>,
val storeKitPurchases: Map<UUID, StoreKitTrace>,
val referrals: Map<UUID, ReferralTrace>,
) {
fun detailsFor(row: UserLedgerRow): AdminLedgerDetailsDto? =
when (row.type) {
LedgerEntryType.MANUAL_GRANT -> manualGrants[row.id]?.let {
AdminLedgerDetailsDto(
kind = "manualGrant",
reason = it.reason,
operatorName = it.operatorName,
)
}
LedgerEntryType.STOREKIT_PURCHASE -> storeKitPurchases[row.id]?.let {
AdminLedgerDetailsDto(
kind = "storeKit",
productId = it.productId,
transactionId = it.transactionId,
originalTransactionId = it.originalTransactionId,
environment = it.environment,
purchasedAt = it.purchasedAt.toString(),
)
}
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
-> row.referenceId?.let(referrals::get)?.let { referral ->
val inviter = row.type == LedgerEntryType.REFERRAL_INVITER
AdminLedgerDetailsDto(
kind = "referral",
role = if (inviter) "inviter" else "invitee",
relatedUserId = (
if (inviter) referral.inviteeUserId else referral.inviterUserId
).toString(),
)
}
LedgerEntryType.USAGE_RESERVE,
LedgerEntryType.USAGE_SETTLE,
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> row.referenceId?.let {
AdminLedgerDetailsDto(
kind = "usage",
reservationId = it.toString(),
)
}
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.SUBSCRIPTION_GRANT,
-> null
}
}
private fun loadLedgerTrace(ledger: List<UserLedgerRow>): LedgerTrace {
if (ledger.isEmpty()) return LedgerTrace(emptyMap(), emptyMap(), emptyMap())
val ledgerIds = ledger.map { it.id.toString() }
val manualGrants = AdminUsersAdminCreditGrantsTable
.innerJoin(
otherTable = AdminUsersAdminOperatorsTable,
onColumn = { operatorId },
otherColumn = { id },
)
.select(
AdminUsersAdminCreditGrantsTable.ledgerEntryId,
AdminUsersAdminCreditGrantsTable.reason,
AdminUsersAdminOperatorsTable.username,
)
.where { AdminUsersAdminCreditGrantsTable.ledgerEntryId inList ledgerIds }
.associate { row ->
UUID.fromString(row[AdminUsersAdminCreditGrantsTable.ledgerEntryId]) to
ManualGrantTrace(
reason = row[AdminUsersAdminCreditGrantsTable.reason],
operatorName = row[AdminUsersAdminOperatorsTable.username],
)
}
val storeKitPurchases = AdminUsersStoreKitCreditPurchasesTable
.select(
AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId,
AdminUsersStoreKitCreditPurchasesTable.productId,
AdminUsersStoreKitCreditPurchasesTable.transactionId,
AdminUsersStoreKitCreditPurchasesTable.originalTransactionId,
AdminUsersStoreKitCreditPurchasesTable.environment,
AdminUsersStoreKitCreditPurchasesTable.purchasedAt,
)
.where { AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId inList ledgerIds }
.associate { row ->
UUID.fromString(row[AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId]) to
StoreKitTrace(
productId = row[AdminUsersStoreKitCreditPurchasesTable.productId],
transactionId = row[AdminUsersStoreKitCreditPurchasesTable.transactionId],
originalTransactionId =
row[AdminUsersStoreKitCreditPurchasesTable.originalTransactionId],
environment = row[AdminUsersStoreKitCreditPurchasesTable.environment],
purchasedAt = row[AdminUsersStoreKitCreditPurchasesTable.purchasedAt],
)
}
val referralIds = ledger.asSequence()
.filter {
it.type == LedgerEntryType.REFERRAL_INVITER ||
it.type == LedgerEntryType.REFERRAL_INVITEE
}
.mapNotNull(UserLedgerRow::referenceId)
.map(UUID::toString)
.distinct()
.toList()
val referrals = if (referralIds.isEmpty()) {
emptyMap()
} else {
AdminUsersReferralBindingsTable.select(
AdminUsersReferralBindingsTable.id,
AdminUsersReferralBindingsTable.inviterUserId,
AdminUsersReferralBindingsTable.inviteeUserId,
)
.where { AdminUsersReferralBindingsTable.id inList referralIds }
.associate { row ->
UUID.fromString(row[AdminUsersReferralBindingsTable.id]) to
ReferralTrace(
inviterUserId =
UUID.fromString(row[AdminUsersReferralBindingsTable.inviterUserId]),
inviteeUserId =
UUID.fromString(row[AdminUsersReferralBindingsTable.inviteeUserId]),
)
}
}
return LedgerTrace(manualGrants, storeKitPurchases, referrals)
}
private fun findReferralCode(userId: UUID): String? =
AdminUsersReferralCodesTable.selectAll()
@@ -473,6 +703,26 @@ private object AdminUsersProviderRequestsTable : Table("provider_requests") {
override val primaryKey = PrimaryKey(accountId, requestId)
}
private object AdminUsersAdminCreditGrantsTable : Table("admin_credit_grants") {
val operatorId = varchar("operator_id", 36)
val reason = varchar("reason", 500)
val ledgerEntryId = varchar("ledger_entry_id", 36)
}
private object AdminUsersAdminOperatorsTable : Table("admin_operators") {
val id = varchar("id", 36)
val username = varchar("username", 64)
}
private object AdminUsersStoreKitCreditPurchasesTable : Table("storekit_credit_purchases") {
val transactionId = varchar("transaction_id", 64)
val originalTransactionId = varchar("original_transaction_id", 64)
val productId = varchar("product_id", 128)
val environment = varchar("environment", 16)
val ledgerEntryId = varchar("ledger_entry_id", 36)
val purchasedAt = timestamp("purchased_at")
}
private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
val userId = varchar("user_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
@@ -484,6 +734,7 @@ private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
}
private object AdminUsersReferralBindingsTable : Table("referral_bindings") {
val id = varchar("id", 36)
val inviterUserId = varchar("inviter_user_id", 36)
val inviteeUserId = varchar("invitee_user_id", 36)
val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24)
@@ -508,14 +759,18 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
)
private fun UserLedgerRow.toDto(usageType: String?) = AdminUserLedgerEntryDto(
private fun UserLedgerRow.toDto(
usageType: String?,
details: AdminLedgerDetailsDto?,
) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = type.name,
entryType = type,
amountDelta = amountDelta,
balanceAfter = balanceAfter,
referenceId = referenceId?.toString(),
usageType = usageType,
details = details,
createdAt = createdAt.toString(),
)
@@ -5,6 +5,7 @@ import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.models.AdminUserPageDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
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
@@ -106,7 +107,9 @@ class AdminUsersService(
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
val decodedCursor = cursor?.let { AdminUserLedgerCursorCodec.decode(it, query.order) }
val decodedCursor = cursor?.let {
AdminUserLedgerCursorCodec.decode(it, query.sort, query.order)
}
val results = load(limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
@@ -114,9 +117,14 @@ class AdminUsersService(
val last = items.last()
AdminUserLedgerCursorCodec.encode(
AdminUserLedgerCursor(
createdAt = Instant.parse(last.createdAt),
sort = query.sort,
createdAt = last.createdAt.takeIf {
query.sort == AdminLedgerSort.CREATED_AT
}?.let(Instant::parse),
amount = last.amountDelta.takeIf { query.sort == AdminLedgerSort.AMOUNT },
ledgerEntryId = UUID.fromString(last.id),
),
query.sort,
query.order,
)
} else {
@@ -173,13 +181,26 @@ internal object AdminUserCursorCodec {
internal object AdminUserLedgerCursorCodec {
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String {
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}"
fun encode(
cursor: AdminUserLedgerCursor,
sort: AdminLedgerSort,
order: AdminSortOrder,
): String {
require(cursor.sort == sort) { INVALID_CURSOR_MESSAGE }
val sortValue = when (sort) {
AdminLedgerSort.CREATED_AT -> requireNotNull(cursor.createdAt).toString()
AdminLedgerSort.AMOUNT -> requireNotNull(cursor.amount).toString()
}
val value = "v2|${sort.name}|${order.name}|$sortValue|${cursor.ledgerEntryId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserLedgerCursor {
fun decode(
value: String,
expectedSort: AdminLedgerSort,
expectedOrder: AdminSortOrder,
): AdminUserLedgerCursor {
require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
return try {
val decoded = String(
@@ -187,13 +208,36 @@ internal object AdminUserLedgerCursorCodec {
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
when (parts.firstOrNull()) {
"v1" -> {
require(expectedSort == AdminLedgerSort.CREATED_AT)
require(parts.size == 4)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminUserLedgerCursor(
sort = AdminLedgerSort.CREATED_AT,
createdAt = Instant.parse(parts[2]),
ledgerEntryId = UUID.fromString(parts[3]),
)
}
"v2" -> {
require(parts.size == 5)
require(parts[1] == expectedSort.name)
require(parts[2] == expectedOrder.name)
AdminUserLedgerCursor(
sort = expectedSort,
createdAt = parts[3].takeIf {
expectedSort == AdminLedgerSort.CREATED_AT
}?.let(Instant::parse),
amount = parts[3].takeIf {
expectedSort == AdminLedgerSort.AMOUNT
}?.toLong(),
ledgerEntryId = UUID.fromString(parts[4]),
)
}
else -> throw IllegalArgumentException(INVALID_CURSOR_MESSAGE)
}
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure)
}
@@ -26,7 +26,6 @@ import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.select
@@ -158,6 +157,14 @@ private object ReferralCodes : Table("referral_codes") {
override val primaryKey = PrimaryKey(id)
}
private object ReferralOwnerCodes : Table("referral_owner_codes") {
val ownerUserId = varchar("owner_user_id", 36)
val codeId = varchar("code_id", 36)
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(ownerUserId)
}
private object ReferralBindings : Table("referral_bindings") {
val id = varchar("id", 36)
val inviterUserId = varchar("inviter_user_id", 36)
@@ -465,27 +472,62 @@ private object ExposedCreditsRepository : CreditsRepository {
}
private object ExposedReferralsRepository : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? {
val query = ReferralCodes
override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? =
ReferralOwnerCodes
.innerJoin(
otherTable = ReferralCodes,
onColumn = { codeId },
otherColumn = { id },
)
.selectAll()
.where { ReferralCodes.ownerUserId eq ownerUserId.toString() }
return if (campaignId == null) {
query.orderBy(ReferralCodes.createdAt, SortOrder.DESC).limit(1).singleOrNull()
} else {
query.andWhere { ReferralCodes.campaignId eq campaignId.toString() }.singleOrNull()
}?.toReferralCode()
}
.where { ReferralOwnerCodes.ownerUserId eq ownerUserId.toString() }
.singleOrNull()
?.toReferralCode()
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
override fun claimPermanentCode(candidate: ReferralCode): ReferralCode? {
findPermanentCodeByOwner(candidate.ownerUserId)?.let { return it }
val inserted = ReferralCodes.insertIgnore {
it[id] = candidate.id.toString()
it[ownerUserId] = candidate.ownerUserId.toString()
it[ownerIdentityFingerprint] = candidate.ownerIdentityFingerprint
it[campaignId] = (candidate.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
it[code] = candidate.code
it[createdAt] = candidate.createdAt
}.insertedCount == 1
val storedCode = if (inserted) {
candidate
} else {
ReferralCodes
.selectAll()
.where {
(ReferralCodes.ownerUserId eq ownerUserId.toString()) and
(ReferralCodes.campaignId eq campaignId.toString())
(ReferralCodes.ownerUserId eq candidate.ownerUserId.toString()) and
(
ReferralCodes.campaignId eq
(candidate.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
)
}
.forUpdate()
.singleOrNull()
?.toReferralCode()
?: return null
}
ReferralOwnerCodes.insertIgnore {
it[ownerUserId] = candidate.ownerUserId.toString()
it[codeId] = storedCode.id.toString()
it[createdAt] = storedCode.createdAt
}
return ReferralOwnerCodes
.innerJoin(
otherTable = ReferralCodes,
onColumn = { codeId },
otherColumn = { id },
)
.selectAll()
.where { ReferralOwnerCodes.ownerUserId eq candidate.ownerUserId.toString() }
.forUpdate()
.single()
.toReferralCode()
}
override fun findCode(code: String): ReferralCode? =
ReferralCodes
@@ -494,16 +536,6 @@ private object ExposedReferralsRepository : ReferralsRepository {
.singleOrNull()
?.toReferralCode()
override fun insertCodeIfAbsent(code: ReferralCode): Boolean =
ReferralCodes.insertIgnore {
it[id] = code.id.toString()
it[ownerUserId] = code.ownerUserId.toString()
it[ownerIdentityFingerprint] = code.ownerIdentityFingerprint
it[campaignId] = (code.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
it[ReferralCodes.code] = code.code
it[createdAt] = code.createdAt
}.insertedCount == 1
override fun findCampaign(id: UUID): ReferralCampaign? =
ReferralCampaigns
.selectAll()
@@ -299,7 +299,16 @@ class ExposedGatewayRepository(
): ComplimentaryRequestClaim? = databaseFactory.query {
val now = clock.instant()
val expiresAt = now.plus(COMPLIMENTARY_CLAIM_TTL)
val inserted = ComplimentaryRequestsTable.insertIgnore {
val reclaimed = ComplimentaryRequestsTable.update({
complimentaryKey(accountId, purpose, capability) and
(ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED) and
(ComplimentaryRequestsTable.expiresAt lessEq now)
}) {
it[ComplimentaryRequestsTable.requestId] = requestId
it[ComplimentaryRequestsTable.expiresAt] = expiresAt
it[updatedAt] = now
} == 1
val inserted = !reclaimed && ComplimentaryRequestsTable.insertIgnore {
it[ComplimentaryRequestsTable.accountId] = accountId
it[ComplimentaryRequestsTable.purpose] = purpose.name
it[ComplimentaryRequestsTable.capability] = capability.name
@@ -309,19 +318,6 @@ class ExposedGatewayRepository(
it[createdAt] = now
it[updatedAt] = now
}.insertedCount == 1
val reclaimed = if (!inserted) {
ComplimentaryRequestsTable.update({
complimentaryKey(accountId, purpose, capability) and
(ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED) and
(ComplimentaryRequestsTable.expiresAt lessEq now)
}) {
it[ComplimentaryRequestsTable.requestId] = requestId
it[ComplimentaryRequestsTable.expiresAt] = expiresAt
it[updatedAt] = now
} == 1
} else {
false
}
if (!inserted && !reclaimed) return@query null
ComplimentaryRequestClaim(accountId, purpose, capability, requestId)
}
@@ -20,8 +20,8 @@ import java.util.Base64
/**
* Read-only boundary used by the public page to verify a referral code.
*
* Implementations must preserve case, apply campaign validity rules, use a bounded database query,
* and never log the code.
* Implementations must preserve case, treat issued codes as permanent, use a bounded database
* query, and never log the code.
*/
fun interface ReferralLookupPort {
suspend fun isValid(code: String): Boolean
@@ -193,7 +193,7 @@ private suspend fun ApplicationCall.respondAasa(aasa: String) {
private suspend fun ApplicationCall.respondInvalidInvitation() {
respondText(
text = "邀请链接无效或已失效 / This invitation link is invalid or expired",
text = "邀请链接无效 / This invitation link is invalid",
contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8),
status = HttpStatusCode.NotFound,
)
@@ -14,12 +14,14 @@ data class BindReferralRequest(
@Serializable
data class ReferralCodeDto(
val code: String,
val inviteUrl: String,
val campaignId: String?,
val createdAt: String,
) {
companion object {
fun fromDomain(value: ReferralCode) = ReferralCodeDto(
fun fromDomain(value: ReferralCode, inviteBaseUrl: String) = ReferralCodeDto(
code = value.code,
inviteUrl = "${inviteBaseUrl.trimEnd('/')}/${value.code}",
campaignId = value.campaignId?.toString(),
createdAt = value.createdAt.toString(),
)
@@ -66,12 +68,12 @@ data class ReferralCampaignDto(
@Serializable
data class ReferralProfileDto(
val code: ReferralCodeDto?,
val code: ReferralCodeDto,
val binding: ReferralBindingDto?,
) {
companion object {
fun fromDomain(value: ReferralProfile) = ReferralProfileDto(
code = value.code?.let(ReferralCodeDto::fromDomain),
fun fromDomain(value: ReferralProfile, inviteBaseUrl: String) = ReferralProfileDto(
code = ReferralCodeDto.fromDomain(value.code, inviteBaseUrl),
binding = value.binding?.let(ReferralBindingDto::fromDomain),
)
}
@@ -8,14 +8,16 @@ import java.time.Instant
import java.util.UUID
interface ReferralsRepository {
fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID? = null): ReferralCode?
fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode?
fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode?
/**
* Atomically returns the account's existing permanent code or claims [candidate].
* Returns null only when the candidate code collided and the caller should retry.
*/
fun claimPermanentCode(candidate: ReferralCode): ReferralCode?
fun findCode(code: String): ReferralCode?
fun insertCodeIfAbsent(code: ReferralCode): Boolean
fun findCampaign(id: UUID): ReferralCampaign?
fun listActiveCampaigns(at: Instant): List<ReferralCampaign>
@@ -26,13 +26,14 @@ import java.util.UUID
class ReferralRouteInstaller(
private val service: ReferralOperations,
private val inviteBaseUrl: String,
private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
fun install(parent: Route) {
parent.route("/v1/referrals") {
get("/me") {
call.referralCall(authenticatedUser) { userId ->
ReferralProfileDto.fromDomain(service.getProfile(userId))
ReferralProfileDto.fromDomain(service.getProfile(userId), inviteBaseUrl)
}
}
get {
@@ -58,7 +59,7 @@ class ReferralRouteInstaller(
}
post("/code") {
call.referralCall(authenticatedUser) { userId ->
ReferralCodeDto.fromDomain(service.getOrCreateCode(userId))
ReferralCodeDto.fromDomain(service.getOrCreateCode(userId), inviteBaseUrl)
}
}
post("/bind") {
@@ -73,9 +74,10 @@ class ReferralRouteInstaller(
fun Route.referralRoutes(
service: ReferralOperations,
inviteBaseUrl: String,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
ReferralRouteInstaller(service, authenticatedUser).install(this)
ReferralRouteInstaller(service, inviteBaseUrl, authenticatedUser).install(this)
}
private suspend fun ApplicationCall.referralCall(
@@ -1,6 +1,7 @@
package com.osglab.account.features.referrals.services
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.InvalidReferralRequest
import com.osglab.account.features.referrals.domain.ReferralBinding
@@ -34,13 +35,13 @@ typealias ReferralRiskIdentity = ReferralRiskAssessment
typealias ReferralRiskProvider = ReferralRiskPort
data class ReferralProfile(
val code: ReferralCode?,
val code: ReferralCode,
val binding: ReferralBinding?,
)
/**
* Public referral boundary. Binding and code creation remain transactionally
* consistent even when callers retry after a timeout.
* Public referral boundary. Each account receives one permanent code, while the
* active campaign is selected only when an invitee redeems that code.
*/
interface ReferralOperations {
suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode
@@ -76,19 +77,22 @@ class ReferralService(
}
override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode {
transactions.inTransaction { unit ->
unit.referrals.findPermanentCodeByOwner(ownerUserId)
}?.let { return it }
val ownerIdentity = requireEligibleIdentity(ownerUserId)
return transactions.inTransaction { unit ->
val now = clock.instant()
val campaign = if (campaignId == null) {
unit.referrals.listActiveCampaigns(now).firstOrNull()
?: throw ReferralNotFound("No active referral campaign exists")
unit.referrals.findPermanentCodeByOwner(ownerUserId)?.let {
return@inTransaction it
}
val storageCampaignId = if (campaignId == null) {
DEFAULT_REFERRAL_CAMPAIGN_ID
} else {
unit.referrals.findCampaign(campaignId)
?: throw ReferralNotFound("Referral campaign does not exist")
}
if (!campaign.isActive(now)) throw ReferralNotFound("Referral campaign is not active")
unit.referrals.findCodeByOwner(ownerUserId, campaign.id)?.let {
return@inTransaction it
campaignId
}
repeat(MAX_CODE_ATTEMPTS) {
val candidate = ReferralCode(
@@ -97,15 +101,14 @@ class ReferralService(
ownerIdentityFingerprint = ownerIdentity.identityFingerprint,
code = codeGenerator.generate(),
createdAt = now,
campaignId = campaign.id,
// The campaign column is retained for historical compatibility only.
// A referral code now belongs to the account for its entire lifetime.
campaignId = storageCampaignId,
)
if (candidate.code.length < 20) {
throw IllegalStateException("Invite code generator must provide at least 120 bits")
}
if (unit.referrals.insertCodeIfAbsent(candidate)) {
return@inTransaction candidate
}
unit.referrals.lockCodeByOwner(ownerUserId, campaign.id)?.let {
unit.referrals.claimPermanentCode(candidate)?.let {
return@inTransaction it
}
}
@@ -123,7 +126,11 @@ class ReferralService(
else throw ReferralConflict("This account is already bound to another inviter")
}
if (existing != null) return existing
val referralCode = transactions.inTransaction { unit ->
unit.referrals.findCode(code)
} ?: throw ReferralNotFound("Referral code does not exist")
val inviteeIdentity = requireEligibleIdentity(inviteeUserId)
requireEligibleIdentity(referralCode.ownerUserId)
val registeredAt = registrationTimeProvider.registeredAt(inviteeUserId)
?: throw ReferralNotFound("Registration time is unavailable")
val now = clock.instant()
@@ -136,15 +143,11 @@ class ReferralService(
}
val referralCode = unit.referrals.findCode(code)
?: throw ReferralNotFound("Referral code does not exist")
val campaign = referralCode.campaignId
?.let(unit.referrals::findCampaign)
if (campaign != null && !campaign.isActive(now)) {
throw ReferralNotFound("Referral campaign is not active")
}
val effectiveWindow = campaign
?.bindingWindowSeconds
?.let(Duration::ofSeconds)
?: bindingWindow
// Campaigns define the reward policy at redemption time; they no longer
// define the lifetime of the account's permanent invitation code.
val campaign = unit.referrals.listActiveCampaigns(now).firstOrNull()
?: throw ReferralNotFound("No active referral campaign exists")
val effectiveWindow = Duration.ofSeconds(campaign.bindingWindowSeconds)
if (!ReferralBindingRules.isWithinWindow(registeredAt, now, effectiveWindow)) {
throw ReferralWindowExpired()
}
@@ -164,7 +167,7 @@ class ReferralService(
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
campaignId = referralCode.campaignId,
campaignId = campaign.id,
)
if (unit.referrals.insertBindingIfAbsent(binding)) {
binding
@@ -0,0 +1,17 @@
ALTER TABLE credit_ledger
MODIFY reference_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL;
ALTER TABLE provider_requests
MODIFY reservation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL;
CREATE INDEX idx_credit_ledger_amount_id
ON credit_ledger (amount_delta, id);
CREATE INDEX idx_credit_ledger_user_amount_id
ON credit_ledger (user_id, amount_delta, id);
CREATE INDEX idx_provider_requests_capability_source_reservation
ON provider_requests (capability, request_source, reservation_id);
CREATE INDEX idx_provider_requests_source_capability_reservation
ON provider_requests (request_source, capability, reservation_id);
@@ -0,0 +1,29 @@
-- Keep historical campaign-scoped codes as valid aliases while selecting exactly
-- one permanent code for every account. New writes claim this mapping atomically.
CREATE TABLE referral_owner_codes (
owner_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
code_id CHAR(36) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (owner_user_id),
UNIQUE KEY uk_referral_owner_codes_code (code_id),
CONSTRAINT fk_referral_owner_codes_code
FOREIGN KEY (code_id) REFERENCES referral_codes (id) ON DELETE CASCADE
) ENGINE = InnoDB;
-- Preserve the code most recently distributed by the previous implementation.
-- Older codes remain in referral_codes so already-shared links never break.
INSERT INTO referral_owner_codes (owner_user_id, code_id, created_at)
SELECT candidate.owner_user_id, candidate.id, candidate.created_at
FROM referral_codes candidate
WHERE NOT EXISTS (
SELECT 1
FROM referral_codes newer
WHERE newer.owner_user_id = candidate.owner_user_id
AND (
newer.created_at > candidate.created_at
OR (
newer.created_at = candidate.created_at
AND newer.id > candidate.id
)
)
);
@@ -39,6 +39,36 @@ class DeploymentConsistencyTest : FunSpec({
openApi shouldContain "referralCode"
}
test("admin ledger operations stay indexed exact and privacy minimized") {
val migration = root.read(
"src/main/resources/db/migration/V19__admin_ledger_operations.sql",
)
migration shouldContain
"MODIFY reference_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL"
migration shouldContain
"MODIFY reservation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL"
migration shouldContain "ON credit_ledger (amount_delta, id)"
migration shouldContain "ON credit_ledger (user_id, amount_delta, id)"
migration shouldContain "ON provider_requests (capability, request_source, reservation_id)"
migration shouldContain "ON provider_requests (request_source, capability, reservation_id)"
val openApi = root.read("docs/openapi.yaml")
val ledgerSchema = openApi
.substringAfter(" LedgerEntryType:")
.substringBefore(" AdminLedgerPage:")
LEDGER_ENTRY_TYPE_NAMES.forEach(ledgerSchema::shouldContain)
ledgerSchema shouldContain "deprecated: true"
ledgerSchema shouldContain "referenceId"
ledgerSchema shouldContain "reservationId"
ledgerSchema shouldContain
"required: [entryId, userId, type, entryType, amount, balanceAfter, reasonCode, createdAt]"
ledgerSchema shouldNotContain "enum: [grant, reserve, settle, refund, adjustment]"
ledgerSchema shouldNotContain "idempotencyKey"
ledgerSchema shouldNotContain "appAccountToken"
ledgerSchema shouldNotContain "signedTransaction"
ledgerSchema shouldNotContain "appleSubject"
}
test("provider defaults and Apple integrity contract stay production compatible") {
val providerConfigurations = listOf(
root.read("src/main/kotlin/com/osglab/account/config/AppConfig.kt"),
@@ -248,6 +278,19 @@ class DeploymentConsistencyTest : FunSpec({
private fun Path.read(relativePath: String): String =
Files.readString(resolve(relativePath))
private val LEDGER_ENTRY_TYPE_NAMES = listOf(
"SIGNUP_TRIAL",
"MANUAL_GRANT",
"USAGE_RESERVE",
"USAGE_SETTLE",
"USAGE_RELEASE",
"USAGE_REFUND",
"REFERRAL_INVITER",
"REFERRAL_INVITEE",
"STOREKIT_PURCHASE",
"SUBSCRIPTION_GRANT",
)
private val EXPECTED_PUBLIC_PATHS = setOf(
"/health",
"/health/live",
@@ -13,12 +13,19 @@ import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
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.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.statement.bodyAsText
import io.ktor.client.request.get
@@ -40,7 +47,9 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
import kotlin.test.Test
@@ -231,12 +240,16 @@ class AdminRoutesTest {
AdminUserLedgerEntryDto(
id = "ffffffff-ffff-ffff-ffff-ffffffffffff",
userId = "11111111-1111-4111-8111-111111111111",
type = "USAGE_SETTLE",
entryType = LedgerEntryType.USAGE_SETTLE,
amountDelta = -18,
balanceAfter = 102,
referenceId = null,
referenceId = "22222222-2222-4222-8222-222222222222",
createdAt = "2026-08-19T09:00:00Z",
usageType = "hotword",
details = AdminLedgerDetailsDto(
kind = "usage",
reservationId = "22222222-2222-4222-8222-222222222222",
),
),
),
nextCursor = null,
@@ -255,8 +268,136 @@ class AdminRoutesTest {
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111""""
response.bodyAsText() shouldContain """"entryType":"USAGE_SETTLE""""
response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE""""
response.bodyAsText() shouldContain """"referenceId":"22222222-2222-4222-8222-222222222222""""
response.bodyAsText() shouldContain """"usageType":"hotword""""
response.bodyAsText() shouldContain """"kind":"usage""""
}
@Test
fun `ledger response maps all exact entry types and privacy safe trace details`() = testApplication {
val usersService = mockk<AdminUsersService>()
val entries = LedgerEntryType.entries.mapIndexed { index, entryType ->
AdminUserLedgerEntryDto(
id = UUID.nameUUIDFromBytes("ledger-$index".toByteArray()).toString(),
userId = "11111111-1111-4111-8111-111111111111",
entryType = entryType,
amountDelta = if (entryType.name.startsWith("USAGE")) -10 else 10,
balanceAfter = 100,
referenceId = null,
createdAt = "2026-08-19T09:00:00Z",
details = when (entryType) {
LedgerEntryType.MANUAL_GRANT -> AdminLedgerDetailsDto(
kind = "manualGrant",
reason = "customer recovery",
operatorName = "support",
)
LedgerEntryType.STOREKIT_PURCHASE -> AdminLedgerDetailsDto(
kind = "storeKit",
productId = "credits.100",
transactionId = "2000000000001",
originalTransactionId = "2000000000001",
environment = "SANDBOX",
purchasedAt = "2026-08-19T08:59:00Z",
)
LedgerEntryType.REFERRAL_INVITER -> AdminLedgerDetailsDto(
kind = "referral",
role = "inviter",
relatedUserId = "22222222-2222-4222-8222-222222222222",
)
else -> null
},
)
} + AdminUserLedgerEntryDto(
id = UUID.randomUUID().toString(),
userId = "11111111-1111-4111-8111-111111111111",
entryType = LedgerEntryType.MANUAL_GRANT,
amountDelta = 10,
balanceAfter = 110,
referenceId = null,
createdAt = "2026-08-19T09:01:00Z",
details = null,
)
coEvery { usersService.latestLedger(any(), any(), any()) } returns
AdminUserLedgerPageDto(entries, null)
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPPORT),
usersService = usersService,
)
}
val response = client.get("/v1/admin/credits/ledger") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
LedgerEntryType.entries.forEach { entryType ->
val coarse = when (entryType) {
LedgerEntryType.USAGE_RESERVE -> "reserve"
LedgerEntryType.USAGE_SETTLE -> "settle"
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> "refund"
else -> "grant"
}
body shouldContain """"type":"$coarse","entryType":"${entryType.name}""""
body shouldContain """"reasonCode":"${entryType.name}""""
}
body shouldContain """"kind":"manualGrant""""
body shouldContain """"operatorName":"support""""
body shouldContain """"kind":"storeKit""""
body shouldContain """"kind":"referral""""
}
@Test
fun `ledger route forwards strict combined filters and amount sort`() = testApplication {
val usersService = mockk<AdminUsersService>()
val captured = slot<AdminLedgerQuery>()
coEvery { usersService.latestLedger(100, null, capture(captured)) } returns
AdminUserLedgerPageDto(emptyList(), null)
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPPORT),
usersService = usersService,
)
}
val referenceId = "22222222-2222-4222-8222-222222222222"
val response = client.get(
"/v1/admin/credits/ledger" +
"?from=2026-08-19T00:00:00Z" +
"&until=2026-08-20T00:00:00Z" +
"&type=settle" +
"&entryType=USAGE_SETTLE" +
"&usageType=hotword" +
"&referenceId=$referenceId" +
"&sort=amount&order=asc",
) {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
captured.captured shouldBe AdminLedgerQuery(
time = com.osglab.account.features.admin.models.AdminTimeFilter(
from = Instant.parse("2026-08-19T00:00:00Z"),
until = Instant.parse("2026-08-20T00:00:00Z"),
),
type = AdminLedgerType.SETTLE,
entryType = LedgerEntryType.USAGE_SETTLE,
usageType = AdminUsageType.HOTWORD,
referenceId = UUID.fromString(referenceId),
sort = AdminLedgerSort.AMOUNT,
order = com.osglab.account.features.admin.models.AdminSortOrder.ASC,
)
}
@Test
@@ -269,6 +410,11 @@ class AdminRoutesTest {
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/credits/ledger?type=adjustment",
"/v1/admin/credits/ledger?entryType=usage_settle",
"/v1/admin/credits/ledger?usageType=voice",
"/v1/admin/credits/ledger?referenceId=not-a-uuid",
"/v1/admin/credits/ledger?sort=balance",
"/v1/admin/operators?enabled=1",
"/v1/admin/audit?action=NOT_AN_ACTION",
"/v1/admin/referrals?range=30d&limit=101",
@@ -0,0 +1,588 @@
package com.osglab.account.features.admin.users
import com.osglab.account.config.DatabaseConfig
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.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.sql.Connection
import java.sql.DriverManager
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.UUID
class AdminUsersRepositoryIntegrationTest : FunSpec({
val fixture = lazy(::startIntegrationDatabase)
afterSpec {
if (fixture.isInitialized()) fixture.value.close()
}
test("amount keyset pagination is stable and V19 indexes have exact column order") {
val database = fixture.value
val userId = database.insertAccount()
val tiedIds = listOf(UUID.randomUUID(), UUID.randomUUID())
.sortedBy(UUID::toString)
val lowAmountId = UUID.randomUUID()
database.insertLedger(
LedgerSeed(lowAmountId, userId, LedgerEntryType.SIGNUP_TRIAL, -5, 95),
LedgerSeed(tiedIds[1], userId, LedgerEntryType.MANUAL_GRANT, 10, 105),
LedgerSeed(tiedIds[0], userId, LedgerEntryType.STOREKIT_PURCHASE, 10, 115),
)
val ascending = database.service.collectLedger(
userId = userId,
limit = 2,
query = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.ASC,
),
)
ascending.map(AdminUserLedgerEntryDto::id) shouldContainExactly
listOf(lowAmountId, tiedIds[0], tiedIds[1]).map(UUID::toString)
ascending.map(AdminUserLedgerEntryDto::id).distinct().size shouldBe 3
val descending = database.service.collectLedger(
userId = userId,
limit = 2,
query = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.DESC,
),
)
descending.map(AdminUserLedgerEntryDto::id) shouldContainExactly
listOf(tiedIds[1], tiedIds[0], lowAmountId).map(UUID::toString)
descending.map(AdminUserLedgerEntryDto::id).distinct().size shouldBe 3
database.indexColumns("credit_ledger", "idx_credit_ledger_amount_id") shouldContainExactly
listOf("amount_delta", "id")
database.indexColumns(
"credit_ledger",
"idx_credit_ledger_user_amount_id",
) shouldContainExactly listOf("user_id", "amount_delta", "id")
database.indexColumns(
"provider_requests",
"idx_provider_requests_capability_source_reservation",
) shouldContainExactly listOf("capability", "request_source", "reservation_id")
database.indexColumns(
"provider_requests",
"idx_provider_requests_source_capability_reservation",
) shouldContainExactly listOf("request_source", "capability", "reservation_id")
database.columnCollation("credit_ledger", "reference_id") shouldBe "ascii_bin"
database.columnCollation("provider_requests", "reservation_id") shouldBe "ascii_bin"
}
test("usage filtering happens before limit and honors HOTWORD source priority") {
val database = fixture.value
val userId = database.insertAccount()
val hotwordPolish = UUID.randomUUID()
val regularPolishNewest = UUID.randomUUID()
val regularAi = UUID.randomUUID()
val hotwordAi = UUID.randomUUID()
val regularPolishOldest = UUID.randomUUID()
listOf(
ProviderSeed(hotwordPolish, "POLISH", "HOTWORD"),
ProviderSeed(regularPolishNewest, "POLISH", null),
ProviderSeed(regularAi, "AI", null),
ProviderSeed(hotwordAi, "AI", "HOTWORD"),
ProviderSeed(regularPolishOldest, "POLISH", null),
).forEach { database.insertProviderRequest(userId, it) }
database.insertLedger(
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
99,
hotwordPolish,
Instant.parse("2026-08-20T00:00:05Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
98,
regularPolishNewest,
Instant.parse("2026-08-20T00:00:04Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
97,
regularAi,
Instant.parse("2026-08-20T00:00:03Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
96,
hotwordAi,
Instant.parse("2026-08-20T00:00:02Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
95,
regularPolishOldest,
Instant.parse("2026-08-20T00:00:01Z"),
),
)
val hotword = database.service.collectLedger(
userId = userId,
limit = 1,
query = AdminLedgerQuery(usageType = AdminUsageType.HOTWORD),
)
hotword.map(AdminUserLedgerEntryDto::referenceId) shouldContainExactly
listOf(hotwordPolish, hotwordAi).map(UUID::toString)
hotword.map(AdminUserLedgerEntryDto::usageType) shouldContainExactly
listOf("hotword", "hotword")
val polish = database.service.collectLedger(
userId = userId,
limit = 1,
query = AdminLedgerQuery(usageType = AdminUsageType.POLISH),
)
polish.map(AdminUserLedgerEntryDto::referenceId) shouldContainExactly
listOf(regularPolishNewest, regularPolishOldest).map(UUID::toString)
polish.map(AdminUserLedgerEntryDto::usageType) shouldContainExactly
listOf("polish", "polish")
}
test("combined filters and privacy safe trace details use real associations") {
val database = fixture.value
val userId = database.insertAccount()
val relatedAccountId = database.insertAccount()
val manualLedgerId = UUID.randomUUID()
val storeKitLedgerId = UUID.randomUUID()
val referralLedgerId = UUID.randomUUID()
val usageLedgerId = UUID.randomUUID()
val missingLedgerId = UUID.randomUUID()
val combinationReference = UUID.randomUUID()
val referralBindingId = UUID.randomUUID()
val usageReservationId = UUID.randomUUID()
database.insertLedger(
LedgerSeed(
manualLedgerId,
userId,
LedgerEntryType.MANUAL_GRANT,
25,
125,
combinationReference,
Instant.parse("2026-08-20T00:00:10Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.MANUAL_GRANT,
25,
150,
combinationReference,
Instant.parse("2026-08-20T00:01:00Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
149,
combinationReference,
Instant.parse("2026-08-20T00:00:20Z"),
),
LedgerSeed(
storeKitLedgerId,
userId,
LedgerEntryType.STOREKIT_PURCHASE,
50,
199,
),
LedgerSeed(
referralLedgerId,
userId,
LedgerEntryType.REFERRAL_INVITER,
10,
209,
referralBindingId,
),
LedgerSeed(
usageLedgerId,
userId,
LedgerEntryType.USAGE_RESERVE,
-3,
206,
usageReservationId,
),
LedgerSeed(
missingLedgerId,
userId,
LedgerEntryType.MANUAL_GRANT,
1,
207,
),
)
val operatorName = database.insertManualGrant(userId, manualLedgerId)
val storeKit = database.insertStoreKitPurchase(userId, storeKitLedgerId)
database.insertReferralBinding(userId, relatedAccountId, referralBindingId)
database.insertProviderRequest(
userId,
ProviderSeed(usageReservationId, "AI", null),
)
val combined = database.service.collectLedger(
userId = userId,
limit = 1,
query = AdminLedgerQuery(
time = AdminTimeFilter(
from = Instant.parse("2026-08-20T00:00:00Z"),
until = Instant.parse("2026-08-20T00:01:00Z"),
),
type = AdminLedgerType.GRANT,
entryType = LedgerEntryType.MANUAL_GRANT,
referenceId = combinationReference,
order = AdminSortOrder.ASC,
),
)
combined.map(AdminUserLedgerEntryDto::id) shouldContainExactly
listOf(manualLedgerId.toString())
val entries = database.service.collectLedger(userId, limit = 100)
.associateBy { UUID.fromString(it.id) }
entries.getValue(manualLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "manualGrant"
reason shouldBe "customer recovery"
this.operatorName shouldBe operatorName
}
entries.getValue(storeKitLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "storeKit"
productId shouldBe "credits.50"
transactionId shouldBe storeKit.transactionId
originalTransactionId shouldBe storeKit.originalTransactionId
environment shouldBe "SANDBOX"
purchasedAt shouldBe "2026-08-20T00:00:30Z"
}
entries.getValue(referralLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "referral"
role shouldBe "inviter"
relatedUserId shouldBe relatedAccountId.toString()
}
entries.getValue(usageLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "usage"
reservationId shouldBe usageReservationId.toString()
}
entries.getValue(missingLedgerId).details.shouldBeNull()
val json = Json {
explicitNulls = false
encodeDefaults = true
}
val serialized = entries.values.joinToString("\n") { json.encodeToString(it) }
serialized shouldContain "\"operatorName\":\"$operatorName\""
serialized shouldContain "\"reservationId\":\"$usageReservationId\""
listOf(
"idempotencyKey",
"appAccountToken",
"signedTransaction",
"appleSubject",
"prompt",
"transcript",
"modelOutput",
).forEach(serialized::shouldNotContain)
json.encodeToString(entries.getValue(missingLedgerId)) shouldNotContain "\"details\""
}
})
private data class LedgerSeed(
val id: UUID,
val userId: UUID,
val entryType: LedgerEntryType,
val amount: Long,
val balanceAfter: Long,
val referenceId: UUID? = null,
val createdAt: Instant = Instant.parse("2026-08-20T00:00:00Z"),
)
private data class ProviderSeed(
val reservationId: UUID,
val capability: String,
val requestSource: String?,
)
private data class StoreKitSeedResult(
val transactionId: String,
val originalTransactionId: String,
)
private class AdminLedgerIntegrationDatabase(
private val jdbcUrl: String,
private val username: String,
private val password: String,
private val container: AdminLedgerMySqlContainer?,
private val factory: DatabaseFactory,
) : AutoCloseable {
val service = AdminUsersService(ExposedAdminUsersRepository(factory))
fun insertAccount(): UUID {
val id = UUID.randomUUID()
execute(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES ('$id', 'integration-$id', CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6))
""",
)
return id
}
fun insertLedger(vararg entries: LedgerSeed) {
entries.forEach { entry ->
val reference = entry.referenceId?.let { "'$it'" } ?: "NULL"
execute(
"""
INSERT INTO credit_ledger (
id, user_id, entry_type, amount_delta, balance_after,
idempotency_key, reference_id, created_at
) VALUES (
'${entry.id}', '${entry.userId}', '${entry.entryType.name}',
${entry.amount}, ${entry.balanceAfter}, 'integration:${entry.id}',
$reference, '${entry.createdAt.toDatabaseTimestamp()}'
)
""",
)
}
}
fun insertProviderRequest(userId: UUID, seed: ProviderSeed) {
val source = seed.requestSource?.let { "'$it'" } ?: "NULL"
execute(
"""
INSERT INTO provider_requests (
request_id, account_id, reservation_id, provider_id,
capability, request_source, status, created_at
) VALUES (
'request-${seed.reservationId}', '$userId', '${seed.reservationId}',
'integration-provider', '${seed.capability}', $source, 'SETTLED',
CURRENT_TIMESTAMP(6)
)
""",
)
}
fun insertManualGrant(userId: UUID, ledgerEntryId: UUID): String {
val operatorId = UUID.randomUUID()
val auditId = UUID.randomUUID()
val operatorName = "support-${operatorId.toString().take(8)}"
execute(
"""
INSERT INTO admin_operators (
id, username, password_hash, encrypted_totp_secret, role
) VALUES (
'$operatorId', '$operatorName', 'integration-password-hash',
'integration-totp-secret', 'SUPPORT'
)
""",
"""
INSERT INTO admin_audit_log (
id, actor_operator_id, action, outcome, target_type,
target_id, occurred_at
) VALUES (
'$auditId', '$operatorId', 'MANUAL_CREDIT_GRANTED', 'SUCCESS',
'ACCOUNT', '$userId', CURRENT_TIMESTAMP(6)
)
""",
"""
INSERT INTO admin_credit_grants (
id, operator_id, account_id, amount, reason, idempotency_key,
ledger_entry_id, audit_log_id, created_at
) VALUES (
'${UUID.randomUUID()}', '$operatorId', '$userId', 25,
'customer recovery', 'manual:$ledgerEntryId', '$ledgerEntryId',
'$auditId', CURRENT_TIMESTAMP(6)
)
""",
)
return operatorName
}
fun insertStoreKitPurchase(userId: UUID, ledgerEntryId: UUID): StoreKitSeedResult {
val transactionId = UUID.randomUUID().toString()
val originalTransactionId = UUID.randomUUID().toString()
execute(
"""
INSERT INTO storekit_credit_purchases (
id, transaction_id, original_transaction_id, user_id,
app_account_token, product_id, environment, credits_granted,
ledger_entry_id, signed_transaction_sha256, purchased_at,
signed_at, created_at
) VALUES (
'${UUID.randomUUID()}', '$transactionId', '$originalTransactionId', '$userId',
'${UUID.randomUUID()}', 'credits.50', 'SANDBOX', 50,
'$ledgerEntryId', '${"a".repeat(64)}',
'${Instant.parse("2026-08-20T00:00:30Z").toDatabaseTimestamp()}',
'${Instant.parse("2026-08-20T00:00:31Z").toDatabaseTimestamp()}',
CURRENT_TIMESTAMP(6)
)
""",
)
return StoreKitSeedResult(transactionId, originalTransactionId)
}
fun insertReferralBinding(
inviterUserId: UUID,
inviteeUserId: UUID,
bindingId: UUID,
) {
val codeId = UUID.randomUUID()
execute(
"""
INSERT INTO referral_codes (id, owner_user_id, code, created_at)
VALUES ('$codeId', '$inviterUserId', 'CODE${codeId.toString().take(8)}', CURRENT_TIMESTAMP(6))
""",
"""
INSERT INTO referral_bindings (
id, inviter_user_id, invitee_user_id, code_id, bound_at
) VALUES (
'$bindingId', '$inviterUserId', '$inviteeUserId', '$codeId',
CURRENT_TIMESTAMP(6)
)
""",
)
}
fun indexColumns(table: String, index: String): List<String> =
connection().use { connection ->
connection.prepareStatement(
"""
SELECT COLUMN_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND INDEX_NAME = ?
ORDER BY SEQ_IN_INDEX
""".trimIndent(),
).use { statement ->
statement.setString(1, table)
statement.setString(2, index)
statement.executeQuery().use { result ->
buildList {
while (result.next()) add(result.getString("COLUMN_NAME"))
}
}
}
}
fun columnCollation(table: String, column: String): String? =
connection().use { connection ->
connection.prepareStatement(
"""
SELECT COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
""".trimIndent(),
).use { statement ->
statement.setString(1, table)
statement.setString(2, column)
statement.executeQuery().use { result ->
if (result.next()) result.getString("COLLATION_NAME") else null
}
}
}
override fun close() {
factory.close()
container?.stop()
}
private fun execute(vararg sql: String) {
connection().use { connection ->
connection.createStatement().use { statement ->
sql.forEach { statement.executeUpdate(it.trimIndent()) }
}
}
}
private fun connection(): Connection =
DriverManager.getConnection(jdbcUrl, username, password)
}
private suspend fun AdminUsersService.collectLedger(
userId: UUID,
limit: Int,
query: AdminLedgerQuery = AdminLedgerQuery(),
): List<AdminUserLedgerEntryDto> {
val results = mutableListOf<AdminUserLedgerEntryDto>()
var cursor: String? = null
do {
val page = ledger(userId, limit, cursor, query)
results += page.items
cursor = page.nextCursor
} while (cursor != null)
return results
}
private fun startIntegrationDatabase(): AdminLedgerIntegrationDatabase {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
AdminLedgerMySqlContainer("mysql:8.4")
.withDatabaseName("osg_admin_ledger_test")
.withUsername("test")
.withPassword("test")
.also(AdminLedgerMySqlContainer::start)
} else {
null
}
val jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl
val username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root"
val password = System.getenv("TEST_MYSQL_PASSWORD")
?: mysql?.password
?: ""
val factory = DatabaseFactory(
DatabaseConfig(
jdbcUrl = jdbcUrl,
username = username,
password = password,
maximumPoolSize = 4,
),
)
factory.database
return AdminLedgerIntegrationDatabase(jdbcUrl, username, password, mysql, factory)
}
private class AdminLedgerMySqlContainer(image: String) :
MySQLContainer<AdminLedgerMySqlContainer>(image)
private fun Instant.toDatabaseTimestamp(): String =
LocalDateTime.ofInstant(this, ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"))
@@ -7,7 +7,9 @@ import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
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
@@ -16,12 +18,14 @@ import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.Base64
import java.util.UUID
class AdminUsersServiceTest : FunSpec({
@@ -270,7 +274,12 @@ class AdminUsersServiceTest : FunSpec({
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"),
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(1),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
@@ -335,6 +344,164 @@ class AdminUsersServiceTest : FunSpec({
)
}
}
test("ledger combines exact type usage reference and time filters") {
val userId = UUID.randomUUID()
val referenceId = UUID.randomUUID()
val from = Instant.parse("2026-08-15T00:00:00Z")
val target = ledgerEntry(
id = UUID.randomUUID(),
createdAt = from.plusSeconds(10),
userId = userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = referenceId,
usageType = AdminUsageType.HOTWORD,
)
val entries = listOf(
target,
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(10),
userId,
entryType = LedgerEntryType.USAGE_RESERVE,
referenceId = referenceId,
usageType = AdminUsageType.HOTWORD,
),
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(10),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = UUID.randomUUID(),
usageType = AdminUsageType.HOTWORD,
),
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(10),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = referenceId,
usageType = AdminUsageType.ASR,
),
ledgerEntry(
UUID.randomUUID(),
from.minusNanos(1),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = referenceId,
usageType = AdminUsageType.HOTWORD,
),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(
userId,
query = AdminLedgerQuery(
time = AdminTimeFilter(from, from.plusSeconds(60)),
type = AdminLedgerType.SETTLE,
entryType = LedgerEntryType.USAGE_SETTLE,
usageType = AdminUsageType.HOTWORD,
referenceId = referenceId,
),
)
page.items shouldBe listOf(target)
}
test("amount sorting paginates equal values stably in both directions") {
val userId = UUID.randomUUID()
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
val lower = ledgerEntry(
UUID.fromString("11111111-1111-4111-8111-111111111111"),
createdAt,
userId,
amount = 10,
)
val higher = ledgerEntry(
UUID.fromString("22222222-2222-4222-8222-222222222222"),
createdAt,
userId,
amount = 10,
)
val smallest = ledgerEntry(UUID.randomUUID(), createdAt, userId, amount = -5)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to listOf(higher, smallest, lower))),
)
val ascendingQuery = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.ASC,
)
val ascendingFirst = service.ledger(userId, limit = 2, query = ascendingQuery)
val ascendingSecond = service.ledger(
userId,
limit = 2,
cursor = ascendingFirst.nextCursor.shouldNotBeNull(),
query = ascendingQuery,
)
ascendingFirst.items shouldBe listOf(smallest, lower)
ascendingSecond.items shouldBe listOf(higher)
val descendingQuery = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.DESC,
)
val descendingFirst = service.ledger(userId, limit = 2, query = descendingQuery)
val descendingSecond = service.ledger(
userId,
limit = 2,
cursor = descendingFirst.nextCursor.shouldNotBeNull(),
query = descendingQuery,
)
descendingFirst.items shouldBe listOf(higher, lower)
descendingSecond.items shouldBe listOf(smallest)
}
test("createdAt accepts legacy v1 cursor while amount requires matching v2") {
val userId = UUID.randomUUID()
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
val firstId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val secondId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val service = AdminUsersService(
PagingUsersRepository(
emptyList(),
ledger = mapOf(
userId to listOf(
ledgerEntry(firstId, createdAt, userId, amount = 20),
ledgerEntry(secondId, createdAt.minusSeconds(1), userId, amount = 10),
),
),
),
)
val legacy = Base64.getUrlEncoder().withoutPadding().encodeToString(
"v1|DESC|$createdAt|$firstId".toByteArray(),
)
service.ledger(userId, cursor = legacy).items shouldBe
listOf(ledgerEntry(secondId, createdAt.minusSeconds(1), userId, amount = 10))
shouldThrow<IllegalArgumentException> {
service.ledger(
userId,
cursor = legacy,
query = AdminLedgerQuery(sort = AdminLedgerSort.AMOUNT),
)
}
val amountPage = service.ledger(
userId,
limit = 1,
query = AdminLedgerQuery(sort = AdminLedgerSort.AMOUNT),
)
shouldThrow<IllegalArgumentException> {
service.ledger(
userId,
cursor = amountPage.nextCursor.shouldNotBeNull(),
query = AdminLedgerQuery(sort = AdminLedgerSort.CREATED_AT),
)
}
}
})
private class PagingUsersRepository(
@@ -393,7 +560,7 @@ private class PagingUsersRepository(
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
ledgerComparator(query.order),
ledgerComparator(query.order, query.sort),
)
.take(limit)
@@ -408,7 +575,7 @@ private class PagingUsersRepository(
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
ledgerComparator(query.order),
ledgerComparator(query.order, query.sort),
)
.take(limit)
}
@@ -441,18 +608,27 @@ private fun userAfter(
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",
val category = when (entryType) {
LedgerEntryType.USAGE_RESERVE -> AdminLedgerType.RESERVE
LedgerEntryType.USAGE_SETTLE -> AdminLedgerType.SETTLE
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> AdminLedgerType.REFUND
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.STOREKIT_PURCHASE,
LedgerEntryType.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)
(query.type == null || query.type == category) &&
(query.entryType == null || query.entryType == entryType) &&
(query.usageType == null || query.usageType.name.lowercase() == usageType) &&
(query.referenceId == null || query.referenceId.toString() == referenceId)
}
private fun ledgerAfter(
@@ -460,23 +636,31 @@ private fun ledgerAfter(
cursor: AdminUserLedgerCursor,
order: AdminSortOrder,
): Boolean {
val createdAt = Instant.parse(item.createdAt)
val primary = when (cursor.sort) {
AdminLedgerSort.CREATED_AT -> Instant.parse(item.createdAt).compareTo(requireNotNull(cursor.createdAt))
AdminLedgerSort.AMOUNT -> item.amountDelta.compareTo(requireNotNull(cursor.amount))
}
return if (order == AdminSortOrder.ASC) {
createdAt > cursor.createdAt ||
(createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString())
primary > 0 || (primary == 0 && item.id > cursor.ledgerEntryId.toString())
} else {
createdAt < cursor.createdAt ||
(createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString())
primary < 0 || (primary == 0 && item.id < cursor.ledgerEntryId.toString())
}
}
private fun ledgerComparator(order: AdminSortOrder): Comparator<AdminUserLedgerEntryDto> =
if (order == AdminSortOrder.ASC) {
private fun ledgerComparator(
order: AdminSortOrder,
sort: AdminLedgerSort = AdminLedgerSort.CREATED_AT,
): Comparator<AdminUserLedgerEntryDto> {
val ascending = when (sort) {
AdminLedgerSort.CREATED_AT ->
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserLedgerEntryDto::id)
} else {
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id)
AdminLedgerSort.AMOUNT ->
compareBy<AdminUserLedgerEntryDto>(AdminUserLedgerEntryDto::amountDelta)
.thenBy(AdminUserLedgerEntryDto::id)
}
return if (order == AdminSortOrder.ASC) ascending else ascending.reversed()
}
private fun summary(
@@ -500,13 +684,17 @@ private fun ledgerEntry(
id: UUID,
createdAt: Instant,
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
type: String = "MANUAL_GRANT",
entryType: LedgerEntryType = LedgerEntryType.MANUAL_GRANT,
amount: Long = 10,
referenceId: UUID? = null,
usageType: AdminUsageType? = null,
) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = type,
amountDelta = 10,
entryType = entryType,
amountDelta = amount,
balanceAfter = 10,
referenceId = null,
referenceId = referenceId?.toString(),
createdAt = createdAt.toString(),
usageType = usageType?.name?.lowercase(),
)
@@ -41,6 +41,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val reservations = mutableMapOf<UUID, CreditReservation>()
val rates = mutableMapOf<UUID, CreditRateVersion>()
val codes = mutableMapOf<UUID, ReferralCode>()
private val permanentCodeIds = mutableMapOf<UUID, UUID>()
val bindings = mutableMapOf<UUID, ReferralBinding>()
val storeKitPurchases = mutableMapOf<String, StoreKitCreditPurchase>()
val campaigns = mutableMapOf(
@@ -83,6 +84,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val usageSnapshot = usageRecords.toList()
val reservationSnapshot = reservations.toMap()
val codeSnapshot = codes.toMap()
val permanentCodeSnapshot = permanentCodeIds.toMap()
val bindingSnapshot = bindings.toMap()
val budgetSnapshot = campaignBudgets.toMap()
val storeKitSnapshot = storeKitPurchases.toMap()
@@ -97,6 +99,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
usageRecords.replaceWith(usageSnapshot)
reservations.replaceWith(reservationSnapshot)
codes.replaceWith(codeSnapshot)
permanentCodeIds.replaceWith(permanentCodeSnapshot)
bindings.replaceWith(bindingSnapshot)
campaignBudgets.replaceWith(budgetSnapshot)
storeKitPurchases.replaceWith(storeKitSnapshot)
@@ -216,28 +219,26 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
}
private inner class Referrals : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? =
codes.values
.filter { it.ownerUserId == ownerUserId }
.filter { campaignId == null || it.campaignId == campaignId }
.maxByOrNull(ReferralCode::createdAt)
override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? =
permanentCodeIds[ownerUserId]?.let(codes::get)
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
findCodeByOwner(ownerUserId, campaignId)
override fun claimPermanentCode(candidate: ReferralCode): ReferralCode? {
findPermanentCodeByOwner(candidate.ownerUserId)?.let { return it }
val storedCode = codes.values.singleOrNull {
it.ownerUserId == candidate.ownerUserId &&
it.campaignId == candidate.campaignId
} ?: run {
if (findCode(candidate.code) != null) return null
codes[candidate.id] = candidate
candidate
}
permanentCodeIds.putIfAbsent(candidate.ownerUserId, storedCode.id)
return findPermanentCodeByOwner(candidate.ownerUserId)
}
override fun findCode(code: String): ReferralCode? =
codes.values.singleOrNull { it.code == code }
override fun insertCodeIfAbsent(code: ReferralCode): Boolean {
if (findCodeByOwner(code.ownerUserId, code.campaignId) != null ||
findCode(code.code) != null
) {
return false
}
codes[code.id] = code
return true
}
override fun findCampaign(id: UUID): ReferralCampaign? = campaigns[id]
override fun listActiveCampaigns(at: Instant): List<ReferralCampaign> =
@@ -91,7 +91,7 @@ class InviteWebRoutesTest {
response.status shouldBe HttpStatusCode.NotFound
response.bodyAsText() shouldBe
"邀请链接无效或已失效 / This invitation link is invalid or expired"
"邀请链接无效 / This invitation link is invalid"
}
@Test
@@ -0,0 +1,87 @@
package com.osglab.account.features.referrals
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralProfile
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID
import kotlin.test.Test
class ReferralRoutesTest {
@Test
fun `profile and compatibility endpoint distribute the same permanent invitation URL`() =
testApplication {
val userId = UUID.fromString("10000000-0000-0000-0000-000000000020")
val code = ReferralCode(
id = UUID.fromString("20000000-0000-0000-0000-000000000020"),
ownerUserId = userId,
ownerIdentityFingerprint = "a".repeat(64),
code = "AbCdEf0123456789_-AbCd",
createdAt = Instant.parse("2026-08-20T00:00:00Z"),
)
val operations = FixedReferralOperations(code)
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
routing {
referralRoutes(
service = operations,
inviteBaseUrl = "https://osglab.com/i",
authenticatedUser = AuthenticatedUserExtractor { userId },
)
}
}
val profile = client.get("/v1/referrals/me")
val compatibilityCode = client.post("/v1/referrals/code")
profile.status shouldBe HttpStatusCode.OK
compatibilityCode.status shouldBe HttpStatusCode.OK
profile.bodyAsText() shouldContain
""""inviteUrl":"https://osglab.com/i/AbCdEf0123456789_-AbCd""""
compatibilityCode.bodyAsText() shouldContain
""""inviteUrl":"https://osglab.com/i/AbCdEf0123456789_-AbCd""""
operations.codeRequests shouldBe 2
}
}
private class FixedReferralOperations(
private val code: ReferralCode,
) : ReferralOperations {
var codeRequests = 0
override suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode {
codeRequests += 1
return code
}
override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode =
getOrCreateCode(ownerUserId)
override suspend fun bind(inviteeUserId: UUID, rawCode: String): ReferralBinding =
error("Not used")
override suspend fun getProfile(userId: UUID): ReferralProfile {
codeRequests += 1
return ReferralProfile(code, binding = null)
}
override suspend fun listActiveCampaigns(): List<ReferralCampaign> = emptyList()
override suspend fun listInvited(userId: UUID, limit: Int): List<ReferralBinding> = emptyList()
}
@@ -1,6 +1,7 @@
package com.osglab.account.features.referrals
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.ReferralBindingRules
import com.osglab.account.features.referrals.domain.ReferralConflict
@@ -15,6 +16,9 @@ import com.osglab.account.features.referrals.services.UserRegistrationTimeProvid
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.time.Clock
import java.time.Duration
import java.time.Instant
@@ -31,13 +35,33 @@ class ReferralServiceTest : FunSpec({
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val first = service.getOrCreateCode(owner)
val second = service.getOrCreateCode(owner)
val laterCampaignId = UUID.randomUUID()
store.campaigns[laterCampaignId] = referralCampaign(
id = laterCampaignId,
startsAt = now.minusSeconds(60),
)
val second = service.getOrCreateCode(owner, laterCampaignId)
second shouldBe first
first.code.length shouldBe 22
store.codes.size shouldBe 1
}
test("concurrent provisioning claims one permanent code") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val codes = coroutineScope {
List(8) {
async { service.getOrCreateCode(owner) }
}.awaitAll()
}
codes.map(ReferralCode::id).distinct().size shouldBe 1
store.codes.size shouldBe 1
}
test("profile lookup automatically provisions a stable invitation code") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
@@ -47,10 +71,61 @@ class ReferralServiceTest : FunSpec({
val second = service.getProfile(owner)
first.code shouldBe second.code
first.code?.code?.length shouldBe 22
first.code.code.length shouldBe 22
store.codes.size shouldBe 1
}
test("permanent code provisioning does not depend on an active reward campaign") {
val store = TestBillingStore()
store.campaigns[DEFAULT_REFERRAL_CAMPAIGN_ID] =
store.campaigns.getValue(DEFAULT_REFERRAL_CAMPAIGN_ID).copy(enabled = false)
val owner = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val code = service.getOrCreateCode(owner)
code.ownerUserId shouldBe owner
store.codes.values.single() shouldBe code
}
test("an existing permanent code is returned without reprovisioning identity") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val original = referralService(store, now) { now.minus(Duration.ofDays(1)) }
.getOrCreateCode(owner)
val identityUnavailable = referralService(
store = store,
now = now,
riskIdentity = { null },
registeredAt = { now.minus(Duration.ofDays(1)) },
)
identityUnavailable.getOrCreateCode(owner) shouldBe original
store.codes.size shouldBe 1
}
test("a permanent code remains redeemable after the reward campaign changes") {
val store = TestBillingStore()
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val code = service.getOrCreateCode(inviter)
store.campaigns[DEFAULT_REFERRAL_CAMPAIGN_ID] =
store.campaigns.getValue(DEFAULT_REFERRAL_CAMPAIGN_ID).copy(enabled = false)
val currentCampaignId = UUID.randomUUID()
store.campaigns[currentCampaignId] = referralCampaign(
id = currentCampaignId,
startsAt = now.minusSeconds(60),
)
store.campaignBudgets[currentCampaignId] =
ReferralCampaignBudget(currentCampaignId, 0, 0, now)
val binding = service.bind(invitee, code.code)
binding.codeId shouldBe code.id
binding.campaignId shouldBe currentCampaignId
}
test("an account binds once and repeated same binding is idempotent") {
val store = TestBillingStore()
val inviter = UUID.randomUUID()
@@ -195,7 +270,7 @@ class ReferralServiceTest : FunSpec({
private fun referralService(
store: TestBillingStore,
now: Instant,
riskIdentity: (UUID) -> ReferralRiskIdentity = { userId ->
riskIdentity: (UUID) -> ReferralRiskIdentity? = { userId ->
ReferralRiskIdentity(fingerprint(userId), restricted = false)
},
registeredAt: (UUID) -> Instant,
@@ -215,5 +290,19 @@ private fun referralService(
)
}
private fun referralCampaign(id: UUID, startsAt: Instant): ReferralCampaign =
ReferralCampaign(
id = id,
name = "Current campaign",
startsAt = startsAt,
endsAt = null,
bindingWindowSeconds = Duration.ofDays(7).seconds,
inviterRewardCredits = 10,
inviteeRewardCredits = 10,
maxRewardedBindings = null,
budgetCredits = null,
enabled = true,
)
private fun fingerprint(userId: UUID): String =
userId.toString().replace("-", "").repeat(2)