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