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 }; 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 = { export const adminApi = {
session: () => request<SessionResponse>("/auth/session"), session: () => request<SessionResponse>("/auth/session"),
@@ -195,12 +200,12 @@ export const adminApi = {
latestLedger: (value?: string | LedgerQuery) => latestLedger: (value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>( request<PageResult<LedgerEntry>>(
`/credits/ledger${encodeQuery(cursorQuery(value))}`, `/credits/ledger${encodeQuery(ledgerQuery(value))}`,
), ),
ledger: (userId: string, value?: string | LedgerQuery) => ledger: (userId: string, value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>( request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(cursorQuery(value))}`, `/users/${encodeURIComponent(userId)}/ledger${encodeQuery(ledgerQuery(value))}`,
), ),
grantCredits: (payload: CreditGrantRequest) => grantCredits: (payload: CreditGrantRequest) =>
+48 -6
View File
@@ -27,8 +27,12 @@ export interface UsersQuery extends CursorPageQuery {
status?: UserSummary["status"]; status?: UserSummary["status"];
} }
export interface LedgerQuery extends CursorPageQuery { export interface LedgerQuery extends Omit<CursorPageQuery, "sort"> {
type?: LedgerEntryType; type?: LedgerAction;
entryType?: LedgerEntryType;
usageType?: UsageType;
referenceId?: string;
sort?: "createdAt" | "amount";
} }
export interface AuditQuery extends CursorPageQuery { export interface AuditQuery extends CursorPageQuery {
@@ -219,23 +223,61 @@ export interface PageResult<T> {
nextCursor?: string; nextCursor?: string;
} }
export type LedgerEntryType = export type LedgerAction =
| "grant" | "grant"
| "reserve" | "reserve"
| "settle" | "settle"
| "refund" | "refund";
| "adjustment";
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 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 { export interface LedgerEntry {
entryId: string; entryId: string;
userId: string; userId: string;
type: LedgerEntryType; type: LedgerAction;
entryType: LedgerEntryType;
amount: number; amount: number;
balanceAfter: number; balanceAfter: number;
reasonCode: string; reasonCode: string;
usageType?: UsageType; usageType?: UsageType;
referenceId?: string;
details?: LedgerEntryDetails;
createdAt: string; createdAt: string;
} }
+68 -8
View File
@@ -5,8 +5,14 @@ import {
type LegacyColumnDef, type LegacyColumnDef,
useLegacyTable, useLegacyTable,
} from "@tanstack/react-table/legacy"; } from "@tanstack/react-table/legacy";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import {
import { type ReactNode } from "react"; ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { Fragment, type ReactNode, useState } from "react";
import type { SortOrder } from "../api/types"; import type { SortOrder } from "../api/types";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { EmptyState } from "./primitives"; import { EmptyState } from "./primitives";
@@ -23,6 +29,9 @@ export function DataTable<T extends RowData>({
sort, sort,
sortableColumns, sortableColumns,
onSortChange, onSortChange,
renderExpandedRow,
getRowId,
expandLabel = "详情",
}: { }: {
data: T[]; data: T[];
columns: DataColumn<T>[]; columns: DataColumn<T>[];
@@ -33,7 +42,11 @@ export function DataTable<T extends RowData>({
sort?: { key: string; order: SortOrder }; sort?: { key: string; order: SortOrder };
sortableColumns?: Record<string, string>; sortableColumns?: Record<string, string>;
onSortChange?: (key: string, order: SortOrder) => void; 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({ const table = useLegacyTable({
data, data,
columns, columns,
@@ -94,15 +107,25 @@ export function DataTable<T extends RowData>({
</th> </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> </tr>
))} ))}
</thead> </thead>
<tbody> <tbody>
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => {
<tr const expansionKey = getRowId?.(row.original) ?? row.id;
key={row.id} const expanded = expandedRows.has(expansionKey);
className="group transition-colors hover:bg-surface-muted/70" 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) => ( {row.getVisibleCells().map((cell) => (
<td <td
key={cell.id} key={cell.id}
@@ -111,8 +134,45 @@ export function DataTable<T extends RowData>({
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </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> </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> </tbody>
</table> </table>
</div> </div>
+3 -1
View File
@@ -5,10 +5,12 @@ export function TableToolbar({
children, children,
active, active,
onClear, onClear,
clearLabel = "清除筛选",
}: { }: {
children: ReactNode; children: ReactNode;
active: boolean; active: boolean;
onClear: () => void; onClear: () => void;
clearLabel?: string;
}) { }) {
return ( return (
<div <div
@@ -24,7 +26,7 @@ export function TableToolbar({
disabled={!active} disabled={!active}
onClick={onClear} onClick={onClear}
> >
{clearLabel}
</Button> </Button>
</div> </div>
); );
+39 -153
View File
@@ -1,13 +1,9 @@
import { ArrowDownUp, Coins, Search } from "lucide-react"; import { ArrowDownUp, Coins, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; import { useCallback, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { adminApi } from "../../api/client"; import { adminApi } from "../../api/client";
import type { LedgerEntry, LedgerEntryType, LedgerQuery, SortOrder } from "../../api/types"; import type { LedgerQuery } 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 { import {
Badge,
Button, Button,
Card, Card,
ErrorState, ErrorState,
@@ -15,45 +11,21 @@ import {
LoadingState, LoadingState,
PageHeader, PageHeader,
} from "../../components/primitives"; } from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page"; import { useCursorPage } from "../../hooks/use-cursor-page";
import { import { formatNumber } from "../../lib/format";
formatDateTime, import { LedgerFilters } from "./ledger-filters";
formatNumber, import { LedgerTable } from "./ledger-table";
formatSignedCredits, import { useLedgerFilters } from "./use-ledger-filters";
statusLabel,
usageTypeLabel,
} from "../../lib/format";
export function CreditsPage() { export function CreditsPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const initialUserId = searchParams.get("userId") ?? ""; const initialUserId = searchParams.get("userId") ?? "";
const [query, setQuery] = useState(initialUserId); const [query, setQuery] = useState(initialUserId);
const [activeUserId, setActiveUserId] = useState(initialUserId); const [activeUserId, setActiveUserId] = useState(initialUserId);
const [from, setFrom] = useState(searchParams.get("from") ?? ""); const filters = useLedgerFilters();
const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [type, setType] = useState<"" | LedgerEntryType>(
(searchParams.get("type") as LedgerEntryType | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("order") === "asc" ? "asc" : "desc",
);
const ledgerQuery = useMemo<LedgerQuery>(
() => ({
from: from || undefined,
until: until || undefined,
type: type || undefined,
sort: "createdAt",
order,
limit: 50,
}),
[from, order, type, until],
);
const fetchLedger = useCallback( const fetchLedger = useCallback(
(value: LedgerQuery) => (value: LedgerQuery) =>
activeUserId activeUserId ? adminApi.ledger(activeUserId, value) : adminApi.latestLedger(value),
? adminApi.ledger(activeUserId, value)
: adminApi.latestLedger(value),
[activeUserId], [activeUserId],
); );
const { const {
@@ -64,88 +36,27 @@ export function CreditsPage() {
error, error,
loadMore, loadMore,
reload, reload,
} = useCursorPage(ledgerQuery, fetchLedger); } = useCursorPage(filters.query, 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]);
function submit(event: FormEvent<HTMLFormElement>) { function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const userId = query.trim() || undefined; const userId = query.trim();
setActiveUserId(userId ?? ""); 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>[]>( const hasBusinessFilter = Boolean(
() => [ filters.state.from ||
{ filters.state.until ||
accessorKey: "createdAt", filters.state.type ||
header: "时间", filters.state.entryType ||
cell: ({ getValue }) => ( filters.state.usageType ||
<span className="whitespace-nowrap text-xs text-muted"> filters.state.referenceId,
{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>
),
},
],
[],
); );
return ( return (
@@ -153,7 +64,7 @@ export function CreditsPage() {
<PageHeader <PageHeader
eyebrow="积分账本" eyebrow="积分账本"
title="积分流水" title="积分流水"
description="查看不可变积分账本,可按完整内部用户 ID 精确查询。" description="从账务动作、业务来源和消费能力三个维度查看不可变积分账本。"
/> />
<Card className="overflow-hidden"> <Card className="overflow-hidden">
@@ -181,38 +92,12 @@ export function CreditsPage() {
</Button> </Button>
</form> </form>
</div> </div>
<TableToolbar
active={Boolean(activeUserId || from || until || type || order !== "desc")} <LedgerFilters
onClear={() => { value={filters.state}
setQuery(""); onChange={filters.update}
setActiveUserId(""); onClear={filters.clear}
setFrom("");
setUntil("");
setType("");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
/> />
<FilterControl
label="流水类型"
value={type}
options={[
{ value: "", label: "全部类型" },
{ value: "grant", label: "赠送" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退款" },
{ value: "adjustment", label: "调整" },
]}
onChange={setType}
/>
</TableToolbar>
<div className="flex items-center gap-3 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6"> <div className="flex items-center gap-3 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
<Coins className="size-4 text-primary" aria-hidden /> <Coins className="size-4 text-primary" aria-hidden />
@@ -233,18 +118,19 @@ export function CreditsPage() {
) : loading ? ( ) : loading ? (
<LoadingState label="加载积分流水" /> <LoadingState label="加载积分流水" />
) : ( ) : (
<DataTable <LedgerTable
data={items} entries={items}
columns={columns}
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"} caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
emptyTitle={ emptyTitle={
activeUserId || from || until || type activeUserId || hasBusinessFilter
? "没有符合当前筛选条件的流水" ? "没有符合当前用户和筛选条件的流水"
: "暂无积分流水" : "暂无积分流水"
} }
sort={{ key: "createdAt", order }} showUser={!activeUserId}
sortableColumns={{ createdAt: "createdAt" }} sort={filters.state.sort}
onSortChange={(_, nextOrder) => setOrder(nextOrder)} order={filters.state.order}
onSortChange={(sort, order) => filters.update({ sort, order })}
onReferenceFilter={(referenceId) => filters.update({ referenceId })}
footer={ footer={
nextCursor ? ( nextCursor ? (
<div className="flex justify-center border-t border-border p-5"> <div className="flex justify-center border-t border-border p-5">
@@ -0,0 +1,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 { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client"; import { adminApi, ApiError } from "../../api/client";
import type { import type {
LedgerEntryType,
LedgerQuery, LedgerQuery,
LedgerEntry,
SortOrder, SortOrder,
UserDetail, UserDetail,
UsersQuery, UsersQuery,
@@ -48,34 +46,37 @@ import {
createIdempotencyKey, createIdempotencyKey,
formatDateTime, formatDateTime,
formatNumber, formatNumber,
formatSignedCredits,
statusLabel, statusLabel,
usageTypeLabel, usageTypeLabel,
} from "../../lib/format"; } from "../../lib/format";
import { useAuth } from "../auth/auth-context"; import { useAuth } from "../auth/auth-context";
import { LedgerFilters } from "../credits/ledger-filters";
const ledgerTypeOptions: Array<{ value: "" | LedgerEntryType; label: string }> = [ import { LedgerTable } from "../credits/ledger-table";
{ value: "", label: "全部类型" }, import { useLedgerFilters } from "../credits/use-ledger-filters";
{ value: "grant", label: "赠送" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退款" },
{ value: "adjustment", label: "调整" },
];
export function UsersPage() { export function UsersPage() {
const { auth } = useAuth(); const { auth } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const role = auth.status === "authenticated" ? auth.role : "ANALYST"; 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 ? ( return selectedUserId ? (
<UserDetailView <UserDetailView
userId={selectedUserId} userId={selectedUserId}
canGrant={role === "SUPER_ADMIN"} canGrant={role === "SUPER_ADMIN"}
onBack={() => setSelectedUserId(undefined)} onBack={() => selectUser()}
/> />
) : ( ) : (
<UserList onSelect={setSelectedUserId} /> <UserList onSelect={selectUser} />
); );
} }
@@ -304,98 +305,49 @@ function UserDetailView({
canGrant: boolean; canGrant: boolean;
onBack: () => void; onBack: () => void;
}) { }) {
const [searchParams, setSearchParams] = useSearchParams();
const [user, setUser] = useState<UserDetail>(); const [user, setUser] = useState<UserDetail>();
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [grantOpen, setGrantOpen] = useState(false); const [grantOpen, setGrantOpen] = useState(false);
const [from, setFrom] = useState(searchParams.get("ledgerFrom") ?? ""); const filters = useLedgerFilters("ledger");
const [until, setUntil] = useState(searchParams.get("ledgerUntil") ?? "");
const [type, setType] = useState<"" | LedgerEntryType>(
(searchParams.get("ledgerType") as LedgerEntryType | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("ledgerOrder") === "asc" ? "asc" : "desc",
);
const requestVersion = useRef(0); const requestVersion = useRef(0);
const ledgerQuery = useMemo<LedgerQuery>( const fetchLedger = useCallback(
() => ({ (value: LedgerQuery) => adminApi.ledger(userId, value),
from: from || undefined, [userId],
until: until || undefined,
type: type || undefined,
sort: "createdAt",
order,
limit: 50,
}),
[from, order, type, until],
); );
const ledgerPage = useCursorPage(filters.query, fetchLedger);
const load = useCallback(async () => { const load = useCallback(async () => {
const version = ++requestVersion.current; const version = ++requestVersion.current;
setLedger([]);
setNextCursor(undefined);
setLoading(true); setLoading(true);
setLoadingMore(false);
setError(undefined); setError(undefined);
try { try {
const [detail, page] = await Promise.all([ const detail = await adminApi.user(userId);
adminApi.user(userId),
adminApi.ledger(userId, ledgerQuery),
]);
if (requestVersion.current !== version) return; if (requestVersion.current !== version) return;
setUser(detail); setUser(detail);
setLedger(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) { } catch (requestError) {
if (requestVersion.current === version) setError(requestError); if (requestVersion.current === version) setError(requestError);
} finally { } finally {
if (requestVersion.current === version) setLoading(false); if (requestVersion.current === version) setLoading(false);
} }
}, [ledgerQuery, userId]); }, [userId]);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
useEffect(() => { if (error || ledgerPage.error) {
setSearchParams((current) => { return (
const params = new URLSearchParams(current); <ErrorState
["ledgerFrom", "ledgerUntil", "ledgerType", "ledgerOrder"].forEach((key) => error={error ?? ledgerPage.error}
params.delete(key), 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);
} }
} if (loading || ledgerPage.loading || !user) return <LoadingState label="加载用户详情" />;
const ledgerColumns = useLedgerColumns();
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (loading || !user) return <LoadingState label="加载用户详情" />;
const usage = user.usage ?? []; const usage = user.usage ?? [];
const requests = usage.reduce((total, item) => total + item.requests, 0); const requests = usage.reduce((total, item) => total + item.requests, 0);
@@ -472,44 +424,35 @@ function UserDetailView({
title="积分流水" title="积分流水"
description="所有变动均来自不可变账本" description="所有变动均来自不可变账本"
/> />
<TableToolbar <LedgerFilters
active={Boolean(from || until || type || order !== "desc")} value={filters.state}
onClear={() => { onChange={filters.update}
setFrom(""); onClear={filters.clear}
setUntil("");
setType("");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
/> />
<FilterControl <LedgerTable
label="流水类型" entries={ledgerPage.items}
value={type}
options={ledgerTypeOptions}
onChange={setType}
/>
</TableToolbar>
<DataTable
data={ledger}
columns={ledgerColumns}
caption={`${user.displayName || user.userId} 的积分流水`} caption={`${user.displayName || user.userId} 的积分流水`}
emptyTitle={from || until || type ? "没有符合当前筛选条件的流水" : "暂无积分流水"} emptyTitle={
sort={{ key: "createdAt", order }} filters.state.from ||
sortableColumns={{ createdAt: "createdAt" }} filters.state.until ||
onSortChange={(_, nextOrder) => setOrder(nextOrder)} 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={ footer={
nextCursor ? ( ledgerPage.nextCursor ? (
<div className="flex justify-center border-t border-border p-5"> <div className="flex justify-center border-t border-border p-5">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => void loadMoreLedger()} onClick={() => void ledgerPage.loadMore()}
loading={loadingMore} loading={ledgerPage.loadingMore}
> >
</Button> </Button>
@@ -528,7 +471,10 @@ function UserDetailView({
open={grantOpen} open={grantOpen}
user={user} user={user}
onOpenChange={setGrantOpen} onOpenChange={setGrantOpen}
onSuccess={load} onSuccess={async () => {
await load();
ledgerPage.reload();
}}
/> />
) : null} ) : null}
</div> </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[] }) { function UsagePanel({ usage }: { usage: UserUsageAggregate[] }) {
const columns = useMemo<DataColumn<UserUsageAggregate>[]>( const columns = useMemo<DataColumn<UserUsageAggregate>[]>(
() => [ () => [
-1
View File
@@ -43,7 +43,6 @@ export function statusLabel(status: string): string {
reserve: "预留", reserve: "预留",
settle: "结算", settle: "结算",
refund: "退还", refund: "退还",
adjustment: "调整",
}; };
return labels[status] ?? status; 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 () => { it("缺少 CSRF 时在发送变更请求前失败", async () => {
const fetchMock = vi.fn(); const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock); 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 { DateRangeControl } from "../components/date-range-control";
import type { SortOrder } from "../api/types"; import type { SortOrder } from "../api/types";
import { stableSort } from "../lib/sort"; import { stableSort } from "../lib/sort";
import { LedgerFilters } from "../features/credits/ledger-filters";
import {
ledgerDatePreset,
readLedgerFilterState,
} from "../features/credits/use-ledger-filters";
afterEach(cleanup); afterEach(cleanup);
@@ -60,4 +65,51 @@ describe("筛选与排序基础能力", () => {
until: "2026-08-21T00:00:00.000Z", 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, statusLabel,
usageTypeLabel, usageTypeLabel,
} from "../lib/format"; } from "../lib/format";
import {
ledgerEntryTypeLabel,
ledgerUsageClass,
} from "../features/credits/ledger-format";
describe("format helpers", () => { describe("format helpers", () => {
it("转义服务端文本以阻止 HTML 注入", () => { it("转义服务端文本以阻止 HTML 注入", () => {
@@ -33,4 +37,39 @@ describe("format helpers", () => {
expect(usageTypeLabel("hotword")).toBe("热词"); expect(usageTypeLabel("hotword")).toBe("热词");
expect(usageTypeLabel()).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", entryId: "ledger-1",
userId, userId,
type: "settle", type: "settle",
entryType: "USAGE_SETTLE",
amount: -18, amount: -18,
balanceAfter: 102, balanceAfter: 102,
reasonCode: "USAGE_SETTLE", reasonCode: "USAGE_SETTLE",
@@ -97,11 +98,100 @@ describe("React 管理页面", () => {
render(<App />); render(<App />);
expect(await screen.findByRole("heading", { name: "积分流水" })).toBeTruthy(); expect(await screen.findByRole("heading", { name: "积分流水" })).toBeTruthy();
expect(await screen.findByText("USAGE_SETTLE")).toBeTruthy(); expect(await screen.findByText("消费结算")).toBeTruthy();
expect(screen.getByText("热词")).toBeTruthy(); expect(screen.getAllByText("热词")).toHaveLength(2);
expect(screen.getByText("-18")).toBeTruthy(); 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 () => { it("安全中心保留管理员游标分页", async () => {
mockSession("SUPER_ADMIN"); mockSession("SUPER_ADMIN");
const first = operator("operator-1", "owner"); const first = operator("operator-1", "owner");
+98 -13
View File
@@ -263,17 +263,18 @@ paths:
default: { $ref: "#/components/responses/Error" } default: { $ref: "#/components/responses/Error" }
/v1/referrals/me: /v1/referrals/me:
get: 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: responses:
"200": "200":
description: Referral profile description: Referral profile
content: content:
application/json: application/json:
schema: { type: object, additionalProperties: true } schema: { $ref: "#/components/schemas/ReferralProfile" }
default: { $ref: "#/components/responses/Error" } default: { $ref: "#/components/responses/Error" }
/v1/referrals/code: /v1/referrals/code:
post: post:
summary: Idempotently create the current campaign invite code deprecated: true
summary: Compatibility endpoint for the permanent account invite code
responses: responses:
"200": "200":
description: Invite code description: Invite code
@@ -685,11 +686,14 @@ paths:
- $ref: "#/components/parameters/AdminFrom" - $ref: "#/components/parameters/AdminFrom"
- $ref: "#/components/parameters/AdminUntil" - $ref: "#/components/parameters/AdminUntil"
- $ref: "#/components/parameters/AdminLedgerType" - $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" - $ref: "#/components/parameters/AdminSortOrder"
responses: responses:
"200": "200":
description: Credit ledger entries ordered by creation time and entry ID description: Credit ledger entries ordered by the requested field and unique entry ID
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" } schema: { $ref: "#/components/schemas/AdminLedgerPage" }
@@ -710,11 +714,14 @@ paths:
- $ref: "#/components/parameters/AdminFrom" - $ref: "#/components/parameters/AdminFrom"
- $ref: "#/components/parameters/AdminUntil" - $ref: "#/components/parameters/AdminUntil"
- $ref: "#/components/parameters/AdminLedgerType" - $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" - $ref: "#/components/parameters/AdminSortOrder"
responses: responses:
"200": "200":
description: Latest credit ledger entries ordered by creation time and entry ID description: Latest credit ledger entries ordered by the requested field and unique entry ID
content: content:
application/json: application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" } schema: { $ref: "#/components/schemas/AdminLedgerPage" }
@@ -1007,7 +1014,26 @@ components:
in: query in: query
schema: schema:
type: string 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: AdminLedgerLimit:
name: limit name: limit
in: query in: query
@@ -1411,21 +1437,58 @@ components:
type: array type: array
items: { $ref: "#/components/schemas/AdminUsageAggregate" } items: { $ref: "#/components/schemas/AdminUsageAggregate" }
referral: { $ref: "#/components/schemas/AdminUserReferral" } 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: AdminLedgerEntry:
type: object type: object
additionalProperties: false additionalProperties: false
required: [entryId, userId, type, amount, balanceAfter, reasonCode, createdAt] required: [entryId, userId, type, entryType, amount, balanceAfter, reasonCode, createdAt]
properties: properties:
entryId: { type: string, format: uuid } entryId: { type: string, format: uuid }
userId: { 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 } amount: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 } 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: usageType:
type: ["string", "null"] type: ["string", "null"]
enum: [polish, asr, ai, agent, hotword, null] enum: [polish, asr, ai, agent, hotword, null]
description: Product usage associated with this ledger operation description: Product usage associated with this ledger operation
details: { $ref: "#/components/schemas/AdminLedgerDetails" }
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
AdminLedgerPage: AdminLedgerPage:
type: object type: object
@@ -1728,9 +1791,31 @@ components:
items: { type: object, additionalProperties: true } items: { type: object, additionalProperties: true }
ReferralCode: ReferralCode:
type: object type: object
additionalProperties: true additionalProperties: false
required: [code, inviteUrl, createdAt]
properties: 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: RedeemReferralRequest:
type: object type: object
additionalProperties: false 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.InviteOpenRecorder
import com.osglab.account.features.inviteweb.ReferralLookupPort import com.osglab.account.features.inviteweb.ReferralLookupPort
import com.osglab.account.features.inviteweb.configureInviteWebRoutes 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.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralService import com.osglab.account.features.referrals.services.ReferralService
@@ -312,7 +311,7 @@ fun Application.module() {
rateLimit(ACCOUNT_RATE_LIMIT) { rateLimit(ACCOUNT_RATE_LIMIT) {
accountRoutes(koin.get()) accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get()) creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get()) referralRoutes(koin.get(), appConfig.inviteBaseUrl, koin.get())
storeKitRoutes(koin.get()) storeKitRoutes(koin.get())
} }
rateLimit(GATEWAY_RATE_LIMIT) { rateLimit(GATEWAY_RATE_LIMIT) {
@@ -519,13 +518,8 @@ fun accountServerModule(config: AppConfig): Module = module {
) )
} }
get<AccountService>().seedDisplayName(accountId, displayName) get<AccountService>().seedDisplayName(accountId, displayName)
try { // Referral provisioning is intentionally handled by /v1/referrals/me
get<ReferralOperations>().getOrCreateCode(accountId) // after authentication so referral storage can never block sign-in.
} catch (exception: CancellationException) {
throw exception
} catch (_: ReferralException) {
// Referral eligibility must not make account sign-in unavailable.
}
} }
} }
single { single {
@@ -585,10 +579,9 @@ fun accountServerModule(config: AppConfig): Module = module {
val transactions = get<BillingTransactionRunner>() val transactions = get<BillingTransactionRunner>()
ReferralLookupPort { code -> ReferralLookupPort { code ->
transactions.inTransaction { unit -> transactions.inTransaction { unit ->
val referralCode = unit.referrals.findCode(code) // Invitation codes are permanent account identifiers. Campaign
referralCode?.campaignId // availability is evaluated only when an invitee redeems one.
?.let(unit.referrals::findCampaign) unit.referrals.findCode(code) != null
?.isActive(Instant.now()) == true
} }
} }
} }
@@ -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.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort import com.osglab.account.features.admin.stats.services.AdminReferralSort
import com.osglab.account.features.admin.stats.services.AdminStatsService import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery import com.osglab.account.features.admin.users.repositories.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.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.AdminUserListQuery
import com.osglab.account.features.admin.users.repositories.AdminUserStatus import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
@@ -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.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest 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.Cookie
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -651,21 +655,61 @@ private fun ApplicationCall.adminUserListQuery(): AdminUserListQuery {
} }
private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery { private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery {
requireQueryParameters(setOf("cursor", "limit", "from", "until", "type", "sort", "order")) requireQueryParameters(
requireCreatedAtSort() setOf(
"cursor",
"limit",
"from",
"until",
"type",
"entryType",
"usageType",
"referenceId",
"sort",
"order",
),
)
val type = request.queryParameters["type"]?.let { val type = request.queryParameters["type"]?.let {
when (it) { when (it) {
"reserve" -> AdminLedgerType.RESERVE "reserve" -> AdminLedgerType.RESERVE
"settle" -> AdminLedgerType.SETTLE "settle" -> AdminLedgerType.SETTLE
"refund" -> AdminLedgerType.REFUND "refund" -> AdminLedgerType.REFUND
"grant" -> AdminLedgerType.GRANT "grant" -> AdminLedgerType.GRANT
"adjustment" -> AdminLedgerType.ADJUSTMENT
else -> throw IllegalArgumentException("Invalid ledger type") 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( return AdminLedgerQuery(
time = adminTimeFilter(), time = adminTimeFilter(),
type = type, type = type,
entryType = entryType,
usageType = usageType,
referenceId = referenceId,
sort = sort,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC), order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
) )
} }
@@ -962,21 +1006,34 @@ private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
AdminLedgerResponse( AdminLedgerResponse(
entryId = id, entryId = id,
userId = userId, userId = userId,
type = when (type) { type = entryType.toAdminLedgerType(),
"USAGE_RESERVE" -> "reserve" entryType = entryType.name,
"USAGE_SETTLE" -> "settle"
"USAGE_RELEASE", "USAGE_REFUND" -> "refund"
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant"
else -> "adjustment"
},
amount = amountDelta, amount = amountDelta,
balanceAfter = balanceAfter, balanceAfter = balanceAfter,
reasonCode = type, reasonCode = entryType.name,
referenceId = referenceId,
usageType = usageType, usageType = usageType,
details = details,
createdAt = createdAt, 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 = private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse =
AdminOperatorResponse( AdminOperatorResponse(
operatorId = id.toString(), operatorId = id.toString(),
@@ -1162,10 +1219,13 @@ private data class AdminLedgerResponse(
val entryId: String, val entryId: String,
val userId: String, val userId: String,
val type: String, val type: String,
val entryType: String,
val amount: Long, val amount: Long,
val balanceAfter: Long, val balanceAfter: Long,
val reasonCode: String, val reasonCode: String,
val referenceId: String?,
val usageType: String?, val usageType: String?,
val details: AdminLedgerDetailsDto?,
val createdAt: String, val createdAt: String,
) )
@@ -1,6 +1,7 @@
package com.osglab.account.features.admin.users.models package com.osglab.account.features.admin.users.models
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.credits.domain.LedgerEntryType
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -27,12 +28,28 @@ data class AdminUserPageDto(
data class AdminUserLedgerEntryDto( data class AdminUserLedgerEntryDto(
val id: String, val id: String,
val userId: String, val userId: String,
val type: String, val entryType: LedgerEntryType,
val amountDelta: Long, val amountDelta: Long,
val balanceAfter: Long, val balanceAfter: Long,
val referenceId: String?, val referenceId: String?,
val createdAt: String, val createdAt: String,
val usageType: String? = null, 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 @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.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto import com.osglab.account.features.admin.users.models.AdminUserReferralDto
@@ -19,11 +20,15 @@ import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.greaterEq import org.jetbrains.exposed.v1.core.greaterEq
import org.jetbrains.exposed.v1.core.inList 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.less
import org.jetbrains.exposed.v1.core.like 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.core.or
import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.andWhere import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -34,9 +39,18 @@ data class AdminUserCursor(
) )
data class AdminUserLedgerCursor( data class AdminUserLedgerCursor(
val createdAt: Instant, val sort: AdminLedgerSort,
val createdAt: Instant? = null,
val amount: Long? = null,
val ledgerEntryId: UUID, 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 { enum class AdminUserStatus {
ACTIVE, ACTIVE,
@@ -65,12 +79,30 @@ enum class AdminLedgerType(
LedgerEntryType.SUBSCRIPTION_GRANT, 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( data class AdminLedgerQuery(
val time: AdminTimeFilter = AdminTimeFilter(), val time: AdminTimeFilter = AdminTimeFilter(),
val type: AdminLedgerType? = null, 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, val order: AdminSortOrder = AdminSortOrder.DESC,
) )
@@ -217,13 +249,19 @@ class ExposedAdminUsersRepository(
) )
} }
.sortedBy(AdminUsageAggregateDto::kind) .sortedBy(AdminUsageAggregateDto::kind)
val recentLedger = support.ledger.filter { it.userId == userId } val recentLedgerRows = support.ledger.filter { it.userId == userId }
.sortedWith( .sortedWith(
compareByDescending<UserLedgerRow>(UserLedgerRow::createdAt) compareByDescending<UserLedgerRow>(UserLedgerRow::createdAt)
.thenByDescending { it.id.toString() }, .thenByDescending { it.id.toString() },
) )
.take(ledgerLimit) .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( AdminUserDetailDto(
summary = account.toSummary(support), summary = account.toSummary(support),
referralCode = findReferralCode(userId), referralCode = findReferralCode(userId),
@@ -245,44 +283,7 @@ class ExposedAdminUsersRepository(
cursor: AdminUserLedgerCursor?, cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery, query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query { ): List<AdminUserLedgerEntryDto> = databaseFactory.query {
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList() loadLedgerEntries(userId, limit, cursor, query)
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]) }
} }
override suspend fun listLatestLedger( override suspend fun listLatestLedger(
@@ -290,8 +291,30 @@ class ExposedAdminUsersRepository(
cursor: AdminUserLedgerCursor?, cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery, query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query { ): List<AdminUserLedgerEntryDto> = databaseFactory.query {
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList() loadLedgerEntries(null, limit, cursor, query)
val statement = AdminUsersCreditLedgerTable.selectAll() }
}
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 -> query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from } statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
} }
@@ -301,32 +324,87 @@ class ExposedAdminUsersRepository(
query.type?.let { type -> query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes } 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 { statement.andWhere {
if (query.order == AdminSortOrder.ASC) { if (usageType == AdminUsageType.HOTWORD) {
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or AdminUsersProviderRequestsTable.requestSource eq usageType.databaseValue
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else { } else {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
( (
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and AdminUsersProviderRequestsTable.requestSource.isNull() or
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString()) (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 sortOrder = query.order.toExposedSortOrder()
val ledger = statement.orderBy( val sortColumn = when (query.sort) {
AdminUsersCreditLedgerTable.createdAt to sortOrder, AdminLedgerSort.CREATED_AT -> AdminUsersCreditLedgerTable.createdAt
AdminLedgerSort.AMOUNT -> AdminUsersCreditLedgerTable.amountDelta
}
val ledger = statement
.orderBy(
sortColumn to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder, AdminUsersCreditLedgerTable.id to sortOrder,
) )
.limit(limit) .limit(limit)
.map(ResultRow::toUserLedgerRow) .map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger) 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 -> .associate { row ->
UUID.fromString(requireNotNull(row[AdminUsersProviderRequestsTable.reservationId])) to UUID.fromString(requireNotNull(row[AdminUsersProviderRequestsTable.reservationId])) to
( if (
row[AdminUsersProviderRequestsTable.requestSource] row[AdminUsersProviderRequestsTable.requestSource] ==
?: row[AdminUsersProviderRequestsTable.capability] AdminUsageType.HOTWORD.databaseValue
).lowercase() ) {
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? = private fun findReferralCode(userId: UUID): String? =
AdminUsersReferralCodesTable.selectAll() AdminUsersReferralCodesTable.selectAll()
@@ -473,6 +703,26 @@ private object AdminUsersProviderRequestsTable : Table("provider_requests") {
override val primaryKey = PrimaryKey(accountId, requestId) 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") { private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
val userId = varchar("user_id", 36) val userId = varchar("user_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8) val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
@@ -484,6 +734,7 @@ private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
} }
private object AdminUsersReferralBindingsTable : Table("referral_bindings") { private object AdminUsersReferralBindingsTable : Table("referral_bindings") {
val id = varchar("id", 36)
val inviterUserId = varchar("inviter_user_id", 36) val inviterUserId = varchar("inviter_user_id", 36)
val inviteeUserId = varchar("invitee_user_id", 36) val inviteeUserId = varchar("invitee_user_id", 36)
val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24) val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24)
@@ -508,14 +759,18 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
createdAt = this[AdminUsersCreditLedgerTable.createdAt], createdAt = this[AdminUsersCreditLedgerTable.createdAt],
) )
private fun UserLedgerRow.toDto(usageType: String?) = AdminUserLedgerEntryDto( private fun UserLedgerRow.toDto(
usageType: String?,
details: AdminLedgerDetailsDto?,
) = AdminUserLedgerEntryDto(
id = id.toString(), id = id.toString(),
userId = userId.toString(), userId = userId.toString(),
type = type.name, entryType = type,
amountDelta = amountDelta, amountDelta = amountDelta,
balanceAfter = balanceAfter, balanceAfter = balanceAfter,
referenceId = referenceId?.toString(), referenceId = referenceId?.toString(),
usageType = usageType, usageType = usageType,
details = details,
createdAt = createdAt.toString(), 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.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.models.AdminUserPageDto import com.osglab.account.features.admin.users.models.AdminUserPageDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminUserCursor import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
@@ -106,7 +107,9 @@ class AdminUsersService(
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>, load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
): AdminUserLedgerPageDto { ): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" } require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
val decodedCursor = cursor?.let { AdminUserLedgerCursorCodec.decode(it, query.order) } val decodedCursor = cursor?.let {
AdminUserLedgerCursorCodec.decode(it, query.sort, query.order)
}
val results = load(limit + 1, decodedCursor) val results = load(limit + 1, decodedCursor)
val hasMore = results.size > limit val hasMore = results.size > limit
val items = results.take(limit) val items = results.take(limit)
@@ -114,9 +117,14 @@ class AdminUsersService(
val last = items.last() val last = items.last()
AdminUserLedgerCursorCodec.encode( AdminUserLedgerCursorCodec.encode(
AdminUserLedgerCursor( 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), ledgerEntryId = UUID.fromString(last.id),
), ),
query.sort,
query.order, query.order,
) )
} else { } else {
@@ -173,13 +181,26 @@ internal object AdminUserCursorCodec {
internal object AdminUserLedgerCursorCodec { internal object AdminUserLedgerCursorCodec {
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid" private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String { fun encode(
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}" 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() return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8)) .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 } require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
return try { return try {
val decoded = String( val decoded = String(
@@ -187,13 +208,36 @@ internal object AdminUserLedgerCursorCodec {
StandardCharsets.UTF_8, StandardCharsets.UTF_8,
) )
val parts = decoded.split('|') val parts = decoded.split('|')
when (parts.firstOrNull()) {
"v1" -> {
require(expectedSort == AdminLedgerSort.CREATED_AT)
require(parts.size == 4) require(parts.size == 4)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name) require(parts[1] == expectedOrder.name)
AdminUserLedgerCursor( AdminUserLedgerCursor(
sort = AdminLedgerSort.CREATED_AT,
createdAt = Instant.parse(parts[2]), createdAt = Instant.parse(parts[2]),
ledgerEntryId = UUID.fromString(parts[3]), 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) { } catch (failure: IllegalArgumentException) {
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure) 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.core.*
import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.Database 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.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.select
@@ -158,6 +157,14 @@ private object ReferralCodes : Table("referral_codes") {
override val primaryKey = PrimaryKey(id) 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") { private object ReferralBindings : Table("referral_bindings") {
val id = varchar("id", 36) val id = varchar("id", 36)
val inviterUserId = varchar("inviter_user_id", 36) val inviterUserId = varchar("inviter_user_id", 36)
@@ -465,27 +472,62 @@ private object ExposedCreditsRepository : CreditsRepository {
} }
private object ExposedReferralsRepository : ReferralsRepository { private object ExposedReferralsRepository : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? { override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? =
val query = ReferralCodes ReferralOwnerCodes
.innerJoin(
otherTable = ReferralCodes,
onColumn = { codeId },
otherColumn = { id },
)
.selectAll() .selectAll()
.where { ReferralCodes.ownerUserId eq ownerUserId.toString() } .where { ReferralOwnerCodes.ownerUserId eq ownerUserId.toString() }
return if (campaignId == null) { .singleOrNull()
query.orderBy(ReferralCodes.createdAt, SortOrder.DESC).limit(1).singleOrNull() ?.toReferralCode()
} else {
query.andWhere { ReferralCodes.campaignId eq campaignId.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 ReferralCodes
.selectAll() .selectAll()
.where { .where {
(ReferralCodes.ownerUserId eq ownerUserId.toString()) and (ReferralCodes.ownerUserId eq candidate.ownerUserId.toString()) and
(ReferralCodes.campaignId eq campaignId.toString()) (
ReferralCodes.campaignId eq
(candidate.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
)
} }
.forUpdate() .forUpdate()
.singleOrNull() .singleOrNull()
?.toReferralCode() ?.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? = override fun findCode(code: String): ReferralCode? =
ReferralCodes ReferralCodes
@@ -494,16 +536,6 @@ private object ExposedReferralsRepository : ReferralsRepository {
.singleOrNull() .singleOrNull()
?.toReferralCode() ?.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? = override fun findCampaign(id: UUID): ReferralCampaign? =
ReferralCampaigns ReferralCampaigns
.selectAll() .selectAll()
@@ -299,7 +299,16 @@ class ExposedGatewayRepository(
): ComplimentaryRequestClaim? = databaseFactory.query { ): ComplimentaryRequestClaim? = databaseFactory.query {
val now = clock.instant() val now = clock.instant()
val expiresAt = now.plus(COMPLIMENTARY_CLAIM_TTL) 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.accountId] = accountId
it[ComplimentaryRequestsTable.purpose] = purpose.name it[ComplimentaryRequestsTable.purpose] = purpose.name
it[ComplimentaryRequestsTable.capability] = capability.name it[ComplimentaryRequestsTable.capability] = capability.name
@@ -309,19 +318,6 @@ class ExposedGatewayRepository(
it[createdAt] = now it[createdAt] = now
it[updatedAt] = now it[updatedAt] = now
}.insertedCount == 1 }.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 if (!inserted && !reclaimed) return@query null
ComplimentaryRequestClaim(accountId, purpose, capability, requestId) 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. * 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, * Implementations must preserve case, treat issued codes as permanent, use a bounded database
* and never log the code. * query, and never log the code.
*/ */
fun interface ReferralLookupPort { fun interface ReferralLookupPort {
suspend fun isValid(code: String): Boolean suspend fun isValid(code: String): Boolean
@@ -193,7 +193,7 @@ private suspend fun ApplicationCall.respondAasa(aasa: String) {
private suspend fun ApplicationCall.respondInvalidInvitation() { private suspend fun ApplicationCall.respondInvalidInvitation() {
respondText( respondText(
text = "邀请链接无效或已失效 / This invitation link is invalid or expired", text = "邀请链接无效 / This invitation link is invalid",
contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8), contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8),
status = HttpStatusCode.NotFound, status = HttpStatusCode.NotFound,
) )
@@ -14,12 +14,14 @@ data class BindReferralRequest(
@Serializable @Serializable
data class ReferralCodeDto( data class ReferralCodeDto(
val code: String, val code: String,
val inviteUrl: String,
val campaignId: String?, val campaignId: String?,
val createdAt: String, val createdAt: String,
) { ) {
companion object { companion object {
fun fromDomain(value: ReferralCode) = ReferralCodeDto( fun fromDomain(value: ReferralCode, inviteBaseUrl: String) = ReferralCodeDto(
code = value.code, code = value.code,
inviteUrl = "${inviteBaseUrl.trimEnd('/')}/${value.code}",
campaignId = value.campaignId?.toString(), campaignId = value.campaignId?.toString(),
createdAt = value.createdAt.toString(), createdAt = value.createdAt.toString(),
) )
@@ -66,12 +68,12 @@ data class ReferralCampaignDto(
@Serializable @Serializable
data class ReferralProfileDto( data class ReferralProfileDto(
val code: ReferralCodeDto?, val code: ReferralCodeDto,
val binding: ReferralBindingDto?, val binding: ReferralBindingDto?,
) { ) {
companion object { companion object {
fun fromDomain(value: ReferralProfile) = ReferralProfileDto( fun fromDomain(value: ReferralProfile, inviteBaseUrl: String) = ReferralProfileDto(
code = value.code?.let(ReferralCodeDto::fromDomain), code = ReferralCodeDto.fromDomain(value.code, inviteBaseUrl),
binding = value.binding?.let(ReferralBindingDto::fromDomain), binding = value.binding?.let(ReferralBindingDto::fromDomain),
) )
} }
@@ -8,14 +8,16 @@ import java.time.Instant
import java.util.UUID import java.util.UUID
interface ReferralsRepository { 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 findCode(code: String): ReferralCode?
fun insertCodeIfAbsent(code: ReferralCode): Boolean
fun findCampaign(id: UUID): ReferralCampaign? fun findCampaign(id: UUID): ReferralCampaign?
fun listActiveCampaigns(at: Instant): List<ReferralCampaign> fun listActiveCampaigns(at: Instant): List<ReferralCampaign>
@@ -26,13 +26,14 @@ import java.util.UUID
class ReferralRouteInstaller( class ReferralRouteInstaller(
private val service: ReferralOperations, private val service: ReferralOperations,
private val inviteBaseUrl: String,
private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor, private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) { ) {
fun install(parent: Route) { fun install(parent: Route) {
parent.route("/v1/referrals") { parent.route("/v1/referrals") {
get("/me") { get("/me") {
call.referralCall(authenticatedUser) { userId -> call.referralCall(authenticatedUser) { userId ->
ReferralProfileDto.fromDomain(service.getProfile(userId)) ReferralProfileDto.fromDomain(service.getProfile(userId), inviteBaseUrl)
} }
} }
get { get {
@@ -58,7 +59,7 @@ class ReferralRouteInstaller(
} }
post("/code") { post("/code") {
call.referralCall(authenticatedUser) { userId -> call.referralCall(authenticatedUser) { userId ->
ReferralCodeDto.fromDomain(service.getOrCreateCode(userId)) ReferralCodeDto.fromDomain(service.getOrCreateCode(userId), inviteBaseUrl)
} }
} }
post("/bind") { post("/bind") {
@@ -73,9 +74,10 @@ class ReferralRouteInstaller(
fun Route.referralRoutes( fun Route.referralRoutes(
service: ReferralOperations, service: ReferralOperations,
inviteBaseUrl: String,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor, authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) { ) {
ReferralRouteInstaller(service, authenticatedUser).install(this) ReferralRouteInstaller(service, inviteBaseUrl, authenticatedUser).install(this)
} }
private suspend fun ApplicationCall.referralCall( private suspend fun ApplicationCall.referralCall(
@@ -1,6 +1,7 @@
package com.osglab.account.features.referrals.services package com.osglab.account.features.referrals.services
import com.osglab.account.features.credits.repositories.BillingTransactionRunner 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.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.InvalidReferralRequest import com.osglab.account.features.referrals.domain.InvalidReferralRequest
import com.osglab.account.features.referrals.domain.ReferralBinding import com.osglab.account.features.referrals.domain.ReferralBinding
@@ -34,13 +35,13 @@ typealias ReferralRiskIdentity = ReferralRiskAssessment
typealias ReferralRiskProvider = ReferralRiskPort typealias ReferralRiskProvider = ReferralRiskPort
data class ReferralProfile( data class ReferralProfile(
val code: ReferralCode?, val code: ReferralCode,
val binding: ReferralBinding?, val binding: ReferralBinding?,
) )
/** /**
* Public referral boundary. Binding and code creation remain transactionally * Public referral boundary. Each account receives one permanent code, while the
* consistent even when callers retry after a timeout. * active campaign is selected only when an invitee redeems that code.
*/ */
interface ReferralOperations { interface ReferralOperations {
suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode
@@ -76,19 +77,22 @@ class ReferralService(
} }
override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode { override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode {
transactions.inTransaction { unit ->
unit.referrals.findPermanentCodeByOwner(ownerUserId)
}?.let { return it }
val ownerIdentity = requireEligibleIdentity(ownerUserId) val ownerIdentity = requireEligibleIdentity(ownerUserId)
return transactions.inTransaction { unit -> return transactions.inTransaction { unit ->
val now = clock.instant() val now = clock.instant()
val campaign = if (campaignId == null) { unit.referrals.findPermanentCodeByOwner(ownerUserId)?.let {
unit.referrals.listActiveCampaigns(now).firstOrNull() return@inTransaction it
?: throw ReferralNotFound("No active referral campaign exists") }
val storageCampaignId = if (campaignId == null) {
DEFAULT_REFERRAL_CAMPAIGN_ID
} else { } else {
unit.referrals.findCampaign(campaignId) unit.referrals.findCampaign(campaignId)
?: throw ReferralNotFound("Referral campaign does not exist") ?: throw ReferralNotFound("Referral campaign does not exist")
} campaignId
if (!campaign.isActive(now)) throw ReferralNotFound("Referral campaign is not active")
unit.referrals.findCodeByOwner(ownerUserId, campaign.id)?.let {
return@inTransaction it
} }
repeat(MAX_CODE_ATTEMPTS) { repeat(MAX_CODE_ATTEMPTS) {
val candidate = ReferralCode( val candidate = ReferralCode(
@@ -97,15 +101,14 @@ class ReferralService(
ownerIdentityFingerprint = ownerIdentity.identityFingerprint, ownerIdentityFingerprint = ownerIdentity.identityFingerprint,
code = codeGenerator.generate(), code = codeGenerator.generate(),
createdAt = now, 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) { if (candidate.code.length < 20) {
throw IllegalStateException("Invite code generator must provide at least 120 bits") throw IllegalStateException("Invite code generator must provide at least 120 bits")
} }
if (unit.referrals.insertCodeIfAbsent(candidate)) { unit.referrals.claimPermanentCode(candidate)?.let {
return@inTransaction candidate
}
unit.referrals.lockCodeByOwner(ownerUserId, campaign.id)?.let {
return@inTransaction it return@inTransaction it
} }
} }
@@ -123,7 +126,11 @@ class ReferralService(
else throw ReferralConflict("This account is already bound to another inviter") else throw ReferralConflict("This account is already bound to another inviter")
} }
if (existing != null) return existing 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) val inviteeIdentity = requireEligibleIdentity(inviteeUserId)
requireEligibleIdentity(referralCode.ownerUserId)
val registeredAt = registrationTimeProvider.registeredAt(inviteeUserId) val registeredAt = registrationTimeProvider.registeredAt(inviteeUserId)
?: throw ReferralNotFound("Registration time is unavailable") ?: throw ReferralNotFound("Registration time is unavailable")
val now = clock.instant() val now = clock.instant()
@@ -136,15 +143,11 @@ class ReferralService(
} }
val referralCode = unit.referrals.findCode(code) val referralCode = unit.referrals.findCode(code)
?: throw ReferralNotFound("Referral code does not exist") ?: throw ReferralNotFound("Referral code does not exist")
val campaign = referralCode.campaignId // Campaigns define the reward policy at redemption time; they no longer
?.let(unit.referrals::findCampaign) // define the lifetime of the account's permanent invitation code.
if (campaign != null && !campaign.isActive(now)) { val campaign = unit.referrals.listActiveCampaigns(now).firstOrNull()
throw ReferralNotFound("Referral campaign is not active") ?: throw ReferralNotFound("No active referral campaign exists")
} val effectiveWindow = Duration.ofSeconds(campaign.bindingWindowSeconds)
val effectiveWindow = campaign
?.bindingWindowSeconds
?.let(Duration::ofSeconds)
?: bindingWindow
if (!ReferralBindingRules.isWithinWindow(registeredAt, now, effectiveWindow)) { if (!ReferralBindingRules.isWithinWindow(registeredAt, now, effectiveWindow)) {
throw ReferralWindowExpired() throw ReferralWindowExpired()
} }
@@ -164,7 +167,7 @@ class ReferralService(
boundAt = now, boundAt = now,
rewardedAt = null, rewardedAt = null,
rewardSettlementId = null, rewardSettlementId = null,
campaignId = referralCode.campaignId, campaignId = campaign.id,
) )
if (unit.referrals.insertBindingIfAbsent(binding)) { if (unit.referrals.insertBindingIfAbsent(binding)) {
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" 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") { test("provider defaults and Apple integrity contract stay production compatible") {
val providerConfigurations = listOf( val providerConfigurations = listOf(
root.read("src/main/kotlin/com/osglab/account/config/AppConfig.kt"), 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 = private fun Path.read(relativePath: String): String =
Files.readString(resolve(relativePath)) 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( private val EXPECTED_PUBLIC_PATHS = setOf(
"/health", "/health",
"/health/live", "/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.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminStatsService 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.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.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.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest 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.kotest.matchers.string.shouldContain
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.client.request.get import io.ktor.client.request.get
@@ -40,7 +47,9 @@ import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.test.Test import kotlin.test.Test
@@ -231,12 +240,16 @@ class AdminRoutesTest {
AdminUserLedgerEntryDto( AdminUserLedgerEntryDto(
id = "ffffffff-ffff-ffff-ffff-ffffffffffff", id = "ffffffff-ffff-ffff-ffff-ffffffffffff",
userId = "11111111-1111-4111-8111-111111111111", userId = "11111111-1111-4111-8111-111111111111",
type = "USAGE_SETTLE", entryType = LedgerEntryType.USAGE_SETTLE,
amountDelta = -18, amountDelta = -18,
balanceAfter = 102, balanceAfter = 102,
referenceId = null, referenceId = "22222222-2222-4222-8222-222222222222",
createdAt = "2026-08-19T09:00:00Z", createdAt = "2026-08-19T09:00:00Z",
usageType = "hotword", usageType = "hotword",
details = AdminLedgerDetailsDto(
kind = "usage",
reservationId = "22222222-2222-4222-8222-222222222222",
),
), ),
), ),
nextCursor = null, nextCursor = null,
@@ -255,8 +268,136 @@ class AdminRoutesTest {
assertEquals(HttpStatusCode.OK, response.status) assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111"""" response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111""""
response.bodyAsText() shouldContain """"entryType":"USAGE_SETTLE""""
response.bodyAsText() shouldContain """"reasonCode":"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 """"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 @Test
@@ -269,6 +410,11 @@ class AdminRoutesTest {
val invalidPaths = listOf( val invalidPaths = listOf(
"/v1/admin/users?from=2026-08-20T00:00:00Z&until=2026-08-20T00:00:00Z", "/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=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/operators?enabled=1",
"/v1/admin/audit?action=NOT_AN_ACTION", "/v1/admin/audit?action=NOT_AN_ACTION",
"/v1/admin/referrals?range=30d&limit=101", "/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.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter 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.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.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
@@ -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.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import java.time.Instant import java.time.Instant
import java.util.Base64
import java.util.UUID import java.util.UUID
class AdminUsersServiceTest : FunSpec({ class AdminUsersServiceTest : FunSpec({
@@ -270,7 +274,12 @@ class AdminUsersServiceTest : FunSpec({
ledgerEntry(lowerId, from, userId), ledgerEntry(lowerId, from, userId),
ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId), ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId),
ledgerEntry(UUID.randomUUID(), until, 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( val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)), 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( private class PagingUsersRepository(
@@ -393,7 +560,7 @@ private class PagingUsersRepository(
cursor == null || ledgerAfter(it, cursor, query.order) cursor == null || ledgerAfter(it, cursor, query.order)
} }
.sortedWith( .sortedWith(
ledgerComparator(query.order), ledgerComparator(query.order, query.sort),
) )
.take(limit) .take(limit)
@@ -408,7 +575,7 @@ private class PagingUsersRepository(
cursor == null || ledgerAfter(it, cursor, query.order) cursor == null || ledgerAfter(it, cursor, query.order)
} }
.sortedWith( .sortedWith(
ledgerComparator(query.order), ledgerComparator(query.order, query.sort),
) )
.take(limit) .take(limit)
} }
@@ -441,18 +608,27 @@ private fun userAfter(
private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean { private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean {
val instant = Instant.parse(createdAt) val instant = Instant.parse(createdAt)
val category = when (type) { val category = when (entryType) {
"USAGE_RESERVE" -> AdminLedgerType.RESERVE LedgerEntryType.USAGE_RESERVE -> AdminLedgerType.RESERVE
"USAGE_SETTLE" -> AdminLedgerType.SETTLE LedgerEntryType.USAGE_SETTLE -> AdminLedgerType.SETTLE
"USAGE_RELEASE", "USAGE_REFUND" -> AdminLedgerType.REFUND LedgerEntryType.USAGE_RELEASE,
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE", LedgerEntryType.USAGE_REFUND,
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT", -> AdminLedgerType.REFUND
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.STOREKIT_PURCHASE,
LedgerEntryType.SUBSCRIPTION_GRANT,
-> AdminLedgerType.GRANT -> AdminLedgerType.GRANT
else -> AdminLedgerType.ADJUSTMENT
} }
return query.time.from?.let { instant >= it } != false && return query.time.from?.let { instant >= it } != false &&
query.time.until?.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( private fun ledgerAfter(
@@ -460,23 +636,31 @@ private fun ledgerAfter(
cursor: AdminUserLedgerCursor, cursor: AdminUserLedgerCursor,
order: AdminSortOrder, order: AdminSortOrder,
): Boolean { ): 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) { return if (order == AdminSortOrder.ASC) {
createdAt > cursor.createdAt || primary > 0 || (primary == 0 && item.id > cursor.ledgerEntryId.toString())
(createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString())
} else { } else {
createdAt < cursor.createdAt || primary < 0 || (primary == 0 && item.id < cursor.ledgerEntryId.toString())
(createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString())
} }
} }
private fun ledgerComparator(order: AdminSortOrder): Comparator<AdminUserLedgerEntryDto> = private fun ledgerComparator(
if (order == AdminSortOrder.ASC) { order: AdminSortOrder,
sort: AdminLedgerSort = AdminLedgerSort.CREATED_AT,
): Comparator<AdminUserLedgerEntryDto> {
val ascending = when (sort) {
AdminLedgerSort.CREATED_AT ->
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) } compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserLedgerEntryDto::id) .thenBy(AdminUserLedgerEntryDto::id)
} else {
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) } AdminLedgerSort.AMOUNT ->
.thenByDescending(AdminUserLedgerEntryDto::id) compareBy<AdminUserLedgerEntryDto>(AdminUserLedgerEntryDto::amountDelta)
.thenBy(AdminUserLedgerEntryDto::id)
}
return if (order == AdminSortOrder.ASC) ascending else ascending.reversed()
} }
private fun summary( private fun summary(
@@ -500,13 +684,17 @@ private fun ledgerEntry(
id: UUID, id: UUID,
createdAt: Instant, createdAt: Instant,
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"), userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
type: String = "MANUAL_GRANT", entryType: LedgerEntryType = LedgerEntryType.MANUAL_GRANT,
amount: Long = 10,
referenceId: UUID? = null,
usageType: AdminUsageType? = null,
) = AdminUserLedgerEntryDto( ) = AdminUserLedgerEntryDto(
id = id.toString(), id = id.toString(),
userId = userId.toString(), userId = userId.toString(),
type = type, entryType = entryType,
amountDelta = 10, amountDelta = amount,
balanceAfter = 10, balanceAfter = 10,
referenceId = null, referenceId = referenceId?.toString(),
createdAt = createdAt.toString(), createdAt = createdAt.toString(),
usageType = usageType?.name?.lowercase(),
) )
@@ -41,6 +41,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val reservations = mutableMapOf<UUID, CreditReservation>() val reservations = mutableMapOf<UUID, CreditReservation>()
val rates = mutableMapOf<UUID, CreditRateVersion>() val rates = mutableMapOf<UUID, CreditRateVersion>()
val codes = mutableMapOf<UUID, ReferralCode>() val codes = mutableMapOf<UUID, ReferralCode>()
private val permanentCodeIds = mutableMapOf<UUID, UUID>()
val bindings = mutableMapOf<UUID, ReferralBinding>() val bindings = mutableMapOf<UUID, ReferralBinding>()
val storeKitPurchases = mutableMapOf<String, StoreKitCreditPurchase>() val storeKitPurchases = mutableMapOf<String, StoreKitCreditPurchase>()
val campaigns = mutableMapOf( val campaigns = mutableMapOf(
@@ -83,6 +84,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val usageSnapshot = usageRecords.toList() val usageSnapshot = usageRecords.toList()
val reservationSnapshot = reservations.toMap() val reservationSnapshot = reservations.toMap()
val codeSnapshot = codes.toMap() val codeSnapshot = codes.toMap()
val permanentCodeSnapshot = permanentCodeIds.toMap()
val bindingSnapshot = bindings.toMap() val bindingSnapshot = bindings.toMap()
val budgetSnapshot = campaignBudgets.toMap() val budgetSnapshot = campaignBudgets.toMap()
val storeKitSnapshot = storeKitPurchases.toMap() val storeKitSnapshot = storeKitPurchases.toMap()
@@ -97,6 +99,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
usageRecords.replaceWith(usageSnapshot) usageRecords.replaceWith(usageSnapshot)
reservations.replaceWith(reservationSnapshot) reservations.replaceWith(reservationSnapshot)
codes.replaceWith(codeSnapshot) codes.replaceWith(codeSnapshot)
permanentCodeIds.replaceWith(permanentCodeSnapshot)
bindings.replaceWith(bindingSnapshot) bindings.replaceWith(bindingSnapshot)
campaignBudgets.replaceWith(budgetSnapshot) campaignBudgets.replaceWith(budgetSnapshot)
storeKitPurchases.replaceWith(storeKitSnapshot) storeKitPurchases.replaceWith(storeKitSnapshot)
@@ -216,28 +219,26 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
} }
private inner class Referrals : ReferralsRepository { private inner class Referrals : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? = override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? =
codes.values permanentCodeIds[ownerUserId]?.let(codes::get)
.filter { it.ownerUserId == ownerUserId }
.filter { campaignId == null || it.campaignId == campaignId }
.maxByOrNull(ReferralCode::createdAt)
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? = override fun claimPermanentCode(candidate: ReferralCode): ReferralCode? {
findCodeByOwner(ownerUserId, campaignId) 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? = override fun findCode(code: String): ReferralCode? =
codes.values.singleOrNull { it.code == code } 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 findCampaign(id: UUID): ReferralCampaign? = campaigns[id]
override fun listActiveCampaigns(at: Instant): List<ReferralCampaign> = override fun listActiveCampaigns(at: Instant): List<ReferralCampaign> =
@@ -91,7 +91,7 @@ class InviteWebRoutesTest {
response.status shouldBe HttpStatusCode.NotFound response.status shouldBe HttpStatusCode.NotFound
response.bodyAsText() shouldBe response.bodyAsText() shouldBe
"邀请链接无效或已失效 / This invitation link is invalid or expired" "邀请链接无效 / This invitation link is invalid"
} }
@Test @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 package com.osglab.account.features.referrals
import com.osglab.account.features.credits.TestBillingStore 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.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.ReferralBindingRules import com.osglab.account.features.referrals.domain.ReferralBindingRules
import com.osglab.account.features.referrals.domain.ReferralConflict 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.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.time.Clock import java.time.Clock
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
@@ -31,13 +35,33 @@ class ReferralServiceTest : FunSpec({
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) } val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val first = service.getOrCreateCode(owner) 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 second shouldBe first
first.code.length shouldBe 22 first.code.length shouldBe 22
store.codes.size shouldBe 1 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") { test("profile lookup automatically provisions a stable invitation code") {
val store = TestBillingStore() val store = TestBillingStore()
val owner = UUID.randomUUID() val owner = UUID.randomUUID()
@@ -47,10 +71,61 @@ class ReferralServiceTest : FunSpec({
val second = service.getProfile(owner) val second = service.getProfile(owner)
first.code shouldBe second.code first.code shouldBe second.code
first.code?.code?.length shouldBe 22 first.code.code.length shouldBe 22
store.codes.size shouldBe 1 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") { test("an account binds once and repeated same binding is idempotent") {
val store = TestBillingStore() val store = TestBillingStore()
val inviter = UUID.randomUUID() val inviter = UUID.randomUUID()
@@ -195,7 +270,7 @@ class ReferralServiceTest : FunSpec({
private fun referralService( private fun referralService(
store: TestBillingStore, store: TestBillingStore,
now: Instant, now: Instant,
riskIdentity: (UUID) -> ReferralRiskIdentity = { userId -> riskIdentity: (UUID) -> ReferralRiskIdentity? = { userId ->
ReferralRiskIdentity(fingerprint(userId), restricted = false) ReferralRiskIdentity(fingerprint(userId), restricted = false)
}, },
registeredAt: (UUID) -> Instant, 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 = private fun fingerprint(userId: UUID): String =
userId.toString().replace("-", "").repeat(2) userId.toString().replace("-", "").repeat(2)