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