diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index a00919c..f74442c 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -160,6 +160,11 @@ function cursorQuery(value?: string | T): CursorQuery | T type CursorQuery = { cursor?: string }; +function ledgerQuery(value?: string | LedgerQuery): CursorQuery | LedgerQuery { + if (typeof value === "string") return { cursor: value }; + return value ? { ...value, referenceId: value.referenceId?.trim() } : {}; +} + export const adminApi = { session: () => request("/auth/session"), @@ -195,12 +200,12 @@ export const adminApi = { latestLedger: (value?: string | LedgerQuery) => request>( - `/credits/ledger${encodeQuery(cursorQuery(value))}`, + `/credits/ledger${encodeQuery(ledgerQuery(value))}`, ), ledger: (userId: string, value?: string | LedgerQuery) => request>( - `/users/${encodeURIComponent(userId)}/ledger${encodeQuery(cursorQuery(value))}`, + `/users/${encodeURIComponent(userId)}/ledger${encodeQuery(ledgerQuery(value))}`, ), grantCredits: (payload: CreditGrantRequest) => diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index b36e3ad..f406377 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -27,8 +27,12 @@ export interface UsersQuery extends CursorPageQuery { status?: UserSummary["status"]; } -export interface LedgerQuery extends CursorPageQuery { - type?: LedgerEntryType; +export interface LedgerQuery extends Omit { + type?: LedgerAction; + entryType?: LedgerEntryType; + usageType?: UsageType; + referenceId?: string; + sort?: "createdAt" | "amount"; } export interface AuditQuery extends CursorPageQuery { @@ -219,23 +223,61 @@ export interface PageResult { nextCursor?: string; } -export type LedgerEntryType = +export type LedgerAction = | "grant" | "reserve" | "settle" - | "refund" - | "adjustment"; + | "refund"; + +export type LedgerEntryType = + | "SIGNUP_TRIAL" + | "MANUAL_GRANT" + | "USAGE_RESERVE" + | "USAGE_SETTLE" + | "USAGE_RELEASE" + | "USAGE_REFUND" + | "REFERRAL_INVITER" + | "REFERRAL_INVITEE" + | "STOREKIT_PURCHASE" + | "SUBSCRIPTION_GRANT"; export type UsageType = "polish" | "asr" | "ai" | "agent" | "hotword"; +export type LedgerEntryDetails = + | { + kind: "manualGrant"; + reason?: string; + operatorName?: string; + } + | { + kind: "storeKit"; + productId?: string; + transactionId?: string; + originalTransactionId?: string; + environment?: string; + purchasedAt?: string; + } + | { + kind: "referral"; + role?: string; + relatedUserId?: string; + } + | { + kind: "usage"; + reservationId?: string; + }; + export interface LedgerEntry { entryId: string; userId: string; - type: LedgerEntryType; + type: LedgerAction; + entryType: LedgerEntryType; amount: number; balanceAfter: number; reasonCode: string; usageType?: UsageType; + referenceId?: string; + details?: LedgerEntryDetails; createdAt: string; } diff --git a/admin-web/src/components/data-table.tsx b/admin-web/src/components/data-table.tsx index ed30062..c200fb8 100644 --- a/admin-web/src/components/data-table.tsx +++ b/admin-web/src/components/data-table.tsx @@ -5,8 +5,14 @@ import { type LegacyColumnDef, useLegacyTable, } from "@tanstack/react-table/legacy"; -import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; -import { type ReactNode } from "react"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + ChevronDown, + ChevronRight, +} from "lucide-react"; +import { Fragment, type ReactNode, useState } from "react"; import type { SortOrder } from "../api/types"; import { cn } from "../lib/utils"; import { EmptyState } from "./primitives"; @@ -23,6 +29,9 @@ export function DataTable({ sort, sortableColumns, onSortChange, + renderExpandedRow, + getRowId, + expandLabel = "详情", }: { data: T[]; columns: DataColumn[]; @@ -33,7 +42,11 @@ export function DataTable({ sort?: { key: string; order: SortOrder }; sortableColumns?: Record; onSortChange?: (key: string, order: SortOrder) => void; + renderExpandedRow?: (row: T) => ReactNode; + getRowId?: (row: T) => string; + expandLabel?: string; }) { + const [expandedRows, setExpandedRows] = useState>(() => new Set()); const table = useLegacyTable({ data, columns, @@ -94,25 +107,72 @@ export function DataTable({ ); })} + {renderExpandedRow ? ( + + {expandLabel} + + ) : null} ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} + {table.getRowModel().rows.map((row) => { + const expansionKey = getRowId?.(row.original) ?? row.id; + const expanded = expandedRows.has(expansionKey); + const panelId = `expanded-row-${expansionKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`; + return ( + + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + {renderExpandedRow ? ( + + + + ) : null} + + {renderExpandedRow && expanded ? ( + + + {renderExpandedRow(row.original)} + + + ) : null} + + ); + })} diff --git a/admin-web/src/components/table-toolbar.tsx b/admin-web/src/components/table-toolbar.tsx index 26878e5..e0edc57 100644 --- a/admin-web/src/components/table-toolbar.tsx +++ b/admin-web/src/components/table-toolbar.tsx @@ -5,10 +5,12 @@ export function TableToolbar({ children, active, onClear, + clearLabel = "清除筛选", }: { children: ReactNode; active: boolean; onClear: () => void; + clearLabel?: string; }) { return (
- 清除筛选 + {clearLabel}
); diff --git a/admin-web/src/features/credits/credits-page.tsx b/admin-web/src/features/credits/credits-page.tsx index 7f40a25..aed2e6d 100644 --- a/admin-web/src/features/credits/credits-page.tsx +++ b/admin-web/src/features/credits/credits-page.tsx @@ -1,13 +1,9 @@ import { ArrowDownUp, Coins, Search } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; +import { useCallback, useState, type FormEvent } from "react"; import { useSearchParams } from "react-router-dom"; import { adminApi } from "../../api/client"; -import type { LedgerEntry, LedgerEntryType, LedgerQuery, SortOrder } from "../../api/types"; -import { DataTable, type DataColumn } from "../../components/data-table"; -import { DateRangeControl } from "../../components/date-range-control"; -import { FilterControl } from "../../components/filter-control"; +import type { LedgerQuery } from "../../api/types"; import { - Badge, Button, Card, ErrorState, @@ -15,45 +11,21 @@ import { LoadingState, PageHeader, } from "../../components/primitives"; -import { TableToolbar } from "../../components/table-toolbar"; import { useCursorPage } from "../../hooks/use-cursor-page"; -import { - formatDateTime, - formatNumber, - formatSignedCredits, - statusLabel, - usageTypeLabel, -} from "../../lib/format"; +import { formatNumber } from "../../lib/format"; +import { LedgerFilters } from "./ledger-filters"; +import { LedgerTable } from "./ledger-table"; +import { useLedgerFilters } from "./use-ledger-filters"; export function CreditsPage() { const [searchParams, setSearchParams] = useSearchParams(); const initialUserId = searchParams.get("userId") ?? ""; const [query, setQuery] = useState(initialUserId); const [activeUserId, setActiveUserId] = useState(initialUserId); - const [from, setFrom] = useState(searchParams.get("from") ?? ""); - const [until, setUntil] = useState(searchParams.get("until") ?? ""); - const [type, setType] = useState<"" | LedgerEntryType>( - (searchParams.get("type") as LedgerEntryType | null) ?? "", - ); - const [order, setOrder] = useState( - searchParams.get("order") === "asc" ? "asc" : "desc", - ); - const ledgerQuery = useMemo( - () => ({ - from: from || undefined, - until: until || undefined, - type: type || undefined, - sort: "createdAt", - order, - limit: 50, - }), - [from, order, type, until], - ); + const filters = useLedgerFilters(); const fetchLedger = useCallback( (value: LedgerQuery) => - activeUserId - ? adminApi.ledger(activeUserId, value) - : adminApi.latestLedger(value), + activeUserId ? adminApi.ledger(activeUserId, value) : adminApi.latestLedger(value), [activeUserId], ); const { @@ -64,88 +36,27 @@ export function CreditsPage() { error, loadMore, reload, - } = useCursorPage(ledgerQuery, fetchLedger); - - useEffect(() => { - const params = new URLSearchParams(); - if (activeUserId) params.set("userId", activeUserId); - if (from) params.set("from", from); - if (until) params.set("until", until); - if (type) params.set("type", type); - params.set("sort", "createdAt"); - params.set("order", order); - setSearchParams(params, { replace: true }); - }, [activeUserId, from, order, setSearchParams, type, until]); + } = useCursorPage(filters.query, fetchLedger); function submit(event: FormEvent) { event.preventDefault(); - const userId = query.trim() || undefined; - setActiveUserId(userId ?? ""); + const userId = query.trim(); + setActiveUserId(userId); + setSearchParams((current) => { + const params = new URLSearchParams(current); + if (userId) params.set("userId", userId); + else params.delete("userId"); + return params; + }, { replace: true }); } - const columns = useMemo[]>( - () => [ - { - accessorKey: "createdAt", - header: "时间", - cell: ({ getValue }) => ( - - {formatDateTime(String(getValue()))} - - ), - }, - { - accessorKey: "userId", - header: "用户 ID", - cell: ({ getValue }) => ( - - {String(getValue())} - - ), - }, - { - accessorKey: "type", - header: "类型", - cell: ({ getValue }) => {statusLabel(String(getValue()))}, - }, - { - accessorKey: "usageType", - header: "消费类型", - cell: ({ getValue }) => - getValue() ? {usageTypeLabel(String(getValue()))} : "—", - }, - { - accessorKey: "amount", - header: "变动", - cell: ({ getValue }) => { - const value = Number(getValue()); - return ( - = 0 ? "text-success" : "text-danger"}`} - > - {formatSignedCredits(value)} - - ); - }, - }, - { - accessorKey: "balanceAfter", - header: "结余", - cell: ({ getValue }) => ( - {formatNumber(Number(getValue()))} - ), - }, - { - accessorKey: "reasonCode", - header: "原因", - cell: ({ getValue }) => ( - - {String(getValue())} - - ), - }, - ], - [], + const hasBusinessFilter = Boolean( + filters.state.from || + filters.state.until || + filters.state.type || + filters.state.entryType || + filters.state.usageType || + filters.state.referenceId, ); return ( @@ -153,7 +64,7 @@ export function CreditsPage() { @@ -181,38 +92,12 @@ export function CreditsPage() { - { - setQuery(""); - setActiveUserId(""); - setFrom(""); - setUntil(""); - setType(""); - setOrder("desc"); - }} - > - { - setFrom(value.from ?? ""); - setUntil(value.until ?? ""); - }} - /> - - + +
@@ -233,18 +118,19 @@ export function CreditsPage() { ) : loading ? ( ) : ( - setOrder(nextOrder)} + showUser={!activeUserId} + sort={filters.state.sort} + order={filters.state.order} + onSortChange={(sort, order) => filters.update({ sort, order })} + onReferenceFilter={(referenceId) => filters.update({ referenceId })} footer={ nextCursor ? (
diff --git a/admin-web/src/features/credits/ledger-filters.tsx b/admin-web/src/features/credits/ledger-filters.tsx new file mode 100644 index 0000000..ea38a5b --- /dev/null +++ b/admin-web/src/features/credits/ledger-filters.tsx @@ -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) => 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) { + 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 ( + <> + + { + if (range === "custom") onChange({ range }); + else onChange(ledgerDatePreset(range)); + }} + /> + {value.range === "custom" ? ( + + onChange({ + from: range.from ?? "", + until: range.until ?? "", + range: "custom", + }) + } + /> + ) : null} + onChange({ type })} + /> + onChange({ entryType })} + /> + onChange({ usageType })} + /> +
+ + + + + setReferenceDraft(event.target.value)} + placeholder="输入 referenceId" + maxLength={36} + autoComplete="off" + aria-invalid={!referenceValid} + aria-describedby={!referenceValid ? "ledger-reference-error" : undefined} + /> + + + + {!referenceValid ? ( + + 请输入完整 UUID + + ) : null} +
+
+
+ + 筛选摘要 + + {summary.length > 0 ? summary.join(" · ") : "全部流水"} + +
+ + ); +} diff --git a/admin-web/src/features/credits/ledger-format.ts b/admin-web/src/features/credits/ledger-format.ts new file mode 100644 index 0000000..fc420f5 --- /dev/null +++ b/admin-web/src/features/credits/ledger-format.ts @@ -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; + +const usageLabels = Object.fromEntries( + ledgerUsageOptions + .filter((option) => option.value) + .map((option) => [option.value, option.label]), +) as Record; + +const actionPresentation: Record = { + grant: { label: "入账", tone: "success" }, + reserve: { label: "预留", tone: "warning" }, + settle: { label: "结算", tone: "info" }, + refund: { label: "退还", tone: "violet" }, +}; + +const usageClasses: Record = { + 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"; +} diff --git a/admin-web/src/features/credits/ledger-table.tsx b/admin-web/src/features/credits/ledger-table.tsx new file mode 100644 index 0000000..88388af --- /dev/null +++ b/admin-web/src/features/credits/ledger-table.tsx @@ -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; + order: SortOrder; + onSortChange: (sort: NonNullable, order: SortOrder) => void; + onReferenceFilter: (referenceId: string) => void; + footer?: ReactNode; +}) { + const columns = useMemo[]>(() => { + const result: DataColumn[] = [ + { + accessorKey: "createdAt", + header: "时间", + cell: ({ getValue }) => ( + + {formatDateTime(String(getValue()))} + + ), + }, + ]; + if (showUser) { + result.push({ + accessorKey: "userId", + header: "用户", + cell: ({ getValue }) => { + const userId = String(getValue()); + return ( + + + + {userId} + + + ); + }, + }); + } + result.push( + { + accessorKey: "type", + header: "账务动作", + cell: ({ getValue }) => ( + + {ledgerActionLabel(String(getValue()))} + + ), + }, + { + accessorKey: "entryType", + header: "业务来源", + cell: ({ getValue }) => ( + + {ledgerEntryTypeLabel(String(getValue() ?? ""))} + + ), + }, + { + accessorKey: "usageType", + header: "消费能力", + cell: ({ getValue }) => { + const usageType = getValue() ? String(getValue()) : undefined; + return usageType ? ( + + {ledgerUsageLabel(usageType)} + + ) : ( + + ); + }, + }, + { + accessorKey: "amount", + header: "变动", + cell: ({ getValue }) => { + const value = Number(getValue()); + return ( + = 0 ? "text-success" : "text-danger"}`} + > + {formatSignedCredits(value)} + + ); + }, + }, + { + accessorKey: "balanceAfter", + header: "结余", + cell: ({ getValue }) => ( + {formatNumber(Number(getValue()))} + ), + }, + ); + return result; + }, [showUser]); + + return ( + + onSortChange(nextSort as NonNullable, nextOrder) + } + renderExpandedRow={(entry) => ( + + )} + getRowId={(entry) => entry.entryId} + footer={footer} + /> + ); +} + +function LedgerDetails({ + entry, + onReferenceFilter, +}: { + entry: LedgerEntry; + onReferenceFilter: (referenceId: string) => void; +}) { + const details = entry.details; + return ( +
+
+ + + + + {entry.referenceId ? ( +
+ + +
+ ) : ( + "—" + )} +
+ + {entry.reasonCode || "—"} + + {details?.kind === "manualGrant" ? ( + <> + {details.reason || "—"} + {details.operatorName || "—"} + + ) : null} + {details?.kind === "storeKit" ? ( + <> + {details.productId || "—"} + + + + + + + {details.environment || "—"} + {formatDateTime(details.purchasedAt)} + + ) : null} + {details?.kind === "referral" ? ( + <> + {referralRoleLabel(details.role)} + + + + + ) : null} + {details?.kind === "usage" ? ( + + + + ) : null} +
+
+ ); +} + +function DetailItem({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function Identifier({ value }: { value?: string }) { + if (!value) return ; + return ( + + {value} + + + ); +} + +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 = { + inviter: "邀请人", + invitee: "受邀人", + INVITER: "邀请人", + INVITEE: "受邀人", + }; + return labels[role] ?? role; +} diff --git a/admin-web/src/features/credits/use-ledger-filters.ts b/admin-web/src/features/credits/use-ledger-filters.ts new file mode 100644 index 0000000..aca93d3 --- /dev/null +++ b/admin-web/src/features/credits/use-ledger-filters.ts @@ -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; + order: SortOrder; +} + +const defaultState: LedgerFilterState = { + from: "", + until: "", + type: "", + entryType: "", + usageType: "", + referenceId: "", + range: "all", + sort: "createdAt", + order: "desc", +}; + +const ranges = new Set(["all", "today", "7d", "30d", "90d", "custom"]); +const sorts = new Set(["createdAt", "amount"]); +const orders = new Set(["asc", "desc"]); + +function parameterName(prefix: string, name: string): string { + if (!prefix) return name; + return `${prefix}${name.charAt(0).toUpperCase()}${name.slice(1)}`; +} + +function validOption( + 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, + now = new Date(), +): Pick { + 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(() => + readLedgerFilterState(searchParams, prefix), + ); + + useEffect(() => { + setSearchParams((current) => { + const params = new URLSearchParams(current); + const keys: Array = [ + "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( + () => ({ + 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) => + setState((current) => ({ ...current, ...patch })), + clear: () => setState(defaultState), + }; +} diff --git a/admin-web/src/features/users/users-page.tsx b/admin-web/src/features/users/users-page.tsx index dd2e0fa..241a104 100644 --- a/admin-web/src/features/users/users-page.tsx +++ b/admin-web/src/features/users/users-page.tsx @@ -19,9 +19,7 @@ import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { adminApi, ApiError } from "../../api/client"; import type { - LedgerEntryType, LedgerQuery, - LedgerEntry, SortOrder, UserDetail, UsersQuery, @@ -48,34 +46,37 @@ import { createIdempotencyKey, formatDateTime, formatNumber, - formatSignedCredits, statusLabel, usageTypeLabel, } from "../../lib/format"; import { useAuth } from "../auth/auth-context"; - -const ledgerTypeOptions: Array<{ value: "" | LedgerEntryType; label: string }> = [ - { value: "", label: "全部类型" }, - { value: "grant", label: "赠送" }, - { value: "reserve", label: "预留" }, - { value: "settle", label: "结算" }, - { value: "refund", label: "退款" }, - { value: "adjustment", label: "调整" }, -]; +import { LedgerFilters } from "../credits/ledger-filters"; +import { LedgerTable } from "../credits/ledger-table"; +import { useLedgerFilters } from "../credits/use-ledger-filters"; export function UsersPage() { const { auth } = useAuth(); + const [searchParams, setSearchParams] = useSearchParams(); const role = auth.status === "authenticated" ? auth.role : "ANALYST"; - const [selectedUserId, setSelectedUserId] = useState(); + 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 ? ( setSelectedUserId(undefined)} + onBack={() => selectUser()} /> ) : ( - + ); } @@ -304,98 +305,49 @@ function UserDetailView({ canGrant: boolean; onBack: () => void; }) { - const [searchParams, setSearchParams] = useSearchParams(); const [user, setUser] = useState(); - const [ledger, setLedger] = useState([]); - const [nextCursor, setNextCursor] = useState(); const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(); const [grantOpen, setGrantOpen] = useState(false); - const [from, setFrom] = useState(searchParams.get("ledgerFrom") ?? ""); - const [until, setUntil] = useState(searchParams.get("ledgerUntil") ?? ""); - const [type, setType] = useState<"" | LedgerEntryType>( - (searchParams.get("ledgerType") as LedgerEntryType | null) ?? "", - ); - const [order, setOrder] = useState( - searchParams.get("ledgerOrder") === "asc" ? "asc" : "desc", - ); + const filters = useLedgerFilters("ledger"); const requestVersion = useRef(0); - const ledgerQuery = useMemo( - () => ({ - from: from || undefined, - until: until || undefined, - type: type || undefined, - sort: "createdAt", - order, - limit: 50, - }), - [from, order, type, until], + const fetchLedger = useCallback( + (value: LedgerQuery) => adminApi.ledger(userId, value), + [userId], ); + const ledgerPage = useCursorPage(filters.query, fetchLedger); const load = useCallback(async () => { const version = ++requestVersion.current; - setLedger([]); - setNextCursor(undefined); setLoading(true); - setLoadingMore(false); setError(undefined); try { - const [detail, page] = await Promise.all([ - adminApi.user(userId), - adminApi.ledger(userId, ledgerQuery), - ]); + const detail = await adminApi.user(userId); if (requestVersion.current !== version) return; setUser(detail); - setLedger(page.items); - setNextCursor(page.nextCursor); } catch (requestError) { if (requestVersion.current === version) setError(requestError); } finally { if (requestVersion.current === version) setLoading(false); } - }, [ledgerQuery, userId]); + }, [userId]); useEffect(() => { void load(); }, [load]); - useEffect(() => { - setSearchParams((current) => { - const params = new URLSearchParams(current); - ["ledgerFrom", "ledgerUntil", "ledgerType", "ledgerOrder"].forEach((key) => - params.delete(key), - ); - if (from) params.set("ledgerFrom", from); - if (until) params.set("ledgerUntil", until); - if (type) params.set("ledgerType", type); - params.set("ledgerOrder", order); - return params; - }, { replace: true }); - }, [from, order, setSearchParams, type, until]); - - async function loadMoreLedger() { - if (!nextCursor) return; - const version = requestVersion.current; - setLoadingMore(true); - try { - const page = await adminApi.ledger(userId, { ...ledgerQuery, cursor: nextCursor }); - if (requestVersion.current !== version) return; - setLedger((current) => [...current, ...page.items]); - setNextCursor(page.nextCursor); - } catch (requestError) { - toast.error( - requestError instanceof ApiError ? requestError.message : "加载积分流水失败", - ); - } finally { - if (requestVersion.current === version) setLoadingMore(false); - } + if (error || ledgerPage.error) { + return ( + { + void load(); + ledgerPage.reload(); + }} + /> + ); } - - const ledgerColumns = useLedgerColumns(); - - if (error) return void load()} />; - if (loading || !user) return ; + if (loading || ledgerPage.loading || !user) return ; const usage = user.usage ?? []; const requests = usage.reduce((total, item) => total + item.requests, 0); @@ -472,44 +424,35 @@ function UserDetailView({ title="积分流水" description="所有变动均来自不可变账本" /> - { - setFrom(""); - setUntil(""); - setType(""); - setOrder("desc"); - }} - > - { - setFrom(value.from ?? ""); - setUntil(value.until ?? ""); - }} - /> - - - + setOrder(nextOrder)} + emptyTitle={ + filters.state.from || + filters.state.until || + filters.state.type || + filters.state.entryType || + filters.state.usageType || + filters.state.referenceId + ? "没有符合当前筛选条件的流水" + : "暂无积分流水" + } + sort={filters.state.sort} + order={filters.state.order} + onSortChange={(sort, order) => filters.update({ sort, order })} + onReferenceFilter={(referenceId) => filters.update({ referenceId })} footer={ - nextCursor ? ( + ledgerPage.nextCursor ? (
@@ -528,7 +471,10 @@ function UserDetailView({ open={grantOpen} user={user} onOpenChange={setGrantOpen} - onSuccess={load} + onSuccess={async () => { + await load(); + ledgerPage.reload(); + }} /> ) : null}
@@ -694,62 +640,6 @@ export function GrantDialog({ ); } -function useLedgerColumns(): DataColumn[] { - return useMemo( - () => [ - { - accessorKey: "createdAt", - header: "时间", - cell: ({ getValue }) => ( - - {formatDateTime(String(getValue()))} - - ), - }, - { - accessorKey: "type", - header: "类型", - cell: ({ getValue }) => {statusLabel(String(getValue()))}, - }, - { - accessorKey: "usageType", - header: "消费类型", - cell: ({ getValue }) => - getValue() ? {usageTypeLabel(String(getValue()))} : "—", - }, - { - accessorKey: "amount", - header: "变动", - cell: ({ getValue }) => { - const value = Number(getValue()); - return ( - = 0 ? "text-success" : "text-danger"}`} - > - {formatSignedCredits(value)} - - ); - }, - }, - { - accessorKey: "balanceAfter", - header: "结余", - cell: ({ getValue }) => ( - {formatNumber(Number(getValue()))} - ), - }, - { - accessorKey: "reasonCode", - header: "原因", - cell: ({ getValue }) => ( - {String(getValue())} - ), - }, - ], - [], - ); -} - function UsagePanel({ usage }: { usage: UserUsageAggregate[] }) { const columns = useMemo[]>( () => [ diff --git a/admin-web/src/lib/format.ts b/admin-web/src/lib/format.ts index d58da75..3ae2ebc 100644 --- a/admin-web/src/lib/format.ts +++ b/admin-web/src/lib/format.ts @@ -43,7 +43,6 @@ export function statusLabel(status: string): string { reserve: "预留", settle: "结算", refund: "退还", - adjustment: "调整", }; return labels[status] ?? status; } diff --git a/admin-web/src/test/client.test.ts b/admin-web/src/test/client.test.ts index cc4a803..3b933f5 100644 --- a/admin-web/src/test/client.test.ts +++ b/admin-web/src/test/client.test.ts @@ -141,6 +141,36 @@ describe("adminApi", () => { ); }); + it("流水查询编码三维筛选、关联 ID 与金额排序", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await adminApi.latestLedger({ + cursor: "next+/=", + limit: 50, + from: "2026-08-01T00:00:00.000Z", + until: "2026-08-21T00:00:00.000Z", + type: "settle", + entryType: "USAGE_SETTLE", + usageType: "hotword", + referenceId: "11111111-1111-4111-8111-111111111111", + sort: "amount", + order: "asc", + }); + + const url = String(fetchMock.mock.calls[0]?.[0]); + expect(url).toContain("cursor=next%2B%2F%3D&limit=50"); + expect(url).toContain("type=settle&entryType=USAGE_SETTLE&usageType=hotword"); + expect(url).toContain( + "referenceId=11111111-1111-4111-8111-111111111111&sort=amount&order=asc", + ); + }); + it("缺少 CSRF 时在发送变更请求前失败", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); diff --git a/admin-web/src/test/filter-sort.test.tsx b/admin-web/src/test/filter-sort.test.tsx index e261d20..c9e61db 100644 --- a/admin-web/src/test/filter-sort.test.tsx +++ b/admin-web/src/test/filter-sort.test.tsx @@ -6,6 +6,11 @@ import { DataTable, type DataColumn } from "../components/data-table"; import { DateRangeControl } from "../components/date-range-control"; import type { SortOrder } from "../api/types"; import { stableSort } from "../lib/sort"; +import { LedgerFilters } from "../features/credits/ledger-filters"; +import { + ledgerDatePreset, + readLedgerFilterState, +} from "../features/credits/use-ledger-filters"; afterEach(cleanup); @@ -60,4 +65,51 @@ describe("筛选与排序基础能力", () => { until: "2026-08-21T00:00:00.000Z", }); }); + + it("流水时间预设生成 UTC 半开区间", () => { + const now = new Date("2026-08-20T21:30:00+08:00"); + + expect(ledgerDatePreset("today", now)).toEqual({ + from: "2026-08-20T00:00:00.000Z", + until: "2026-08-21T00:00:00.000Z", + range: "today", + }); + expect(ledgerDatePreset("7d", now)).toEqual({ + from: "2026-08-14T00:00:00.000Z", + until: "2026-08-21T00:00:00.000Z", + range: "7d", + }); + expect(ledgerDatePreset("all", now)).toEqual({ + from: "", + until: "", + range: "all", + }); + }); + + it("关联 ID 完整且合法后才应用筛选", async () => { + const onChange = vi.fn(); + render( + , + ); + + 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", + }); + }); }); diff --git a/admin-web/src/test/format.test.ts b/admin-web/src/test/format.test.ts index 1843b3b..719c12c 100644 --- a/admin-web/src/test/format.test.ts +++ b/admin-web/src/test/format.test.ts @@ -6,6 +6,10 @@ import { statusLabel, usageTypeLabel, } from "../lib/format"; +import { + ledgerEntryTypeLabel, + ledgerUsageClass, +} from "../features/credits/ledger-format"; describe("format helpers", () => { it("转义服务端文本以阻止 HTML 注入", () => { @@ -33,4 +37,39 @@ describe("format helpers", () => { expect(usageTypeLabel("hotword")).toBe("热词"); expect(usageTypeLabel()).toBe("—"); }); + + it("十种流水业务来源使用稳定中文标签,未知值保留原值", () => { + expect([ + "SIGNUP_TRIAL", + "MANUAL_GRANT", + "USAGE_RESERVE", + "USAGE_SETTLE", + "USAGE_RELEASE", + "USAGE_REFUND", + "REFERRAL_INVITER", + "REFERRAL_INVITEE", + "STOREKIT_PURCHASE", + "SUBSCRIPTION_GRANT", + ].map(ledgerEntryTypeLabel)).toEqual([ + "注册试用赠送", + "人工赠送", + "消费预留", + "消费结算", + "消费释放", + "消费退款", + "邀请人奖励", + "受邀人奖励", + "App Store 购买", + "订阅赠送", + ]); + expect(ledgerEntryTypeLabel("FUTURE_TYPE")).toBe("未知类型(FUTURE_TYPE)"); + }); + + it("五种消费能力使用固定且可读的类别色", () => { + expect(ledgerUsageClass("polish")).toContain("text-violet"); + expect(ledgerUsageClass("asr")).toContain("text-primary"); + expect(ledgerUsageClass("ai")).toContain("text-success"); + expect(ledgerUsageClass("agent")).toContain("text-warning"); + expect(ledgerUsageClass("hotword")).toContain("text-pink"); + }); }); diff --git a/admin-web/src/test/pages.test.tsx b/admin-web/src/test/pages.test.tsx index e369413..abadd23 100644 --- a/admin-web/src/test/pages.test.tsx +++ b/admin-web/src/test/pages.test.tsx @@ -85,6 +85,7 @@ describe("React 管理页面", () => { entryId: "ledger-1", userId, type: "settle", + entryType: "USAGE_SETTLE", amount: -18, balanceAfter: 102, reasonCode: "USAGE_SETTLE", @@ -97,11 +98,100 @@ describe("React 管理页面", () => { render(); expect(await screen.findByRole("heading", { name: "积分流水" })).toBeTruthy(); - expect(await screen.findByText("USAGE_SETTLE")).toBeTruthy(); - expect(screen.getByText("热词")).toBeTruthy(); + expect(await screen.findByText("消费结算")).toBeTruthy(); + expect(screen.getAllByText("热词")).toHaveLength(2); expect(screen.getByText("-18")).toBeTruthy(); }); + it("积分流水支持金额排序、展开安全详情、关联筛选与用户跳转", async () => { + mockSession("SUPPORT"); + const referenceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const entry: LedgerEntry = { + entryId: "ledger-manual-1", + userId, + type: "grant", + entryType: "MANUAL_GRANT", + amount: 50, + balanceAfter: 170, + reasonCode: "MANUAL_CREDIT_GRANT", + referenceId, + details: { + kind: "manualGrant", + reason: "客服补偿", + operatorName: "support", + }, + createdAt: "2026-08-20T09:00:00Z", + }; + vi.spyOn(adminApi, "latestLedger").mockResolvedValue({ items: [entry] }); + window.location.hash = "#/credits"; + + render(); + + 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(); + + expect(await screen.findByRole("heading", { name: "链接用户" })).toBeTruthy(); + await waitFor(() => { + expect(adminApi.ledger).toHaveBeenCalledWith( + userId, + expect.objectContaining({ + type: "reserve", + entryType: "USAGE_RESERVE", + usageType: "ai", + }), + ); + }); + expect(window.location.hash).toContain("q=11111111"); + expect(window.location.hash).toContain("ledgerType=reserve"); + + await userEvent.click(screen.getByRole("button", { name: "返回用户列表" })); + + await waitFor(() => { + expect(window.location.hash).not.toContain("userId="); + expect(window.location.hash).toContain("q=11111111"); + expect(window.location.hash).toContain("status=active"); + expect(window.location.hash).toContain("order=asc"); + }); + }); + it("安全中心保留管理员游标分页", async () => { mockSession("SUPER_ADMIN"); const first = operator("operator-1", "owner"); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2f18d43..b557458 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -263,17 +263,18 @@ paths: default: { $ref: "#/components/responses/Error" } /v1/referrals/me: get: - summary: Return the referral profile and idempotently provision its invite code + summary: Return the referral profile and provision its permanent account invite code responses: "200": description: Referral profile content: application/json: - schema: { type: object, additionalProperties: true } + schema: { $ref: "#/components/schemas/ReferralProfile" } default: { $ref: "#/components/responses/Error" } /v1/referrals/code: post: - summary: Idempotently create the current campaign invite code + deprecated: true + summary: Compatibility endpoint for the permanent account invite code responses: "200": description: Invite code @@ -685,11 +686,14 @@ paths: - $ref: "#/components/parameters/AdminFrom" - $ref: "#/components/parameters/AdminUntil" - $ref: "#/components/parameters/AdminLedgerType" - - $ref: "#/components/parameters/AdminCreatedAtSort" + - $ref: "#/components/parameters/AdminLedgerEntryType" + - $ref: "#/components/parameters/AdminLedgerUsageType" + - $ref: "#/components/parameters/AdminLedgerReferenceId" + - $ref: "#/components/parameters/AdminLedgerSort" - $ref: "#/components/parameters/AdminSortOrder" responses: "200": - description: Credit ledger entries ordered by creation time and entry ID + description: Credit ledger entries ordered by the requested field and unique entry ID content: application/json: schema: { $ref: "#/components/schemas/AdminLedgerPage" } @@ -710,11 +714,14 @@ paths: - $ref: "#/components/parameters/AdminFrom" - $ref: "#/components/parameters/AdminUntil" - $ref: "#/components/parameters/AdminLedgerType" - - $ref: "#/components/parameters/AdminCreatedAtSort" + - $ref: "#/components/parameters/AdminLedgerEntryType" + - $ref: "#/components/parameters/AdminLedgerUsageType" + - $ref: "#/components/parameters/AdminLedgerReferenceId" + - $ref: "#/components/parameters/AdminLedgerSort" - $ref: "#/components/parameters/AdminSortOrder" responses: "200": - description: Latest credit ledger entries ordered by creation time and entry ID + description: Latest credit ledger entries ordered by the requested field and unique entry ID content: application/json: schema: { $ref: "#/components/schemas/AdminLedgerPage" } @@ -1007,7 +1014,26 @@ components: in: query schema: type: string - enum: [grant, reserve, settle, refund, adjustment] + enum: [grant, reserve, settle, refund] + AdminLedgerEntryType: + name: entryType + in: query + description: Exact immutable ledger entry type. This filter is ANDed with all other filters. + schema: + $ref: "#/components/schemas/LedgerEntryType" + AdminLedgerUsageType: + name: usageType + in: query + description: Product usage associated with the reservation. HOTWORD request source takes precedence over capability. + schema: { type: string, enum: [polish, asr, ai, agent, hotword] } + AdminLedgerReferenceId: + name: referenceId + in: query + schema: { type: string, format: uuid } + AdminLedgerSort: + name: sort + in: query + schema: { type: string, enum: [createdAt, amount], default: createdAt } AdminLedgerLimit: name: limit in: query @@ -1411,21 +1437,58 @@ components: type: array items: { $ref: "#/components/schemas/AdminUsageAggregate" } referral: { $ref: "#/components/schemas/AdminUserReferral" } + LedgerEntryType: + type: string + enum: + - SIGNUP_TRIAL + - MANUAL_GRANT + - USAGE_RESERVE + - USAGE_SETTLE + - USAGE_RELEASE + - USAGE_REFUND + - REFERRAL_INVITER + - REFERRAL_INVITEE + - STOREKIT_PURCHASE + - SUBSCRIPTION_GRANT + AdminLedgerDetails: + type: object + additionalProperties: false + required: [kind] + description: Privacy-minimized source metadata. Missing source associations omit the details object. + properties: + kind: { type: string, enum: [manualGrant, storeKit, referral, usage] } + reason: { type: string, description: Present for manualGrant details } + operatorName: { type: string, description: Present for manualGrant details } + productId: { type: string, description: Present for storeKit details } + transactionId: { type: string, description: Present for storeKit details } + originalTransactionId: { type: string, description: Present for storeKit details } + environment: { type: string, enum: [SANDBOX, PRODUCTION], description: Present for storeKit details } + purchasedAt: { type: string, format: date-time, description: Present for storeKit details } + role: { type: string, enum: [inviter, invitee], description: Present for referral details } + relatedUserId: { type: string, format: uuid, description: Present for referral details } + reservationId: { type: string, format: uuid, description: Present for usage details } AdminLedgerEntry: type: object additionalProperties: false - required: [entryId, userId, type, amount, balanceAfter, reasonCode, createdAt] + required: [entryId, userId, type, entryType, amount, balanceAfter, reasonCode, createdAt] properties: entryId: { type: string, format: uuid } userId: { type: string, format: uuid } - type: { type: string, enum: [grant, reserve, settle, refund, adjustment] } + type: { type: string, enum: [grant, reserve, settle, refund] } + entryType: { $ref: "#/components/schemas/LedgerEntryType" } amount: { type: integer, format: int64 } balanceAfter: { type: integer, format: int64, minimum: 0 } - reasonCode: { type: string } + reasonCode: + allOf: + - $ref: "#/components/schemas/LedgerEntryType" + deprecated: true + description: Compatibility alias for entryType + referenceId: { type: ["string", "null"], format: uuid } usageType: type: ["string", "null"] enum: [polish, asr, ai, agent, hotword, null] description: Product usage associated with this ledger operation + details: { $ref: "#/components/schemas/AdminLedgerDetails" } createdAt: { type: string, format: date-time } AdminLedgerPage: type: object @@ -1728,9 +1791,31 @@ components: items: { type: object, additionalProperties: true } ReferralCode: type: object - additionalProperties: true + additionalProperties: false + required: [code, inviteUrl, createdAt] properties: - code: { type: string, pattern: "^[A-Za-z0-9_-]{22}$" } + code: + type: string + pattern: "^[A-Za-z0-9_-]{22}$" + description: Permanent opaque identifier assigned once to the account. + inviteUrl: + type: string + format: uri + description: Stable first-party invitation URL containing the permanent code. + campaignId: + type: ["string", "null"] + format: uuid + description: Legacy creation metadata; it does not limit the invitation lifetime. + createdAt: { type: string, format: date-time } + ReferralProfile: + type: object + additionalProperties: false + required: [code] + properties: + code: { $ref: "#/components/schemas/ReferralCode" } + binding: + type: ["object", "null"] + additionalProperties: true RedeemReferralRequest: type: object additionalProperties: false diff --git a/src/main/kotlin/com/osglab/account/Application.kt b/src/main/kotlin/com/osglab/account/Application.kt index 74c07e6..2e469e0 100644 --- a/src/main/kotlin/com/osglab/account/Application.kt +++ b/src/main/kotlin/com/osglab/account/Application.kt @@ -115,7 +115,6 @@ import com.osglab.account.features.inviteweb.InviteWebConfig import com.osglab.account.features.inviteweb.InviteOpenRecorder import com.osglab.account.features.inviteweb.ReferralLookupPort import com.osglab.account.features.inviteweb.configureInviteWebRoutes -import com.osglab.account.features.referrals.domain.ReferralException import com.osglab.account.features.referrals.routes.referralRoutes import com.osglab.account.features.referrals.services.ReferralOperations import com.osglab.account.features.referrals.services.ReferralService @@ -312,7 +311,7 @@ fun Application.module() { rateLimit(ACCOUNT_RATE_LIMIT) { accountRoutes(koin.get()) creditRoutes(koin.get(), koin.get()) - referralRoutes(koin.get(), koin.get()) + referralRoutes(koin.get(), appConfig.inviteBaseUrl, koin.get()) storeKitRoutes(koin.get()) } rateLimit(GATEWAY_RATE_LIMIT) { @@ -519,13 +518,8 @@ fun accountServerModule(config: AppConfig): Module = module { ) } get().seedDisplayName(accountId, displayName) - try { - get().getOrCreateCode(accountId) - } catch (exception: CancellationException) { - throw exception - } catch (_: ReferralException) { - // Referral eligibility must not make account sign-in unavailable. - } + // Referral provisioning is intentionally handled by /v1/referrals/me + // after authentication so referral storage can never block sign-in. } } single { @@ -585,10 +579,9 @@ fun accountServerModule(config: AppConfig): Module = module { val transactions = get() ReferralLookupPort { code -> transactions.inTransaction { unit -> - val referralCode = unit.referrals.findCode(code) - referralCode?.campaignId - ?.let(unit.referrals::findCampaign) - ?.isActive(Instant.now()) == true + // Invitation codes are permanent account identifiers. Campaign + // availability is evaluated only when an invitee redeems one. + unit.referrals.findCode(code) != null } } } diff --git a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt index d02ce0f..5510dca 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt @@ -28,12 +28,15 @@ import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService import com.osglab.account.features.admin.stats.services.AdminReferralSort import com.osglab.account.features.admin.stats.services.AdminStatsService +import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto import com.osglab.account.features.admin.users.models.AdminUserDetailDto import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto import com.osglab.account.features.admin.users.models.AdminUserReferralDto import com.osglab.account.features.admin.users.models.AdminUserSummaryDto import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery +import com.osglab.account.features.admin.users.repositories.AdminLedgerSort import com.osglab.account.features.admin.users.repositories.AdminLedgerType +import com.osglab.account.features.admin.users.repositories.AdminUsageType import com.osglab.account.features.admin.users.repositories.AdminUserListQuery import com.osglab.account.features.admin.users.repositories.AdminUserStatus import com.osglab.account.features.admin.users.services.AdminUserNotFoundException @@ -41,6 +44,7 @@ import com.osglab.account.features.admin.users.services.AdminUsersService import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.InvalidCreditRequest +import com.osglab.account.features.credits.domain.LedgerEntryType import io.ktor.http.Cookie import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode @@ -651,21 +655,61 @@ private fun ApplicationCall.adminUserListQuery(): AdminUserListQuery { } private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery { - requireQueryParameters(setOf("cursor", "limit", "from", "until", "type", "sort", "order")) - requireCreatedAtSort() + requireQueryParameters( + setOf( + "cursor", + "limit", + "from", + "until", + "type", + "entryType", + "usageType", + "referenceId", + "sort", + "order", + ), + ) val type = request.queryParameters["type"]?.let { when (it) { "reserve" -> AdminLedgerType.RESERVE "settle" -> AdminLedgerType.SETTLE "refund" -> AdminLedgerType.REFUND "grant" -> AdminLedgerType.GRANT - "adjustment" -> AdminLedgerType.ADJUSTMENT else -> throw IllegalArgumentException("Invalid ledger type") } } + val entryType = request.queryParameters["entryType"]?.let { + runCatching { LedgerEntryType.valueOf(it) }.getOrNull() + ?: throw IllegalArgumentException("Invalid ledger entry type") + } + val usageType = request.queryParameters["usageType"]?.let { + when (it) { + "polish" -> AdminUsageType.POLISH + "asr" -> AdminUsageType.ASR + "ai" -> AdminUsageType.AI + "agent" -> AdminUsageType.AGENT + "hotword" -> AdminUsageType.HOTWORD + else -> throw IllegalArgumentException("Invalid ledger usage type") + } + } + val referenceId = request.queryParameters["referenceId"]?.let { + runCatching { UUID.fromString(it) }.getOrNull() + ?: throw IllegalArgumentException("Invalid ledger reference ID") + } + val sort = request.queryParameters["sort"]?.let { + when (it) { + "createdAt" -> AdminLedgerSort.CREATED_AT + "amount" -> AdminLedgerSort.AMOUNT + else -> throw IllegalArgumentException("Invalid ledger sort") + } + } ?: AdminLedgerSort.CREATED_AT return AdminLedgerQuery( time = adminTimeFilter(), type = type, + entryType = entryType, + usageType = usageType, + referenceId = referenceId, + sort = sort, order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC), ) } @@ -962,21 +1006,34 @@ private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse = AdminLedgerResponse( entryId = id, userId = userId, - type = when (type) { - "USAGE_RESERVE" -> "reserve" - "USAGE_SETTLE" -> "settle" - "USAGE_RELEASE", "USAGE_REFUND" -> "refund" - "SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE", - "STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant" - else -> "adjustment" - }, + type = entryType.toAdminLedgerType(), + entryType = entryType.name, amount = amountDelta, balanceAfter = balanceAfter, - reasonCode = type, + reasonCode = entryType.name, + referenceId = referenceId, usageType = usageType, + details = details, createdAt = createdAt, ) +private fun LedgerEntryType.toAdminLedgerType(): String = + when (this) { + LedgerEntryType.USAGE_RESERVE -> "reserve" + LedgerEntryType.USAGE_SETTLE -> "settle" + LedgerEntryType.USAGE_RELEASE, + LedgerEntryType.USAGE_REFUND, + -> "refund" + + LedgerEntryType.SIGNUP_TRIAL, + LedgerEntryType.MANUAL_GRANT, + LedgerEntryType.REFERRAL_INVITER, + LedgerEntryType.REFERRAL_INVITEE, + LedgerEntryType.STOREKIT_PURCHASE, + LedgerEntryType.SUBSCRIPTION_GRANT, + -> "grant" + } + private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse = AdminOperatorResponse( operatorId = id.toString(), @@ -1162,10 +1219,13 @@ private data class AdminLedgerResponse( val entryId: String, val userId: String, val type: String, + val entryType: String, val amount: Long, val balanceAfter: Long, val reasonCode: String, + val referenceId: String?, val usageType: String?, + val details: AdminLedgerDetailsDto?, val createdAt: String, ) diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt b/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt index d65d91b..9c714d6 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt @@ -1,6 +1,7 @@ package com.osglab.account.features.admin.users.models import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto +import com.osglab.account.features.credits.domain.LedgerEntryType import kotlinx.serialization.Serializable @Serializable @@ -27,12 +28,28 @@ data class AdminUserPageDto( data class AdminUserLedgerEntryDto( val id: String, val userId: String, - val type: String, + val entryType: LedgerEntryType, val amountDelta: Long, val balanceAfter: Long, val referenceId: String?, val createdAt: String, val usageType: String? = null, + val details: AdminLedgerDetailsDto? = null, +) + +@Serializable +data class AdminLedgerDetailsDto( + val kind: String, + val reason: String? = null, + val operatorName: String? = null, + val productId: String? = null, + val transactionId: String? = null, + val originalTransactionId: String? = null, + val environment: String? = null, + val purchasedAt: String? = null, + val role: String? = null, + val relatedUserId: String? = null, + val reservationId: String? = null, ) @Serializable diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt index 6e49764..f406bfd 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt @@ -4,6 +4,7 @@ import com.osglab.account.config.DatabaseFactory import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.models.AdminTimeFilter import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto +import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto import com.osglab.account.features.admin.users.models.AdminUserDetailDto import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto import com.osglab.account.features.admin.users.models.AdminUserReferralDto @@ -19,11 +20,15 @@ import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.greater import org.jetbrains.exposed.v1.core.greaterEq import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.innerJoin +import org.jetbrains.exposed.v1.core.isNull import org.jetbrains.exposed.v1.core.less import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.core.neq import org.jetbrains.exposed.v1.core.or import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.jdbc.andWhere +import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.selectAll import java.time.Instant import java.util.UUID @@ -34,9 +39,18 @@ data class AdminUserCursor( ) data class AdminUserLedgerCursor( - val createdAt: Instant, + val sort: AdminLedgerSort, + val createdAt: Instant? = null, + val amount: Long? = null, val ledgerEntryId: UUID, -) +) { + init { + require( + (sort == AdminLedgerSort.CREATED_AT && createdAt != null && amount == null) || + (sort == AdminLedgerSort.AMOUNT && amount != null && createdAt == null), + ) { "Ledger cursor value does not match its sort" } + } +} enum class AdminUserStatus { ACTIVE, @@ -65,12 +79,30 @@ enum class AdminLedgerType( LedgerEntryType.SUBSCRIPTION_GRANT, ), ), - ADJUSTMENT(emptySet()), +} + +enum class AdminLedgerSort { + CREATED_AT, + AMOUNT, +} + +enum class AdminUsageType( + internal val databaseValue: String, +) { + POLISH("POLISH"), + ASR("ASR"), + AI("AI"), + AGENT("AGENT"), + HOTWORD("HOTWORD"), } data class AdminLedgerQuery( val time: AdminTimeFilter = AdminTimeFilter(), val type: AdminLedgerType? = null, + val entryType: LedgerEntryType? = null, + val usageType: AdminUsageType? = null, + val referenceId: UUID? = null, + val sort: AdminLedgerSort = AdminLedgerSort.CREATED_AT, val order: AdminSortOrder = AdminSortOrder.DESC, ) @@ -217,13 +249,19 @@ class ExposedAdminUsersRepository( ) } .sortedBy(AdminUsageAggregateDto::kind) - val recentLedger = support.ledger.filter { it.userId == userId } + val recentLedgerRows = support.ledger.filter { it.userId == userId } .sortedWith( compareByDescending(UserLedgerRow::createdAt) .thenByDescending { it.id.toString() }, ) .take(ledgerLimit) - .map { it.toDto(support.ledgerUsageTypes[it.referenceId]) } + val recentTrace = loadLedgerTrace(recentLedgerRows) + val recentLedger = recentLedgerRows.map { row -> + row.toDto( + usageType = support.ledgerUsageTypes[row.referenceId], + details = recentTrace.detailsFor(row), + ) + } AdminUserDetailDto( summary = account.toSummary(support), referralCode = findReferralCode(userId), @@ -245,44 +283,7 @@ class ExposedAdminUsersRepository( cursor: AdminUserLedgerCursor?, query: AdminLedgerQuery, ): List = databaseFactory.query { - if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList() - val statement = AdminUsersCreditLedgerTable.selectAll() - .where { AdminUsersCreditLedgerTable.userId eq userId.toString() } - query.time.from?.let { from -> - statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from } - } - query.time.until?.let { until -> - statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until } - } - query.type?.let { type -> - statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes } - } - if (cursor != null) { - statement.andWhere { - if (query.order == AdminSortOrder.ASC) { - (AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or - ( - (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and - (AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString()) - ) - } else { - (AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or - ( - (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and - (AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString()) - ) - } - } - } - val sortOrder = query.order.toExposedSortOrder() - val ledger = statement.orderBy( - AdminUsersCreditLedgerTable.createdAt to sortOrder, - AdminUsersCreditLedgerTable.id to sortOrder, - ) - .limit(limit) - .map(ResultRow::toUserLedgerRow) - val usageTypes = loadLedgerUsageTypes(ledger) - ledger.map { it.toDto(usageTypes[it.referenceId]) } + loadLedgerEntries(userId, limit, cursor, query) } override suspend fun listLatestLedger( @@ -290,43 +291,120 @@ class ExposedAdminUsersRepository( cursor: AdminUserLedgerCursor?, query: AdminLedgerQuery, ): List = databaseFactory.query { - if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList() - val statement = AdminUsersCreditLedgerTable.selectAll() - query.time.from?.let { from -> - statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from } - } - query.time.until?.let { until -> - statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until } - } - query.type?.let { type -> - statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes } - } - if (cursor != null) { - 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()) - ) - } + loadLedgerEntries(null, limit, cursor, query) + } +} + +private fun loadLedgerEntries( + userId: UUID?, + limit: Int, + cursor: AdminUserLedgerCursor?, + query: AdminLedgerQuery, +): List { + val statement = if (query.usageType == null) { + AdminUsersCreditLedgerTable.selectAll() + } else { + AdminUsersCreditLedgerTable + .innerJoin( + otherTable = AdminUsersProviderRequestsTable, + onColumn = { referenceId }, + otherColumn = { reservationId }, + ) + .select(AdminUsersCreditLedgerTable.columns) + } + userId?.let { id -> + statement.andWhere { AdminUsersCreditLedgerTable.userId eq id.toString() } + } + query.time.from?.let { from -> + statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from } + } + 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( - AdminUsersCreditLedgerTable.createdAt to sortOrder, + } + cursor?.let { + require(it.sort == query.sort) { "Ledger cursor sort does not match query" } + statement.andWhere { ledgerAfterCursor(it, query) } + } + val sortOrder = query.order.toExposedSortOrder() + val sortColumn = when (query.sort) { + AdminLedgerSort.CREATED_AT -> AdminUsersCreditLedgerTable.createdAt + AdminLedgerSort.AMOUNT -> AdminUsersCreditLedgerTable.amountDelta + } + val ledger = statement + .orderBy( + sortColumn to sortOrder, AdminUsersCreditLedgerTable.id to sortOrder, ) - .limit(limit) - .map(ResultRow::toUserLedgerRow) - val usageTypes = loadLedgerUsageTypes(ledger) - ledger.map { it.toDto(usageTypes[it.referenceId]) } + .limit(limit) + .map(ResultRow::toUserLedgerRow) + val usageTypes = loadLedgerUsageTypes(ledger) + 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): Map } .associate { row -> UUID.fromString(requireNotNull(row[AdminUsersProviderRequestsTable.reservationId])) to - ( - row[AdminUsersProviderRequestsTable.requestSource] - ?: row[AdminUsersProviderRequestsTable.capability] - ).lowercase() + if ( + row[AdminUsersProviderRequestsTable.requestSource] == + AdminUsageType.HOTWORD.databaseValue + ) { + AdminUsageType.HOTWORD.name.lowercase() + } else { + row[AdminUsersProviderRequestsTable.capability].lowercase() + } } } +private data class ManualGrantTrace( + val reason: String, + val operatorName: String, +) + +private data class StoreKitTrace( + val productId: String, + val transactionId: String, + val originalTransactionId: String, + val environment: String, + val purchasedAt: Instant, +) + +private data class ReferralTrace( + val inviterUserId: UUID, + val inviteeUserId: UUID, +) + +private data class LedgerTrace( + val manualGrants: Map, + val storeKitPurchases: Map, + val referrals: Map, +) { + 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): LedgerTrace { + if (ledger.isEmpty()) return LedgerTrace(emptyMap(), emptyMap(), emptyMap()) + val ledgerIds = ledger.map { it.id.toString() } + val manualGrants = AdminUsersAdminCreditGrantsTable + .innerJoin( + otherTable = AdminUsersAdminOperatorsTable, + onColumn = { operatorId }, + otherColumn = { id }, + ) + .select( + AdminUsersAdminCreditGrantsTable.ledgerEntryId, + AdminUsersAdminCreditGrantsTable.reason, + AdminUsersAdminOperatorsTable.username, + ) + .where { AdminUsersAdminCreditGrantsTable.ledgerEntryId inList ledgerIds } + .associate { row -> + UUID.fromString(row[AdminUsersAdminCreditGrantsTable.ledgerEntryId]) to + ManualGrantTrace( + reason = row[AdminUsersAdminCreditGrantsTable.reason], + operatorName = row[AdminUsersAdminOperatorsTable.username], + ) + } + val storeKitPurchases = AdminUsersStoreKitCreditPurchasesTable + .select( + AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId, + AdminUsersStoreKitCreditPurchasesTable.productId, + AdminUsersStoreKitCreditPurchasesTable.transactionId, + AdminUsersStoreKitCreditPurchasesTable.originalTransactionId, + AdminUsersStoreKitCreditPurchasesTable.environment, + AdminUsersStoreKitCreditPurchasesTable.purchasedAt, + ) + .where { AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId inList ledgerIds } + .associate { row -> + UUID.fromString(row[AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId]) to + StoreKitTrace( + productId = row[AdminUsersStoreKitCreditPurchasesTable.productId], + transactionId = row[AdminUsersStoreKitCreditPurchasesTable.transactionId], + originalTransactionId = + row[AdminUsersStoreKitCreditPurchasesTable.originalTransactionId], + environment = row[AdminUsersStoreKitCreditPurchasesTable.environment], + purchasedAt = row[AdminUsersStoreKitCreditPurchasesTable.purchasedAt], + ) + } + val referralIds = ledger.asSequence() + .filter { + it.type == LedgerEntryType.REFERRAL_INVITER || + it.type == LedgerEntryType.REFERRAL_INVITEE + } + .mapNotNull(UserLedgerRow::referenceId) + .map(UUID::toString) + .distinct() + .toList() + val referrals = if (referralIds.isEmpty()) { + emptyMap() + } else { + AdminUsersReferralBindingsTable.select( + AdminUsersReferralBindingsTable.id, + AdminUsersReferralBindingsTable.inviterUserId, + AdminUsersReferralBindingsTable.inviteeUserId, + ) + .where { AdminUsersReferralBindingsTable.id inList referralIds } + .associate { row -> + UUID.fromString(row[AdminUsersReferralBindingsTable.id]) to + ReferralTrace( + inviterUserId = + UUID.fromString(row[AdminUsersReferralBindingsTable.inviterUserId]), + inviteeUserId = + UUID.fromString(row[AdminUsersReferralBindingsTable.inviteeUserId]), + ) + } + } + return LedgerTrace(manualGrants, storeKitPurchases, referrals) +} + private fun findReferralCode(userId: UUID): String? = AdminUsersReferralCodesTable.selectAll() .where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() } @@ -473,6 +703,26 @@ private object AdminUsersProviderRequestsTable : Table("provider_requests") { override val primaryKey = PrimaryKey(accountId, requestId) } +private object AdminUsersAdminCreditGrantsTable : Table("admin_credit_grants") { + val operatorId = varchar("operator_id", 36) + val reason = varchar("reason", 500) + val ledgerEntryId = varchar("ledger_entry_id", 36) +} + +private object AdminUsersAdminOperatorsTable : Table("admin_operators") { + val id = varchar("id", 36) + val username = varchar("username", 64) +} + +private object AdminUsersStoreKitCreditPurchasesTable : Table("storekit_credit_purchases") { + val transactionId = varchar("transaction_id", 64) + val originalTransactionId = varchar("original_transaction_id", 64) + val productId = varchar("product_id", 128) + val environment = varchar("environment", 16) + val ledgerEntryId = varchar("ledger_entry_id", 36) + val purchasedAt = timestamp("purchased_at") +} + private object AdminUsersCreditUsageTable : Table("credit_usage_records") { val userId = varchar("user_id", 36) val usageKind = enumerationByName("usage_kind", 8) @@ -484,6 +734,7 @@ private object AdminUsersCreditUsageTable : Table("credit_usage_records") { } private object AdminUsersReferralBindingsTable : Table("referral_bindings") { + val id = varchar("id", 36) val inviterUserId = varchar("inviter_user_id", 36) val inviteeUserId = varchar("invitee_user_id", 36) val rewardStatus = enumerationByName("reward_status", 24) @@ -508,14 +759,18 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow( createdAt = this[AdminUsersCreditLedgerTable.createdAt], ) -private fun UserLedgerRow.toDto(usageType: String?) = AdminUserLedgerEntryDto( +private fun UserLedgerRow.toDto( + usageType: String?, + details: AdminLedgerDetailsDto?, +) = AdminUserLedgerEntryDto( id = id.toString(), userId = userId.toString(), - type = type.name, + entryType = type, amountDelta = amountDelta, balanceAfter = balanceAfter, referenceId = referenceId?.toString(), usageType = usageType, + details = details, createdAt = createdAt.toString(), ) diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt index d7b5484..e986d69 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt @@ -5,6 +5,7 @@ import com.osglab.account.features.admin.users.models.AdminUserDetailDto import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto import com.osglab.account.features.admin.users.models.AdminUserPageDto +import com.osglab.account.features.admin.users.repositories.AdminLedgerSort import com.osglab.account.features.admin.users.repositories.AdminUserCursor import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor @@ -106,7 +107,9 @@ class AdminUsersService( load: suspend (Int, AdminUserLedgerCursor?) -> List, ): AdminUserLedgerPageDto { require(limit in 1..100) { "Ledger page limit must be between 1 and 100" } - val decodedCursor = cursor?.let { AdminUserLedgerCursorCodec.decode(it, query.order) } + val decodedCursor = cursor?.let { + AdminUserLedgerCursorCodec.decode(it, query.sort, query.order) + } val results = load(limit + 1, decodedCursor) val hasMore = results.size > limit val items = results.take(limit) @@ -114,9 +117,14 @@ class AdminUsersService( val last = items.last() AdminUserLedgerCursorCodec.encode( AdminUserLedgerCursor( - createdAt = Instant.parse(last.createdAt), + sort = query.sort, + createdAt = last.createdAt.takeIf { + query.sort == AdminLedgerSort.CREATED_AT + }?.let(Instant::parse), + amount = last.amountDelta.takeIf { query.sort == AdminLedgerSort.AMOUNT }, ledgerEntryId = UUID.fromString(last.id), ), + query.sort, query.order, ) } else { @@ -173,13 +181,26 @@ internal object AdminUserCursorCodec { internal object AdminUserLedgerCursorCodec { private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid" - fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String { - val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}" + fun encode( + cursor: AdminUserLedgerCursor, + sort: AdminLedgerSort, + order: AdminSortOrder, + ): String { + require(cursor.sort == sort) { INVALID_CURSOR_MESSAGE } + val sortValue = when (sort) { + AdminLedgerSort.CREATED_AT -> requireNotNull(cursor.createdAt).toString() + AdminLedgerSort.AMOUNT -> requireNotNull(cursor.amount).toString() + } + val value = "v2|${sort.name}|${order.name}|$sortValue|${cursor.ledgerEntryId}" return Base64.getUrlEncoder().withoutPadding() .encodeToString(value.toByteArray(StandardCharsets.UTF_8)) } - fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserLedgerCursor { + fun decode( + value: String, + expectedSort: AdminLedgerSort, + expectedOrder: AdminSortOrder, + ): AdminUserLedgerCursor { require(value.length in 1..256) { INVALID_CURSOR_MESSAGE } return try { val decoded = String( @@ -187,13 +208,36 @@ internal object AdminUserLedgerCursorCodec { StandardCharsets.UTF_8, ) val parts = decoded.split('|') - require(parts.size == 4) - require(parts[0] == "v1") - require(parts[1] == expectedOrder.name) - AdminUserLedgerCursor( - createdAt = Instant.parse(parts[2]), - ledgerEntryId = UUID.fromString(parts[3]), - ) + when (parts.firstOrNull()) { + "v1" -> { + require(expectedSort == AdminLedgerSort.CREATED_AT) + require(parts.size == 4) + require(parts[1] == expectedOrder.name) + AdminUserLedgerCursor( + sort = AdminLedgerSort.CREATED_AT, + createdAt = Instant.parse(parts[2]), + ledgerEntryId = UUID.fromString(parts[3]), + ) + } + + "v2" -> { + require(parts.size == 5) + require(parts[1] == expectedSort.name) + require(parts[2] == expectedOrder.name) + AdminUserLedgerCursor( + sort = expectedSort, + createdAt = parts[3].takeIf { + expectedSort == AdminLedgerSort.CREATED_AT + }?.let(Instant::parse), + amount = parts[3].takeIf { + expectedSort == AdminLedgerSort.AMOUNT + }?.toLong(), + ledgerEntryId = UUID.fromString(parts[4]), + ) + } + + else -> throw IllegalArgumentException(INVALID_CURSOR_MESSAGE) + } } catch (failure: IllegalArgumentException) { throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure) } diff --git a/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt b/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt index 8b1fa9d..23b74c8 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt @@ -26,7 +26,6 @@ import kotlinx.coroutines.withContext import org.jetbrains.exposed.v1.core.* import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.jdbc.Database -import org.jetbrains.exposed.v1.jdbc.andWhere import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.insertIgnore import org.jetbrains.exposed.v1.jdbc.select @@ -158,6 +157,14 @@ private object ReferralCodes : Table("referral_codes") { override val primaryKey = PrimaryKey(id) } +private object ReferralOwnerCodes : Table("referral_owner_codes") { + val ownerUserId = varchar("owner_user_id", 36) + val codeId = varchar("code_id", 36) + val createdAt = timestamp("created_at") + + override val primaryKey = PrimaryKey(ownerUserId) +} + private object ReferralBindings : Table("referral_bindings") { val id = varchar("id", 36) val inviterUserId = varchar("inviter_user_id", 36) @@ -465,28 +472,63 @@ private object ExposedCreditsRepository : CreditsRepository { } private object ExposedReferralsRepository : ReferralsRepository { - override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? { - val query = ReferralCodes + override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? = + ReferralOwnerCodes + .innerJoin( + otherTable = ReferralCodes, + onColumn = { codeId }, + otherColumn = { id }, + ) .selectAll() - .where { ReferralCodes.ownerUserId eq ownerUserId.toString() } - return if (campaignId == null) { - query.orderBy(ReferralCodes.createdAt, SortOrder.DESC).limit(1).singleOrNull() - } else { - query.andWhere { ReferralCodes.campaignId eq campaignId.toString() }.singleOrNull() - }?.toReferralCode() - } - - override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? = - ReferralCodes - .selectAll() - .where { - (ReferralCodes.ownerUserId eq ownerUserId.toString()) and - (ReferralCodes.campaignId eq campaignId.toString()) - } - .forUpdate() + .where { ReferralOwnerCodes.ownerUserId eq ownerUserId.toString() } .singleOrNull() ?.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? = ReferralCodes .selectAll() @@ -494,16 +536,6 @@ private object ExposedReferralsRepository : ReferralsRepository { .singleOrNull() ?.toReferralCode() - override fun insertCodeIfAbsent(code: ReferralCode): Boolean = - ReferralCodes.insertIgnore { - it[id] = code.id.toString() - it[ownerUserId] = code.ownerUserId.toString() - it[ownerIdentityFingerprint] = code.ownerIdentityFingerprint - it[campaignId] = (code.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString() - it[ReferralCodes.code] = code.code - it[createdAt] = code.createdAt - }.insertedCount == 1 - override fun findCampaign(id: UUID): ReferralCampaign? = ReferralCampaigns .selectAll() diff --git a/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt b/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt index b563b18..60cc127 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt @@ -299,7 +299,16 @@ class ExposedGatewayRepository( ): ComplimentaryRequestClaim? = databaseFactory.query { val now = clock.instant() val expiresAt = now.plus(COMPLIMENTARY_CLAIM_TTL) - val inserted = ComplimentaryRequestsTable.insertIgnore { + val reclaimed = ComplimentaryRequestsTable.update({ + complimentaryKey(accountId, purpose, capability) and + (ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED) and + (ComplimentaryRequestsTable.expiresAt lessEq now) + }) { + it[ComplimentaryRequestsTable.requestId] = requestId + it[ComplimentaryRequestsTable.expiresAt] = expiresAt + it[updatedAt] = now + } == 1 + val inserted = !reclaimed && ComplimentaryRequestsTable.insertIgnore { it[ComplimentaryRequestsTable.accountId] = accountId it[ComplimentaryRequestsTable.purpose] = purpose.name it[ComplimentaryRequestsTable.capability] = capability.name @@ -309,19 +318,6 @@ class ExposedGatewayRepository( it[createdAt] = now it[updatedAt] = now }.insertedCount == 1 - val reclaimed = if (!inserted) { - ComplimentaryRequestsTable.update({ - complimentaryKey(accountId, purpose, capability) and - (ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED) and - (ComplimentaryRequestsTable.expiresAt lessEq now) - }) { - it[ComplimentaryRequestsTable.requestId] = requestId - it[ComplimentaryRequestsTable.expiresAt] = expiresAt - it[updatedAt] = now - } == 1 - } else { - false - } if (!inserted && !reclaimed) return@query null ComplimentaryRequestClaim(accountId, purpose, capability, requestId) } diff --git a/src/main/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutes.kt b/src/main/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutes.kt index 3f55f1e..922325f 100644 --- a/src/main/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutes.kt @@ -20,8 +20,8 @@ import java.util.Base64 /** * Read-only boundary used by the public page to verify a referral code. * - * Implementations must preserve case, apply campaign validity rules, use a bounded database query, - * and never log the code. + * Implementations must preserve case, treat issued codes as permanent, use a bounded database + * query, and never log the code. */ fun interface ReferralLookupPort { suspend fun isValid(code: String): Boolean @@ -193,7 +193,7 @@ private suspend fun ApplicationCall.respondAasa(aasa: String) { private suspend fun ApplicationCall.respondInvalidInvitation() { respondText( - text = "邀请链接无效或已失效 / This invitation link is invalid or expired", + text = "邀请链接无效 / This invitation link is invalid", contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8), status = HttpStatusCode.NotFound, ) diff --git a/src/main/kotlin/com/osglab/account/features/referrals/models/ReferralDtos.kt b/src/main/kotlin/com/osglab/account/features/referrals/models/ReferralDtos.kt index ea0a95e..d68b528 100644 --- a/src/main/kotlin/com/osglab/account/features/referrals/models/ReferralDtos.kt +++ b/src/main/kotlin/com/osglab/account/features/referrals/models/ReferralDtos.kt @@ -14,12 +14,14 @@ data class BindReferralRequest( @Serializable data class ReferralCodeDto( val code: String, + val inviteUrl: String, val campaignId: String?, val createdAt: String, ) { companion object { - fun fromDomain(value: ReferralCode) = ReferralCodeDto( + fun fromDomain(value: ReferralCode, inviteBaseUrl: String) = ReferralCodeDto( code = value.code, + inviteUrl = "${inviteBaseUrl.trimEnd('/')}/${value.code}", campaignId = value.campaignId?.toString(), createdAt = value.createdAt.toString(), ) @@ -66,12 +68,12 @@ data class ReferralCampaignDto( @Serializable data class ReferralProfileDto( - val code: ReferralCodeDto?, + val code: ReferralCodeDto, val binding: ReferralBindingDto?, ) { companion object { - fun fromDomain(value: ReferralProfile) = ReferralProfileDto( - code = value.code?.let(ReferralCodeDto::fromDomain), + fun fromDomain(value: ReferralProfile, inviteBaseUrl: String) = ReferralProfileDto( + code = ReferralCodeDto.fromDomain(value.code, inviteBaseUrl), binding = value.binding?.let(ReferralBindingDto::fromDomain), ) } diff --git a/src/main/kotlin/com/osglab/account/features/referrals/repositories/ReferralsRepository.kt b/src/main/kotlin/com/osglab/account/features/referrals/repositories/ReferralsRepository.kt index 0c966c5..6c60a87 100644 --- a/src/main/kotlin/com/osglab/account/features/referrals/repositories/ReferralsRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/referrals/repositories/ReferralsRepository.kt @@ -8,14 +8,16 @@ import java.time.Instant import java.util.UUID interface ReferralsRepository { - fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID? = null): ReferralCode? + fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? - fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? + /** + * Atomically returns the account's existing permanent code or claims [candidate]. + * Returns null only when the candidate code collided and the caller should retry. + */ + fun claimPermanentCode(candidate: ReferralCode): ReferralCode? fun findCode(code: String): ReferralCode? - fun insertCodeIfAbsent(code: ReferralCode): Boolean - fun findCampaign(id: UUID): ReferralCampaign? fun listActiveCampaigns(at: Instant): List diff --git a/src/main/kotlin/com/osglab/account/features/referrals/routes/ReferralRoutes.kt b/src/main/kotlin/com/osglab/account/features/referrals/routes/ReferralRoutes.kt index fded1d1..cd608c9 100644 --- a/src/main/kotlin/com/osglab/account/features/referrals/routes/ReferralRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/referrals/routes/ReferralRoutes.kt @@ -26,13 +26,14 @@ import java.util.UUID class ReferralRouteInstaller( private val service: ReferralOperations, + private val inviteBaseUrl: String, private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor, ) { fun install(parent: Route) { parent.route("/v1/referrals") { get("/me") { call.referralCall(authenticatedUser) { userId -> - ReferralProfileDto.fromDomain(service.getProfile(userId)) + ReferralProfileDto.fromDomain(service.getProfile(userId), inviteBaseUrl) } } get { @@ -58,7 +59,7 @@ class ReferralRouteInstaller( } post("/code") { call.referralCall(authenticatedUser) { userId -> - ReferralCodeDto.fromDomain(service.getOrCreateCode(userId)) + ReferralCodeDto.fromDomain(service.getOrCreateCode(userId), inviteBaseUrl) } } post("/bind") { @@ -73,9 +74,10 @@ class ReferralRouteInstaller( fun Route.referralRoutes( service: ReferralOperations, + inviteBaseUrl: String, authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor, ) { - ReferralRouteInstaller(service, authenticatedUser).install(this) + ReferralRouteInstaller(service, inviteBaseUrl, authenticatedUser).install(this) } private suspend fun ApplicationCall.referralCall( diff --git a/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt b/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt index 030d2b8..4fd9631 100644 --- a/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt +++ b/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt @@ -1,6 +1,7 @@ package com.osglab.account.features.referrals.services import com.osglab.account.features.credits.repositories.BillingTransactionRunner +import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID import com.osglab.account.features.referrals.domain.InviteCodeGenerator import com.osglab.account.features.referrals.domain.InvalidReferralRequest import com.osglab.account.features.referrals.domain.ReferralBinding @@ -34,13 +35,13 @@ typealias ReferralRiskIdentity = ReferralRiskAssessment typealias ReferralRiskProvider = ReferralRiskPort data class ReferralProfile( - val code: ReferralCode?, + val code: ReferralCode, val binding: ReferralBinding?, ) /** - * Public referral boundary. Binding and code creation remain transactionally - * consistent even when callers retry after a timeout. + * Public referral boundary. Each account receives one permanent code, while the + * active campaign is selected only when an invitee redeems that code. */ interface ReferralOperations { suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode @@ -76,19 +77,22 @@ class ReferralService( } override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode { + transactions.inTransaction { unit -> + unit.referrals.findPermanentCodeByOwner(ownerUserId) + }?.let { return it } + val ownerIdentity = requireEligibleIdentity(ownerUserId) return transactions.inTransaction { unit -> val now = clock.instant() - val campaign = if (campaignId == null) { - unit.referrals.listActiveCampaigns(now).firstOrNull() - ?: throw ReferralNotFound("No active referral campaign exists") + unit.referrals.findPermanentCodeByOwner(ownerUserId)?.let { + return@inTransaction it + } + val storageCampaignId = if (campaignId == null) { + DEFAULT_REFERRAL_CAMPAIGN_ID } else { unit.referrals.findCampaign(campaignId) ?: throw ReferralNotFound("Referral campaign does not exist") - } - if (!campaign.isActive(now)) throw ReferralNotFound("Referral campaign is not active") - unit.referrals.findCodeByOwner(ownerUserId, campaign.id)?.let { - return@inTransaction it + campaignId } repeat(MAX_CODE_ATTEMPTS) { val candidate = ReferralCode( @@ -97,15 +101,14 @@ class ReferralService( ownerIdentityFingerprint = ownerIdentity.identityFingerprint, code = codeGenerator.generate(), createdAt = now, - campaignId = campaign.id, + // The campaign column is retained for historical compatibility only. + // A referral code now belongs to the account for its entire lifetime. + campaignId = storageCampaignId, ) if (candidate.code.length < 20) { throw IllegalStateException("Invite code generator must provide at least 120 bits") } - if (unit.referrals.insertCodeIfAbsent(candidate)) { - return@inTransaction candidate - } - unit.referrals.lockCodeByOwner(ownerUserId, campaign.id)?.let { + unit.referrals.claimPermanentCode(candidate)?.let { return@inTransaction it } } @@ -123,7 +126,11 @@ class ReferralService( else throw ReferralConflict("This account is already bound to another inviter") } if (existing != null) return existing + val referralCode = transactions.inTransaction { unit -> + unit.referrals.findCode(code) + } ?: throw ReferralNotFound("Referral code does not exist") val inviteeIdentity = requireEligibleIdentity(inviteeUserId) + requireEligibleIdentity(referralCode.ownerUserId) val registeredAt = registrationTimeProvider.registeredAt(inviteeUserId) ?: throw ReferralNotFound("Registration time is unavailable") val now = clock.instant() @@ -136,15 +143,11 @@ class ReferralService( } val referralCode = unit.referrals.findCode(code) ?: throw ReferralNotFound("Referral code does not exist") - val campaign = referralCode.campaignId - ?.let(unit.referrals::findCampaign) - if (campaign != null && !campaign.isActive(now)) { - throw ReferralNotFound("Referral campaign is not active") - } - val effectiveWindow = campaign - ?.bindingWindowSeconds - ?.let(Duration::ofSeconds) - ?: bindingWindow + // Campaigns define the reward policy at redemption time; they no longer + // define the lifetime of the account's permanent invitation code. + val campaign = unit.referrals.listActiveCampaigns(now).firstOrNull() + ?: throw ReferralNotFound("No active referral campaign exists") + val effectiveWindow = Duration.ofSeconds(campaign.bindingWindowSeconds) if (!ReferralBindingRules.isWithinWindow(registeredAt, now, effectiveWindow)) { throw ReferralWindowExpired() } @@ -164,7 +167,7 @@ class ReferralService( boundAt = now, rewardedAt = null, rewardSettlementId = null, - campaignId = referralCode.campaignId, + campaignId = campaign.id, ) if (unit.referrals.insertBindingIfAbsent(binding)) { binding diff --git a/src/main/resources/db/migration/V19__admin_ledger_operations.sql b/src/main/resources/db/migration/V19__admin_ledger_operations.sql new file mode 100644 index 0000000..eed8b98 --- /dev/null +++ b/src/main/resources/db/migration/V19__admin_ledger_operations.sql @@ -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); diff --git a/src/main/resources/db/migration/V20__permanent_referral_codes.sql b/src/main/resources/db/migration/V20__permanent_referral_codes.sql new file mode 100644 index 0000000..3979574 --- /dev/null +++ b/src/main/resources/db/migration/V20__permanent_referral_codes.sql @@ -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 + ) + ) +); diff --git a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt index cbd126f..fa8e5de 100644 --- a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt +++ b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt @@ -39,6 +39,36 @@ class DeploymentConsistencyTest : FunSpec({ openApi shouldContain "referralCode" } + test("admin ledger operations stay indexed exact and privacy minimized") { + val migration = root.read( + "src/main/resources/db/migration/V19__admin_ledger_operations.sql", + ) + migration shouldContain + "MODIFY reference_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL" + migration shouldContain + "MODIFY reservation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL" + migration shouldContain "ON credit_ledger (amount_delta, id)" + migration shouldContain "ON credit_ledger (user_id, amount_delta, id)" + migration shouldContain "ON provider_requests (capability, request_source, reservation_id)" + migration shouldContain "ON provider_requests (request_source, capability, reservation_id)" + + val openApi = root.read("docs/openapi.yaml") + val ledgerSchema = openApi + .substringAfter(" LedgerEntryType:") + .substringBefore(" AdminLedgerPage:") + LEDGER_ENTRY_TYPE_NAMES.forEach(ledgerSchema::shouldContain) + ledgerSchema shouldContain "deprecated: true" + ledgerSchema shouldContain "referenceId" + ledgerSchema shouldContain "reservationId" + ledgerSchema shouldContain + "required: [entryId, userId, type, entryType, amount, balanceAfter, reasonCode, createdAt]" + ledgerSchema shouldNotContain "enum: [grant, reserve, settle, refund, adjustment]" + ledgerSchema shouldNotContain "idempotencyKey" + ledgerSchema shouldNotContain "appAccountToken" + ledgerSchema shouldNotContain "signedTransaction" + ledgerSchema shouldNotContain "appleSubject" + } + test("provider defaults and Apple integrity contract stay production compatible") { val providerConfigurations = listOf( root.read("src/main/kotlin/com/osglab/account/config/AppConfig.kt"), @@ -248,6 +278,19 @@ class DeploymentConsistencyTest : FunSpec({ private fun Path.read(relativePath: String): String = Files.readString(resolve(relativePath)) +private val LEDGER_ENTRY_TYPE_NAMES = listOf( + "SIGNUP_TRIAL", + "MANUAL_GRANT", + "USAGE_RESERVE", + "USAGE_SETTLE", + "USAGE_RELEASE", + "USAGE_REFUND", + "REFERRAL_INVITER", + "REFERRAL_INVITEE", + "STOREKIT_PURCHASE", + "SUBSCRIPTION_GRANT", +) + private val EXPECTED_PUBLIC_PATHS = setOf( "/health", "/health/live", diff --git a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt index e7645d7..9c0b54b 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt @@ -13,12 +13,19 @@ import com.osglab.account.features.admin.services.AdminOperatorException import com.osglab.account.features.admin.services.AdminSessionService import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService import com.osglab.account.features.admin.stats.services.AdminStatsService +import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto +import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery +import com.osglab.account.features.admin.users.repositories.AdminLedgerSort +import com.osglab.account.features.admin.users.repositories.AdminLedgerType +import com.osglab.account.features.admin.users.repositories.AdminUsageType import com.osglab.account.features.admin.users.services.AdminUsersService import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.InvalidCreditRequest +import com.osglab.account.features.credits.domain.LedgerEntryType +import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.ktor.client.statement.bodyAsText import io.ktor.client.request.get @@ -40,7 +47,9 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.slot import kotlinx.serialization.json.Json +import java.time.Instant import java.util.UUID import kotlin.time.Duration.Companion.minutes import kotlin.test.Test @@ -231,12 +240,16 @@ class AdminRoutesTest { AdminUserLedgerEntryDto( id = "ffffffff-ffff-ffff-ffff-ffffffffffff", userId = "11111111-1111-4111-8111-111111111111", - type = "USAGE_SETTLE", + entryType = LedgerEntryType.USAGE_SETTLE, amountDelta = -18, balanceAfter = 102, - referenceId = null, + referenceId = "22222222-2222-4222-8222-222222222222", createdAt = "2026-08-19T09:00:00Z", usageType = "hotword", + details = AdminLedgerDetailsDto( + kind = "usage", + reservationId = "22222222-2222-4222-8222-222222222222", + ), ), ), nextCursor = null, @@ -255,8 +268,136 @@ class AdminRoutesTest { assertEquals(HttpStatusCode.OK, response.status) response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111"""" + response.bodyAsText() shouldContain """"entryType":"USAGE_SETTLE"""" response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE"""" + response.bodyAsText() shouldContain """"referenceId":"22222222-2222-4222-8222-222222222222"""" response.bodyAsText() shouldContain """"usageType":"hotword"""" + response.bodyAsText() shouldContain """"kind":"usage"""" + } + + @Test + fun `ledger response maps all exact entry types and privacy safe trace details`() = testApplication { + val usersService = mockk() + 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() + val captured = slot() + coEvery { usersService.latestLedger(100, null, capture(captured)) } returns + AdminUserLedgerPageDto(emptyList(), null) + application { + installAdminTestRoutes( + sessionService = sessionFixture(AdminRole.SUPPORT), + usersService = usersService, + ) + } + val referenceId = "22222222-2222-4222-8222-222222222222" + + val response = client.get( + "/v1/admin/credits/ledger" + + "?from=2026-08-19T00:00:00Z" + + "&until=2026-08-20T00:00:00Z" + + "&type=settle" + + "&entryType=USAGE_SETTLE" + + "&usageType=hotword" + + "&referenceId=$referenceId" + + "&sort=amount&order=asc", + ) { + header("X-OSG-mTLS-Verified", "SUCCESS") + header(HttpHeaders.Cookie, "osg_admin_session=session-token") + } + + assertEquals(HttpStatusCode.OK, response.status) + captured.captured shouldBe AdminLedgerQuery( + time = com.osglab.account.features.admin.models.AdminTimeFilter( + from = Instant.parse("2026-08-19T00:00:00Z"), + until = Instant.parse("2026-08-20T00:00:00Z"), + ), + type = AdminLedgerType.SETTLE, + entryType = LedgerEntryType.USAGE_SETTLE, + usageType = AdminUsageType.HOTWORD, + referenceId = UUID.fromString(referenceId), + sort = AdminLedgerSort.AMOUNT, + order = com.osglab.account.features.admin.models.AdminSortOrder.ASC, + ) } @Test @@ -269,6 +410,11 @@ class AdminRoutesTest { val invalidPaths = listOf( "/v1/admin/users?from=2026-08-20T00:00:00Z&until=2026-08-20T00:00:00Z", "/v1/admin/credits/ledger?type=unknown", + "/v1/admin/credits/ledger?type=adjustment", + "/v1/admin/credits/ledger?entryType=usage_settle", + "/v1/admin/credits/ledger?usageType=voice", + "/v1/admin/credits/ledger?referenceId=not-a-uuid", + "/v1/admin/credits/ledger?sort=balance", "/v1/admin/operators?enabled=1", "/v1/admin/audit?action=NOT_AN_ACTION", "/v1/admin/referrals?range=30d&limit=101", diff --git a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersRepositoryIntegrationTest.kt b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersRepositoryIntegrationTest.kt new file mode 100644 index 0000000..15f10e3 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersRepositoryIntegrationTest.kt @@ -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 = + 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 { + val results = mutableListOf() + 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(image) + +private fun Instant.toDatabaseTimestamp(): String = + LocalDateTime.ofInstant(this, ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS")) diff --git a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt index b0d0320..d4b3d9e 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt @@ -7,7 +7,9 @@ import com.osglab.account.features.admin.users.models.AdminUserSummaryDto import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto import com.osglab.account.features.admin.models.AdminSortOrder import com.osglab.account.features.admin.models.AdminTimeFilter +import com.osglab.account.features.admin.users.repositories.AdminLedgerSort import com.osglab.account.features.admin.users.repositories.AdminLedgerType +import com.osglab.account.features.admin.users.repositories.AdminUsageType import com.osglab.account.features.admin.users.repositories.AdminUserCursor import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor @@ -16,12 +18,14 @@ import com.osglab.account.features.admin.users.repositories.AdminUserStatus import com.osglab.account.features.admin.users.repositories.AdminUsersRepository import com.osglab.account.features.admin.users.services.AdminUserNotFoundException import com.osglab.account.features.admin.users.services.AdminUsersService +import com.osglab.account.features.credits.domain.LedgerEntryType import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import java.time.Instant +import java.util.Base64 import java.util.UUID class AdminUsersServiceTest : FunSpec({ @@ -270,7 +274,12 @@ class AdminUsersServiceTest : FunSpec({ ledgerEntry(lowerId, from, userId), ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId), ledgerEntry(UUID.randomUUID(), until, userId), - ledgerEntry(UUID.randomUUID(), from.plusSeconds(1), userId, type = "USAGE_SETTLE"), + ledgerEntry( + UUID.randomUUID(), + from.plusSeconds(1), + userId, + entryType = LedgerEntryType.USAGE_SETTLE, + ), ) val service = AdminUsersService( PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)), @@ -335,6 +344,164 @@ class AdminUsersServiceTest : FunSpec({ ) } } + + test("ledger combines exact type usage reference and time filters") { + val userId = UUID.randomUUID() + val referenceId = UUID.randomUUID() + val from = Instant.parse("2026-08-15T00:00:00Z") + val target = ledgerEntry( + id = UUID.randomUUID(), + createdAt = from.plusSeconds(10), + userId = userId, + entryType = LedgerEntryType.USAGE_SETTLE, + referenceId = referenceId, + usageType = AdminUsageType.HOTWORD, + ) + val entries = listOf( + target, + ledgerEntry( + UUID.randomUUID(), + from.plusSeconds(10), + userId, + entryType = LedgerEntryType.USAGE_RESERVE, + referenceId = referenceId, + usageType = AdminUsageType.HOTWORD, + ), + ledgerEntry( + UUID.randomUUID(), + from.plusSeconds(10), + userId, + entryType = LedgerEntryType.USAGE_SETTLE, + referenceId = UUID.randomUUID(), + usageType = AdminUsageType.HOTWORD, + ), + ledgerEntry( + UUID.randomUUID(), + from.plusSeconds(10), + userId, + entryType = LedgerEntryType.USAGE_SETTLE, + referenceId = referenceId, + usageType = AdminUsageType.ASR, + ), + ledgerEntry( + UUID.randomUUID(), + from.minusNanos(1), + userId, + entryType = LedgerEntryType.USAGE_SETTLE, + referenceId = referenceId, + usageType = AdminUsageType.HOTWORD, + ), + ) + val service = AdminUsersService( + PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)), + ) + + val page = service.ledger( + userId, + query = AdminLedgerQuery( + time = AdminTimeFilter(from, from.plusSeconds(60)), + type = AdminLedgerType.SETTLE, + entryType = LedgerEntryType.USAGE_SETTLE, + usageType = AdminUsageType.HOTWORD, + referenceId = referenceId, + ), + ) + + page.items shouldBe listOf(target) + } + + test("amount sorting paginates equal values stably in both directions") { + val userId = UUID.randomUUID() + val createdAt = Instant.parse("2026-08-15T00:00:00Z") + val lower = ledgerEntry( + UUID.fromString("11111111-1111-4111-8111-111111111111"), + createdAt, + userId, + amount = 10, + ) + val higher = ledgerEntry( + UUID.fromString("22222222-2222-4222-8222-222222222222"), + createdAt, + userId, + amount = 10, + ) + val smallest = ledgerEntry(UUID.randomUUID(), createdAt, userId, amount = -5) + val service = AdminUsersService( + PagingUsersRepository(emptyList(), ledger = mapOf(userId to listOf(higher, smallest, lower))), + ) + + val ascendingQuery = AdminLedgerQuery( + sort = AdminLedgerSort.AMOUNT, + order = AdminSortOrder.ASC, + ) + val ascendingFirst = service.ledger(userId, limit = 2, query = ascendingQuery) + val ascendingSecond = service.ledger( + userId, + limit = 2, + cursor = ascendingFirst.nextCursor.shouldNotBeNull(), + query = ascendingQuery, + ) + ascendingFirst.items shouldBe listOf(smallest, lower) + ascendingSecond.items shouldBe listOf(higher) + + val descendingQuery = AdminLedgerQuery( + sort = AdminLedgerSort.AMOUNT, + order = AdminSortOrder.DESC, + ) + val descendingFirst = service.ledger(userId, limit = 2, query = descendingQuery) + val descendingSecond = service.ledger( + userId, + limit = 2, + cursor = descendingFirst.nextCursor.shouldNotBeNull(), + query = descendingQuery, + ) + descendingFirst.items shouldBe listOf(higher, lower) + descendingSecond.items shouldBe listOf(smallest) + } + + test("createdAt accepts legacy v1 cursor while amount requires matching v2") { + val userId = UUID.randomUUID() + val createdAt = Instant.parse("2026-08-15T00:00:00Z") + val firstId = UUID.fromString("22222222-2222-4222-8222-222222222222") + val secondId = UUID.fromString("11111111-1111-4111-8111-111111111111") + val service = AdminUsersService( + PagingUsersRepository( + emptyList(), + ledger = mapOf( + userId to listOf( + ledgerEntry(firstId, createdAt, userId, amount = 20), + ledgerEntry(secondId, createdAt.minusSeconds(1), userId, amount = 10), + ), + ), + ), + ) + val legacy = Base64.getUrlEncoder().withoutPadding().encodeToString( + "v1|DESC|$createdAt|$firstId".toByteArray(), + ) + + service.ledger(userId, cursor = legacy).items shouldBe + listOf(ledgerEntry(secondId, createdAt.minusSeconds(1), userId, amount = 10)) + shouldThrow { + service.ledger( + userId, + cursor = legacy, + query = AdminLedgerQuery(sort = AdminLedgerSort.AMOUNT), + ) + } + + val amountPage = service.ledger( + userId, + limit = 1, + query = AdminLedgerQuery(sort = AdminLedgerSort.AMOUNT), + ) + shouldThrow { + service.ledger( + userId, + cursor = amountPage.nextCursor.shouldNotBeNull(), + query = AdminLedgerQuery(sort = AdminLedgerSort.CREATED_AT), + ) + } + } }) private class PagingUsersRepository( @@ -393,7 +560,7 @@ private class PagingUsersRepository( cursor == null || ledgerAfter(it, cursor, query.order) } .sortedWith( - ledgerComparator(query.order), + ledgerComparator(query.order, query.sort), ) .take(limit) @@ -408,7 +575,7 @@ private class PagingUsersRepository( cursor == null || ledgerAfter(it, cursor, query.order) } .sortedWith( - ledgerComparator(query.order), + ledgerComparator(query.order, query.sort), ) .take(limit) } @@ -441,18 +608,27 @@ private fun userAfter( private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean { val instant = Instant.parse(createdAt) - val category = when (type) { - "USAGE_RESERVE" -> AdminLedgerType.RESERVE - "USAGE_SETTLE" -> AdminLedgerType.SETTLE - "USAGE_RELEASE", "USAGE_REFUND" -> AdminLedgerType.REFUND - "SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE", - "STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT", + val category = when (entryType) { + LedgerEntryType.USAGE_RESERVE -> AdminLedgerType.RESERVE + LedgerEntryType.USAGE_SETTLE -> AdminLedgerType.SETTLE + LedgerEntryType.USAGE_RELEASE, + LedgerEntryType.USAGE_REFUND, + -> AdminLedgerType.REFUND + + LedgerEntryType.SIGNUP_TRIAL, + LedgerEntryType.MANUAL_GRANT, + LedgerEntryType.REFERRAL_INVITER, + LedgerEntryType.REFERRAL_INVITEE, + LedgerEntryType.STOREKIT_PURCHASE, + LedgerEntryType.SUBSCRIPTION_GRANT, -> AdminLedgerType.GRANT - else -> AdminLedgerType.ADJUSTMENT } return query.time.from?.let { instant >= it } != false && query.time.until?.let { instant < it } != false && - (query.type == null || query.type == category) + (query.type == null || query.type == category) && + (query.entryType == null || query.entryType == entryType) && + (query.usageType == null || query.usageType.name.lowercase() == usageType) && + (query.referenceId == null || query.referenceId.toString() == referenceId) } private fun ledgerAfter( @@ -460,24 +636,32 @@ private fun ledgerAfter( cursor: AdminUserLedgerCursor, order: AdminSortOrder, ): Boolean { - val createdAt = Instant.parse(item.createdAt) + val primary = when (cursor.sort) { + AdminLedgerSort.CREATED_AT -> Instant.parse(item.createdAt).compareTo(requireNotNull(cursor.createdAt)) + AdminLedgerSort.AMOUNT -> item.amountDelta.compareTo(requireNotNull(cursor.amount)) + } return if (order == AdminSortOrder.ASC) { - createdAt > cursor.createdAt || - (createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString()) + primary > 0 || (primary == 0 && item.id > cursor.ledgerEntryId.toString()) } else { - createdAt < cursor.createdAt || - (createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString()) + primary < 0 || (primary == 0 && item.id < cursor.ledgerEntryId.toString()) } } -private fun ledgerComparator(order: AdminSortOrder): Comparator = - if (order == AdminSortOrder.ASC) { - compareBy { Instant.parse(it.createdAt) } - .thenBy(AdminUserLedgerEntryDto::id) - } else { - compareByDescending { Instant.parse(it.createdAt) } - .thenByDescending(AdminUserLedgerEntryDto::id) +private fun ledgerComparator( + order: AdminSortOrder, + sort: AdminLedgerSort = AdminLedgerSort.CREATED_AT, +): Comparator { + val ascending = when (sort) { + AdminLedgerSort.CREATED_AT -> + compareBy { Instant.parse(it.createdAt) } + .thenBy(AdminUserLedgerEntryDto::id) + + AdminLedgerSort.AMOUNT -> + compareBy(AdminUserLedgerEntryDto::amountDelta) + .thenBy(AdminUserLedgerEntryDto::id) } + return if (order == AdminSortOrder.ASC) ascending else ascending.reversed() +} private fun summary( id: UUID, @@ -500,13 +684,17 @@ private fun ledgerEntry( id: UUID, createdAt: Instant, userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"), - type: String = "MANUAL_GRANT", + entryType: LedgerEntryType = LedgerEntryType.MANUAL_GRANT, + amount: Long = 10, + referenceId: UUID? = null, + usageType: AdminUsageType? = null, ) = AdminUserLedgerEntryDto( id = id.toString(), userId = userId.toString(), - type = type, - amountDelta = 10, + entryType = entryType, + amountDelta = amount, balanceAfter = 10, - referenceId = null, + referenceId = referenceId?.toString(), createdAt = createdAt.toString(), + usageType = usageType?.name?.lowercase(), ) diff --git a/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt index 3823388..ac555de 100644 --- a/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt +++ b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt @@ -41,6 +41,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { val reservations = mutableMapOf() val rates = mutableMapOf() val codes = mutableMapOf() + private val permanentCodeIds = mutableMapOf() val bindings = mutableMapOf() val storeKitPurchases = mutableMapOf() val campaigns = mutableMapOf( @@ -83,6 +84,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { val usageSnapshot = usageRecords.toList() val reservationSnapshot = reservations.toMap() val codeSnapshot = codes.toMap() + val permanentCodeSnapshot = permanentCodeIds.toMap() val bindingSnapshot = bindings.toMap() val budgetSnapshot = campaignBudgets.toMap() val storeKitSnapshot = storeKitPurchases.toMap() @@ -97,6 +99,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { usageRecords.replaceWith(usageSnapshot) reservations.replaceWith(reservationSnapshot) codes.replaceWith(codeSnapshot) + permanentCodeIds.replaceWith(permanentCodeSnapshot) bindings.replaceWith(bindingSnapshot) campaignBudgets.replaceWith(budgetSnapshot) storeKitPurchases.replaceWith(storeKitSnapshot) @@ -216,28 +219,26 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { } private inner class Referrals : ReferralsRepository { - override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? = - codes.values - .filter { it.ownerUserId == ownerUserId } - .filter { campaignId == null || it.campaignId == campaignId } - .maxByOrNull(ReferralCode::createdAt) + override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? = + permanentCodeIds[ownerUserId]?.let(codes::get) - override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? = - findCodeByOwner(ownerUserId, campaignId) + override fun claimPermanentCode(candidate: ReferralCode): ReferralCode? { + findPermanentCodeByOwner(candidate.ownerUserId)?.let { return it } + val storedCode = codes.values.singleOrNull { + it.ownerUserId == candidate.ownerUserId && + it.campaignId == candidate.campaignId + } ?: run { + if (findCode(candidate.code) != null) return null + codes[candidate.id] = candidate + candidate + } + permanentCodeIds.putIfAbsent(candidate.ownerUserId, storedCode.id) + return findPermanentCodeByOwner(candidate.ownerUserId) + } override fun findCode(code: String): ReferralCode? = codes.values.singleOrNull { it.code == code } - override fun insertCodeIfAbsent(code: ReferralCode): Boolean { - if (findCodeByOwner(code.ownerUserId, code.campaignId) != null || - findCode(code.code) != null - ) { - return false - } - codes[code.id] = code - return true - } - override fun findCampaign(id: UUID): ReferralCampaign? = campaigns[id] override fun listActiveCampaigns(at: Instant): List = diff --git a/src/test/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutesTest.kt index 61e51b2..699197a 100644 --- a/src/test/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/inviteweb/InviteWebRoutesTest.kt @@ -91,7 +91,7 @@ class InviteWebRoutesTest { response.status shouldBe HttpStatusCode.NotFound response.bodyAsText() shouldBe - "邀请链接无效或已失效 / This invitation link is invalid or expired" + "邀请链接无效 / This invitation link is invalid" } @Test diff --git a/src/test/kotlin/com/osglab/account/features/referrals/ReferralRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/referrals/ReferralRoutesTest.kt new file mode 100644 index 0000000..2e4810a --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/referrals/ReferralRoutesTest.kt @@ -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 = emptyList() + + override suspend fun listInvited(userId: UUID, limit: Int): List = emptyList() +} diff --git a/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt b/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt index c6b54ce..a5b4137 100644 --- a/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt @@ -1,6 +1,7 @@ package com.osglab.account.features.referrals import com.osglab.account.features.credits.TestBillingStore +import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID import com.osglab.account.features.referrals.domain.InviteCodeGenerator import com.osglab.account.features.referrals.domain.ReferralBindingRules import com.osglab.account.features.referrals.domain.ReferralConflict @@ -15,6 +16,9 @@ import com.osglab.account.features.referrals.services.UserRegistrationTimeProvid import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import java.time.Clock import java.time.Duration import java.time.Instant @@ -31,13 +35,33 @@ class ReferralServiceTest : FunSpec({ val service = referralService(store, now) { now.minus(Duration.ofDays(1)) } val first = service.getOrCreateCode(owner) - val second = service.getOrCreateCode(owner) + val laterCampaignId = UUID.randomUUID() + store.campaigns[laterCampaignId] = referralCampaign( + id = laterCampaignId, + startsAt = now.minusSeconds(60), + ) + val second = service.getOrCreateCode(owner, laterCampaignId) second shouldBe first first.code.length shouldBe 22 store.codes.size shouldBe 1 } + test("concurrent provisioning claims one permanent code") { + val store = TestBillingStore() + val owner = UUID.randomUUID() + val service = referralService(store, now) { now.minus(Duration.ofDays(1)) } + + val codes = coroutineScope { + List(8) { + async { service.getOrCreateCode(owner) } + }.awaitAll() + } + + codes.map(ReferralCode::id).distinct().size shouldBe 1 + store.codes.size shouldBe 1 + } + test("profile lookup automatically provisions a stable invitation code") { val store = TestBillingStore() val owner = UUID.randomUUID() @@ -47,10 +71,61 @@ class ReferralServiceTest : FunSpec({ val second = service.getProfile(owner) first.code shouldBe second.code - first.code?.code?.length shouldBe 22 + first.code.code.length shouldBe 22 store.codes.size shouldBe 1 } + test("permanent code provisioning does not depend on an active reward campaign") { + val store = TestBillingStore() + store.campaigns[DEFAULT_REFERRAL_CAMPAIGN_ID] = + store.campaigns.getValue(DEFAULT_REFERRAL_CAMPAIGN_ID).copy(enabled = false) + val owner = UUID.randomUUID() + val service = referralService(store, now) { now.minus(Duration.ofDays(1)) } + + val code = service.getOrCreateCode(owner) + + code.ownerUserId shouldBe owner + store.codes.values.single() shouldBe code + } + + test("an existing permanent code is returned without reprovisioning identity") { + val store = TestBillingStore() + val owner = UUID.randomUUID() + val original = referralService(store, now) { now.minus(Duration.ofDays(1)) } + .getOrCreateCode(owner) + val identityUnavailable = referralService( + store = store, + now = now, + riskIdentity = { null }, + registeredAt = { now.minus(Duration.ofDays(1)) }, + ) + + identityUnavailable.getOrCreateCode(owner) shouldBe original + store.codes.size shouldBe 1 + } + + test("a permanent code remains redeemable after the reward campaign changes") { + val store = TestBillingStore() + val inviter = UUID.randomUUID() + val invitee = UUID.randomUUID() + val service = referralService(store, now) { now.minus(Duration.ofDays(1)) } + val code = service.getOrCreateCode(inviter) + store.campaigns[DEFAULT_REFERRAL_CAMPAIGN_ID] = + store.campaigns.getValue(DEFAULT_REFERRAL_CAMPAIGN_ID).copy(enabled = false) + val currentCampaignId = UUID.randomUUID() + store.campaigns[currentCampaignId] = referralCampaign( + id = currentCampaignId, + startsAt = now.minusSeconds(60), + ) + store.campaignBudgets[currentCampaignId] = + ReferralCampaignBudget(currentCampaignId, 0, 0, now) + + val binding = service.bind(invitee, code.code) + + binding.codeId shouldBe code.id + binding.campaignId shouldBe currentCampaignId + } + test("an account binds once and repeated same binding is idempotent") { val store = TestBillingStore() val inviter = UUID.randomUUID() @@ -195,7 +270,7 @@ class ReferralServiceTest : FunSpec({ private fun referralService( store: TestBillingStore, now: Instant, - riskIdentity: (UUID) -> ReferralRiskIdentity = { userId -> + riskIdentity: (UUID) -> ReferralRiskIdentity? = { userId -> ReferralRiskIdentity(fingerprint(userId), restricted = false) }, registeredAt: (UUID) -> Instant, @@ -215,5 +290,19 @@ private fun referralService( ) } +private fun referralCampaign(id: UUID, startsAt: Instant): ReferralCampaign = + ReferralCampaign( + id = id, + name = "Current campaign", + startsAt = startsAt, + endsAt = null, + bindingWindowSeconds = Duration.ofDays(7).seconds, + inviterRewardCredits = 10, + inviteeRewardCredits = 10, + maxRewardedBindings = null, + budgetCredits = null, + enabled = true, + ) + private fun fingerprint(userId: UUID): String = userId.toString().replace("-", "").repeat(2)