Compare commits

..

10 Commits

Author SHA1 Message Date
Rocky 231c5040a5 Add StoreKit history and modernize admin console
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
2026-08-19 22:13:13 +08:00
Rocky 11ec34dacb Enforce deterministic gateway task policies
Make the server authoritative for thinking, model, search, tools, retry, and output budgets while preserving legacy client behavior.
2026-08-19 20:55:05 +08:00
Rocky 3edc86a9a0 Show product usage types in credit ledger
Persist hotword request origin so every reservation lifecycle entry can be classified without mutating the immutable ledger.
2026-08-19 19:47:12 +08:00
Rocky e1fd35b1ff Fix managed ASR settlement and empty polish recovery
Accept the final sequence format returned by Volcengine without weakening ordering checks, and retry one safe buffered DeepSeek empty response under the same credit reservation.
2026-08-19 19:16:05 +08:00
Rocky 2194e69bb8 Accept bounded StoreKit signing clock skew
Allow legitimate App Store transaction timestamps to differ within the existing verification tolerance while preserving rejection beyond that boundary.
2026-08-19 18:26:54 +08:00
Rocky 75c046d91d Show latest credit ledger in admin
Expose a paginated global ledger timeline and load it automatically so operators can see recent credit activity without first locating a user.
2026-08-19 18:05:54 +08:00
Rocky 58445dd880 Show complete user credit data in admin
Load users by registration order, expose consumed and current credits, and accept short internal IDs so support can reliably locate accounts.
2026-08-19 17:54:27 +08:00
Rocky c592f426be Enable StoreKit credit purchases in production
Keep runtime grant verification aligned with all database migrations so release builds fail before missing table privileges reach deployment.
2026-08-19 15:57:45 +08:00
Rocky 1737106560 checkpoint before checking out feature/account-managed-gateway 2026-08-19 15:53:08 +08:00
Rocky 25cfbfa4e6 Polish admin layout and navigation rhythm
Prevent Web Awesome controls from overflowing while giving dashboard sections and navigation states consistent visual spacing.
2026-08-18 09:22:17 +08:00
119 changed files with 9665 additions and 4017 deletions
+11 -2
View File
@@ -61,13 +61,22 @@ VOLCENGINE_ASR_ENDPOINT=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel
DEEPSEEK_API_KEY=replace-with-deepseek-api-key
DEEPSEEK_MODEL=deepseek-v4-flash
# Optional; defaults to DEEPSEEK_MODEL when omitted.
DEEPSEEK_REASONING_MODEL=
DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1
SIGNUP_TRIAL_CREDITS=1000
REFERRAL_INVITER_CREDITS=3000
REFERRAL_INVITEE_CREDITS=3000
REFERRAL_INVITER_CREDITS=1000
REFERRAL_INVITEE_CREDITS=1000
REFERRAL_BINDING_DAYS=7
# Keep voluntary tips separate. Every entry must be a dedicated consumable in
# productId:credits format and use an appAccountToken supplied by the app.
STOREKIT_ENABLED=false
STOREKIT_BUNDLE_ID=com.osgkeyboard.ios
STOREKIT_APP_APPLE_ID=6781553267
STOREKIT_PRODUCTS=500tks:500,1500tks:1500,3000tks:3000
# Production startup requires both flags and the production Apple environment.
ENFORCE_DEVICE_CHECK=false
ENFORCE_APP_ATTEST=false
+12 -4
View File
@@ -136,7 +136,7 @@ Gateway execution and settlement rules:
Existing `application.yaml` values can be mapped into:
- `DeepSeekConfig(endpoint, apiKey, model)`
- `DeepSeekConfig(endpoint, apiKey, model, reasoningModel)`
- `VolcengineAsrConfig(endpoint, resourceId, appId, accessToken)`
- `InviteWebConfig(appStoreUrl, appleAppId, universalLinkBaseUrl)`
@@ -152,8 +152,16 @@ Configuration ownership:
`APPLE_INTEGRITY_ENVIRONMENT`, plus the two integrity enforcement flags.
- Volcengine: prefer `VOLCENGINE_API_KEY`; set the SAUC v3 `VOLCENGINE_RESOURCE_ID` and WSS
`VOLCENGINE_ASR_ENDPOINT`. The legacy app ID/access token pair remains optional.
- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned `DEEPSEEK_MODEL`, and HTTPS
`DEEPSEEK_ENDPOINT`.
- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned low-latency `DEEPSEEK_MODEL`,
and HTTPS `DEEPSEEK_ENDPOINT`. `DEEPSEEK_REASONING_MODEL` is optional and
falls back to `DEEPSEEK_MODEL`.
Gateway text requests may include the optional stable `taskKind` values documented in
`docs/openapi.yaml`. The server maps `capability + taskKind` to a deterministic execution policy;
it never infers task type from user content. Polish and transform tasks explicitly disable DeepSeek
thinking and do not retry an empty buffered result. AI questions and agent planning explicitly use
high-effort thinking. Search and tools remain disabled for every task because no safe, billable
implementation is configured.
Store production values in 1Panel's secret/environment facility. The Compose environment receives
them at runtime because this application does not read Docker `/run/secrets/*` files directly.
@@ -236,7 +244,7 @@ Internet.
- Apple: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`,
`APPLE_INTEGRITY_ENVIRONMENT`.
- Providers: `VOLCENGINE_API_KEY`, `VOLCENGINE_RESOURCE_ID`, `DEEPSEEK_API_KEY`,
`DEEPSEEK_MODEL`.
`DEEPSEEK_MODEL`; optionally `DEEPSEEK_REASONING_MODEL`.
- Production controls: `APP_ENV=production`, `ENFORCE_DEVICE_CHECK=true`,
`ENFORCE_APP_ATTEST=true`.
- Optional tuning: token lifetimes, gateway grant days, credit values, binding window and pool size;
+2 -1
View File
@@ -4,11 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f5f7fb" />
<meta name="referrer" content="same-origin" />
<title>OSG 运营后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1629 -330
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -9,13 +9,29 @@
"test": "vitest run"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^27.0.1",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.2",
"vite": "^7.1.2",
"vitest": "^3.2.4"
},
"dependencies": {
"@awesome.me/webawesome": "^3.11.0"
"@base-ui/react": "^1.7.0",
"@tanstack/react-table": "^9.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0"
}
}
+4 -1
View File
@@ -165,7 +165,7 @@ export const adminApi = {
referrals: (range: string) =>
request<ReferralOverview>(`/referrals${query({ range })}`),
users: (search: string, cursor?: string) =>
users: (search = "", cursor?: string) =>
request<PageResult<UserSummary>>(
`/users${query({ q: search.trim(), cursor })}`,
),
@@ -173,6 +173,9 @@ export const adminApi = {
user: (userId: string) =>
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
latestLedger: (cursor?: string) =>
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
ledger: (userId: string, cursor?: string) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
+5
View File
@@ -58,6 +58,7 @@ export interface UserSummary {
maskedEmail?: string;
status: "active" | "suspended" | "closed";
creditBalance: number;
consumedCredits: number;
createdAt: string;
}
@@ -97,12 +98,16 @@ export type LedgerEntryType =
| "refund"
| "adjustment";
export type UsageType = "polish" | "asr" | "ai" | "agent" | "hotword";
export interface LedgerEntry {
entryId: string;
userId: string;
type: LedgerEntryType;
amount: number;
balanceAfter: number;
reasonCode: string;
usageType?: UsageType;
createdAt: string;
}
-289
View File
@@ -1,289 +0,0 @@
import { adminApi, ApiError, setCsrfToken } from "./api/client";
import type { AdminRole, AuthState } from "./api/types";
import { renderLoading, showToast } from "./components/ui";
import { escapeHtml } from "./lib/format";
import { renderAudit } from "./pages/audit";
import { renderPasswordLogin } from "./pages/auth";
import { renderCredits } from "./pages/credits";
import { renderOverview } from "./pages/overview";
import { renderReferrals } from "./pages/referrals";
import { renderSecurity } from "./pages/security";
import { renderUsers } from "./pages/users";
type Route =
| "overview"
| "referrals"
| "users"
| "credits"
| "audit"
| "security";
interface RouteDefinition {
id: Route;
label: string;
icon: string;
roles: AdminRole[];
}
const ALL_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"];
const SUPPORT_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"];
const SUPER_ADMIN_ONLY: AdminRole[] = ["SUPER_ADMIN"];
const routes: RouteDefinition[] = [
{
id: "overview",
label: "运营总览",
roles: ALL_ROLES,
icon: '<path d="M4 18V10M10 18V6M16 18V3M2 18h18" />',
},
{
id: "referrals",
label: "裂变分析",
roles: ALL_ROLES,
icon:
'<circle cx="5" cy="11" r="2.5" /><circle cx="17" cy="5" r="2.5" /><circle cx="17" cy="17" r="2.5" /><path d="m7.3 9.8 7.4-3.6M7.3 12.2l7.4 3.6" />',
},
{
id: "users",
label: "用户查询",
roles: SUPPORT_ROLES,
icon:
'<circle cx="8" cy="7" r="3" /><circle cx="16" cy="8" r="2.5" /><path d="M2.5 18c.5-3.5 2.3-5.5 5.5-5.5s5 2 5.5 5.5M13 13.5c3.8-.7 6 1 6.5 4.5" />',
},
{
id: "credits",
label: "积分流水",
roles: SUPPORT_ROLES,
icon:
'<rect x="3" y="4" width="16" height="15" rx="2" /><path d="M7 8h8M7 12h8M7 16h5" />',
},
{
id: "audit",
label: "审计日志",
roles: SUPER_ADMIN_ONLY,
icon:
'<path d="M11 2.5 18 5v5.5c0 4.5-2.7 7.3-7 9-4.3-1.7-7-4.5-7-9V5l7-2.5Z" /><path d="m8 11 2 2 4-4" />',
},
{
id: "security",
label: "安全中心",
roles: SUPER_ADMIN_ONLY,
icon:
'<rect x="4" y="9" width="14" height="10" rx="2" /><path d="M7.5 9V6.5a3.5 3.5 0 0 1 7 0V9M11 13v2.5" />',
},
];
export class AdminApp {
private auth: AuthState = { status: "anonymous" };
private renderVersion = 0;
constructor(private readonly root: HTMLElement) {
window.addEventListener("hashchange", () => void this.renderRoute());
window.addEventListener("admin:unauthorized", () => {
setCsrfToken();
this.auth = { status: "anonymous" };
this.render();
showToast("登录状态已失效,请重新登录", "error");
});
}
async start(): Promise<void> {
renderLoading(this.root, "检查登录状态");
try {
const session = await adminApi.session();
if (session.authenticated && session.operatorName && session.role) {
this.auth = {
status: "authenticated",
operatorName: session.operatorName,
role: session.role,
};
}
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) {
showToast("暂时无法确认登录状态", "error");
}
}
this.render();
}
private render(): void {
if (this.auth.status === "anonymous") {
renderPasswordLogin(this.root, { onChange: (state) => this.setAuth(state) });
return;
}
this.renderShell();
void this.renderRoute();
}
private setAuth(state: AuthState): void {
this.auth = state;
this.render();
}
private currentRoute(): Route {
const value = window.location.hash.replace(/^#\/?/, "") as Route;
return this.availableRoutes().some((route) => route.id === value)
? value
: "overview";
}
private availableRoutes(): RouteDefinition[] {
if (this.auth.status !== "authenticated") return [];
const role = this.auth.role;
return routes.filter((route) => route.roles.includes(role));
}
private renderShell(): void {
if (this.auth.status !== "authenticated") return;
this.root.innerHTML = `
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell">
<aside class="sidebar" id="primary-navigation" data-sidebar>
<div class="sidebar-brand">
<div class="brand-mark brand-mark--small">OSG</div>
<div><strong>管理中心</strong><span>OSG ACCOUNT</span></div>
</div>
<nav class="nav-list" aria-label="主导航">
${this.availableRoutes()
.map(
(route) => `
<a href="#/${route.id}" data-route="${route.id}">
<svg class="nav-icon" viewBox="0 0 22 22" aria-hidden="true">${route.icon}</svg>
${route.label}
</a>
`,
)
.join("")}
</nav>
<div class="sidebar-footer">
<div class="operator">
<span class="operator-avatar" aria-hidden="true">管</span>
<div><strong>${escapeHtml(this.auth.operatorName)}</strong><span>${roleLabel(this.auth.role)}</span></div>
</div>
<wa-button class="logout-button" appearance="plain" data-logout title="安全退出">
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M9 4H4v14h5M13 7l4 4-4 4M17 11H8" /></svg>
<span>退出</span>
</wa-button>
</div>
</aside>
<div class="main-column">
<header class="mobile-header">
<wa-button class="icon-button" appearance="plain" data-menu aria-label="打开导航" aria-expanded="false" aria-controls="primary-navigation">
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M3 6h16M3 11h16M3 16h16" /></svg>
</wa-button>
<strong data-mobile-title>运营总览</strong>
</header>
<main class="page-content" id="main-content" data-content tabindex="-1"></main>
</div>
<button class="sidebar-scrim" data-scrim aria-label="关闭导航"></button>
</div>
`;
const sidebar = this.root.querySelector<HTMLElement>("[data-sidebar]");
const menuButton =
this.root.querySelector<HTMLButtonElement>("[data-menu]");
const mobileLayout = window.matchMedia("(max-width: 1023px)");
const syncSidebarState = () => {
if (!sidebar) return;
sidebar.inert =
mobileLayout.matches && !sidebar.classList.contains("sidebar--open");
};
const closeMenu = (restoreFocus = false) => {
sidebar?.classList.remove("sidebar--open");
menuButton?.setAttribute("aria-expanded", "false");
syncSidebarState();
if (restoreFocus) menuButton?.focus();
};
const openMenu = () => {
sidebar?.classList.add("sidebar--open");
menuButton?.setAttribute("aria-expanded", "true");
syncSidebarState();
sidebar
?.querySelector<HTMLAnchorElement>('a[aria-current="page"], a')
?.focus();
};
menuButton?.addEventListener("click", openMenu);
this.root
.querySelector<HTMLButtonElement>("[data-scrim]")
?.addEventListener("click", () => closeMenu(true));
this.root.querySelectorAll("[data-route]").forEach((link) => {
link.addEventListener("click", () => closeMenu());
});
sidebar?.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenu(true);
});
mobileLayout.addEventListener("change", syncSidebarState);
syncSidebarState();
this.root
.querySelector<HTMLButtonElement>("[data-logout]")
?.addEventListener("click", () => void this.logout());
}
private async renderRoute(): Promise<void> {
if (this.auth.status !== "authenticated") return;
const route = this.currentRoute();
this.root.querySelectorAll<HTMLElement>("[data-route]").forEach((link) => {
const active = link.dataset.route === route;
link.classList.toggle("active", active);
if (active) {
link.setAttribute("aria-current", "page");
} else {
link.removeAttribute("aria-current");
}
});
const routeDefinition = routes.find((item) => item.id === route);
document.title = `${routeDefinition?.label ?? "管理中心"} · OSG`;
const mobileTitle =
this.root.querySelector<HTMLElement>("[data-mobile-title]");
if (mobileTitle) mobileTitle.textContent = routeDefinition?.label ?? "管理中心";
const content = this.root.querySelector<HTMLElement>("[data-content]");
if (!content) return;
const version = ++this.renderVersion;
const stage = document.createElement("div");
stage.className = "page-stage";
renderLoading(content);
if (route === "overview") await renderOverview(stage);
if (route === "referrals") await renderReferrals(stage);
if (route === "users") renderUsers(stage, this.auth.role);
if (route === "credits") renderCredits(stage);
if (route === "audit") await renderAudit(stage);
if (route === "security") {
await renderSecurity(stage, this.auth.operatorName);
}
if (version === this.renderVersion) {
content.replaceChildren(stage);
const heading = content.querySelector<HTMLElement>("h1");
if (heading) {
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
} else {
content.focus({ preventScroll: true });
}
}
}
private async logout(): Promise<void> {
try {
await adminApi.logout();
} catch {
// 即使服务端退出失败,也立即清理前端认证状态。
} finally {
setCsrfToken();
this.auth = { status: "anonymous" };
window.history.replaceState(null, "", window.location.pathname);
this.render();
}
}
}
function roleLabel(role: AdminRole): string {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return labels[role];
}
+470
View File
@@ -0,0 +1,470 @@
import {
Activity,
BookOpenCheck,
ChevronRight,
Coins,
GitBranch,
LogOut,
Menu,
Moon,
Search,
ShieldCheck,
Sun,
Users,
X,
type LucideIcon,
} from "lucide-react";
import {
lazy,
Suspense,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
HashRouter,
Navigate,
NavLink,
Route,
Routes,
useLocation,
} from "react-router-dom";
import { Toaster, toast } from "sonner";
import type { AdminRole } from "./api/types";
import { ErrorBoundary } from "./components/error-boundary";
import { Button, LoadingState } from "./components/primitives";
import { AuthProvider, useAuth } from "./features/auth/auth-context";
import { LoginPage } from "./features/auth/login-page";
import { cn } from "./lib/utils";
const OverviewPage = lazy(() =>
import("./features/overview/overview-page").then((module) => ({
default: module.OverviewPage,
})),
);
const ReferralsPage = lazy(() =>
import("./features/referrals/referrals-page").then((module) => ({
default: module.ReferralsPage,
})),
);
const UsersPage = lazy(() =>
import("./features/users/users-page").then((module) => ({
default: module.UsersPage,
})),
);
const CreditsPage = lazy(() =>
import("./features/credits/credits-page").then((module) => ({
default: module.CreditsPage,
})),
);
const AuditPage = lazy(() =>
import("./features/audit/audit-page").then((module) => ({
default: module.AuditPage,
})),
);
const SecurityPage = lazy(() =>
import("./features/security/security-page").then((module) => ({
default: module.SecurityPage,
})),
);
interface NavItem {
path: string;
label: string;
description: string;
icon: LucideIcon;
roles: AdminRole[];
}
const allRoles: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"];
const supportRoles: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"];
const navigation: NavItem[] = [
{
path: "/overview",
label: "运营总览",
description: "增长与消耗",
icon: Activity,
roles: allRoles,
},
{
path: "/referrals",
label: "裂变分析",
description: "转化与排行",
icon: GitBranch,
roles: allRoles,
},
{
path: "/users",
label: "用户查询",
description: "账户与使用",
icon: Users,
roles: supportRoles,
},
{
path: "/credits",
label: "积分流水",
description: "不可变账本",
icon: Coins,
roles: supportRoles,
},
{
path: "/audit",
label: "审计日志",
description: "操作追踪",
icon: BookOpenCheck,
roles: ["SUPER_ADMIN"],
},
{
path: "/security",
label: "安全中心",
description: "权限与会话",
icon: ShieldCheck,
roles: ["SUPER_ADMIN"],
},
];
export function App() {
return (
<AuthProvider>
<HashRouter>
<AppContent />
</HashRouter>
<Toaster
position="bottom-right"
richColors
closeButton
toastOptions={{
classNames: {
toast: "osg-toast",
},
}}
/>
</AuthProvider>
);
}
function AppContent() {
const { auth, checking } = useAuth();
useEffect(() => {
function handleToast(event: Event) {
const detail = (event as CustomEvent<{ message: string; tone: "success" | "error" }>)
.detail;
if (!detail) return;
if (detail.tone === "error") toast.error(detail.message);
else toast.success(detail.message);
}
window.addEventListener("admin:toast", handleToast);
return () => window.removeEventListener("admin:toast", handleToast);
}, []);
if (checking) {
return (
<main className="grid min-h-screen place-items-center bg-page">
<LoadingState label="检查登录状态" />
</main>
);
}
if (auth.status === "anonymous") return <LoginPage />;
return <AuthenticatedApp role={auth.role} />;
}
function AuthenticatedApp({ role }: { role: AdminRole }) {
const available = useMemo(
() => navigation.filter((item) => item.roles.includes(role)),
[role],
);
return (
<AppShell navigationItems={available}>
<ErrorBoundary>
<Suspense fallback={<LoadingState label="加载页面" />}>
<Routes>
<Route path="/overview" element={<OverviewPage />} />
<Route path="/referrals" element={<ReferralsPage />} />
{supportRoles.includes(role) ? (
<>
<Route path="/users" element={<UsersPage />} />
<Route path="/credits" element={<CreditsPage />} />
</>
) : null}
{role === "SUPER_ADMIN" ? (
<>
<Route path="/audit" element={<AuditPage />} />
<Route path="/security" element={<SecurityPage />} />
</>
) : null}
<Route path="*" element={<Navigate to="/overview" replace />} />
</Routes>
</Suspense>
</ErrorBoundary>
</AppShell>
);
}
function AppShell({
navigationItems,
children,
}: {
navigationItems: NavItem[];
children: ReactNode;
}) {
const { auth, logout } = useAuth();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [mobile, setMobile] = useState(
() =>
typeof window.matchMedia === "function" &&
window.matchMedia("(max-width: 1023px)").matches,
);
const sidebarRef = useRef<HTMLElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const [dark, setDark] = useState(() => {
const saved = localStorage.getItem("osg-admin-theme");
return saved
? saved === "dark"
: typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
});
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
localStorage.setItem("osg-admin-theme", dark ? "dark" : "light");
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", dark ? "#0a0c10" : "#f5f7fb");
}, [dark]);
useEffect(() => {
setSidebarOpen(false);
const active = navigation.find((item) => item.path === location.pathname);
document.title = `${active?.label ?? "管理中心"} · OSG`;
const main = document.querySelector<HTMLElement>("#main-content");
const focusHeading = () => {
const heading = main?.querySelector<HTMLElement>("h1");
if (!heading) return false;
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
return true;
};
if (focusHeading()) return;
const observer = new MutationObserver(() => {
if (focusHeading()) observer.disconnect();
});
if (main) observer.observe(main, { childList: true, subtree: true });
return () => observer.disconnect();
}, [location.pathname]);
useEffect(() => {
if (!sidebarOpen) return;
function close(event: KeyboardEvent) {
if (event.key === "Escape") {
setSidebarOpen(false);
menuButtonRef.current?.focus();
}
}
window.addEventListener("keydown", close);
return () => window.removeEventListener("keydown", close);
}, [sidebarOpen]);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(max-width: 1023px)");
const update = () => setMobile(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useEffect(() => {
if (!sidebarOpen || !mobile) return;
requestAnimationFrame(() => {
sidebarRef.current
?.querySelector<HTMLAnchorElement>('a[aria-current="page"], a[href]')
?.focus();
});
}, [mobile, sidebarOpen]);
if (auth.status !== "authenticated") return null;
return (
<div className="min-h-screen bg-page text-foreground">
<a
className="fixed left-3 top-3 z-[100] -translate-y-24 rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white transition focus:translate-y-0"
href="#main-content"
>
</a>
<aside
ref={sidebarRef}
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-[272px] flex-col border-r border-border bg-sidebar/95 p-4 backdrop-blur-2xl transition-transform duration-300 lg:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
inert={mobile && !sidebarOpen}
aria-hidden={mobile && !sidebarOpen}
aria-label="主导航"
>
<div className="flex h-16 items-center justify-between px-2">
<div className="flex items-center gap-3">
<span className="brand-symbol size-10 rounded-xl text-sm">O</span>
<span>
<strong className="block text-sm font-bold tracking-tight">OSG</strong>
<span className="block text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
Account Intelligence
</span>
</span>
</div>
<Button
ref={menuButtonRef}
className="lg:hidden"
size="icon"
variant="ghost"
onClick={() => setSidebarOpen(false)}
aria-label="关闭导航"
>
<X className="size-5" aria-hidden />
</Button>
</div>
<nav className="mt-5 flex-1 space-y-1.5">
<p className="mb-3 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-muted/80">
</p>
{navigationItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
"group flex min-h-14 items-center gap-3 rounded-2xl px-3 transition-all",
isActive
? "bg-surface text-primary shadow-[0_1px_2px_rgb(15_23_42/0.06),0_8px_25px_rgb(15_23_42/0.05)]"
: "text-muted hover:bg-surface-muted hover:text-foreground",
)
}
>
{({ isActive }) => (
<>
<span
className={cn(
"grid size-9 shrink-0 place-items-center rounded-xl transition",
isActive ? "bg-primary-soft text-primary" : "bg-transparent",
)}
>
<item.icon className="size-[18px]" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">{item.label}</span>
<span className="mt-0.5 block text-[10px] text-muted">{item.description}</span>
</span>
<ChevronRight
className={cn(
"size-4 opacity-0 transition group-hover:opacity-60",
isActive && "opacity-60",
)}
aria-hidden
/>
</>
)}
</NavLink>
))}
</nav>
<div className="rounded-2xl border border-border bg-surface/70 p-3">
<div className="flex items-center gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-primary to-violet text-sm font-bold text-white shadow-md">
{auth.operatorName.slice(0, 1).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<strong className="block truncate text-xs">{auth.operatorName}</strong>
<span className="mt-0.5 block text-[10px] text-muted">{roleLabel(auth.role)}</span>
</div>
<Button
size="icon"
variant="ghost"
onClick={() => void logout()}
aria-label="安全退出"
title="安全退出"
>
<LogOut className="size-4" aria-hidden />
</Button>
</div>
</div>
</aside>
<div className="lg:pl-[272px]">
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border bg-page/85 px-4 backdrop-blur-xl sm:px-6 lg:px-10">
<Button
className="lg:hidden"
size="icon"
variant="ghost"
onClick={() => setSidebarOpen(true)}
aria-label="打开导航"
aria-expanded={sidebarOpen}
>
<Menu className="size-5" aria-hidden />
</Button>
<div className="hidden items-center gap-2 text-xs text-muted sm:flex">
<span>OSG </span>
<ChevronRight className="size-3.5" aria-hidden />
<strong className="font-semibold text-foreground">
{navigation.find((item) => item.path === location.pathname)?.label ?? "运营总览"}
</strong>
</div>
<div className="ml-auto flex items-center gap-1">
<Button
className="hidden sm:inline-flex"
size="icon"
variant="ghost"
aria-label="全局搜索即将提供"
title="全局搜索即将提供"
disabled
>
<Search className="size-4" aria-hidden />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => setDark((current) => !current)}
aria-label={dark ? "切换浅色模式" : "切换深色模式"}
title={dark ? "浅色模式" : "深色模式"}
>
{dark ? (
<Sun className="size-4" aria-hidden />
) : (
<Moon className="size-4" aria-hidden />
)}
</Button>
</div>
</header>
<main id="main-content" className="mx-auto w-full max-w-[1500px] px-4 py-7 sm:px-6 sm:py-9 lg:px-10 lg:py-11">
<div className="animate-page-in" key={location.pathname}>
{children}
</div>
</main>
</div>
{sidebarOpen ? (
<button
className="fixed inset-0 z-30 bg-slate-950/45 backdrop-blur-[2px] lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-label="关闭导航"
/>
) : null}
</div>
);
}
function roleLabel(role: AdminRole): string {
return {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
}[role];
}
-80
View File
@@ -1,80 +0,0 @@
import type { FunnelStep, TrendPoint } from "../api/types";
import { escapeHtml, formatNumber } from "../lib/format";
export function trendChart(points: TrendPoint[]): string {
if (points.length === 0) return '<div class="empty-state">暂无趋势数据</div>';
const width = 720;
const height = 240;
const padding = 28;
const values = points.map((point) => point.registrations);
const max = Math.max(...values, 1);
const step = points.length > 1 ? (width - padding * 2) / (points.length - 1) : 0;
const coordinates = points.map((point, index) => ({
x: points.length === 1 ? width / 2 : padding + index * step,
y: height - padding - (point.registrations / max) * (height - padding * 2),
point,
}));
const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(" ");
const midpoint = Math.ceil(max / 2);
return `
<div class="chart-scroll">
<svg class="trend-chart" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-chart-title trend-chart-description">
<title id="trend-chart-title">新增用户趋势</title>
<desc id="trend-chart-description">横轴为 UTC 日期,纵轴为每日新增用户数。图表后提供完整数据表。</desc>
<line x1="${padding}" y1="${padding}" x2="${width - padding}" y2="${padding}" class="chart-axis" />
<line x1="${padding}" y1="${height / 2}" x2="${width - padding}" y2="${height / 2}" class="chart-axis" />
<line x1="${padding}" y1="${height - padding}" x2="${width - padding}" y2="${height - padding}" class="chart-axis" />
<text x="${padding}" y="${padding - 8}" class="chart-label">${formatNumber(max)}</text>
<text x="${padding}" y="${height / 2 - 8}" class="chart-label">${formatNumber(midpoint)}</text>
<text x="${padding}" y="${height - 8}" class="chart-label">0</text>
<polyline points="${polyline}" class="chart-line" />
${coordinates
.map(
({ x, y, point }) => `
<circle cx="${x}" cy="${y}" r="4" class="chart-dot">
<title>${escapeHtml(point.date)}${formatNumber(point.registrations)} </title>
</circle>
`,
)
.join("")}
</svg>
</div>
<table class="sr-only">
<caption>新增用户与积分消耗趋势完整数据</caption>
<thead><tr><th scope="col">UTC 日期</th><th scope="col">新增用户</th><th scope="col">消耗积分</th></tr></thead>
<tbody>
${points
.map(
(point) =>
`<tr><td>${escapeHtml(point.date)}</td><td>${formatNumber(point.registrations)}</td><td>${formatNumber(point.creditsUsed)}</td></tr>`,
)
.join("")}
</tbody>
</table>
`;
}
export function funnelChart(steps: FunnelStep[]): string {
if (steps.length === 0) return '<div class="empty-state">暂无漏斗数据</div>';
const max = Math.max(...steps.map((step) => step.count), 1);
return `
<div class="funnel" aria-label="裂变漏斗">
${steps
.map((step, index) => {
return `
<div class="funnel-step">
<div class="funnel-label">
<span>${escapeHtml(step.label)}</span>
<strong>${formatNumber(step.count)}</strong>
</div>
<progress class="funnel-progress funnel-progress--${(index % 4) + 1}" max="${max}" value="${step.count}" aria-label="${escapeHtml(step.label)}${formatNumber(step.count)}"></progress>
</div>
`;
})
.join("")}
</div>
`;
}
+80
View File
@@ -0,0 +1,80 @@
import { flexRender } from "@tanstack/react-table";
import type { RowData } from "@tanstack/table-core";
import {
getCoreRowModel,
type LegacyColumnDef,
useLegacyTable,
} from "@tanstack/react-table/legacy";
import { type ReactNode } from "react";
import { cn } from "../lib/utils";
import { EmptyState } from "./primitives";
export type DataColumn<T extends RowData> = LegacyColumnDef<T, unknown>;
export function DataTable<T extends RowData>({
data,
columns,
caption,
emptyTitle = "暂无数据",
footer,
className,
}: {
data: T[];
columns: DataColumn<T>[];
caption: string;
emptyTitle?: string;
footer?: ReactNode;
className?: string;
}) {
const table = useLegacyTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
if (data.length === 0) return <EmptyState title={emptyTitle} />;
return (
<div className={cn("overflow-hidden", className)}>
<div className="overflow-x-auto">
<table className="w-full min-w-max border-separate border-spacing-0 text-sm">
<caption className="sr-only">{caption}</caption>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="group transition-colors hover:bg-surface-muted/70"
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className="border-b border-border/70 px-5 py-4 align-middle text-foreground last:text-right group-last:border-b-0"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{footer}
</div>
);
}
@@ -0,0 +1,40 @@
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Button, Card } from "./primitives";
interface State {
failed: boolean;
}
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
state: State = { failed: false };
static getDerivedStateFromError(): State {
return { failed: true };
}
componentDidCatch(_error: Error, _info: ErrorInfo): void {
// 管理端禁止记录可能包含用户或运营数据的渲染上下文。
}
render() {
if (!this.state.failed) return this.props.children;
return (
<Card className="mx-auto grid min-h-80 max-w-lg place-items-center border-danger/20 p-8 text-center">
<div>
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl bg-danger-soft text-danger">
<AlertTriangle className="size-5" aria-hidden />
</span>
<h1 className="text-lg font-bold"></h1>
<p className="mt-2 text-sm leading-6 text-muted">
</p>
<Button className="mt-6" variant="secondary" onClick={() => window.location.reload()}>
<RefreshCw className="size-4" aria-hidden />
</Button>
</div>
</Card>
);
}
}
+308
View File
@@ -0,0 +1,308 @@
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { cva, type VariantProps } from "class-variance-authority";
import {
AlertTriangle,
Inbox,
LoaderCircle,
RefreshCw,
X,
type LucideIcon,
} from "lucide-react";
import {
forwardRef,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
} from "react";
import { cn } from "../lib/utils";
const buttonVariants = cva(
"inline-flex min-h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
{
variants: {
variant: {
primary:
"bg-primary text-primary-foreground shadow-[0_8px_24px_-10px_var(--primary)] hover:-translate-y-0.5 hover:bg-primary/90",
secondary:
"border border-border bg-surface text-foreground shadow-sm hover:border-border-strong hover:bg-surface-muted",
ghost: "text-muted hover:bg-surface-muted hover:text-foreground",
danger:
"bg-danger text-white shadow-[0_8px_24px_-10px_var(--danger)] hover:-translate-y-0.5 hover:bg-danger/90",
},
size: {
sm: "min-h-8 rounded-lg px-3 text-xs",
md: "min-h-10 px-4",
lg: "min-h-12 px-5",
icon: "size-10 px-0",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
loading?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, children, disabled, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
{...props}
>
{loading ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
{children}
</button>
),
);
Button.displayName = "Button";
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
"h-11 w-full rounded-xl border border-border bg-input px-3.5 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:bg-surface-muted disabled:text-muted",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export const Textarea = forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"min-h-24 w-full resize-y rounded-xl border border-border bg-input px-3.5 py-3 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10",
className,
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-2xl border border-border bg-surface shadow-[0_1px_2px_rgb(15_23_42/0.02),0_10px_35px_rgb(15_23_42/0.035)]",
className,
)}
{...props}
/>
);
}
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold",
{
variants: {
tone: {
neutral: "bg-surface-muted text-muted",
success: "bg-success-soft text-success",
danger: "bg-danger-soft text-danger",
warning: "bg-warning-soft text-warning",
info: "bg-primary-soft text-primary",
violet: "bg-violet-soft text-violet",
},
},
defaultVariants: { tone: "neutral" },
},
);
export function Badge({
className,
tone,
...props
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
}
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-primary">
{eyebrow}
</p>
<h1 className="text-balance text-3xl font-bold tracking-[-0.045em] text-foreground sm:text-4xl">
{title}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{description}</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</header>
);
}
export function LoadingState({ label = "正在加载" }: { label?: string }) {
return (
<div className="grid min-h-72 place-items-center" role="status" aria-live="polite">
<div className="flex flex-col items-center gap-3 text-sm text-muted">
<span className="grid size-11 place-items-center rounded-2xl bg-primary-soft text-primary">
<LoaderCircle className="size-5 animate-spin" aria-hidden />
</span>
{label}
</div>
</div>
);
}
export function EmptyState({
title = "暂无数据",
description,
}: {
title?: string;
description?: string;
}) {
return (
<div className="grid min-h-56 place-items-center px-6 text-center">
<div>
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-surface-muted text-muted">
<Inbox className="size-5" aria-hidden />
</span>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{description ? <p className="mt-1 text-sm text-muted">{description}</p> : null}
</div>
</div>
);
}
export function ErrorState({
error,
retry,
}: {
error: unknown;
retry?: () => void;
}) {
const message = error instanceof Error ? error.message : "出现未知错误,请稍后重试";
return (
<Card className="mx-auto grid min-h-72 max-w-lg place-items-center border-danger/20 p-8 text-center">
<div>
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-danger-soft text-danger">
<AlertTriangle className="size-5" aria-hidden />
</span>
<h2 className="font-semibold text-foreground"></h2>
<p className="mt-2 text-sm text-muted">{message}</p>
{retry ? (
<Button className="mt-5" variant="secondary" onClick={retry}>
<RefreshCw className="size-4" aria-hidden />
</Button>
) : null}
</div>
</Card>
);
}
export function Dialog({
open,
onOpenChange,
title,
description,
children,
preventClose = false,
className,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
children: ReactNode;
preventClose?: boolean;
className?: string;
}) {
return (
<BaseDialog.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && preventClose) return;
onOpenChange(nextOpen);
}}
>
<BaseDialog.Portal>
<BaseDialog.Backdrop className="fixed inset-0 z-50 bg-slate-950/45 backdrop-blur-[3px] transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0" />
<BaseDialog.Viewport className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4">
<BaseDialog.Popup
className={cn(
"relative my-8 w-full max-w-lg rounded-3xl border border-white/10 bg-surface-elevated p-6 shadow-2xl outline-none transition-all data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 sm:p-8",
className,
)}
>
{!preventClose ? (
<BaseDialog.Close
className="absolute right-4 top-4 grid size-9 place-items-center rounded-xl text-muted transition hover:bg-surface-muted hover:text-foreground"
aria-label="关闭"
>
<X className="size-4" aria-hidden />
</BaseDialog.Close>
) : null}
<BaseDialog.Title className="pr-10 text-xl font-bold tracking-tight text-foreground">
{title}
</BaseDialog.Title>
{description ? (
<BaseDialog.Description className="mt-2 text-sm leading-6 text-muted">
{description}
</BaseDialog.Description>
) : null}
<div className="mt-6">{children}</div>
</BaseDialog.Popup>
</BaseDialog.Viewport>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
export function StatCard({
label,
value,
hint,
icon: Icon,
tone = "primary",
}: {
label: string;
value: string;
hint: string;
icon: LucideIcon;
tone?: "primary" | "success" | "violet" | "warning";
}) {
return (
<Card className="group relative overflow-hidden p-5 sm:p-6">
<div className={`stat-glow stat-glow--${tone}`} aria-hidden />
<div className="relative">
<div className="flex items-start justify-between gap-4">
<p className="text-sm font-medium text-muted">{label}</p>
<span className={`stat-icon stat-icon--${tone}`}>
<Icon className="size-4" aria-hidden />
</span>
</div>
<strong className="mt-5 block text-3xl font-bold tracking-[-0.05em] text-foreground tabular-nums">
{value}
</strong>
<p className="mt-2 text-xs text-muted">{hint}</p>
</div>
</Card>
);
}
-72
View File
@@ -1,72 +0,0 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { ApiError } from "../api/client";
import { escapeHtml } from "../lib/format";
export function renderLoading(container: HTMLElement, label = "正在加载"): void {
container.innerHTML = `
<div class="state-card" role="status" aria-live="polite">
<wa-spinner class="state-spinner" aria-hidden="true"></wa-spinner>
<p>${escapeHtml(label)}…</p>
</div>
`;
}
export function renderError(
container: HTMLElement,
error: unknown,
retry?: () => void,
): void {
const message =
error instanceof ApiError ? error.message : "出现未知错误,请稍后重试";
container.innerHTML = `
<wa-callout class="state-card state-card--error" variant="danger" appearance="outlined" role="alert">
<span class="state-icon" aria-hidden="true">!</span>
<h2>无法加载数据</h2>
<p>${escapeHtml(message)}</p>
${retry ? '<wa-button variant="neutral" appearance="outlined" data-retry>重试</wa-button>' : ""}
</wa-callout>
`;
container.querySelector<HTMLElement>("[data-retry]")?.addEventListener(
"click",
() => retry?.(),
);
}
export function renderEmpty(message: string): string {
return `<div class="empty-state"><p>${escapeHtml(message)}</p></div>`;
}
export function showToast(message: string, tone: "success" | "error"): void {
const toast = document.createElement("wa-callout");
toast.className = `toast toast--${tone}`;
toast.setAttribute("variant", tone === "error" ? "danger" : "success");
toast.setAttribute("appearance", "filled-outlined");
toast.setAttribute("role", tone === "error" ? "alert" : "status");
toast.textContent = message;
document.body.append(toast);
requestAnimationFrame(() => toast.classList.add("toast--visible"));
window.setTimeout(() => {
toast.classList.remove("toast--visible");
window.setTimeout(() => toast.remove(), 180);
}, 3_200);
}
export function setButtonBusy(
button: HTMLButtonElement | WaButton,
busy: boolean,
busyLabel = "处理中…",
): void {
if (button.tagName === "WA-BUTTON") {
(button as WaButton).loading = busy;
}
if (busy) {
button.dataset.label = button.textContent ?? "";
button.textContent = busyLabel;
button.disabled = true;
button.setAttribute("aria-busy", "true");
} else {
button.textContent = button.dataset.label ?? button.textContent;
button.disabled = false;
button.removeAttribute("aria-busy");
}
}
-15
View File
@@ -1,15 +0,0 @@
import "@awesome.me/webawesome/dist/styles/webawesome.css";
import "@awesome.me/webawesome/dist/components/button/button.js";
import "@awesome.me/webawesome/dist/components/callout/callout.js";
import "@awesome.me/webawesome/dist/components/input/input.js";
import "@awesome.me/webawesome/dist/components/spinner/spinner.js";
const darkMode = window.matchMedia("(prefers-color-scheme: dark)");
function syncColorScheme(event: MediaQueryList | MediaQueryListEvent): void {
document.documentElement.classList.toggle("wa-dark", event.matches);
document.documentElement.classList.toggle("wa-light", !event.matches);
}
syncColorScheme(darkMode);
darkMode.addEventListener("change", syncColorScheme);
+169
View File
@@ -0,0 +1,169 @@
import { FileCheck2, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type { AuditLogEntry } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Badge,
Button,
Card,
ErrorState,
LoadingState,
PageHeader,
} from "../../components/primitives";
import { formatDateTime, statusLabel } from "../../lib/format";
export function AuditPage() {
const [items, setItems] = useState<AuditLogEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
const page = await adminApi.auditLogs();
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.auditLogs(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载审计记录失败",
);
} finally {
setLoadingMore(false);
}
}
const columns = useMemo<DataColumn<AuditLogEntry>[]>(
() => [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
accessorKey: "operatorName",
header: "操作员",
cell: ({ getValue }) => <span className="font-semibold">{String(getValue())}</span>,
},
{
accessorKey: "action",
header: "操作",
cell: ({ getValue }) => (
<span className="rounded-lg bg-primary-soft px-2 py-1 font-mono text-[11px] text-primary">
{String(getValue())}
</span>
),
},
{
id: "target",
header: "目标",
cell: ({ row }) => (
<span>
<span className="block text-xs font-medium">{row.original.targetType}</span>
<span className="mt-1 block max-w-48 truncate font-mono text-[11px] text-muted">
{row.original.targetId}
</span>
</span>
),
},
{
accessorKey: "requestId",
header: "请求 ID",
cell: ({ getValue }) => (
<span className="font-mono text-[11px] text-muted">{String(getValue() || "—")}</span>
),
},
{
accessorKey: "result",
header: "结果",
cell: ({ getValue }) => {
const result = String(getValue());
return (
<Badge tone={result === "success" ? "success" : "danger"}>
{statusLabel(result)}
</Badge>
);
},
},
],
[],
);
return (
<div className="space-y-6">
<PageHeader
eyebrow="安全与合规"
title="审计日志"
description="追踪管理员操作、目标与执行结果,形成完整可追溯链路。"
/>
<Card className="flex flex-col gap-4 border-primary/15 bg-gradient-to-r from-primary-soft/80 to-surface p-5 sm:flex-row sm:items-center sm:p-6">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-surface text-primary shadow-sm">
<ShieldCheck className="size-5" aria-hidden />
</span>
<div>
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-xs leading-5 text-muted">
</p>
</div>
<Badge className="sm:ml-auto" tone="info">
<FileCheck2 className="size-3.5" aria-hidden />
{items.length}
</Badge>
</Card>
<Card className="overflow-hidden">
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load()} />
</div>
) : loading ? (
<LoadingState label="加载审计日志" />
) : (
<DataTable
data={items}
columns={columns}
caption="管理员审计事件"
emptyTitle="暂无审计记录"
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
</Button>
</div>
) : null
}
/>
)}
</Card>
</div>
);
}
@@ -0,0 +1,109 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { adminApi, ApiError, setCsrfToken } from "../../api/client";
import type { AdminRole, AuthState } from "../../api/types";
interface AuthContextValue {
auth: AuthState;
checking: boolean;
login: (username: string, password: string, totpCode: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [auth, setAuth] = useState<AuthState>({ status: "anonymous" });
const [checking, setChecking] = useState(true);
const expireSession = useCallback(() => {
setCsrfToken();
setAuth({ status: "anonymous" });
}, []);
useEffect(() => {
let active = true;
void adminApi
.session()
.then((session) => {
if (
active &&
session.authenticated &&
session.operatorName &&
isAdminRole(session.role)
) {
setAuth({
status: "authenticated",
operatorName: session.operatorName,
role: session.role,
});
}
})
.catch((error: unknown) => {
if (!(error instanceof ApiError) || error.status !== 401) {
window.dispatchEvent(
new CustomEvent("admin:toast", {
detail: { message: "暂时无法确认登录状态", tone: "error" },
}),
);
}
})
.finally(() => {
if (active) setChecking(false);
});
window.addEventListener("admin:unauthorized", expireSession);
return () => {
active = false;
window.removeEventListener("admin:unauthorized", expireSession);
};
}, [expireSession]);
const login = useCallback(
async (username: string, password: string, totpCode: string) => {
const response = await adminApi.login(username, password, totpCode);
setCsrfToken(response.csrfToken);
setAuth({
status: "authenticated",
operatorName: response.operatorName,
role: response.role,
});
},
[],
);
const logout = useCallback(async () => {
try {
await adminApi.logout();
} catch {
// 即使服务端退出失败,也立即清理前端认证状态。
} finally {
expireSession();
window.history.replaceState(null, "", window.location.pathname);
}
}, [expireSession]);
const value = useMemo(
() => ({ auth, checking, login, logout }),
[auth, checking, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth 必须在 AuthProvider 中使用");
return context;
}
function isAdminRole(role?: AdminRole): role is AdminRole {
return role === "SUPER_ADMIN" || role === "SUPPORT" || role === "ANALYST";
}
+178
View File
@@ -0,0 +1,178 @@
import { ArrowRight, Fingerprint, LockKeyhole, ShieldCheck, Sparkles } from "lucide-react";
import { useState, type FormEvent } from "react";
import { ApiError } from "../../api/client";
import { Button, Input } from "../../components/primitives";
import { useAuth } from "./auth-context";
export function LoginPage() {
const { login } = useAuth();
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const username = data.get("username")?.toString().trim() ?? "";
const password = data.get("password")?.toString() ?? "";
const totpCode = data.get("totpCode")?.toString().trim() ?? "";
if (username.length < 3 || password.length < 12 || !/^\d{6}$/.test(totpCode)) {
setError("请输入有效的用户名、密码和 6 位动态验证码");
return;
}
setSubmitting(true);
setError("");
try {
await login(username, password, totpCode);
form.reset();
} catch (requestError) {
setError(requestError instanceof ApiError ? requestError.message : "登录验证失败");
setSubmitting(false);
}
}
return (
<main className="relative min-h-screen overflow-hidden bg-auth">
<div className="auth-orb auth-orb--one" aria-hidden />
<div className="auth-orb auth-orb--two" aria-hidden />
<div className="relative mx-auto grid min-h-screen max-w-7xl items-center gap-16 px-5 py-10 lg:grid-cols-[1.1fr_0.9fr] lg:px-12">
<section className="animate-page-in hidden lg:block" aria-label="OSG 运营后台">
<BrandMark size="large" />
<p className="mt-10 inline-flex items-center gap-2 rounded-full border border-primary/15 bg-primary-soft px-3 py-1.5 text-xs font-semibold text-primary">
<Sparkles className="size-3.5" aria-hidden />
OSG Account Intelligence
</p>
<h1 className="mt-6 max-w-xl text-5xl font-bold leading-[1.08] tracking-[-0.06em] text-foreground">
<br />
</h1>
<p className="mt-6 max-w-lg text-base leading-7 text-muted">
</p>
<div className="mt-12 grid max-w-xl grid-cols-3 gap-4">
<TrustItem icon={ShieldCheck} label="角色权限" />
<TrustItem icon={Fingerprint} label="双重认证" />
<TrustItem icon={LockKeyhole} label="不可变审计" />
</div>
</section>
<section className="animate-card-in mx-auto w-full max-w-md rounded-[2rem] border border-white/60 bg-surface-elevated/90 p-7 shadow-[0_30px_90px_rgb(15_23_42/0.12)] backdrop-blur-2xl sm:p-10">
<div className="lg:hidden">
<BrandMark size="small" />
</div>
<p className="mt-8 text-xs font-bold uppercase tracking-[0.18em] text-primary lg:mt-0">
访
</p>
<h2 className="mt-3 text-3xl font-bold tracking-[-0.045em] text-foreground">
</h2>
<p className="mt-2 text-sm leading-6 text-muted">
使
</p>
<form className="mt-8 space-y-5" onSubmit={handleSubmit} noValidate>
<Field label="管理员用户名" htmlFor="username">
<Input
id="username"
name="username"
autoComplete="username"
minLength={3}
maxLength={64}
autoFocus
required
/>
</Field>
<Field label="管理员密码" htmlFor="password">
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
minLength={12}
required
/>
</Field>
<Field label="动态验证码" htmlFor="totpCode">
<Input
id="totpCode"
name="totpCode"
className="text-center text-lg font-semibold tracking-[0.35em]"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
required
/>
</Field>
<p className="min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<Button className="w-full" size="lg" type="submit" loading={submitting}>
{!submitting ? <ArrowRight className="size-4" aria-hidden /> : null}
</Button>
</form>
<p className="mt-6 text-center text-xs leading-5 text-muted">
</p>
</section>
</div>
</main>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor: string;
children: React.ReactNode;
}) {
return (
<label className="block" htmlFor={htmlFor}>
<span className="mb-2 block text-sm font-semibold text-foreground">{label}</span>
{children}
</label>
);
}
function BrandMark({ size }: { size: "small" | "large" }) {
return (
<div className="flex items-center gap-3">
<span
className={`brand-symbol ${size === "large" ? "size-14 rounded-2xl text-base" : "size-11 rounded-xl text-sm"}`}
>
O
</span>
<span>
<strong className="block text-sm font-bold tracking-tight text-foreground">OSG</strong>
<span className="block text-[10px] font-semibold tracking-[0.16em] text-muted">
</span>
</span>
</div>
);
}
function TrustItem({
icon: Icon,
label,
}: {
icon: typeof ShieldCheck;
label: string;
}) {
return (
<div className="flex items-center gap-2.5 text-xs font-semibold text-muted">
<span className="grid size-8 place-items-center rounded-xl bg-surface shadow-sm">
<Icon className="size-4 text-primary" aria-hidden />
</span>
{label}
</div>
);
}
@@ -0,0 +1,213 @@
import { ArrowDownUp, Coins, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { adminApi, ApiError } from "../../api/client";
import type { LedgerEntry } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Badge,
Button,
Card,
ErrorState,
Input,
LoadingState,
PageHeader,
} from "../../components/primitives";
import {
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { toast } from "sonner";
export function CreditsPage() {
const [query, setQuery] = useState("");
const [activeUserId, setActiveUserId] = useState<string>();
const [items, setItems] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async (userId?: string) => {
setLoading(true);
setError(undefined);
try {
const page = userId
? await adminApi.ledger(userId)
: await adminApi.latestLedger();
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = activeUserId
? await adminApi.ledger(activeUserId, nextCursor)
: await adminApi.latestLedger(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : "加载流水失败");
} finally {
setLoadingMore(false);
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const userId = query.trim() || undefined;
setActiveUserId(userId);
void load(userId);
}
const columns = useMemo<DataColumn<LedgerEntry>[]>(
() => [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
accessorKey: "userId",
header: "用户 ID",
cell: ({ getValue }) => (
<span className="block max-w-48 truncate font-mono text-xs" title={String(getValue())}>
{String(getValue())}
</span>
),
},
{
accessorKey: "type",
header: "类型",
cell: ({ getValue }) => <Badge>{statusLabel(String(getValue()))}</Badge>,
},
{
accessorKey: "usageType",
header: "消费类型",
cell: ({ getValue }) =>
getValue() ? <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge> : "—",
},
{
accessorKey: "amount",
header: "变动",
cell: ({ getValue }) => {
const value = Number(getValue());
return (
<span
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
>
{formatSignedCredits(value)}
</span>
);
},
},
{
accessorKey: "balanceAfter",
header: "结余",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "reasonCode",
header: "原因",
cell: ({ getValue }) => (
<span className="rounded-lg bg-surface-muted px-2 py-1 font-mono text-[11px] text-muted">
{String(getValue())}
</span>
),
},
],
[],
);
return (
<div className="space-y-6">
<PageHeader
eyebrow="积分账本"
title="积分流水"
description="查看不可变积分账本,可按完整内部用户 ID 精确查询。"
/>
<Card className="overflow-hidden">
<div className="border-b border-border p-5 sm:p-6">
<form className="flex max-w-2xl flex-col gap-3 sm:flex-row" onSubmit={submit}>
<label className="relative flex-1">
<span className="sr-only"> ID</span>
<Search
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted"
aria-hidden
/>
<Input
className="pl-10"
value={query}
onChange={(event) => setQuery(event.target.value)}
type="search"
placeholder="输入完整用户 ID,留空查看全部"
maxLength={36}
autoComplete="off"
/>
</label>
<Button type="submit">
<ArrowDownUp className="size-4" aria-hidden />
</Button>
</form>
</div>
<div className="flex items-center gap-3 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
<Coins className="size-4 text-primary" aria-hidden />
<span>{activeUserId ? "指定用户流水" : "最新积分流水"}</span>
<span aria-hidden>·</span>
<strong className="font-semibold text-foreground">{formatNumber(items.length)} </strong>
{activeUserId ? (
<span className="ml-auto max-w-64 truncate font-mono" title={activeUserId}>
{activeUserId}
</span>
) : null}
</div>
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load(activeUserId)} />
</div>
) : loading ? (
<LoadingState label="加载积分流水" />
) : (
<DataTable
data={items}
columns={columns}
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
emptyTitle={activeUserId ? "该用户暂无积分流水" : "暂无积分流水"}
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
</Button>
</div>
) : null
}
/>
)}
</Card>
</div>
);
}
@@ -0,0 +1,330 @@
import {
Activity,
ArrowUpRight,
Coins,
CreditCard,
Sparkles,
UserPlus,
Users,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { adminApi } from "../../api/client";
import type { Overview, TrendPoint } from "../../api/types";
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { formatNumber, usageTypeLabel } from "../../lib/format";
const ranges = [
{ value: "7d", label: "7 天" },
{ value: "30d", label: "30 天" },
{ value: "90d", label: "90 天" },
] as const;
export function OverviewPage() {
const [range, setRange] = useState("30d");
const [data, setData] = useState<Overview>();
const [error, setError] = useState<unknown>();
const load = useCallback(async () => {
setError(undefined);
try {
setData(await adminApi.overview(range));
} catch (requestError) {
setError(requestError);
}
}, [range]);
useEffect(() => {
void load();
}, [load]);
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (!data) return <LoadingState label="加载运营总览" />;
const activeRate =
data.totalUsers > 0 ? Math.round((data.activeUsers / data.totalUsers) * 100) : 0;
const usageRequests = data.usage.reduce((sum, item) => sum + item.requests, 0);
return (
<div className="space-y-6">
<PageHeader
eyebrow="核心指标"
title="运营总览"
description="聚合用户增长、活跃度与积分流转,快速识别业务变化。"
actions={<RangeControl value={range} onChange={setRange} />}
/>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="关键指标">
<StatCard
label="用户总数"
value={formatNumber(data.totalUsers)}
hint={`当前周期新增 ${formatNumber(data.newUsers)}`}
icon={Users}
/>
<StatCard
label="活跃用户"
value={formatNumber(data.activeUsers)}
hint={`活跃率 ${activeRate}%`}
icon={Activity}
tone="success"
/>
<StatCard
label="积分消耗"
value={formatNumber(data.creditsUsed)}
hint={`${formatNumber(usageRequests)} 次已结算请求`}
icon={CreditCard}
tone="violet"
/>
<StatCard
label="积分余额"
value={formatNumber(data.totalCreditBalance)}
hint={`周期赠送 ${formatNumber(data.creditsGranted)}`}
icon={Coins}
tone="warning"
/>
</section>
<section className="grid gap-6 xl:grid-cols-[minmax(0,1.65fr)_minmax(280px,0.65fr)]">
<Card className="min-w-0 p-5 sm:p-6">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-base font-bold text-foreground"></h2>
<p className="mt-1 text-xs text-muted"> UTC </p>
</div>
<div className="flex items-center gap-4 text-xs text-muted">
<Legend color="bg-primary" label="新增用户" />
<Legend color="bg-violet" label="积分消耗" />
</div>
</div>
<div className="h-[320px] w-full">
<TrendChart points={data.trend} />
</div>
</Card>
<Card className="overflow-hidden">
<div className="border-b border-border p-5 sm:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-bold text-foreground"></h2>
<p className="mt-1 text-xs text-muted"></p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
<Sparkles className="size-4" aria-hidden />
</span>
</div>
</div>
<div className="divide-y divide-border">
<SummaryRow
icon={UserPlus}
label="新增用户"
value={formatNumber(data.newUsers)}
/>
<SummaryRow
icon={CreditCard}
label="赠送 / 消耗"
value={`${formatNumber(data.creditsGranted)} / ${formatNumber(data.creditsUsed)}`}
/>
<SummaryRow
icon={ArrowUpRight}
label="使用类型"
value={`${formatNumber(data.usage.length)}`}
/>
</div>
</Card>
</section>
<Card className="overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div>
<h2 className="text-base font-bold text-foreground">使</h2>
<p className="mt-1 text-xs text-muted"></p>
</div>
</div>
{data.usage.length === 0 ? (
<div className="p-8 text-center text-sm text-muted">使</div>
) : (
<div className="grid divide-y divide-border md:grid-cols-2 md:divide-x md:divide-y-0 xl:grid-cols-3">
{data.usage.map((item) => (
<div key={item.kind} className="p-5 sm:p-6">
<div className="flex items-center justify-between">
<span className="rounded-full bg-violet-soft px-2.5 py-1 text-xs font-semibold text-violet">
{usageTypeLabel(item.kind)}
</span>
<span className="text-xs text-muted">{formatNumber(item.requests)} </span>
</div>
<strong className="mt-5 block text-2xl font-bold tracking-tight tabular-nums">
{formatNumber(item.chargedCredits)}
</strong>
<span className="mt-1 block text-xs text-muted"></span>
</div>
))}
</div>
)}
</Card>
</div>
);
}
function RangeControl({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
{ranges.map((range) => (
<button
key={range.value}
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
value === range.value
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
onClick={() => onChange(range.value)}
type="button"
aria-pressed={value === range.value}
>
{range.label}
</button>
))}
</div>
);
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-2">
<i className={`size-2 rounded-full ${color}`} aria-hidden />
{label}
</span>
);
}
function SummaryRow({
icon: Icon,
label,
value,
}: {
icon: typeof Users;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-3 px-5 py-4 sm:px-6">
<span className="grid size-9 place-items-center rounded-xl bg-surface-muted text-muted">
<Icon className="size-4" aria-hidden />
</span>
<span className="text-sm text-muted">{label}</span>
<strong className="ml-auto text-sm font-semibold tabular-nums text-foreground">{value}</strong>
</div>
);
}
export function TrendChart({ points }: { points: TrendPoint[] }) {
if (points.length === 0) {
return <div className="grid h-full place-items-center text-sm text-muted"></div>;
}
const width = 760;
const height = 300;
const paddingX = 42;
const paddingY = 30;
const plotHeight = height - paddingY * 2;
const step = points.length > 1 ? (width - paddingX * 2) / (points.length - 1) : 0;
const registrationMax = Math.max(...points.map((point) => point.registrations), 1);
const creditMax = Math.max(...points.map((point) => point.creditsUsed), 1);
const coordinates = points.map((point, index) => {
const x = points.length === 1 ? width / 2 : paddingX + index * step;
return {
point,
x,
registrationY:
height - paddingY - (point.registrations / registrationMax) * plotHeight,
creditY: height - paddingY - (point.creditsUsed / creditMax) * plotHeight,
};
});
const registrationLine = coordinates
.map(({ x, registrationY }) => `${x},${registrationY}`)
.join(" ");
const creditLine = coordinates.map(({ x, creditY }) => `${x},${creditY}`).join(" ");
const labelEvery = Math.max(Math.ceil(points.length / 6), 1);
return (
<>
<div className="chart-scroll h-full overflow-x-auto">
<svg
className="trend-chart block h-full min-w-[620px] overflow-visible"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-labelledby="overview-trend-title overview-trend-description"
>
<title id="overview-trend-title"></title>
<desc id="overview-trend-description">
UTC 线
</desc>
{[paddingY, height / 2, height - paddingY].map((y) => (
<line
key={y}
x1={paddingX}
x2={width - paddingX}
y1={y}
y2={y}
className="chart-grid-line"
/>
))}
<text x={paddingX} y={paddingY - 9} className="chart-label">
{formatNumber(registrationMax)}
</text>
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
{formatNumber(creditMax)}
</text>
<polyline points={registrationLine} className="chart-line chart-line--primary" />
<polyline points={creditLine} className="chart-line chart-line--violet" />
{coordinates.map(({ point, x, registrationY, creditY }, index) => (
<g key={point.date}>
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
<title>
{point.date} {formatNumber(point.registrations)}
</title>
</circle>
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
<title>
{point.date} {formatNumber(point.creditsUsed)}
</title>
</circle>
{index % labelEvery === 0 || index === points.length - 1 ? (
<text
x={x}
y={height - 5}
textAnchor={index === 0 ? "start" : index === points.length - 1 ? "end" : "middle"}
className="chart-label"
>
{point.date.slice(5)}
</text>
) : null}
</g>
))}
</svg>
</div>
<table className="sr-only">
<caption></caption>
<thead>
<tr>
<th scope="col">UTC </th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{points.map((point) => (
<tr key={point.date}>
<td>{point.date}</td>
<td>{formatNumber(point.registrations)}</td>
<td>{formatNumber(point.creditsUsed)}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
@@ -0,0 +1,232 @@
import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { adminApi } from "../../api/client";
import type { ReferralOverview, ReferralRankingItem } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Card,
ErrorState,
LoadingState,
PageHeader,
StatCard,
} from "../../components/primitives";
import { formatNumber } from "../../lib/format";
export function ReferralsPage() {
const [range, setRange] = useState("30d");
const [data, setData] = useState<ReferralOverview>();
const [error, setError] = useState<unknown>();
const load = useCallback(async () => {
setError(undefined);
try {
setData(await adminApi.referrals(range));
} catch (requestError) {
setError(requestError);
}
}, [range]);
useEffect(() => {
void load();
}, [load]);
const columns = useMemo<DataColumn<ReferralRankingItem>[]>(
() => [
{
id: "rank",
header: "名次",
cell: ({ row }) => <Rank value={row.index + 1} />,
},
{
accessorKey: "userId",
header: "用户 ID",
cell: ({ getValue }) => (
<span className="font-mono text-xs text-muted">{String(getValue())}</span>
),
},
{
accessorKey: "invited",
header: "邀请",
cell: ({ getValue }) => (
<span className="tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "qualified",
header: "有效",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums text-success">
{formatNumber(Number(getValue()))}
</span>
),
},
{
accessorKey: "creditsEarned",
header: "奖励积分",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">
{formatNumber(Number(getValue()))}
</span>
),
},
],
[],
);
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (!data) return <LoadingState label="加载裂变分析" />;
const first = data.funnel.at(0)?.count ?? 0;
const last = data.funnel.at(-1)?.count ?? 0;
const conversion = first > 0 ? Math.round((last / first) * 100) : 0;
return (
<div className="space-y-6">
<PageHeader
eyebrow="增长分析"
title="裂变与排行"
description="奖励以有效使用为前提,关注真实转化而不是单纯注册量。"
actions={<RangeControl value={range} onChange={setRange} />}
/>
<section className="grid gap-4 sm:grid-cols-3">
<StatCard
label="待资格确认"
value={formatNumber(data.pendingBindings)}
hint="等待首次有效使用"
icon={Clock3}
tone="warning"
/>
<StatCard
label="未达奖励条件"
value={formatNumber(data.ineligibleBindings)}
hint="未产生积分奖励"
icon={CircleOff}
tone="violet"
/>
<StatCard
label="漏斗转化率"
value={`${conversion}%`}
hint="首环节至最终有效使用"
icon={TrendingUp}
tone="success"
/>
</section>
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
<Card className="p-5 sm:p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted">使</p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
<GitBranch className="size-4" aria-hidden />
</span>
</div>
<div className="mt-8 space-y-6">
{data.funnel.length === 0 ? (
<p className="py-16 text-center text-sm text-muted"></p>
) : (
data.funnel.map((step, index) => {
const max = Math.max(data.funnel[0]?.count ?? 1, 1);
return (
<div key={`${step.label}-${index}`}>
<div className="mb-2.5 flex items-center justify-between text-sm">
<span className="font-medium text-muted">{step.label}</span>
<strong className="tabular-nums">{formatNumber(step.count)}</strong>
</div>
<progress
className="funnel-progress block h-3 w-full overflow-hidden rounded-full"
max={max}
value={step.count}
aria-label={`${step.label}${formatNumber(step.count)}`}
/>
</div>
);
})
)}
</div>
</Card>
<Card className="overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div>
<h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted"></p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
<Award className="size-4" aria-hidden />
</span>
</div>
<DataTable
data={data.ranking}
columns={columns}
caption="有效邀请用户排行"
emptyTitle="当前周期暂无排行数据"
/>
</Card>
</section>
<Card className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:p-6">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-success-soft text-success">
<Users className="size-5" aria-hidden />
</span>
<div>
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-xs leading-5 text-muted">
使
</p>
</div>
</Card>
</div>
);
}
function Rank({ value }: { value: number }) {
const tone =
value === 1
? "bg-warning-soft text-warning"
: value === 2
? "bg-surface-muted text-muted"
: value === 3
? "bg-danger-soft text-danger"
: "bg-surface-muted text-muted";
return (
<span className={`grid size-7 place-items-center rounded-lg text-xs font-bold ${tone}`}>
{value}
</span>
);
}
function RangeControl({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
{[
["7d", "7 天"],
["30d", "30 天"],
["90d", "90 天"],
].map(([range, label]) => (
<button
key={range}
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
value === range
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
onClick={() => onChange(range ?? "30d")}
type="button"
aria-pressed={value === range}
>
{label}
</button>
))}
</div>
);
}
@@ -0,0 +1,714 @@
import {
Ban,
Check,
Clipboard,
KeyRound,
LockKeyhole,
Plus,
RotateCcwKey,
ShieldCheck,
Unlock,
UserCheck,
UsersRound,
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent,
} from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
AdminOperator,
AdminOperatorProvisioning,
AdminRole,
AdminSecuritySummary,
} from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Badge,
Button,
Card,
Dialog,
ErrorState,
Input,
LoadingState,
PageHeader,
StatCard,
} from "../../components/primitives";
import { formatDateTime, formatNumber } from "../../lib/format";
import { useAuth } from "../auth/auth-context";
type OperatorAction = "enable" | "disable" | "unlock" | "sessions";
interface ConfirmationState {
operator: AdminOperator;
action: OperatorAction;
}
export function SecurityPage() {
const { auth } = useAuth();
const currentUsername = auth.status === "authenticated" ? auth.operatorName : "";
const [operators, setOperators] = useState<AdminOperator[]>([]);
const [summary, setSummary] = useState<AdminSecuritySummary>();
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const [createOpen, setCreateOpen] = useState(false);
const [resetOperator, setResetOperator] = useState<AdminOperator>();
const [confirmation, setConfirmation] = useState<ConfirmationState>();
const [provisioning, setProvisioning] = useState<{
data: AdminOperatorProvisioning;
title: string;
}>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
const [page, securitySummary] = await Promise.all([
adminApi.operators(),
adminApi.operatorSummary(),
]);
setOperators(page.items);
setNextCursor(page.nextCursor);
setSummary(securitySummary);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.operators(nextCursor);
setOperators((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载管理员失败",
);
} finally {
setLoadingMore(false);
}
}
async function handleConfirmedAction() {
if (!confirmation) return;
const { operator, action } = confirmation;
setConfirmation(undefined);
try {
if (action === "enable") {
await adminApi.setOperatorEnabled(operator.operatorId, true);
} else if (action === "disable") {
await adminApi.setOperatorEnabled(operator.operatorId, false);
} else if (action === "unlock") {
await adminApi.unlockOperator(operator.operatorId);
} else {
await adminApi.revokeOperatorSessions(operator.operatorId);
}
toast.success("安全设置已更新");
await load();
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "安全设置更新失败",
);
}
}
const columns = useMemo<DataColumn<AdminOperator>[]>(
() => [
{
id: "operator",
header: "管理员",
cell: ({ row }) => {
const current = row.original.username === currentUsername;
return (
<span>
<span className="flex items-center gap-2 font-semibold">
{row.original.username}
{current ? <Badge></Badge> : null}
</span>
<span className="mt-1 block font-mono text-[11px] text-muted">
{row.original.operatorId}
</span>
</span>
);
},
},
{
accessorKey: "role",
header: "角色",
cell: ({ getValue }) => <RoleBadge role={String(getValue()) as AdminRole} />,
},
{
id: "status",
header: "状态",
cell: ({ row }) => {
const locked =
Boolean(row.original.lockedUntil) &&
new Date(row.original.lockedUntil ?? "").getTime() > Date.now();
return (
<span className="flex gap-1.5">
<Badge tone={row.original.enabled ? "success" : "danger"}>
{row.original.enabled ? "已启用" : "已停用"}
</Badge>
{locked ? <Badge tone="warning"></Badge> : null}
</span>
);
},
},
{
accessorKey: "lastLoginAt",
header: "最近登录",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(getValue() ? String(getValue()) : undefined)}
</span>
),
},
{
id: "actions",
header: "操作",
cell: ({ row }) => (
<OperatorActions
operator={row.original}
currentUsername={currentUsername}
onAction={(action) => setConfirmation({ operator: row.original, action })}
onReset={() => setResetOperator(row.original)}
/>
),
},
],
[currentUsername],
);
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (loading || !summary) return <LoadingState label="加载安全中心" />;
return (
<div className="space-y-6">
<PageHeader
eyebrow="访问控制"
title="安全中心"
description="管理运营人员、角色、登录锁定、活动会话与双重认证。"
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" aria-hidden />
</Button>
}
/>
<Card className="flex flex-col gap-4 border-primary/15 bg-gradient-to-r from-primary-soft/80 to-surface p-5 sm:flex-row sm:items-center sm:p-6">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-surface text-primary shadow-sm">
<ShieldCheck className="size-5" aria-hidden />
</span>
<div>
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 text-xs leading-5 text-muted">
访
</p>
</div>
<Badge className="sm:ml-auto" tone="success">
<Check className="size-3.5" aria-hidden />
</Badge>
</Card>
<section className="grid gap-4 sm:grid-cols-3">
<StatCard
label="已启用管理员"
value={formatNumber(summary.enabledOperators)}
hint="具备登录资格的账户"
icon={UserCheck}
tone="success"
/>
<StatCard
label="已锁定账户"
value={formatNumber(summary.lockedOperators)}
hint="等待解锁或锁定到期"
icon={LockKeyhole}
tone="warning"
/>
<StatCard
label="活动会话"
value={formatNumber(summary.activeSessions)}
hint="尚未过期且未撤销"
icon={UsersRound}
tone="violet"
/>
</section>
<Card className="overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div>
<h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted"> {formatNumber(operators.length)} </p>
</div>
<KeyRound className="size-5 text-muted" aria-hidden />
</div>
<DataTable
data={operators}
columns={columns}
caption="管理员账户与安全状态"
emptyTitle="暂无管理员账户"
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
</Button>
</div>
) : null
}
/>
</Card>
<CreateOperatorDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(data) => {
setCreateOpen(false);
setProvisioning({ data, title: "管理员已创建" });
}}
/>
<ResetCredentialsDialog
operator={resetOperator}
onOpenChange={(open) => {
if (!open) setResetOperator(undefined);
}}
onReset={(data) => {
setResetOperator(undefined);
setProvisioning({ data, title: "登录凭据已重置" });
}}
/>
<ConfirmationDialog
state={confirmation}
onOpenChange={(open) => {
if (!open) setConfirmation(undefined);
}}
onConfirm={() => void handleConfirmedAction()}
/>
<ProvisioningDialog
value={provisioning}
onComplete={() => {
setProvisioning(undefined);
void load();
}}
/>
</div>
);
}
function OperatorActions({
operator,
currentUsername,
onAction,
onReset,
}: {
operator: AdminOperator;
currentUsername: string;
onAction: (action: OperatorAction) => void;
onReset: () => void;
}) {
const locked =
Boolean(operator.lockedUntil) &&
new Date(operator.lockedUntil ?? "").getTime() > Date.now();
const current = operator.username === currentUsername;
return (
<div className="flex max-w-md flex-wrap justify-end gap-2">
{locked ? (
<Button size="sm" variant="secondary" onClick={() => onAction("unlock")}>
<Unlock className="size-3.5" aria-hidden />
</Button>
) : null}
<Button size="sm" variant="secondary" onClick={() => onAction("sessions")}>
</Button>
<Button size="sm" variant="secondary" onClick={onReset}>
<RotateCcwKey className="size-3.5" aria-hidden />
</Button>
<Button
size="sm"
variant={operator.enabled ? "danger" : "secondary"}
disabled={current && operator.enabled}
onClick={() => onAction(operator.enabled ? "disable" : "enable")}
>
{operator.enabled ? <Ban className="size-3.5" aria-hidden /> : null}
{operator.enabled ? "停用" : "启用"}
</Button>
</div>
);
}
function CreateOperatorDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (data: AdminOperatorProvisioning) => void;
}) {
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const username = data.get("username")?.toString().trim() ?? "";
const role = data.get("role")?.toString() as AdminRole;
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
if (
!/^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/.test(username) ||
!["SUPER_ADMIN", "SUPPORT", "ANALYST"].includes(role) ||
password.length < 12 ||
password !== confirmation
) {
setError("请检查用户名、角色及两次输入的密码");
return;
}
setSubmitting(true);
setError("");
try {
const result = await adminApi.createOperator({ username, role, password });
form.reset();
setSubmitting(false);
onCreated(result);
} catch (requestError) {
setError(requestError instanceof ApiError ? requestError.message : "创建管理员失败");
setSubmitting(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setError("");
onOpenChange(nextOpen);
}}
title="添加管理员"
description="创建后,TOTP 密钥只显示一次,请准备安全交付。"
>
<form className="space-y-5" onSubmit={submit} noValidate>
<Field label="用户名">
<Input
name="username"
autoComplete="off"
minLength={3}
maxLength={64}
pattern="[A-Za-z0-9][A-Za-z0-9._@-]{2,63}"
required
/>
</Field>
<Field label="角色">
<select
className="h-11 w-full rounded-xl border border-border bg-input px-3.5 text-sm outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
name="role"
defaultValue="ANALYST"
required
>
<option value="ANALYST"> · </option>
<option value="SUPPORT"> · </option>
<option value="SUPER_ADMIN"> · </option>
</select>
</Field>
<Field label="初始密码">
<Input
name="password"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
/>
</Field>
<Field label="确认密码">
<Input
name="passwordConfirmation"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
/>
</Field>
<p className="min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" loading={submitting}>
TOTP
</Button>
</div>
</form>
</Dialog>
);
}
function ResetCredentialsDialog({
operator,
onOpenChange,
onReset,
}: {
operator?: AdminOperator;
onOpenChange: (open: boolean) => void;
onReset: (data: AdminOperatorProvisioning) => void;
}) {
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!operator) return;
const data = new FormData(event.currentTarget);
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
if (password.length < 12 || password !== confirmation) {
setError("请输入至少 12 位且两次一致的新密码");
return;
}
setSubmitting(true);
setError("");
try {
const result = await adminApi.resetOperatorCredentials(operator.operatorId, password);
setSubmitting(false);
onReset(result);
} catch (requestError) {
setError(requestError instanceof ApiError ? requestError.message : "凭据重置失败");
setSubmitting(false);
}
}
return (
<Dialog
open={Boolean(operator)}
onOpenChange={(open) => {
setError("");
onOpenChange(open);
}}
title="重置登录凭据"
description={`将为“${operator?.username ?? ""}”重置密码与 TOTP,并立即撤销全部会话。`}
>
<form className="space-y-5" onSubmit={submit}>
<Field label="新密码">
<Input
name="password"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
/>
</Field>
<Field label="确认新密码">
<Input
name="passwordConfirmation"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={128}
required
/>
</Field>
<p className="min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" variant="danger" loading={submitting}>
</Button>
</div>
</form>
</Dialog>
);
}
function ConfirmationDialog({
state,
onOpenChange,
onConfirm,
}: {
state?: ConfirmationState;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
if (!state) return null;
const content = confirmationContent(state.action, state.operator.username);
return (
<Dialog
open
onOpenChange={onOpenChange}
title={content.title}
description={content.message}
>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => onOpenChange(false)}>
</Button>
<Button variant={content.dangerous ? "danger" : "primary"} onClick={onConfirm}>
{content.label}
</Button>
</div>
</Dialog>
);
}
function ProvisioningDialog({
value,
onComplete,
}: {
value?: { data: AdminOperatorProvisioning; title: string };
onComplete: () => void;
}) {
const [saved, setSaved] = useState(false);
if (!value) return null;
async function copy(text: string, label: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(`${label}已复制,请妥善保管`);
} catch {
toast.error(`无法复制${label},请手动选择`);
}
}
return (
<Dialog
open
onOpenChange={() => undefined}
title={value.title}
description="TOTP 配置仅显示一次,请立即安全保存并交付给对应管理员。"
preventClose
className="max-w-xl"
>
<div className="rounded-2xl border border-warning/20 bg-warning-soft/60 p-5">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-warning">Base32 </p>
<code className="mt-3 block select-all break-all font-mono text-base font-semibold tracking-wider">
{value.data.totpSecret}
</code>
<Button
className="mt-4"
size="sm"
variant="secondary"
onClick={() => void copy(value.data.totpSecret, "TOTP 密钥")}
>
<Clipboard className="size-3.5" aria-hidden />
</Button>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<a
className="inline-flex min-h-10 items-center rounded-xl border border-border bg-surface px-4 text-sm font-semibold transition hover:bg-surface-muted"
href={value.data.otpauthUri}
>
</a>
<Button
variant="secondary"
onClick={() => void copy(value.data.otpauthUri, "认证器配置链接")}
>
</Button>
</div>
<label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-border p-4 text-sm">
<input
className="mt-0.5 size-4 accent-primary"
type="checkbox"
checked={saved}
onChange={(event) => setSaved(event.target.checked)}
/>
<span></span>
</label>
<Button
className="mt-5 w-full"
disabled={!saved}
onClick={() => {
setSaved(false);
onComplete();
}}
>
</Button>
</Dialog>
);
}
function confirmationContent(action: OperatorAction, username: string) {
const target = `${username}`;
if (action === "disable") {
return {
title: "停用管理员?",
message: `${target} 将无法登录,全部活动会话会立即失效。`,
label: "停用管理员",
dangerous: true,
};
}
if (action === "enable") {
return {
title: "启用管理员?",
message: `${target} 将恢复登录权限。`,
label: "确认启用",
dangerous: false,
};
}
if (action === "unlock") {
return {
title: "解除登录锁定?",
message: `${target} 可以立即重新尝试登录。`,
label: "确认解锁",
dangerous: false,
};
}
return {
title: "撤销全部会话?",
message: `${target} 已登录的所有设备都需要重新认证。`,
label: "撤销会话",
dangerous: true,
};
}
function RoleBadge({ role }: { role: AdminRole }) {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return (
<Badge tone={role === "SUPER_ADMIN" ? "violet" : role === "SUPPORT" ? "info" : "neutral"}>
{labels[role]}
</Badge>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-2 block text-sm font-semibold">{label}</span>
{children}
</label>
);
}
+733
View File
@@ -0,0 +1,733 @@
import {
ArrowLeft,
CheckCircle2,
Coins,
Gift,
Search,
Sparkles,
Users,
} from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useState,
type FormEvent,
} from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
LedgerEntry,
UserDetail,
UserSummary,
UserUsageAggregate,
} from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import {
Badge,
Button,
Card,
Dialog,
ErrorState,
Input,
LoadingState,
PageHeader,
Textarea,
} from "../../components/primitives";
import {
createIdempotencyKey,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { useAuth } from "../auth/auth-context";
export function UsersPage() {
const { auth } = useAuth();
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
const [selectedUserId, setSelectedUserId] = useState<string>();
return selectedUserId ? (
<UserDetailView
userId={selectedUserId}
canGrant={role === "SUPER_ADMIN"}
onBack={() => setSelectedUserId(undefined)}
/>
) : (
<UserList onSelect={setSelectedUserId} />
);
}
function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
const [query, setQuery] = useState("");
const [activeQuery, setActiveQuery] = useState("");
const [items, setItems] = useState<UserSummary[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async (search = "") => {
setLoading(true);
setError(undefined);
try {
const page = await adminApi.users(search);
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const search = query.trim();
setActiveQuery(search);
void load(search);
}
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.users(activeQuery, nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : "加载用户失败");
} finally {
setLoadingMore(false);
}
}
const columns = useMemo<DataColumn<UserSummary>[]>(
() => [
{
id: "user",
header: "用户",
cell: ({ row }) => (
<button
className="group/user max-w-64 text-left"
type="button"
onClick={() => onSelect(row.original.userId)}
>
<span className="block font-semibold transition group-hover/user:text-primary">
{row.original.displayName || "未命名用户"}
</span>
<span className="mt-1 block truncate font-mono text-[11px] text-muted">
{row.original.userId}
</span>
</button>
),
},
{
accessorKey: "status",
header: "状态",
cell: ({ getValue }) => <UserStatus status={String(getValue())} />,
},
{
accessorKey: "consumedCredits",
header: "累计使用",
cell: ({ getValue }) => (
<span className="tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "creditBalance",
header: "当前积分",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "createdAt",
header: "注册时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
id: "actions",
header: "操作",
cell: ({ row }) => (
<Button size="sm" variant="secondary" onClick={() => onSelect(row.original.userId)}>
</Button>
),
},
],
[onSelect],
);
return (
<div className="space-y-6">
<PageHeader
eyebrow="账户管理"
title="用户列表"
description="按注册时间浏览账户,支持完整或后 8 位内部用户 ID 查询。"
/>
<Card className="overflow-hidden">
<div className="border-b border-border p-5 sm:p-6">
<form className="flex max-w-2xl flex-col gap-3 sm:flex-row" onSubmit={submit}>
<label className="relative flex-1">
<span className="sr-only"> ID</span>
<Search
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted"
aria-hidden
/>
<Input
className="pl-10"
value={query}
onChange={(event) => setQuery(event.target.value)}
type="search"
placeholder="输入完整或后 8 位用户 ID"
maxLength={36}
autoComplete="off"
/>
</label>
<Button type="submit">
<Search className="size-4" aria-hidden />
</Button>
</form>
</div>
<div className="flex items-center gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
<Users className="size-4 text-primary" aria-hidden />
{activeQuery ? "查询结果" : "全部用户"}
<span aria-hidden>·</span>
<strong className="text-foreground">{formatNumber(items.length)} </strong>
</div>
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load(activeQuery)} />
</div>
) : loading ? (
<LoadingState label="加载用户列表" />
) : (
<DataTable
data={items}
columns={columns}
caption={activeQuery ? "用户查询结果" : "全部用户"}
emptyTitle="未找到匹配用户"
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
</Button>
</div>
) : null
}
/>
)}
</Card>
</div>
);
}
function UserDetailView({
userId,
canGrant,
onBack,
}: {
userId: string;
canGrant: boolean;
onBack: () => void;
}) {
const [user, setUser] = useState<UserDetail>();
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const [grantOpen, setGrantOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
const [detail, page] = await Promise.all([
adminApi.user(userId),
adminApi.ledger(userId),
]);
setUser(detail);
setLedger(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
void load();
}, [load]);
async function loadMoreLedger() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.ledger(userId, nextCursor);
setLedger((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载积分流水失败",
);
} finally {
setLoadingMore(false);
}
}
const ledgerColumns = useLedgerColumns();
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (loading || !user) return <LoadingState label="加载用户详情" />;
const usage = user.usage ?? [];
const requests = usage.reduce((total, item) => total + item.requests, 0);
const chargedCredits = usage.reduce((total, item) => total + item.chargedCredits, 0);
const inviterUserId = user.referral?.inviterUserId ?? user.referredByUserId;
const qualifiedUsage = user.qualifiedUsage || requests > 0;
return (
<div className="space-y-6">
<div>
<Button className="-ml-3 mb-4" variant="ghost" onClick={onBack}>
<ArrowLeft className="size-4" aria-hidden />
</Button>
<PageHeader
eyebrow="用户详情"
title={user.displayName || "未命名用户"}
description={user.userId}
actions={
canGrant ? (
<Button onClick={() => setGrantOpen(true)}>
<Gift className="size-4" aria-hidden />
</Button>
) : undefined
}
/>
</div>
<section className="grid gap-6 xl:grid-cols-[340px_minmax(0,1fr)]">
<Card className="h-fit overflow-hidden">
<div className="bg-gradient-to-br from-primary-soft to-violet-soft/40 p-6">
<div className="flex items-center gap-4">
<span className="grid size-14 place-items-center rounded-2xl bg-surface text-xl font-bold text-primary shadow-sm">
{(user.displayName || "用").slice(0, 1)}
</span>
<div className="min-w-0">
<strong className="block truncate">{user.displayName || "未命名用户"}</strong>
<div className="mt-2">
<UserStatus status={user.status} />
</div>
</div>
</div>
</div>
<div className="p-6">
<p className="text-xs font-medium text-muted"></p>
<strong className="mt-2 block text-4xl font-bold tracking-[-0.06em] tabular-nums">
{formatNumber(user.creditBalance)}
</strong>
<dl className="mt-6 divide-y divide-border">
<DetailRow label="注册时间" value={formatDateTime(user.createdAt)} />
<DetailRow label="最近活跃" value={formatDateTime(user.lastActiveAt)} />
<DetailRow
label="有效使用"
value={qualifiedUsage ? `已达成 · ${formatNumber(requests)}` : "未达成"}
/>
<DetailRow label="累计消耗" value={`${formatNumber(chargedCredits)} 积分`} />
<DetailRow label="邀请码" value={user.referralCode || "—"} mono />
<DetailRow label="邀请来源" value={inviterUserId || "—"} mono />
{user.referral ? (
<DetailRow
label="邀请成效"
value={`${formatNumber(user.referral.rewardedInvites)} / ${formatNumber(user.referral.invitedUsers)} 已奖励`}
/>
) : null}
</dl>
</div>
</Card>
<div className="space-y-6">
<Card className="overflow-hidden">
<SectionHeading
icon={Coins}
title="积分流水"
description="所有变动均来自不可变账本"
/>
<DataTable
data={ledger}
columns={ledgerColumns}
caption={`${user.displayName || user.userId} 的积分流水`}
emptyTitle="暂无积分流水"
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
<Button
variant="secondary"
onClick={() => void loadMoreLedger()}
loading={loadingMore}
>
</Button>
</div>
) : null
}
/>
</Card>
{usage.length > 0 ? <UsagePanel usage={usage} /> : null}
</div>
</section>
{canGrant ? (
<GrantDialog
open={grantOpen}
user={user}
onOpenChange={setGrantOpen}
onSuccess={load}
/>
) : null}
</div>
);
}
export function GrantDialog({
open,
user,
onOpenChange,
onSuccess,
}: {
open: boolean;
user: UserDetail;
onOpenChange: (open: boolean) => void;
onSuccess: () => Promise<void>;
}) {
const [step, setStep] = useState<"input" | "confirm">("input");
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [outcomeUnknown, setOutcomeUnknown] = useState(false);
const [idempotencyKey, setIdempotencyKey] = useState(createIdempotencyKey);
function reset() {
setStep("input");
setAmount("");
setReason("");
setError("");
setSubmitting(false);
setOutcomeUnknown(false);
setIdempotencyKey(createIdempotencyKey());
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && outcomeUnknown) {
setError("赠送结果尚未确认,请在当前窗口使用同一请求安全重试");
return;
}
onOpenChange(nextOpen);
if (!nextOpen) reset();
}
function review() {
const numericAmount = Number(amount);
if (!Number.isSafeInteger(numericAmount) || numericAmount < 1 || numericAmount > 100_000) {
setError("积分必须是 1 至 100,000 的整数");
return;
}
if (reason.trim().length < 4) {
setError("请填写至少 4 个字符的赠送原因");
return;
}
setError("");
setStep("confirm");
}
async function submit() {
setSubmitting(true);
setError("");
try {
await adminApi.grantCredits({
userId: user.userId,
amount: Number(amount),
reason: reason.trim(),
idempotencyKey,
});
onOpenChange(false);
reset();
toast.success("积分赠送成功,账本已更新");
await onSuccess();
} catch (requestError) {
const unknown =
!(requestError instanceof ApiError) ||
requestError.status === 0 ||
requestError.status >= 500;
setOutcomeUnknown(unknown);
setError(
unknown
? "赠送结果尚未确认;重试会复用同一请求,不会重复到账"
: requestError instanceof ApiError
? requestError.message
: "赠送失败,请重试",
);
setSubmitting(false);
}
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={step === "input" ? "人工赠送积分" : "确认赠送"}
description="赠送将写入不可变账本,并记录完整管理员审计日志。"
preventClose={outcomeUnknown}
>
{step === "input" ? (
<div className="space-y-5">
<Field label="用户 ID">
<Input value={user.userId} disabled />
</Field>
<Field label="赠送积分">
<Input
value={amount}
onChange={(event) => setAmount(event.target.value)}
type="number"
min={1}
max={100000}
step={1}
inputMode="numeric"
/>
</Field>
<Field label="赠送原因">
<Textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
minLength={4}
maxLength={200}
placeholder="填写可审计的业务原因"
/>
</Field>
<p className="min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
</Button>
<Button onClick={review}></Button>
</div>
</div>
) : (
<div>
<div className="rounded-2xl border border-danger/15 bg-danger-soft/60 p-5">
<p className="text-sm text-muted"></p>
<strong className="mt-2 block text-2xl font-bold tabular-nums">
{formatNumber(Number(amount))}
</strong>
<p className="mt-3 text-sm">{user.displayName || user.userId}</p>
<p className="mt-2 rounded-xl bg-surface/70 p-3 text-xs leading-5 text-muted">
{reason.trim()}
</p>
</div>
<p className="mt-4 min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<div className="mt-5 flex justify-end gap-3">
<Button
variant="secondary"
disabled={outcomeUnknown || submitting}
onClick={() => setStep("input")}
>
</Button>
<Button variant="danger" loading={submitting} onClick={() => void submit()}>
</Button>
</div>
</div>
)}
</Dialog>
);
}
function useLedgerColumns(): DataColumn<LedgerEntry>[] {
return useMemo(
() => [
{
accessorKey: "createdAt",
header: "时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
accessorKey: "type",
header: "类型",
cell: ({ getValue }) => <Badge>{statusLabel(String(getValue()))}</Badge>,
},
{
accessorKey: "usageType",
header: "消费类型",
cell: ({ getValue }) =>
getValue() ? <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge> : "—",
},
{
accessorKey: "amount",
header: "变动",
cell: ({ getValue }) => {
const value = Number(getValue());
return (
<span
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
>
{formatSignedCredits(value)}
</span>
);
},
},
{
accessorKey: "balanceAfter",
header: "结余",
cell: ({ getValue }) => (
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
),
},
{
accessorKey: "reasonCode",
header: "原因",
cell: ({ getValue }) => (
<span className="font-mono text-[11px] text-muted">{String(getValue())}</span>
),
},
],
[],
);
}
function UsagePanel({ usage }: { usage: UserUsageAggregate[] }) {
const columns = useMemo<DataColumn<UserUsageAggregate>[]>(
() => [
{
accessorKey: "kind",
header: "类型",
cell: ({ getValue }) => <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge>,
},
{
accessorKey: "requests",
header: "请求",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
{
accessorKey: "chargedCredits",
header: "消耗积分",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
{
accessorKey: "asrMillis",
header: "语音毫秒",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
{
accessorKey: "inputTokens",
header: "输入 Token",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
{
accessorKey: "outputTokens",
header: "输出 Token",
cell: ({ getValue }) => formatNumber(Number(getValue())),
},
],
[],
);
return (
<Card className="overflow-hidden">
<SectionHeading
icon={Sparkles}
title="使用统计"
description="按使用类型汇总,不包含任何用户内容"
/>
<DataTable data={usage} columns={columns} caption="用户使用统计" />
</Card>
);
}
function SectionHeading({
icon: Icon,
title,
description,
}: {
icon: typeof Coins;
title: string;
description: string;
}) {
return (
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div>
<h2 className="text-base font-bold">{title}</h2>
<p className="mt-1 text-xs text-muted">{description}</p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
<Icon className="size-4" aria-hidden />
</span>
</div>
);
}
function DetailRow({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-4 py-3 text-xs">
<dt className="shrink-0 text-muted">{label}</dt>
<dd
className={`m-0 max-w-[190px] break-all text-right font-medium ${mono ? "font-mono text-[11px]" : ""}`}
>
{value}
</dd>
</div>
);
}
function UserStatus({ status }: { status: string }) {
const tone = status === "active" ? "success" : status === "suspended" ? "danger" : "neutral";
return (
<Badge tone={tone}>
{status === "active" ? <CheckCircle2 className="size-3" aria-hidden /> : null}
{statusLabel(status)}
</Badge>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-2 block text-sm font-semibold">{label}</span>
{children}
</label>
);
}
+11
View File
@@ -48,6 +48,17 @@ export function statusLabel(status: string): string {
return labels[status] ?? status;
}
export function usageTypeLabel(usageType?: string): string {
const labels: Record<string, string> = {
polish: "润色",
asr: "ASR",
ai: "AI",
agent: "Agent",
hotword: "热词",
};
return usageType ? (labels[usageType] ?? usageType) : "—";
}
export function createIdempotencyKey(): string {
return globalThis.crypto?.randomUUID?.() ??
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
-8
View File
@@ -1,8 +0,0 @@
import "./components/webawesome";
import "./styles.css";
import { AdminApp } from "./app";
const root = document.querySelector<HTMLElement>("#app");
if (!root) throw new Error("应用挂载节点不存在");
void new AdminApp(root).start();
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./styles.css";
const root = document.querySelector<HTMLElement>("#app");
if (!root) throw new Error("应用挂载节点不存在");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
-99
View File
@@ -1,99 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type { AuditLogEntry } from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import { escapeHtml, formatDateTime, statusLabel } from "../lib/format";
export async function renderAudit(container: HTMLElement): Promise<void> {
renderLoading(container, "加载审计日志");
try {
const page = await adminApi.auditLogs();
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">安全与合规</div>
<h1>审计日志</h1>
<p>追踪管理员操作、目标和执行结果。</p>
</div>
</div>
<section class="panel">
${
page.items.length === 0
? renderEmpty("暂无审计记录")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">管理员审计事件</caption>
<thead><tr><th scope="col">时间</th><th scope="col">操作员</th><th scope="col">操作</th><th scope="col">目标</th><th scope="col">请求 ID</th><th scope="col">结果</th></tr></thead>
<tbody data-audit-body>${auditRows(page.items)}</tbody>
</table>
</div>
${
page.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-audit-more>加载更早记录</wa-button></div>'
: ""
}
`
}
</section>
`;
if (page.nextCursor) bindAuditPagination(container, page.nextCursor);
} catch (error) {
renderError(container, error, () => void renderAudit(container));
}
}
function auditRows(items: AuditLogEntry[]): string {
return items
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(entry.operatorName)}</td>
<td><code>${escapeHtml(entry.action)}</code></td>
<td>
<span>${escapeHtml(entry.targetType)}</span>
<div class="subtle mono">${escapeHtml(entry.targetId)}</div>
</td>
<td class="mono">${escapeHtml(entry.requestId || "—")}</td>
<td><span class="badge badge--${entry.result}">${statusLabel(entry.result)}</span></td>
</tr>
`,
)
.join("");
}
function bindAuditPagination(container: HTMLElement, initialCursor: string): void {
const button =
container.querySelector<HTMLButtonElement>("[data-audit-more]");
const body = container.querySelector<HTMLTableSectionElement>("[data-audit-body]");
if (!button || !body) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.auditLogs(cursor);
body.insertAdjacentHTML("beforeend", auditRows(page.items));
cursor = page.nextCursor;
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载审计记录失败",
"error",
);
setButtonBusy(button, false);
}
});
}
-75
View File
@@ -1,75 +0,0 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { adminApi, ApiError, setCsrfToken } from "../api/client";
import type { AuthState } from "../api/types";
import { setButtonBusy } from "../components/ui";
interface AuthCallbacks {
onChange: (state: AuthState) => void;
}
function authFrame(content: string): string {
return `
<main class="auth-shell">
<section class="auth-brand" aria-label="OSG 运营后台">
<div class="brand-mark">OSG</div>
<p>安全、克制、可追溯的运营管理</p>
</section>
<section class="auth-card">${content}</section>
</main>
`;
}
export function renderPasswordLogin(
root: HTMLElement,
callbacks: AuthCallbacks,
): void {
root.innerHTML = authFrame(`
<div class="eyebrow">运营后台</div>
<h1>管理员登录</h1>
<p class="muted">使用管理员凭据和认证器动态验证码登录。凭据不会保存到浏览器。</p>
<form class="form-stack" data-login-form novalidate>
<wa-input name="username" label="管理员用户名" type="text" autocomplete="username" minlength="3" maxlength="64" appearance="outlined" required autofocus></wa-input>
<wa-input name="password" label="管理员密码" type="password" autocomplete="current-password" minlength="12" appearance="outlined" password-toggle required></wa-input>
<wa-input class="totp-input" name="totpCode" label="动态验证码" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" appearance="outlined" required></wa-input>
<p class="form-error" data-error role="alert"></p>
<wa-button class="button--wide" variant="brand" appearance="accent" type="submit">安全登录</wa-button>
</form>
`);
const form = root.querySelector<HTMLFormElement>("[data-login-form]");
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector<WaButton>('wa-button[type="submit"]');
const errorNode = form.querySelector<HTMLElement>("[data-error]");
const username = new FormData(form).get("username")?.toString().trim() ?? "";
const password = new FormData(form).get("password")?.toString() ?? "";
const totpCode = new FormData(form).get("totpCode")?.toString().trim() ?? "";
if (
username.length < 3 ||
password.length < 12 ||
!/^\d{6}$/.test(totpCode) ||
!button ||
!errorNode
) {
if (errorNode) errorNode.textContent = "请输入有效的用户名、密码和 6 位动态验证码";
return;
}
setButtonBusy(button, true, "验证中…");
errorNode.textContent = "";
try {
const response = await adminApi.login(username, password, totpCode);
form.reset();
setCsrfToken(response.csrfToken);
callbacks.onChange({
status: "authenticated",
operatorName: response.operatorName,
role: response.role,
});
} catch (error) {
errorNode.textContent =
error instanceof ApiError ? error.message : "验证码验证失败";
setButtonBusy(button, false);
}
});
}
-78
View File
@@ -1,78 +0,0 @@
import { adminApi } from "../api/client";
import { renderEmpty, renderError, renderLoading } from "../components/ui";
import {
escapeHtml,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
} from "../lib/format";
export function renderCredits(container: HTMLElement): void {
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">积分账本</div>
<h1>积分流水</h1>
<p>按内部用户 ID 查询不可变积分流水。</p>
</div>
</div>
<section class="panel">
<form class="search-form" data-ledger-form>
<label class="search-box">
<span class="sr-only">内部用户 ID</span>
<span aria-hidden="true">⌕</span>
<input name="userId" type="search" placeholder="输入完整用户 ID" autocomplete="off" maxlength="36" required />
</label>
<wa-button variant="brand" appearance="accent" type="submit">查询流水</wa-button>
</form>
<div data-ledger-results>${renderEmpty("输入内部用户 ID 开始查询")}</div>
</section>
`;
const form = container.querySelector<HTMLFormElement>("[data-ledger-form]");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const userId = new FormData(form).get("userId")?.toString().trim() ?? "";
if (userId) void loadLedger(container, userId);
});
}
async function loadLedger(container: HTMLElement, userId: string): Promise<void> {
const results = container.querySelector<HTMLElement>("[data-ledger-results]");
if (!results) return;
renderLoading(results, "加载积分流水");
try {
const page = await adminApi.ledger(userId);
results.innerHTML =
page.items.length === 0
? renderEmpty("该用户暂无积分流水")
: `
<div class="result-summary">用户 <span class="mono">${escapeHtml(userId)}</span> · ${formatNumber(page.items.length)} 条记录</div>
<div class="table-wrap">
<table>
<caption class="sr-only">用户 ${escapeHtml(userId)} 的积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">流水号</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody>
${page.items
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td class="mono">${escapeHtml(entry.entryId)}</td>
<td>${statusLabel(entry.type)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`;
} catch (error) {
renderError(results, error, () => void loadLedger(container, userId));
}
}
-102
View File
@@ -1,102 +0,0 @@
import { adminApi } from "../api/client";
import { trendChart } from "../components/charts";
import { renderError, renderLoading } from "../components/ui";
import { escapeHtml, formatNumber } from "../lib/format";
export async function renderOverview(
container: HTMLElement,
range = "30d",
): Promise<void> {
renderLoading(container, "加载总览");
try {
const data = await adminApi.overview(range);
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">核心指标</div>
<h1>运营总览</h1>
<p>快速掌握增长与积分消耗情况。</p>
</div>
<fieldset class="segmented-control">
<legend class="sr-only">统计周期</legend>
${rangeOption("7d", "7 天", range)}
${rangeOption("30d", "30 天", range)}
${rangeOption("90d", "90 天", range)}
</fieldset>
</div>
<section class="metric-grid" aria-label="关键指标">
${metric("用户总数", data.totalUsers, "累计账户")}
${metric("活跃用户", data.activeUsers, "当前周期")}
${metric("新增用户", data.newUsers, "当前周期")}
${metric("积分余额", data.totalCreditBalance, "所有账户")}
${metric("赠送积分", data.creditsGranted, "整数积分")}
${metric("消耗积分", data.creditsUsed, "已结算")}
</section>
<section class="panel">
<div class="panel-heading">
<div><h2>新增用户趋势</h2><p>按 UTC 日期统计注册数</p></div>
<span class="legend"><i></i>新增用户</span>
</div>
${trendChart(data.trend)}
</section>
<section class="panel">
<div class="panel-heading"><div><h2>使用汇总</h2><p>按类型统计,不包含用户内容</p></div></div>
${
data.usage.length === 0
? '<p class="muted">当前周期暂无使用记录</p>'
: `
<div class="table-wrap">
<table>
<caption class="sr-only">当前周期使用统计</caption>
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音毫秒</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
<tbody>
${data.usage
.map(
(item) => `
<tr>
<td><code>${escapeHtml(item.kind)}</code></td>
<td>${formatNumber(item.requests)}</td>
<td>${formatNumber(item.chargedCredits)}</td>
<td>${formatNumber(item.asrMillis)}</td>
<td>${formatNumber(item.inputTokens)}</td>
<td>${formatNumber(item.outputTokens)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
}
</section>
`;
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
input.addEventListener("change", (event) => {
void renderOverview(container, (event.target as HTMLInputElement).value);
});
});
} catch (error) {
renderError(container, error, () => void renderOverview(container, range));
}
}
function rangeOption(value: string, label: string, selected: string): string {
return `
<label>
<input data-range name="overview-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
<span>${label}</span>
</label>
`;
}
function metric(label: string, value: number, hint: string): string {
return `
<article class="metric-card">
<p>${label}</p>
<strong>${formatNumber(value)}</strong>
<span>${hint}</span>
</article>
`;
}
-97
View File
@@ -1,97 +0,0 @@
import { adminApi } from "../api/client";
import { funnelChart } from "../components/charts";
import { renderEmpty, renderError, renderLoading } from "../components/ui";
import { escapeHtml, formatNumber } from "../lib/format";
export async function renderReferrals(
container: HTMLElement,
range = "30d",
): Promise<void> {
renderLoading(container, "加载裂变数据");
try {
const data = await adminApi.referrals(range);
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">增长分析</div>
<h1>裂变与排行</h1>
<p>奖励以有效使用为前提,不以注册量代替真实转化。</p>
</div>
<fieldset class="segmented-control">
<legend class="sr-only">统计周期</legend>
${rangeOption("7d", "7 天", range)}
${rangeOption("30d", "30 天", range)}
${rangeOption("90d", "90 天", range)}
</fieldset>
</div>
<section class="metric-grid metric-grid--two" aria-label="邀请资格状态">
<article class="metric-card">
<p>待资格确认</p>
<strong>${formatNumber(data.pendingBindings)}</strong>
<span>等待有效使用</span>
</article>
<article class="metric-card">
<p>不符合奖励条件</p>
<strong>${formatNumber(data.ineligibleBindings)}</strong>
<span>未发放积分</span>
</article>
</section>
<div class="two-column">
<section class="panel">
<div class="panel-heading"><div><h2>裂变漏斗</h2><p>从分享至有效使用</p></div></div>
${funnelChart(data.funnel)}
</section>
<section class="panel">
<div class="panel-heading"><div><h2>邀请排行</h2><p>按有效邀请数排序</p></div></div>
${
data.ranking.length === 0
? renderEmpty("当前周期暂无排行数据")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">有效邀请用户排行</caption>
<thead><tr><th scope="col">名次</th><th scope="col">用户 ID</th><th scope="col">邀请</th><th scope="col">有效</th><th scope="col">奖励积分</th></tr></thead>
<tbody>
${data.ranking
.map(
(item, index) => `
<tr>
<td><span class="rank rank--${index + 1}">${index + 1}</span></td>
<td class="mono">${escapeHtml(item.userId)}</td>
<td>${formatNumber(item.invited)}</td>
<td>${formatNumber(item.qualified)}</td>
<td>${formatNumber(item.creditsEarned)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
}
</section>
</div>
`;
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
input.addEventListener("change", (event) => {
void renderReferrals(
container,
(event.target as HTMLInputElement).value,
);
});
});
} catch (error) {
renderError(container, error, () => void renderReferrals(container, range));
}
}
function rangeOption(value: string, label: string, selected: string): string {
return `
<label>
<input data-range name="referral-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
<span>${label}</span>
</label>
`;
}
-585
View File
@@ -1,585 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type {
AdminOperator,
AdminOperatorProvisioning,
AdminRole,
AdminSecuritySummary,
} from "../api/types";
import {
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import { escapeHtml, formatDateTime } from "../lib/format";
export async function renderSecurity(
container: HTMLElement,
currentUsername: string,
): Promise<void> {
renderLoading(container, "加载安全中心");
try {
const [page, summary] = await Promise.all([
adminApi.operators(),
adminApi.operatorSummary(),
]);
const operators = [...page.items];
container.innerHTML = securityTemplate(
operators,
currentUsername,
summary,
page.nextCursor,
);
bindSecurityActions(container, operators, currentUsername);
bindOperatorPagination(
container,
operators,
currentUsername,
page.nextCursor,
);
} catch (error) {
renderError(container, error, () => {
void renderSecurity(container, currentUsername);
});
}
}
function securityTemplate(
operators: AdminOperator[],
currentUsername: string,
summary: AdminSecuritySummary,
nextCursor?: string,
): string {
return `
<div class="page-heading">
<div>
<div class="eyebrow">访问控制</div>
<h1>安全中心</h1>
<p>管理运营人员、角色、登录锁定、会话与双重认证。</p>
</div>
<wa-button variant="brand" appearance="accent" data-create-operator>添加管理员</wa-button>
</div>
<aside class="security-notice" aria-label="安全说明">
<strong>双重边界已启用</strong>
<span>访问仍需受信客户端证书;所有管理员变更都会写入不可变审计日志。</span>
</aside>
<section class="metric-grid metric-grid--three" aria-label="安全指标">
${securityMetric("已启用管理员", summary.enabledOperators, "可登录账户")}
${securityMetric("已锁定账户", summary.lockedOperators, "等待解锁或锁定到期")}
${securityMetric("活动会话", summary.activeSessions, "尚未过期且未撤销")}
</section>
<section class="panel">
<div class="panel-heading">
<div><h2>管理员</h2><p data-operator-count>已加载 ${operators.length} 个账户</p></div>
</div>
<div class="table-wrap">
<table>
<caption class="sr-only">管理员账户与安全状态</caption>
<thead>
<tr><th scope="col">管理员</th><th scope="col">角色</th><th scope="col">状态</th><th scope="col">最近登录</th><th scope="col">操作</th></tr>
</thead>
<tbody data-operator-body>
${operators.map((operator) => operatorRow(operator, currentUsername)).join("")}
</tbody>
</table>
</div>
${
nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-operator-more aria-describedby="operator-pagination-status">加载更多管理员</wa-button></div>'
: ""
}
<p class="sr-only" id="operator-pagination-status" data-operator-status role="status" aria-live="polite"></p>
</section>
${operatorFormDialog()}
${credentialResetDialog()}
`;
}
function securityMetric(label: string, value: number, hint: string): string {
return `
<article class="metric-card">
<span>${escapeHtml(label)}</span>
<strong>${value.toLocaleString("zh-CN")}</strong>
<small>${escapeHtml(hint)}</small>
</article>
`;
}
function operatorRow(
operator: AdminOperator,
currentUsername: string,
): string {
const current = operator.username === currentUsername;
const locked = Boolean(
operator.lockedUntil && new Date(operator.lockedUntil).getTime() > Date.now(),
);
return `
<tr>
<td>
<strong>${escapeHtml(operator.username)} ${current ? '<span class="badge">当前</span>' : ""}</strong>
<div class="subtle mono">${escapeHtml(operator.operatorId)}</div>
</td>
<td>${roleLabel(operator.role)}</td>
<td>
<span class="badge ${operator.enabled ? "badge--success" : "badge--rejected"}">${operator.enabled ? "已启用" : "已停用"}</span>
${locked ? '<span class="badge badge--rejected">已锁定</span>' : ""}
</td>
<td>${formatDateTime(operator.lastLoginAt)}</td>
<td>
<div class="row-actions" data-operator-id="${escapeHtml(operator.operatorId)}">
${
locked
? '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="unlock">解锁</wa-button>'
: ""
}
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="sessions">撤销会话</wa-button>
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="reset">重置凭据</wa-button>
${
operator.enabled
? `<wa-button variant="danger" appearance="filled" size="s" data-operator-action="disable" ${current ? "disabled" : ""}>停用</wa-button>`
: '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="enable">启用</wa-button>'
}
</div>
</td>
</tr>
`;
}
function bindOperatorPagination(
container: HTMLElement,
operators: AdminOperator[],
currentUsername: string,
initialCursor?: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-operator-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-operator-body]");
const count = container.querySelector<HTMLElement>("[data-operator-count]");
const status = container.querySelector<HTMLElement>("[data-operator-status]");
if (!button || !body || !initialCursor) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.operators(cursor);
body.insertAdjacentHTML(
"beforeend",
page.items.map((operator) => operatorRow(operator, currentUsername)).join(""),
);
operators.push(...page.items);
cursor = page.nextCursor;
if (count) count.textContent = `已加载 ${operators.length} 个账户`;
if (status) {
status.textContent = cursor
? `已加载 ${page.items.length} 个更多管理员`
: `已加载 ${page.items.length} 个管理员,全部账户已加载`;
}
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(errorMessage(error, "加载管理员失败"), "error");
setButtonBusy(button, false);
button.focus();
}
});
}
function operatorFormDialog(): string {
return `
<dialog class="dialog" data-operator-dialog aria-labelledby="operator-dialog-title">
<form class="dialog-card form-stack" data-operator-form novalidate>
<div class="eyebrow">访问授权</div>
<h2 id="operator-dialog-title">添加管理员</h2>
<p class="muted">创建后,TOTP 密钥只显示一次。</p>
<label><span>用户名</span><input name="username" autocomplete="off" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._@-]{2,63}" required /></label>
<label>
<span>角色</span>
<select name="role" required>
<option value="ANALYST">分析员 · 仅统计</option>
<option value="SUPPORT">支持人员 · 用户与流水只读</option>
<option value="SUPER_ADMIN">超级管理员 · 完整权限</option>
</select>
</label>
<label><span>初始密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<label><span>确认密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<p class="form-error" data-operator-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-close-operator>取消</button>
<button class="button button--primary" type="submit">创建并生成 TOTP</button>
</div>
</form>
</dialog>
`;
}
function credentialResetDialog(): string {
return `
<dialog class="dialog" data-reset-dialog aria-labelledby="reset-dialog-title">
<form class="dialog-card form-stack" data-reset-form novalidate>
<div class="eyebrow">高风险操作</div>
<h2 id="reset-dialog-title">重置登录凭据</h2>
<p>将为 <strong data-reset-username></strong> 重置密码和 TOTP,并立即撤销其全部会话。</p>
<label><span>新密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<label><span>确认新密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<p class="form-error" data-reset-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-close-reset>取消</button>
<button class="button button--danger" type="submit">重置并撤销会话</button>
</div>
</form>
</dialog>
`;
}
function bindSecurityActions(
container: HTMLElement,
operators: AdminOperator[],
currentUsername: string,
): void {
const createDialog =
container.querySelector<HTMLDialogElement>("[data-operator-dialog]");
const createForm =
container.querySelector<HTMLFormElement>("[data-operator-form]");
container
.querySelector<HTMLButtonElement>("[data-create-operator]")
?.addEventListener("click", () => {
createForm?.reset();
clearError(createForm, "[data-operator-error]");
createDialog?.showModal();
createForm?.querySelector<HTMLInputElement>('input[name="username"]')?.focus();
});
container
.querySelector<HTMLButtonElement>("[data-close-operator]")
?.addEventListener("click", () => createDialog?.close());
createForm?.addEventListener("submit", (event) => {
event.preventDefault();
void createOperator(container, createDialog, createForm, currentUsername);
});
const resetDialog =
container.querySelector<HTMLDialogElement>("[data-reset-dialog]");
const resetForm = container.querySelector<HTMLFormElement>("[data-reset-form]");
container
.querySelector<HTMLButtonElement>("[data-close-reset]")
?.addEventListener("click", () => resetDialog?.close());
resetForm?.addEventListener("submit", (event) => {
event.preventDefault();
void resetCredentials(container, resetDialog, resetForm, currentUsername);
});
container.querySelector("tbody")?.addEventListener("click", (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>(
"[data-operator-action]",
);
const operatorId = button?.closest<HTMLElement>("[data-operator-id]")?.dataset
.operatorId;
const operator = operators.find((item) => item.operatorId === operatorId);
if (!button || !operator) return;
void handleOperatorAction(
container,
button,
operator,
resetDialog,
resetForm,
currentUsername,
);
});
}
async function createOperator(
container: HTMLElement,
dialog: HTMLDialogElement | null,
form: HTMLFormElement,
currentUsername: string,
): Promise<void> {
const data = new FormData(form);
const username = data.get("username")?.toString().trim() ?? "";
const role = data.get("role")?.toString() as AdminRole;
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-operator-error]");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (
!/^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/.test(username) ||
!["SUPER_ADMIN", "SUPPORT", "ANALYST"].includes(role) ||
password.length < 12 ||
password !== confirmation ||
!submit
) {
if (errorNode) errorNode.textContent = "请检查用户名、角色及两次输入的密码";
return;
}
setButtonBusy(submit, true, "创建中…");
clearError(form, "[data-operator-error]");
try {
const provisioning = await adminApi.createOperator({
username,
password,
role,
});
form.reset();
dialog?.close();
await showProvisioning(container, provisioning, "管理员已创建");
await renderSecurity(container, currentUsername);
} catch (error) {
if (errorNode) errorNode.textContent = errorMessage(error, "创建管理员失败");
setButtonBusy(submit, false);
}
}
async function handleOperatorAction(
container: HTMLElement,
button: HTMLButtonElement,
operator: AdminOperator,
resetDialog: HTMLDialogElement | null,
resetForm: HTMLFormElement | null,
currentUsername: string,
): Promise<void> {
const action = button.dataset.operatorAction;
if (action === "reset" && resetDialog && resetForm) {
resetForm.reset();
resetForm.dataset.operatorId = operator.operatorId;
const username = resetForm.querySelector<HTMLElement>("[data-reset-username]");
if (username) username.textContent = operator.username;
clearError(resetForm, "[data-reset-error]");
resetDialog.showModal();
resetForm.querySelector<HTMLInputElement>('input[name="password"]')?.focus();
return;
}
const confirmation = actionConfirmation(action, operator.username);
if (!confirmation || !(await confirmAction(container, confirmation))) return;
setButtonBusy(button, true);
try {
if (action === "enable") {
await adminApi.setOperatorEnabled(operator.operatorId, true);
} else if (action === "disable") {
await adminApi.setOperatorEnabled(operator.operatorId, false);
} else if (action === "unlock") {
await adminApi.unlockOperator(operator.operatorId);
} else if (action === "sessions") {
await adminApi.revokeOperatorSessions(operator.operatorId);
}
showToast("安全设置已更新", "success");
await renderSecurity(container, currentUsername);
} catch (error) {
showToast(errorMessage(error, "安全设置更新失败"), "error");
setButtonBusy(button, false);
}
}
async function resetCredentials(
container: HTMLElement,
dialog: HTMLDialogElement | null,
form: HTMLFormElement,
currentUsername: string,
): Promise<void> {
const operatorId = form.dataset.operatorId;
const data = new FormData(form);
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-reset-error]");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (!operatorId || password.length < 12 || password !== confirmation || !submit) {
if (errorNode) errorNode.textContent = "请输入至少 12 位且两次一致的新密码";
return;
}
setButtonBusy(submit, true, "重置中…");
clearError(form, "[data-reset-error]");
try {
const provisioning = await adminApi.resetOperatorCredentials(
operatorId,
password,
);
form.reset();
dialog?.close();
await showProvisioning(container, provisioning, "登录凭据已重置");
await renderSecurity(container, currentUsername);
} catch (error) {
if (errorNode) errorNode.textContent = errorMessage(error, "凭据重置失败");
setButtonBusy(submit, false);
}
}
interface Confirmation {
title: string;
message: string;
label: string;
dangerous?: boolean;
}
function actionConfirmation(
action: string | undefined,
username: string,
): Confirmation | null {
const target = `${username}`;
if (action === "disable") {
return {
title: "停用管理员?",
message: `${target} 将无法登录,全部活动会话会立即失效。`,
label: "停用管理员",
dangerous: true,
};
}
if (action === "enable") {
return {
title: "启用管理员?",
message: `${target} 将恢复登录权限。`,
label: "确认启用",
};
}
if (action === "unlock") {
return {
title: "解除登录锁定?",
message: `${target} 可以立即重新尝试登录。`,
label: "确认解锁",
};
}
if (action === "sessions") {
return {
title: "撤销全部会话?",
message: `${target} 已登录的所有设备都需要重新认证。`,
label: "撤销会话",
dangerous: true,
};
}
return null;
}
function confirmAction(
container: HTMLElement,
confirmation: Confirmation,
): Promise<boolean> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "dialog";
dialog.setAttribute("aria-labelledby", "confirm-action-title");
dialog.innerHTML = `
<form method="dialog" class="dialog-card">
<div class="eyebrow">确认操作</div>
<h2 id="confirm-action-title">${escapeHtml(confirmation.title)}</h2>
<p>${escapeHtml(confirmation.message)}</p>
<div class="dialog-actions">
<button class="button button--secondary" value="cancel">取消</button>
<button class="button ${confirmation.dangerous ? "button--danger" : "button--primary"}" value="confirm">${escapeHtml(confirmation.label)}</button>
</div>
</form>
`;
container.append(dialog);
dialog.addEventListener(
"close",
() => {
const confirmed = dialog.returnValue === "confirm";
dialog.remove();
resolve(confirmed);
},
{ once: true },
);
dialog.showModal();
});
}
function showProvisioning(
container: HTMLElement,
provisioning: AdminOperatorProvisioning,
title: string,
): Promise<void> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "dialog dialog--wide";
dialog.setAttribute("aria-labelledby", "provisioning-title");
dialog.innerHTML = `
<div class="dialog-card provisioning-card">
<div class="eyebrow">仅显示一次</div>
<h2 id="provisioning-title">${escapeHtml(title)}</h2>
<p>请立即把 TOTP 密钥交给对应管理员,并确认已添加到认证器。</p>
<div class="secret-panel">
<span>Base32 密钥</span>
<code>${escapeHtml(provisioning.totpSecret)}</code>
<button class="button button--secondary" type="button" data-copy-secret>复制密钥</button>
</div>
<div class="dialog-actions dialog-actions--spread">
<a class="button button--secondary" href="${escapeHtml(provisioning.otpauthUri)}">在认证器中打开</a>
<button class="button button--secondary" type="button" data-copy-uri>复制配置链接</button>
</div>
<label class="confirmation-check">
<input type="checkbox" data-provisioning-saved />
<span>我已安全保存密钥,理解关闭后无法再次查看</span>
</label>
<button class="button button--primary button--wide" type="button" data-close-provisioning disabled>完成</button>
</div>
`;
const close = dialog.querySelector<HTMLButtonElement>("[data-close-provisioning]");
dialog
.querySelector<HTMLInputElement>("[data-provisioning-saved]")
?.addEventListener("change", (event) => {
if (close) close.disabled = !(event.target as HTMLInputElement).checked;
});
dialog.addEventListener("cancel", (event) => event.preventDefault());
dialog
.querySelector<HTMLButtonElement>("[data-copy-secret]")
?.addEventListener("click", () => {
void copySensitiveValue(provisioning.totpSecret, "TOTP 密钥");
});
dialog
.querySelector<HTMLButtonElement>("[data-copy-uri]")
?.addEventListener("click", () => {
void copySensitiveValue(provisioning.otpauthUri, "认证器配置链接");
});
close?.addEventListener("click", () => dialog.close());
dialog.addEventListener(
"close",
() => {
dialog.remove();
resolve();
},
{ once: true },
);
container.append(dialog);
dialog.showModal();
});
}
async function copySensitiveValue(value: string, label: string): Promise<void> {
try {
await navigator.clipboard.writeText(value);
showToast(`${label}已复制,请妥善保管`, "success");
} catch {
showToast(`无法复制${label},请手动选择`, "error");
}
}
function clearError(
container: ParentNode | null,
selector: string,
): void {
const node = container?.querySelector<HTMLElement>(selector);
if (node) node.textContent = "";
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof ApiError ? error.message : fallback;
}
function roleLabel(role: AdminRole): string {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return labels[role];
}
-454
View File
@@ -1,454 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type {
AdminRole,
LedgerEntry,
PageResult,
UserDetail,
UserSummary,
UserUsageAggregate,
} from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import {
createIdempotencyKey,
escapeHtml,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
} from "../lib/format";
export function renderUsers(container: HTMLElement, role: AdminRole): void {
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">账户管理</div>
<h1>用户查询</h1>
<p>按内部用户 ID 精确查询,不展示 Apple 身份标识。</p>
</div>
</div>
<section class="panel">
<form class="search-form" data-search-form>
<label class="search-box">
<span class="sr-only">内部用户 ID</span>
<span aria-hidden="true">⌕</span>
<input name="query" type="search" placeholder="输入完整内部用户 ID" autocomplete="off" maxlength="36" required />
</label>
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-button>
</form>
<div data-results>${renderEmpty("输入查询条件开始搜索")}</div>
</section>
`;
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const search = new FormData(form).get("query")?.toString().trim() ?? "";
if (search) void searchUsers(container, search, role);
});
}
async function searchUsers(
container: HTMLElement,
search: string,
role: AdminRole,
): Promise<void> {
const results = container.querySelector<HTMLElement>("[data-results]");
if (!results) return;
renderLoading(results, "搜索用户");
try {
const page = await adminApi.users(search);
if (page.items.length === 0) {
results.innerHTML = renderEmpty("未找到匹配用户");
return;
}
results.innerHTML = `
<div class="table-wrap">
<table>
<caption class="sr-only">用户查询结果</caption>
<thead><tr><th scope="col">用户</th><th scope="col">状态</th><th scope="col">积分余额</th><th scope="col">注册时间</th><th scope="col">操作</th></tr></thead>
<tbody>
${page.items.map(userRow).join("")}
</tbody>
</table>
</div>
`;
results.querySelectorAll<HTMLButtonElement>("[data-user-id]").forEach(
(button) => {
button.addEventListener("click", () => {
const userId = button.dataset.userId;
if (userId) void renderUserDetail(container, userId, role);
});
},
);
} catch (error) {
renderError(results, error, () => void searchUsers(container, search, role));
}
}
function userRow(user: UserSummary): string {
return `
<tr>
<td>
<strong>${escapeHtml(user.displayName || "未命名用户")}</strong>
<div class="subtle mono">${escapeHtml(user.userId)}</div>
</td>
<td><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></td>
<td>${formatNumber(user.creditBalance)}</td>
<td>${formatDateTime(user.createdAt)}</td>
<td><wa-button variant="neutral" appearance="outlined" size="s" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</wa-button></td>
</tr>
`;
}
async function renderUserDetail(
container: HTMLElement,
userId: string,
role: AdminRole,
): Promise<void> {
renderLoading(container, "加载用户详情");
try {
const [user, ledger] = await Promise.all([
adminApi.user(userId),
adminApi.ledger(userId),
]);
container.innerHTML = detailTemplate(user, ledger, role);
container
.querySelector<HTMLButtonElement>("[data-back]")
?.addEventListener("click", () => {
renderUsers(container, role);
container.querySelector<HTMLInputElement>('input[name="query"]')?.focus();
});
bindLedgerPagination(container, user, ledger.nextCursor);
if (role === "SUPER_ADMIN") bindGrantDialog(container, user, role);
const heading = container.querySelector<HTMLElement>("h1");
if (heading) {
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
}
} catch (error) {
renderError(container, error, () =>
void renderUserDetail(container, userId, role),
);
}
}
function detailTemplate(
user: UserDetail,
ledger: PageResult<LedgerEntry>,
role: AdminRole,
): string {
const usage = user.usage ?? [];
const usageRequests = usage.reduce((total, item) => total + item.requests, 0);
const chargedCredits = usage.reduce(
(total, item) => total + item.chargedCredits,
0,
);
const referral = user.referral;
const inviterUserId = referral?.inviterUserId ?? user.referredByUserId;
const qualifiedUsage = user.qualifiedUsage || usageRequests > 0;
return `
<div class="page-heading page-heading--detail">
<div>
<wa-button class="back-link" appearance="plain" data-back>← 返回用户查询</wa-button>
<div class="eyebrow">用户详情</div>
<h1>${escapeHtml(user.displayName || "未命名用户")}</h1>
<p class="mono">${escapeHtml(user.userId)}</p>
</div>
${
role === "SUPER_ADMIN"
? '<wa-button variant="brand" appearance="accent" data-open-grant>人工赠送积分</wa-button>'
: ""
}
</div>
<section class="detail-grid">
<article class="panel profile-card">
<div class="profile-header">
<div class="avatar" aria-hidden="true">${escapeHtml((user.displayName || "用").slice(0, 1))}</div>
<div><strong>${escapeHtml(user.displayName || "未命名用户")}</strong><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></div>
</div>
<dl class="detail-list">
<div><dt>积分余额</dt><dd class="credit-value">${formatNumber(user.creditBalance)}</dd></div>
<div><dt>注册时间</dt><dd>${formatDateTime(user.createdAt)}</dd></div>
<div><dt>最近活跃</dt><dd>${formatDateTime(user.lastActiveAt)}</dd></div>
<div><dt>有效使用</dt><dd>${qualifiedUsage ? "已达成" : "未达成"}${usage.length > 0 ? ` · ${formatNumber(usageRequests)} 次请求` : ""}</dd></div>
${
usage.length > 0
? `<div><dt>累计消耗</dt><dd>${formatNumber(chargedCredits)} 积分</dd></div>`
: ""
}
<div><dt>邀请码</dt><dd class="mono">${escapeHtml(user.referralCode || "—")}</dd></div>
<div><dt>邀请来源</dt><dd class="mono">${escapeHtml(inviterUserId || "—")}</dd></div>
${
referral
? `<div><dt>邀请成效</dt><dd>${formatNumber(referral.rewardedInvites)} / ${formatNumber(referral.invitedUsers)} 已奖励</dd></div>`
: ""
}
</dl>
</article>
<section class="panel">
<div class="panel-heading"><div><h2>积分流水</h2><p>不可变账本记录</p></div></div>
${
ledger.items.length === 0
? renderEmpty("暂无积分流水")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">${escapeHtml(user.displayName || user.userId)} 的积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody data-ledger-body>${ledgerRows(ledger.items)}</tbody>
</table>
</div>
${
ledger.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-ledger-more aria-describedby="ledger-pagination-status">加载更多流水</wa-button></div>'
: ""
}
<p class="sr-only" id="ledger-pagination-status" data-ledger-status role="status" aria-live="polite"></p>
`
}
</section>
${usagePanel(usage)}
</section>
${role === "SUPER_ADMIN" ? grantDialog(user) : ""}
`;
}
function ledgerRows(entries: LedgerEntry[]): string {
return entries
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(statusLabel(entry.type))}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("");
}
function usagePanel(usage: UserUsageAggregate[]): string {
if (usage.length === 0) return "";
return `
<section class="panel detail-panel--wide">
<div class="panel-heading"><div><h2>使用统计</h2><p>按使用类型汇总,不包含用户内容</p></div></div>
<div class="table-wrap">
<table>
<caption class="sr-only">用户使用统计</caption>
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音时长(毫秒)</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
<tbody>
${usage
.map(
(item) => `
<tr>
<td><code>${escapeHtml(item.kind)}</code></td>
<td>${formatNumber(item.requests)}</td>
<td>${formatNumber(item.chargedCredits)}</td>
<td>${formatNumber(item.asrMillis)}</td>
<td>${formatNumber(item.inputTokens)}</td>
<td>${formatNumber(item.outputTokens)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</section>
`;
}
function bindLedgerPagination(
container: HTMLElement,
user: UserDetail,
initialCursor?: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-ledger-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-ledger-body]");
const status = container.querySelector<HTMLElement>("[data-ledger-status]");
if (!button || !body || !initialCursor) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.ledger(user.userId, cursor);
body.insertAdjacentHTML("beforeend", ledgerRows(page.items));
cursor = page.nextCursor;
if (status) {
status.textContent = cursor
? `已加载 ${formatNumber(page.items.length)} 条更多流水`
: `已加载 ${formatNumber(page.items.length)} 条流水,全部记录已加载`;
}
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载积分流水失败",
"error",
);
setButtonBusy(button, false);
button.focus();
}
});
}
function grantDialog(user: UserDetail): string {
return `
<dialog class="dialog" data-grant-dialog aria-labelledby="grant-dialog-title">
<form method="dialog" class="dialog-card" data-grant-form>
<div data-grant-inputs>
<div class="eyebrow">高风险操作</div>
<h2 id="grant-dialog-title">人工赠送积分</h2>
<p class="muted">赠送将写入不可变账本,并记录管理员审计日志。</p>
<label><span>用户 ID</span><input value="${escapeHtml(user.userId)}" disabled /></label>
<label><span>赠送积分</span><input name="amount" type="number" inputmode="numeric" min="1" max="100000" step="1" aria-describedby="grant-input-error" required /></label>
<label><span>赠送原因</span><textarea name="reason" minlength="4" maxlength="200" placeholder="请填写可审计的业务原因" aria-describedby="grant-input-error" required></textarea></label>
<p class="form-error" id="grant-input-error" data-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" value="cancel">取消</button>
<button class="button button--primary" type="button" data-review-grant>下一步</button>
</div>
</div>
<div data-grant-confirm hidden>
<div class="confirm-icon" aria-hidden="true">!</div>
<h2 data-confirm-title tabindex="-1">确认赠送?</h2>
<p>将向 <strong>${escapeHtml(user.displayName || user.userId)}</strong> 赠送 <strong data-confirm-amount></strong> 积分。</p>
<p class="confirm-reason" data-confirm-reason></p>
<p class="form-error" id="grant-confirm-error" data-confirm-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-edit-grant>返回修改</button>
<button class="button button--danger" type="button" data-submit-grant aria-describedby="grant-confirm-error">确认赠送</button>
</div>
</div>
</form>
</dialog>
`;
}
function bindGrantDialog(
container: HTMLElement,
user: UserDetail,
role: AdminRole,
): void {
const dialog = container.querySelector<HTMLDialogElement>("[data-grant-dialog]");
const form = container.querySelector<HTMLFormElement>("[data-grant-form]");
const inputs = container.querySelector<HTMLElement>("[data-grant-inputs]");
const confirm = container.querySelector<HTMLElement>("[data-grant-confirm]");
if (!dialog || !form || !inputs || !confirm) return;
let idempotencyKey = createIdempotencyKey();
let outcomeUnknown = false;
container
.querySelector<HTMLButtonElement>("[data-open-grant]")
?.addEventListener("click", () => {
idempotencyKey = createIdempotencyKey();
outcomeUnknown = false;
form.reset();
inputs.hidden = false;
confirm.hidden = true;
form.querySelectorAll<HTMLElement>(".form-error").forEach((node) => {
node.textContent = "";
});
dialog.showModal();
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
});
dialog.addEventListener("cancel", (event) => {
if (!outcomeUnknown) return;
event.preventDefault();
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
if (errorNode) {
errorNode.textContent = "赠送结果尚未确认,请使用当前窗口安全重试";
}
});
container
.querySelector<HTMLButtonElement>("[data-review-grant]")
?.addEventListener("click", () => {
const data = new FormData(form);
const amount = Number(data.get("amount"));
const reason = data.get("reason")?.toString().trim() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-error]");
if (!Number.isSafeInteger(amount) || amount < 1 || amount > 100_000) {
if (errorNode) errorNode.textContent = "积分必须是 1 至 100,000 的整数";
return;
}
if (reason.length < 4) {
if (errorNode) errorNode.textContent = "请填写至少 4 个字符的赠送原因";
return;
}
if (errorNode) errorNode.textContent = "";
const amountNode = form.querySelector<HTMLElement>("[data-confirm-amount]");
const reasonNode = form.querySelector<HTMLElement>("[data-confirm-reason]");
if (amountNode) amountNode.textContent = formatNumber(amount);
if (reasonNode) reasonNode.textContent = `原因:${reason}`;
inputs.hidden = true;
confirm.hidden = false;
form.querySelector<HTMLElement>("[data-confirm-title]")?.focus();
});
container
.querySelector<HTMLButtonElement>("[data-edit-grant]")
?.addEventListener("click", () => {
if (outcomeUnknown) {
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
if (errorNode) {
errorNode.textContent = "结果尚未确认,不能修改本次赠送内容;请直接重试";
}
return;
}
confirm.hidden = true;
inputs.hidden = false;
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
});
const submit = container.querySelector<HTMLButtonElement>("[data-submit-grant]");
submit?.addEventListener("click", async () => {
const data = new FormData(form);
const amount = Number(data.get("amount"));
const reason = data.get("reason")?.toString().trim() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
setButtonBusy(submit, true, "赠送中…");
if (errorNode) errorNode.textContent = "";
try {
await adminApi.grantCredits({
userId: user.userId,
amount,
reason,
idempotencyKey,
});
dialog.close();
showToast("积分赠送成功,账本已更新", "success");
await renderUserDetail(container, user.userId, role);
} catch (error) {
const message =
error instanceof ApiError ? error.message : "赠送失败,请重试";
outcomeUnknown =
!(error instanceof ApiError) || error.status === 0 || error.status >= 500;
if (errorNode) {
errorNode.textContent = outcomeUnknown
? "赠送结果尚未确认;重试会复用同一请求,不会重复到账"
: message;
}
setButtonBusy(submit, false);
}
});
}
+236 -1350
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { funnelChart, trendChart } from "../components/charts";
describe("accessible charts", () => {
it("provides a titled SVG and complete data table for trends", () => {
const html = trendChart([
{ date: "2026-08-16", registrations: 12, creditsUsed: 345 },
]);
expect(html).toContain('aria-labelledby="trend-chart-title trend-chart-description"');
expect(html).toContain("<caption>新增用户与积分消耗趋势完整数据</caption>");
expect(html).toContain("<td>345</td>");
});
it("renders the funnel without CSP-sensitive inline styles", () => {
const html = funnelChart([
{ label: "访问", count: 20 },
{ label: "注册", count: 10 },
]);
expect(html).toContain("<progress");
expect(html).not.toContain('style="');
});
});
+5 -1
View File
@@ -90,13 +90,17 @@ describe("adminApi", () => {
});
vi.stubGlobal("fetch", fetchMock);
await adminApi.latestLedger("latest+/=");
await adminApi.ledger("user/with space", "ledger+/=");
await adminApi.operators("operator+/=");
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
"/v1/admin/credits/ledger?cursor=latest%2B%2F%3D",
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
);
expect(fetchMock.mock.calls[2]?.[0]).toBe(
"/v1/admin/operators?cursor=operator%2B%2F%3D",
);
});
+7
View File
@@ -4,6 +4,7 @@ import {
formatDateTime,
formatSignedCredits,
statusLabel,
usageTypeLabel,
} from "../lib/format";
describe("format helpers", () => {
@@ -26,4 +27,10 @@ describe("format helpers", () => {
it("未知状态保持原值", () => {
expect(statusLabel("custom")).toBe("custom");
});
it("消费类型使用清晰的中文标签", () => {
expect(usageTypeLabel("polish")).toBe("润色");
expect(usageTypeLabel("hotword")).toBe("热词");
expect(usageTypeLabel()).toBe("—");
});
});
-191
View File
@@ -1,191 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../api/client";
import type {
AdminOperator,
AdminSecuritySummary,
UserDetail,
UserSummary,
} from "../api/types";
import { renderSecurity } from "../pages/security";
import { renderUsers } from "../pages/users";
const userId = "11111111-1111-4111-8111-111111111111";
const userSummary: UserSummary = {
userId,
displayName: "测试用户",
status: "active",
creditBalance: 120,
createdAt: "2026-08-01T08:00:00Z",
};
const userDetail: UserDetail = {
...userSummary,
lastActiveAt: "2026-08-16T08:00:00Z",
qualifiedUsage: true,
referralCode: "OSG-TEST",
usage: [
{
kind: "ASR",
requests: 3,
chargedCredits: 18,
asrMillis: 12_000,
inputTokens: 0,
outputTokens: 0,
},
],
referral: {
inviterUserId: "22222222-2222-4222-8222-222222222222",
invitedUsers: 4,
rewardedInvites: 2,
},
};
afterEach(() => {
vi.restoreAllMocks();
document.body.replaceChildren();
});
describe("用户页", () => {
it("支持人员可查看详情但不显示人工赠送", async () => {
mockUserRequests();
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
const container = document.createElement("main");
document.body.append(container);
await openUserDetail(container, "SUPPORT");
expect(container.querySelector("[data-open-grant]")).toBeNull();
expect(container.querySelector("[data-grant-dialog]")).toBeNull();
});
it("超级管理员可赠送,并展示 usage/referral 与游标流水", async () => {
mockUserRequests();
vi.spyOn(adminApi, "ledger")
.mockResolvedValueOnce({
items: [
{
entryId: "ledger-1",
type: "grant",
amount: 100,
balanceAfter: 100,
reasonCode: "SIGNUP_TRIAL",
createdAt: "2026-08-01T08:00:00Z",
},
],
nextCursor: "ledger-next",
})
.mockResolvedValueOnce({
items: [
{
entryId: "ledger-2",
type: "settle",
amount: -18,
balanceAfter: 82,
reasonCode: "USAGE_SETTLE",
createdAt: "2026-08-02T08:00:00Z",
},
],
});
const container = document.createElement("main");
document.body.append(container);
await openUserDetail(container, "SUPER_ADMIN");
expect(container.querySelector("[data-open-grant]")).not.toBeNull();
expect(container.textContent).toContain("OSG-TEST");
expect(container.textContent).toContain("3 次请求");
expect(container.textContent).toContain("18 积分");
expect(container.textContent).toContain("2 / 4 已奖励");
expect(container.textContent).toContain(
"22222222-2222-4222-8222-222222222222",
);
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.ledger).toHaveBeenNthCalledWith(
2,
userId,
"ledger-next",
);
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-ledger-more]")).toBeNull();
expect(container.querySelector("[data-ledger-status]")?.textContent).toContain(
"全部记录已加载",
);
});
});
describe("安全中心", () => {
it("按后端 nextCursor 加载更多管理员", async () => {
const firstOperator = operator("operator-1", "owner");
const secondOperator = operator("operator-2", "support");
vi.spyOn(adminApi, "operators")
.mockResolvedValueOnce({
items: [firstOperator],
nextCursor: "operator-next",
})
.mockResolvedValueOnce({ items: [secondOperator] });
vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary());
const container = document.createElement("main");
document.body.append(container);
await renderSecurity(container, "owner");
container.querySelector<HTMLButtonElement>("[data-operator-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
expect(container.querySelectorAll("[data-operator-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-operator-count]")?.textContent).toBe(
"已加载 2 个账户",
);
expect(container.querySelector("[data-operator-more]")).toBeNull();
expect(
container.querySelector("[data-operator-status]")?.textContent,
).toContain("全部账户已加载");
});
});
function mockUserRequests(): void {
vi.spyOn(adminApi, "users").mockResolvedValue({ items: [userSummary] });
vi.spyOn(adminApi, "user").mockResolvedValue(userDetail);
}
async function openUserDetail(
container: HTMLElement,
role: "SUPER_ADMIN" | "SUPPORT",
): Promise<void> {
renderUsers(container, role);
const input = container.querySelector<HTMLInputElement>('input[name="query"]');
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
if (!input || !form) throw new Error("用户查询表单未渲染");
input.value = userId;
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(container.querySelector("[data-user-id]")).not.toBeNull();
});
container.querySelector<HTMLButtonElement>("[data-user-id]")?.click();
await vi.waitFor(() => {
expect(container.querySelector("h1")?.textContent).toBe("测试用户");
});
}
function operator(operatorId: string, username: string): AdminOperator {
return {
operatorId,
username,
role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT",
enabled: true,
failedLoginCount: 0,
createdAt: "2026-08-01T08:00:00Z",
updatedAt: "2026-08-01T08:00:00Z",
};
}
function summary(): AdminSecuritySummary {
return {
enabledOperators: 2,
lockedOperators: 0,
activeSessions: 1,
};
}
+127
View File
@@ -0,0 +1,127 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { App } from "../app";
import { adminApi } from "../api/client";
import type {
AdminOperator,
AdminSecuritySummary,
LedgerEntry,
UserSummary,
} from "../api/types";
const userId = "11111111-1111-4111-8111-111111111111";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.location.hash = "";
});
describe("React 管理页面", () => {
it("用户列表保留游标分页与角色权限", async () => {
mockSession("SUPPORT");
const first = user("测试用户", userId);
const second = user("第二位用户", "22222222-2222-4222-8222-222222222222");
vi.spyOn(adminApi, "users")
.mockResolvedValueOnce({ items: [first], nextCursor: "user-next" })
.mockResolvedValueOnce({ items: [second] });
window.location.hash = "#/users";
render(<App />);
expect(await screen.findByRole("heading", { name: "用户列表" })).toBeTruthy();
expect(await screen.findByText("测试用户")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
await waitFor(() => {
expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next");
});
expect(await screen.findByText("第二位用户")).toBeTruthy();
expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull();
});
it("积分页自动显示最新不可变流水", async () => {
mockSession("SUPPORT");
const entry: LedgerEntry = {
entryId: "ledger-1",
userId,
type: "settle",
amount: -18,
balanceAfter: 102,
reasonCode: "USAGE_SETTLE",
usageType: "hotword",
createdAt: "2026-08-19T09:00:00Z",
};
vi.spyOn(adminApi, "latestLedger").mockResolvedValue({ items: [entry] });
window.location.hash = "#/credits";
render(<App />);
expect(await screen.findByRole("heading", { name: "积分流水" })).toBeTruthy();
expect(await screen.findByText("USAGE_SETTLE")).toBeTruthy();
expect(screen.getByText("热词")).toBeTruthy();
expect(screen.getByText("-18")).toBeTruthy();
});
it("安全中心保留管理员游标分页", async () => {
mockSession("SUPER_ADMIN");
const first = operator("operator-1", "owner");
const second = operator("operator-2", "support");
vi.spyOn(adminApi, "operators")
.mockResolvedValueOnce({ items: [first], nextCursor: "operator-next" })
.mockResolvedValueOnce({ items: [second] });
vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary());
window.location.hash = "#/security";
render(<App />);
expect(await screen.findByRole("heading", { name: "安全中心" })).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" }));
await waitFor(() => {
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
});
expect(await screen.findByText("support")).toBeTruthy();
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
});
});
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
vi.spyOn(adminApi, "session").mockResolvedValue({
authenticated: true,
operatorName: "owner",
role,
});
}
function user(displayName: string, id: string): UserSummary {
return {
userId: id,
displayName,
status: "active",
creditBalance: 120,
consumedCredits: 18,
createdAt: "2026-08-01T08:00:00Z",
};
}
function operator(operatorId: string, username: string): AdminOperator {
return {
operatorId,
username,
role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT",
enabled: true,
failedLoginCount: 0,
createdAt: "2026-08-01T08:00:00Z",
updatedAt: "2026-08-01T08:00:00Z",
};
}
function summary(): AdminSecuritySummary {
return {
enabledOperators: 2,
lockedOperators: 0,
activeSessions: 1,
};
}
+72
View File
@@ -0,0 +1,72 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi, ApiError } from "../api/client";
import type { UserDetail } from "../api/types";
import { TrendChart } from "../features/overview/overview-page";
import { GrantDialog } from "../features/users/users-page";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("高风险交互与 CSP", () => {
it("趋势图不生成内联样式并提供完整辅助数据表", () => {
const { container } = render(
<TrendChart
points={[
{ date: "2026-08-18", registrations: 4, creditsUsed: 20 },
{ date: "2026-08-19", registrations: 7, creditsUsed: 35 },
]}
/>,
);
expect(screen.getByRole("img", { name: /新增用户与积分消耗趋势/ })).toBeTruthy();
expect(container.querySelector("[style]")).toBeNull();
expect(screen.getByText("新增用户与积分消耗趋势完整数据")).toBeTruthy();
expect(screen.getByText("35")).toBeTruthy();
});
it("赠送结果未知时保持弹窗并复用同一幂等键重试", async () => {
const grant = vi.spyOn(adminApi, "grantCredits").mockRejectedValue(
new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0),
);
const user = userDetail();
render(
<GrantDialog
open
user={user}
onOpenChange={vi.fn()}
onSuccess={vi.fn()}
/>,
);
await userEvent.type(screen.getByLabelText("赠送积分"), "100");
await userEvent.type(screen.getByLabelText("赠送原因"), "运营补偿");
await userEvent.click(screen.getByRole("button", { name: "下一步" }));
await userEvent.click(screen.getByRole("button", { name: "确认赠送" }));
expect(
await screen.findByText("赠送结果尚未确认;重试会复用同一请求,不会重复到账"),
).toBeTruthy();
const firstKey = grant.mock.calls[0]?.[0].idempotencyKey;
await userEvent.click(screen.getByRole("button", { name: "确认赠送" }));
await waitFor(() => expect(grant).toHaveBeenCalledTimes(2));
expect(grant.mock.calls[1]?.[0].idempotencyKey).toBe(firstKey);
expect(screen.getByRole("dialog")).toBeTruthy();
});
});
function userDetail(): UserDetail {
return {
userId: "11111111-1111-4111-8111-111111111111",
displayName: "测试用户",
status: "active",
creditBalance: 120,
consumedCredits: 18,
createdAt: "2026-08-01T08:00:00Z",
qualifiedUsage: true,
};
}
-35
View File
@@ -1,35 +0,0 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { describe, expect, it, vi } from "vitest";
import { renderError, renderLoading, setButtonBusy } from "../components/ui";
describe("Web Awesome UI states", () => {
it("renders accessible loading and retry components", () => {
const container = document.createElement("div");
const retry = vi.fn();
renderLoading(container, "读取用户");
expect(container.querySelector("wa-spinner")).not.toBeNull();
expect(container.textContent).toContain("读取用户");
renderError(container, new Error("failed"), retry);
const retryButton = container.querySelector<HTMLElement>("wa-button[data-retry]");
expect(container.querySelector('wa-callout[variant="danger"]')).not.toBeNull();
retryButton?.click();
expect(retry).toHaveBeenCalledOnce();
});
it("uses the Web Awesome loading state and restores the label", () => {
const button = document.createElement("wa-button") as WaButton;
button.textContent = "提交";
setButtonBusy(button, true, "提交中…");
expect(button.loading).toBe(true);
expect(button.disabled).toBe(true);
expect(button.textContent).toBe("提交中…");
setButtonBusy(button, false);
expect(button.loading).toBe(false);
expect(button.disabled).toBe(false);
expect(button.textContent).toBe("提交");
});
});
+1
View File
@@ -6,6 +6,7 @@
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["node"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
+18
View File
@@ -1,7 +1,10 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
base: "/admin/",
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/v1/admin": {
@@ -12,6 +15,21 @@ export default defineConfig({
},
build: {
target: "es2022",
rollupOptions: {
output: {
manualChunks(id) {
if (
id.includes("/node_modules/react/") ||
id.includes("/node_modules/react-dom/") ||
id.includes("/node_modules/react-router")
) {
return "react-vendor";
}
if (id.includes("/node_modules/@base-ui/")) return "base-ui";
return undefined;
},
},
},
},
test: {
environment: "jsdom",
+1
View File
@@ -134,6 +134,7 @@ dependencies {
implementation("com.auth0:java-jwt:4.6.0")
implementation("com.nimbusds:nimbus-jose-jwt:10.9.1")
implementation("com.apple.itunes.storekit:app-store-server-library:5.2.0")
implementation("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
implementation("com.upokecenter:cbor:4.5.6")
+7 -2
View File
@@ -54,12 +54,17 @@ services:
VOLCENGINE_ASR_ENDPOINT: ${VOLCENGINE_ASR_ENDPOINT:-wss://openspeech.bytedance.com/api/v3/sauc/bigmodel}
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?set DeepSeek API key}
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash}
DEEPSEEK_REASONING_MODEL: ${DEEPSEEK_REASONING_MODEL:-}
DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1}
SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000}
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-3000}
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-3000}
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-1000}
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-1000}
REFERRAL_BINDING_DAYS: ${REFERRAL_BINDING_DAYS:-7}
STOREKIT_ENABLED: ${STOREKIT_ENABLED:-true}
STOREKIT_BUNDLE_ID: ${STOREKIT_BUNDLE_ID:-com.osgkeyboard.ios}
STOREKIT_APP_APPLE_ID: ${STOREKIT_APP_APPLE_ID:-6781553267}
STOREKIT_PRODUCTS: ${STOREKIT_PRODUCTS:-500tks:500,1500tks:1500,3000tks:3000}
ports:
- "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080"
read_only: true
+39 -2
View File
@@ -319,6 +319,12 @@ verify_immutable_history_denials() {
expect_runtime_denied \
"admin grant DELETE" \
"DELETE FROM admin_credit_grants WHERE 1 = 0"
expect_runtime_denied \
"StoreKit purchase UPDATE" \
"UPDATE storekit_credit_purchases SET credits_granted = credits_granted WHERE 1 = 0"
expect_runtime_denied \
"StoreKit purchase DELETE" \
"DELETE FROM storekit_credit_purchases WHERE 1 = 0"
expect_runtime_denied \
"Flyway metadata read" \
"SELECT version FROM flyway_schema_history LIMIT 1"
@@ -464,9 +470,40 @@ WHERE version IS NOT NULL
ORDER BY installed_rank;
SQL
)"
EXPECTED_MIGRATIONS=$'1:1\n2:1\n3:1\n4:1\n5:1\n6:1\n7:1\n8:1'
EXPECTED_MIGRATIONS=$'1:1\n2:1\n3:1\n4:1\n5:1\n6:1\n7:1\n8:1\n9:1\n10:1\n11:1\n12:1'
[[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] ||
fail "Flyway history was not exactly successful V1-V8"
fail "Flyway history was not exactly successful V1-V12"
REFERRAL_REWARDS="$(
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits)
FROM referral_campaigns
WHERE id = '00000000-0000-0000-0000-000000000001';
SQL
)"
[[ "$REFERRAL_REWARDS" == "1000:1000" ]] ||
fail "default referral rewards were not 1000 credits for both accounts"
ACTIVE_RATES="$(
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
SELECT CONCAT_WS(
':',
kind,
provider,
model,
COALESCE(asr_credits_numerator, '-'),
COALESCE(asr_millis_denominator, '-'),
COALESCE(input_credits_numerator, '-'),
COALESCE(input_tokens_denominator, '-'),
COALESCE(output_credits_numerator, '-'),
COALESCE(output_tokens_denominator, '-')
)
FROM credit_rate_versions
WHERE effective_until IS NULL
ORDER BY kind, provider, model;
SQL
)"
EXPECTED_ACTIVE_RATES=$'ASR:volcengine-sauc-v3:volc.seedasr.sauc.duration:1:3000:-:-:-:-\nLLM:deepseek:deepseek-v4-flash:-:-:1:1000:1:400'
[[ "$ACTIVE_RATES" == "$EXPECTED_ACTIVE_RATES" ]] ||
fail "active smaller credit rates did not match the V10 contract"
compose --profile setup stop schema-migrator >/dev/null
log "installing exact runtime grants and disposable fixture"
+4
View File
@@ -22,10 +22,12 @@ GRANT SELECT ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'
GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.account_profiles TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.accounts TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.apple_credentials TO 'osg_smoke_runtime'@'%';
@@ -48,7 +50,9 @@ GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_ru
GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.account_profiles TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
+10
View File
@@ -0,0 +1,10 @@
# Account data lifecycle
- Apple subjects, refresh tokens, and account nicknames are encrypted at rest.
- Apple email and avatar data are not requested or stored.
- Deleting an account removes its session, profile, referral, grant, and mutable
account records in the same local transaction before Apple revocation is retried.
- Pseudonymous immutable credit-ledger entries, StoreKit transaction audit data,
and time-limited anti-abuse tombstones remain after deletion where required to
prevent replay, preserve financial integrity, and stop repeated trial abuse.
- Logs must never include Apple subjects, credentials, tokens, or nicknames.
+60
View File
@@ -0,0 +1,60 @@
# StoreKit credit product
The voluntary `ByRockyACoffee` tip remains independent and never grants credits.
Credit products are separate consumables:
- Product ID `500tks`: 500 integer credits at USD 0.99
- Product ID `1500tks`: 1,500 integer credits at USD 1.99 / CNY 18
- Product ID `3000tks`: 3,000 integer credits at USD 2.99 / CNY 28
- Territory prices remain controlled by App Store Connect.
- Restore Purchases: not offered for this consumable
## Cost basis
Reviewed on 2026-08-18 against the provider pricing pages:
- DeepSeek V4 Flash peak pricing is CNY 3 per million cache-miss input tokens
and CNY 9 per million output tokens. Off-peak pricing is half.
<https://api-docs.deepseek.com/zh-cn/quick_start/pricing>
- Doubao SeedASR 2.0 streaming recognition is CNY 4.5 per hour.
<https://ai.volcengine.com/model>
The V10 immutable rate card charges:
- ASR: one credit per started three-second interval. The 3,000-credit pack
provides up to 150 minutes and has a worst-case provider cost of CNY 11.25.
- DeepSeek: one credit per 1,000 input tokens plus one credit per 400 output
tokens, with each dimension rounded upward. At peak pricing, using all 3,000
credits exclusively on input or output costs at most about CNY 9.00 or
CNY 10.80 respectively.
- New signup, inviter and invitee grants are 1,000 credits each. Existing
immutable balances are adjusted only through explicit admin grants.
- Existing immutable ledger balances are grandfathered and are not rewritten
during the denomination change.
At a CNY 28 sale price, the ASR-heavy worst case leaves CNY 12.55 after a 15%
App Store commission, or CNY 8.35 after a 30% commission, before tax and
infrastructure costs. USD 2.99 territories are tighter at the worst-case ASR
mix and require ongoing margin monitoring.
## Transaction rules
- The app supplies the authenticated account UUID as StoreKit `appAccountToken`.
- The server verifies Apple's JWS signature, certificate chain, bundle ID,
App Apple ID, environment, consumable type, account token, and product ID.
- The App Store transaction ID is globally unique and idempotent.
- Credit balance and append-only purchase/ledger records commit in one database
transaction. A client retry returns the original grant.
- The app finishes the StoreKit transaction only after server acknowledgement.
- Signed transaction bodies and Apple certificate contents are never logged.
## Purchase history
- `GET /v1/storekit/transactions` requires the existing Bearer access token.
- The account comes only from the authenticated session; clients never submit it.
- History is read from the existing purchase audit and immutable credit ledger,
so purchases credited before the endpoint was deployed are included.
- Results are ordered by `purchasedAt` and then `transactionId`, both descending,
with opaque cursor pagination (`limit` defaults to 50 and is bounded to 1100).
- Responses omit JWS data, Apple identifiers, account IDs, and internal record IDs.
- History queries never call the App Store.
+4
View File
@@ -34,10 +34,12 @@ GRANT SELECT ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.2
GRANT SELECT ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.account_profiles TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE, DELETE ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%';
@@ -60,12 +62,14 @@ GRANT INSERT, UPDATE ON osg_account.app_attest_challenges TO 'osg_account_runtim
GRANT INSERT, UPDATE ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.account_profiles TO 'osg_account_runtime'@'10.20.%';
-- Operators and sessions are mutable authentication state. Audit and grant
-- records remain append-only and deliberately receive no UPDATE or DELETE.
GRANT INSERT, UPDATE ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
+217 -8
View File
@@ -75,7 +75,7 @@ paths:
default: { $ref: "#/components/responses/Error" }
/v1/account:
get:
summary: Return the minimal account profile
summary: Return the account profile
responses:
"200":
description: Account profile
@@ -83,6 +83,20 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/AccountEnvelope" }
default: { $ref: "#/components/responses/Error" }
patch:
summary: Update the current account nickname
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/UpdateAccountProfileRequest" }
responses:
"200":
description: Updated account profile
content:
application/json:
schema: { $ref: "#/components/schemas/AccountEnvelope" }
default: { $ref: "#/components/responses/Error" }
delete:
summary: Reauthenticate with Apple, delete the account, and revoke authorization
requestBody:
@@ -112,7 +126,7 @@ paths:
default: { $ref: "#/components/responses/Error" }
/v1/credits/balance:
get:
summary: Return available integer credits
summary: Return available and consumed integer credits
responses:
"200":
description: Credit account
@@ -146,6 +160,66 @@ paths:
type: array
items: { type: object, additionalProperties: true }
default: { $ref: "#/components/responses/Error" }
/v1/storekit/products:
get:
summary: Return enabled consumable credit products
description: |
Current catalog: `500tks` grants 500 credits, `1500tks` grants 1,500
credits, and `3000tks` grants 3,000 credits. Localized prices are
supplied by StoreKit.
responses:
"200":
description: StoreKit credit product catalog
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/StoreKitProduct" }
default: { $ref: "#/components/responses/Error" }
/v1/storekit/transactions:
get:
summary: Return credited StoreKit purchase history for the current account
description: |
Returns only App Store transactions that were previously verified and
committed with their immutable credit-ledger entries. Results include
purchases credited before this endpoint was introduced and never call
the App Store at query time. The opaque cursor follows the stable
descending order of `purchasedAt` and `transactionId`.
parameters:
- $ref: "#/components/parameters/Limit"
- name: cursor
in: query
description: Opaque cursor returned by the preceding page.
schema: { type: string, minLength: 1, maxLength: 256 }
responses:
"200":
description: Credited StoreKit purchases for the authenticated account
content:
application/json:
schema: { $ref: "#/components/schemas/StoreKitTransactionHistory" }
"400": { $ref: "#/components/responses/Error" }
default: { $ref: "#/components/responses/Error" }
post:
summary: Verify an App Store transaction and idempotently grant credits
description: |
Submit the StoreKit 2 `VerificationResult.jwsRepresentation` before
finishing the consumable transaction. The purchase must include an
`appAccountToken` equal to the authenticated account UUID. Replaying
the same App Store transaction returns the original grant.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/StoreKitTransactionRequest" }
responses:
"200":
description: Verified purchase grant or idempotent replay
content:
application/json:
schema: { $ref: "#/components/schemas/StoreKitPurchase" }
"409": { $ref: "#/components/responses/Error" }
"422": { $ref: "#/components/responses/Error" }
default: { $ref: "#/components/responses/Error" }
/v1/referrals:
get:
summary: List invitees without exposing their Apple identity
@@ -162,7 +236,7 @@ paths:
default: { $ref: "#/components/responses/Error" }
/v1/referrals/me:
get:
summary: Return the current referral code and binding
summary: Return the referral profile and idempotently provision its invite code
responses:
"200":
description: Referral profile
@@ -304,6 +378,11 @@ paths:
/v1/gateway/llm/{capability}:
post:
summary: Run a metered polish, AI, or agent request
description: |
The server deterministically selects model, thinking, search, tools, retry,
and output-budget policy from `capability` plus optional `taskKind`. It
never infers task type from `input` or `context`, and clients cannot
supply provider parameters. Search and tools are currently disabled.
parameters:
- $ref: "#/components/parameters/RequestId"
- name: capability
@@ -497,7 +576,7 @@ paths:
security:
- adminMtls: []
adminSession: []
summary: List users or search by exact internal user ID
summary: List users or search by full or 8-character internal user ID suffix
parameters:
- name: q
in: query
@@ -552,6 +631,25 @@ paths:
"400": { description: Cursor is malformed }
"403": { description: ANALYST role cannot access credit ledger records }
"404": { description: User was not found }
/v1/admin/credits/ledger:
get:
security:
- adminMtls: []
adminSession: []
summary: Return the latest immutable credit ledger entries across users
parameters:
- name: cursor
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Latest credit ledger entries ordered by creation time and entry ID
content:
application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" }
"400": { description: Cursor is malformed }
"403": { description: ANALYST role cannot access credit ledger records }
/v1/admin/credits/grants:
post:
security:
@@ -907,12 +1005,13 @@ components:
AdminUserSummary:
type: object
additionalProperties: false
required: [userId, displayName, status, creditBalance, createdAt]
required: [userId, displayName, status, creditBalance, consumedCredits, createdAt]
properties:
userId: { type: string, format: uuid }
displayName: { type: string }
status: { type: string, enum: [active, suspended, closed] }
creditBalance: { type: integer, format: int64, minimum: 0 }
consumedCredits: { type: integer, format: int64, minimum: 0 }
createdAt: { type: string, format: date-time }
AdminUserPage:
type: object
@@ -939,6 +1038,7 @@ components:
- displayName
- status
- creditBalance
- consumedCredits
- createdAt
- qualifiedUsage
- usage
@@ -948,6 +1048,7 @@ components:
displayName: { type: string }
status: { type: string, enum: [active, suspended, closed] }
creditBalance: { type: integer, format: int64, minimum: 0 }
consumedCredits: { type: integer, format: int64, minimum: 0 }
createdAt: { type: string, format: date-time }
lastActiveAt: { type: ["string", "null"], format: date-time }
qualifiedUsage: { type: boolean }
@@ -960,13 +1061,18 @@ components:
AdminLedgerEntry:
type: object
additionalProperties: false
required: [entryId, type, amount, balanceAfter, reasonCode, createdAt]
required: [entryId, userId, type, amount, balanceAfter, reasonCode, createdAt]
properties:
entryId: { type: string, format: uuid }
userId: { type: string, format: uuid }
type: { type: string, enum: [grant, reserve, settle, refund, adjustment] }
amount: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
reasonCode: { type: string }
usageType:
type: ["string", "null"]
enum: [polish, asr, ai, agent, hotword, null]
description: Product usage associated with this ledger operation
createdAt: { type: string, format: date-time }
AdminLedgerPage:
type: object
@@ -1088,6 +1194,10 @@ components:
nonce:
type: string
description: Raw nonce whose lowercase SHA-256 hex digest was sent to Apple.
displayName:
type: ["string", "null"]
maxLength: 128
description: Optional first-authorization Apple name used only to seed the nickname.
deviceCheckToken:
type: ["string", "null"]
description: Ephemeral DeviceCheck token; never persisted in plaintext.
@@ -1143,12 +1253,24 @@ components:
properties:
id: { type: string, format: uuid }
createdAtEpochSeconds: { type: integer, format: int64 }
displayName:
type: ["string", "null"]
maxLength: 64
AccountEnvelope:
type: object
additionalProperties: false
required: [data]
properties:
data: { $ref: "#/components/schemas/Account" }
UpdateAccountProfileRequest:
type: object
additionalProperties: false
required: [displayName]
properties:
displayName:
type: string
minLength: 1
maxLength: 64
DeleteAccountRequest:
type: object
additionalProperties: false
@@ -1160,10 +1282,15 @@ components:
CreditAccount:
type: object
additionalProperties: true
required: [userId, balance]
required: [userId, balance, lifetimeUsed]
properties:
userId: { type: string, format: uuid }
balance: { type: integer, format: int64, minimum: 0 }
lifetimeUsed:
type: integer
format: int64
minimum: 0
description: Settled usage minus credits returned by refunds
LedgerEntry:
type: object
additionalProperties: true
@@ -1172,6 +1299,58 @@ components:
id: { type: string, format: uuid }
amountDelta: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
StoreKitProduct:
type: object
additionalProperties: false
required: [productId, credits]
properties:
productId:
type: string
enum: [500tks, 1500tks, 3000tks]
credits: { type: integer, format: int64, minimum: 1 }
StoreKitTransactionRequest:
type: object
additionalProperties: false
required: [signedTransaction]
properties:
signedTransaction:
type: string
minLength: 100
maxLength: 32768
description: StoreKit 2 VerificationResult.jwsRepresentation
StoreKitPurchase:
type: object
additionalProperties: false
required: [transactionId, productId, creditsGranted, balanceAfter, replayed]
properties:
transactionId: { type: string, pattern: "^[0-9]{1,64}$" }
productId: { type: string, minLength: 3, maxLength: 128 }
creditsGranted: { type: integer, format: int64, minimum: 1 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
replayed: { type: boolean }
StoreKitTransactionHistoryItem:
type: object
additionalProperties: false
required:
[transactionId, productId, creditsGranted, balanceAfter, purchasedAt, status]
properties:
transactionId: { type: string, pattern: "^[0-9]{1,64}$" }
productId: { type: string, minLength: 3, maxLength: 128 }
creditsGranted: { type: integer, format: int64, minimum: 1 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
purchasedAt: { type: string, format: date-time }
status: { type: string, enum: [credited] }
StoreKitTransactionHistory:
type: object
additionalProperties: false
required: [items, nextCursor]
properties:
items:
type: array
items: { $ref: "#/components/schemas/StoreKitTransactionHistoryItem" }
nextCursor:
type: ["string", "null"]
maxLength: 256
AppleAppSiteAssociation:
type: object
additionalProperties: false
@@ -1245,9 +1424,39 @@ components:
properties:
input: { type: string, minLength: 1, maxLength: 32000 }
context: { type: ["string", "null"], maxLength: 32000 }
maxOutputTokens: { type: integer, minimum: 1, maximum: 4096, default: 512 }
maxOutputTokens:
type: integer
minimum: 1
maximum: 4096
default: 512
description: |
Requested output budget. The server clamps dictation polish and
edit-last-input to 512 tokens; translation, clipboard transform,
and custom skill to 2,048; and reasoning tasks to 4,096.
temperature: { type: number, minimum: 0, maximum: 1, default: 0.2 }
stream: { type: boolean, default: false }
taskKind:
type: ["string", "null"]
enum:
- dictation_polish
- translation
- edit_last_input
- ai_question
- clipboard_transform
- custom_skill
- agent_planning
- null
description: |
Optional deterministic task selector. Allowed combinations are:
`polish` with `dictation_polish`, `translation`, or `edit_last_input`;
`ai` with `ai_question`, `clipboard_transform`, or `custom_skill`;
and `agent` with `agent_planning`. Omission defaults respectively to
`dictation_polish`, `ai_question`, and `agent_planning`. A mismatch
returns `400 invalid_request`.
requestSource:
type: ["string", "null"]
enum: [hotword, null]
description: Optional product entry point; hotword is accepted only for AI requests
CreateGatewayGrantRequest:
type: object
additionalProperties: false
@@ -28,8 +28,9 @@ import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountOperations
import com.osglab.account.features.account.AccountReauthenticator
import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountService
import com.osglab.account.features.account.AppleAccountReauthenticator
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
@@ -103,12 +104,18 @@ import com.osglab.account.features.integrity.integrityRoutes
import com.osglab.account.features.inviteweb.InviteWebConfig
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
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
import com.osglab.account.features.referrals.services.ReferralRiskProvider
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.routes.storeKitRoutes
import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
@@ -288,6 +295,7 @@ fun Application.module() {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get())
storeKitRoutes(koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
configureGatewayRoutes(
@@ -431,6 +439,23 @@ fun accountServerModule(config: AppConfig): Module = module {
)
}
single<CreditOperations> { get<CreditService>() }
single<StoreKitTransactionVerifier> {
if (config.storeKit.enabled) {
AppleStoreKitTransactionVerifier(
bundleId = config.storeKit.bundleId,
appAppleId = requireNotNull(config.storeKit.appAppleId),
)
} else {
StoreKitTransactionVerifier { throw StoreKitUnavailable() }
}
}
single {
StoreKitService(
products = if (config.storeKit.enabled) config.storeKit.products else emptyList(),
verifier = get(),
transactions = get(),
)
}
single<TrialCreditGranter> {
TrialCreditGranter { accountId ->
get<CreditService>().grantSignupTrial(
@@ -454,7 +479,7 @@ fun accountServerModule(config: AppConfig): Module = module {
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
single<AccountProvisioner> {
AccountProvisioner { accountId, deviceCheckToken ->
AccountProvisioner { accountId, deviceCheckToken, displayName ->
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (deviceCheckToken != null && !granted) {
get<AuthRepository>().restrictAccountForAntiAbuse(
@@ -462,6 +487,14 @@ fun accountServerModule(config: AppConfig): Module = module {
java.time.Instant.now(),
)
}
get<AccountService>().seedDisplayName(accountId, displayName)
try {
get<ReferralOperations>().getOrCreateCode(accountId)
} catch (exception: CancellationException) {
throw exception
} catch (_: ReferralException) {
// Referral eligibility must not make account sign-in unavailable.
}
}
}
single {
@@ -494,6 +527,7 @@ fun accountServerModule(config: AppConfig): Module = module {
reauthenticator = get(),
)
}
single<AccountOperations> { get<AccountService>() }
single<UserRegistrationTimeProvider> {
UserRegistrationTimeProvider { accountId ->
@@ -575,6 +609,7 @@ private fun configuredProviders(config: AppConfig, client: HttpClient): List<Gat
endpoint = config.providers.deepSeek.endpoint,
apiKey = apiKey,
model = config.providers.deepSeek.model,
reasoningModel = config.providers.deepSeek.reasoningModel,
),
),
)
@@ -1,5 +1,6 @@
package com.osglab.account.config
import com.osglab.account.features.storekit.domain.StoreKitProduct
import io.ktor.server.config.ApplicationConfig
import java.net.URI
import java.util.Base64
@@ -16,6 +17,7 @@ data class AppConfig(
val antiAbuse: AntiAbuseConfig,
val apple: AppleConfig,
val credits: CreditsConfig,
val storeKit: StoreKitConfig = StoreKitConfig(),
val providers: ProvidersConfig,
val integrity: IntegrityConfig,
val admin: AdminConfig = AdminConfig(),
@@ -76,10 +78,36 @@ data class AppConfig(
)
val credits = CreditsConfig(
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000),
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_000),
referralInviter = config.positiveLong("app.credits.referralInviter", 1_000),
referralInvitee = config.positiveLong("app.credits.referralInvitee", 1_000),
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
)
val storeKitEnabled = config.booleanOrDefault("app.storeKit.enabled", false)
val storeKitAppAppleId = config.optionalValue("app.storeKit.appAppleId")?.let { raw ->
raw.toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("app.storeKit.appAppleId must be positive")
}
val storeKit = StoreKitConfig(
enabled = storeKitEnabled,
bundleId = config.valueOrDefault("app.storeKit.bundleId", apple.clientId),
appAppleId = storeKitAppAppleId,
products = config.storeKitProducts("app.storeKit.products"),
)
if (storeKitEnabled) {
require(storeKit.bundleId == apple.clientId) {
"app.storeKit.bundleId must match app.apple.clientId"
}
require(storeKit.appAppleId != null) {
"app.storeKit.appAppleId is required when StoreKit is enabled"
}
require(storeKit.products.isNotEmpty()) {
"app.storeKit.products is required when StoreKit is enabled"
}
}
val deepSeekModel = config.valueOrDefault(
"app.providers.deepseek.model",
"deepseek-v4-flash",
)
val providers = ProvidersConfig(
volcengine = VolcengineConfig(
endpoint = config.valueOrDefault(
@@ -100,7 +128,9 @@ data class AppConfig(
"https://api.deepseek.com/v1",
),
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
model = config.valueOrDefault("app.providers.deepseek.model", "deepseek-v4-flash"),
model = deepSeekModel,
reasoningModel = config.optionalValue("app.providers.deepseek.reasoningModel")
?: deepSeekModel,
),
)
val integrity = IntegrityConfig(
@@ -294,6 +324,7 @@ data class AppConfig(
antiAbuse = antiAbuse,
apple = apple,
credits = credits,
storeKit = storeKit,
providers = providers,
integrity = integrity,
admin = admin,
@@ -359,6 +390,13 @@ data class CreditsConfig(
val referralBindingDays: Long,
)
data class StoreKitConfig(
val enabled: Boolean = false,
val bundleId: String = "com.osgkeyboard.ios",
val appAppleId: Long? = null,
val products: List<StoreKitProduct> = emptyList(),
)
data class ProvidersConfig(
val volcengine: VolcengineConfig,
val deepSeek: DeepSeekConfig,
@@ -379,6 +417,7 @@ data class DeepSeekConfig(
val endpoint: String,
val apiKey: String?,
val model: String,
val reasoningModel: String = model,
) {
val credentialsAvailable: Boolean
get() = !apiKey.isNullOrBlank()
@@ -492,6 +531,24 @@ private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
?: throw ConfigValidationException("$path must be a positive integer")
} ?: default
private fun ApplicationConfig.storeKitProducts(path: String): List<StoreKitProduct> {
val raw = optionalValue(path) ?: return emptyList()
val products = raw.split(',').map { entry ->
val parts = entry.split(':', limit = 2).map(String::trim)
if (parts.size != 2) {
throw ConfigValidationException("$path must use productId:credits entries")
}
val credits = parts[1].toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path credits must be positive integers")
runCatching { StoreKitProduct(productId = parts[0], credits = credits) }
.getOrElse { throw ConfigValidationException("$path contains an invalid product", it) }
}
if (products.map(StoreKitProduct::productId).distinct().size != products.size) {
throw ConfigValidationException("$path contains duplicate product IDs")
}
return products
}
private fun ApplicationConfig.boolean(path: String): Boolean =
required(path).let {
when (it.lowercase()) {
@@ -26,11 +26,13 @@ data class AccountRecord(
val identityFingerprint: String,
val antiAbuseRestricted: Boolean,
val encryptedAppleRefreshToken: String?,
val encryptedDisplayName: String? = null,
val createdAt: Instant,
) {
override fun toString(): String =
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
"encryptedDisplayName=[REDACTED], " +
"createdAt=$createdAt)"
}
@@ -61,8 +63,18 @@ internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
override val primaryKey = PrimaryKey(id)
}
private object AccountProfilesTable : Table("account_profiles") {
val accountId = varchar("account_id", 36)
val encryptedDisplayName = text("encrypted_display_name")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(accountId)
}
interface AccountRepository {
suspend fun findById(accountId: UUID): AccountRecord?
suspend fun seedDisplayNameIfAbsent(accountId: UUID, encryptedDisplayName: String, now: Instant)
suspend fun updateDisplayName(accountId: UUID, encryptedDisplayName: String, now: Instant)
suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
@@ -89,17 +101,58 @@ class ExposedAccountRepository(
.where { AppleCredentialsTable.accountId eq accountId.toString() }
.singleOrNull()
?.get(AppleCredentialsTable.encryptedRefreshToken)
val encryptedDisplayName = AccountProfilesTable.selectAll()
.where { AccountProfilesTable.accountId eq accountId.toString() }
.singleOrNull()
?.get(AccountProfilesTable.encryptedDisplayName)
row.let {
AccountRecord(
id = UUID.fromString(it[AccountsTable.id]),
identityFingerprint = fingerprint,
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
encryptedAppleRefreshToken = encryptedRefreshToken,
encryptedDisplayName = encryptedDisplayName,
createdAt = it[AccountsTable.createdAt],
)
}
}
override suspend fun seedDisplayNameIfAbsent(
accountId: UUID,
encryptedDisplayName: String,
now: Instant,
) {
databaseFactory.query {
AccountProfilesTable.insertIgnore {
it[AccountProfilesTable.accountId] = accountId.toString()
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
it[createdAt] = now
it[updatedAt] = now
}
}
}
override suspend fun updateDisplayName(
accountId: UUID,
encryptedDisplayName: String,
now: Instant,
) {
databaseFactory.query {
AccountProfilesTable.insertIgnore {
it[AccountProfilesTable.accountId] = accountId.toString()
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
it[createdAt] = now
it[updatedAt] = now
}
AccountProfilesTable.update({
AccountProfilesTable.accountId eq accountId.toString()
}) {
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
it[updatedAt] = now
}
}
}
override suspend fun deleteById(
accountId: UUID,
deletedAt: Instant,
@@ -12,6 +12,7 @@ import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@@ -19,8 +20,12 @@ import kotlinx.serialization.Serializable
data class AccountResponse(
val id: String,
val createdAtEpochSeconds: Long,
val displayName: String?,
)
@Serializable
data class UpdateAccountProfileRequest(val displayName: String)
@Serializable
data class DeleteAccountRequest(
val identityToken: String,
@@ -39,7 +44,7 @@ data class DeleteAccountRequest(
}
class AccountRoutes(
private val accountService: AccountService,
private val accountService: AccountOperations,
) {
fun register(parent: Route) {
with(parent) {
@@ -49,14 +54,17 @@ class AccountRoutes(
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
val account = accountService.get(principal.userId)
call.respond(
ApiResponse(
data = AccountResponse(
id = account.id.toString(),
createdAtEpochSeconds = account.createdAt.epochSecond,
),
),
call.respond(ApiResponse(data = account.toResponse()))
}
patch {
val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException()
val request = call.receive<UpdateAccountProfileRequest>()
val account = accountService.updateDisplayName(
principal.userId,
request.displayName,
)
call.respond(ApiResponse(data = account.toResponse()))
}
delete {
val principal = call.principal<AccountPrincipal>()
@@ -73,5 +81,11 @@ class AccountRoutes(
}
}
fun Route.accountRoutes(accountService: AccountService) =
fun Route.accountRoutes(accountService: AccountOperations) =
AccountRoutes(accountService).register(this)
private fun AccountView.toResponse() = AccountResponse(
id = id.toString(),
createdAtEpochSeconds = createdAt.epochSecond,
displayName = displayName,
)
@@ -1,6 +1,7 @@
package com.osglab.account.features.account
import com.osglab.account.common.errors.ExternalServiceUnavailableException
import com.osglab.account.common.errors.InvalidRequestException
import com.osglab.account.common.errors.UnauthorizedException
import com.osglab.account.common.security.FieldDecryptionException
import com.osglab.account.common.security.FieldEncryptor
@@ -16,11 +17,13 @@ import kotlinx.coroutines.CancellationException
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.text.Normalizer
import java.util.UUID
data class AccountView(
val id: UUID,
val createdAt: Instant,
val displayName: String?,
)
data class AppleReauthenticationProof(
@@ -41,6 +44,13 @@ fun interface AccountReauthenticator {
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
}
interface AccountOperations {
suspend fun get(accountId: UUID): AccountView
suspend fun seedDisplayName(accountId: UUID, candidate: String?)
suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof)
}
class AppleAccountReauthenticator(
private val identityVerifier: AppleIdentityTokenVerifier,
private val appleTokenClient: AppleTokenClient,
@@ -94,13 +104,34 @@ class AccountService(
private val revocationProcessor: AppleRevocationOutboxProcessor,
private val reauthenticator: AccountReauthenticator,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun get(accountId: UUID): AccountView {
) : AccountOperations {
override suspend fun get(accountId: UUID): AccountView {
val account = repository.findById(accountId) ?: throw UnauthorizedException()
return AccountView(account.id, account.createdAt)
return account.toView()
}
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
override suspend fun seedDisplayName(accountId: UUID, candidate: String?) {
val displayName = candidate?.let(::normalizedDisplayNameOrNull) ?: return
val account = repository.findById(accountId) ?: return
repository.seedDisplayNameIfAbsent(
accountId,
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
clock.instant(),
)
}
override suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView {
val account = repository.findById(accountId) ?: throw UnauthorizedException()
val displayName = normalizedDisplayName(candidate)
repository.updateDisplayName(
accountId,
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
clock.instant(),
)
return requireNotNull(repository.findById(accountId)).toView()
}
override suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
val account = repository.findById(accountId) ?: return
val now = clock.instant()
val currentRefreshToken = reauthenticator.verify(account, proof)
@@ -126,6 +157,14 @@ class AccountService(
// Local deletion is final. The durable outbox retry loop handles Apple outages.
}
}
private fun AccountRecord.toView(): AccountView = AccountView(
id = id,
createdAt = createdAt,
displayName = encryptedDisplayName?.let {
fieldEncryptor.decrypt(it, accountProfileContext(id))
},
)
}
class AppleRevocationOutboxProcessor(
@@ -175,3 +214,24 @@ class AppleRevocationOutboxProcessor(
}
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
private fun accountProfileContext(id: UUID): String = "account-profile:$id"
private fun normalizedDisplayNameOrNull(candidate: String): String? =
runCatching { normalizedDisplayName(candidate) }.getOrNull()
private fun normalizedDisplayName(candidate: String): String {
val normalized = Normalizer.normalize(candidate.trim(), Normalizer.Form.NFC)
.replace(WHITESPACE_REGEX, " ")
if (
normalized.isBlank() ||
normalized.codePointCount(0, normalized.length) > MAX_DISPLAY_NAME_CODE_POINTS ||
normalized.any { it.isISOControl() }
) {
throw InvalidRequestException("Display name is invalid")
}
return normalized
}
private val WHITESPACE_REGEX = Regex("\\s+")
private const val MAX_DISPLAY_NAME_CODE_POINTS = 64
@@ -237,6 +237,33 @@ fun Route.adminApiRoutes(
}
}
get("/credits/ledger") {
if (
call.requireRole(
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
try {
val page = usersService.latestLedger(
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
call.respond(
PageResponse(
page.items.map(AdminUserLedgerEntryDto::toLedgerResponse),
page.nextCursor,
),
)
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
}
}
post("/credits/grants") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
if (principal.role != AdminRole.SUPER_ADMIN) {
@@ -646,9 +673,10 @@ private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
AdminUserSummaryResponse(
userId = id,
displayName = "用户 ${id.take(8)}",
displayName = "用户 ${id.takeLast(8).uppercase()}",
status = if (antiAbuseRestricted) "suspended" else "active",
creditBalance = creditBalance,
consumedCredits = consumedCredits,
createdAt = createdAt,
)
@@ -666,6 +694,7 @@ private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
AdminLedgerResponse(
entryId = id,
userId = userId,
type = when (type) {
"USAGE_RESERVE" -> "reserve"
"USAGE_SETTLE" -> "settle"
@@ -677,6 +706,7 @@ private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
amount = amountDelta,
balanceAfter = balanceAfter,
reasonCode = type,
usageType = usageType,
createdAt = createdAt,
)
@@ -817,6 +847,7 @@ private data class AdminUserSummaryResponse(
val displayName: String,
val status: String,
val creditBalance: Long,
val consumedCredits: Long,
val createdAt: String,
)
@@ -826,6 +857,7 @@ private data class AdminUserDetailResponse(
val displayName: String,
val status: String,
val creditBalance: Long,
val consumedCredits: Long,
val createdAt: String,
val lastActiveAt: String?,
val qualifiedUsage: Boolean,
@@ -847,6 +879,7 @@ private data class AdminUserDetailResponse(
displayName = summary.displayName,
status = summary.status,
creditBalance = summary.creditBalance,
consumedCredits = summary.consumedCredits,
createdAt = summary.createdAt,
lastActiveAt = lastActiveAt,
qualifiedUsage = qualifiedUsage,
@@ -860,10 +893,12 @@ private data class AdminUserDetailResponse(
@Serializable
private data class AdminLedgerResponse(
val entryId: String,
val userId: String,
val type: String,
val amount: Long,
val balanceAfter: Long,
val reasonCode: String,
val usageType: String?,
val createdAt: String,
)
@@ -26,11 +26,13 @@ data class AdminUserPageDto(
@Serializable
data class AdminUserLedgerEntryDto(
val id: String,
val userId: String,
val type: String,
val amountDelta: Long,
val balanceAfter: Long,
val referenceId: String?,
val createdAt: String,
val usageType: String? = null,
)
@Serializable
@@ -16,6 +16,7 @@ import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.selectAll
@@ -35,6 +36,8 @@ data class AdminUserLedgerCursor(
interface AdminUsersRepository {
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
suspend fun findByIdSuffix(suffix: String, limit: Int): List<AdminUserSummaryDto>
suspend fun exists(userId: UUID): Boolean
suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto?
@@ -44,6 +47,11 @@ interface AdminUsersRepository {
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto>
suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto>
}
class ExposedAdminUsersRepository(
@@ -74,6 +82,22 @@ class ExposedAdminUsersRepository(
accountRows.map { it.toSummary(support) }
}
override suspend fun findByIdSuffix(
suffix: String,
limit: Int,
): List<AdminUserSummaryDto> = databaseFactory.query {
val accountRows = AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id like "%$suffix" }
.orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
AdminUsersAccountsTable.id to SortOrder.DESC,
)
.limit(limit)
.toList()
val support = loadSupport(accountRows.map { it.userId() }.toSet())
accountRows.map { it.toSummary(support) }
}
override suspend fun exists(userId: UUID): Boolean = databaseFactory.query {
AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id eq userId.toString() }
@@ -111,7 +135,7 @@ class ExposedAdminUsersRepository(
.thenByDescending { it.id.toString() },
)
.take(ledgerLimit)
.map(UserLedgerRow::toDto)
.map { it.toDto(support.ledgerUsageTypes[it.referenceId]) }
AdminUserDetailDto(
summary = account.toSummary(support),
referralCode = findReferralCode(userId),
@@ -147,18 +171,45 @@ class ExposedAdminUsersRepository(
)
}
}
query.orderBy(
val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
)
.limit(limit)
.map { it.toUserLedgerRow().toDto() }
.map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
}
override suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll()
if (cursor != null) {
query.where {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
)
.limit(limit)
.map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
}
}
private data class UserSupportRows(
val balances: Map<UUID, Long>,
val ledger: List<UserLedgerRow>,
val ledgerUsageTypes: Map<UUID, String>,
val usage: List<UserUsageRow>,
val bindings: List<UserReferralBindingRow>,
)
@@ -190,16 +241,20 @@ private data class UserReferralBindingRow(
)
private fun loadSupport(userIds: Set<UUID>): UserSupportRows {
if (userIds.isEmpty()) return UserSupportRows(emptyMap(), emptyList(), emptyList(), emptyList())
if (userIds.isEmpty()) {
return UserSupportRows(emptyMap(), emptyList(), emptyMap(), emptyList(), emptyList())
}
val ids = userIds.map(UUID::toString)
val ledger = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId inList ids }
.map(ResultRow::toUserLedgerRow)
return UserSupportRows(
balances = AdminUsersCreditAccountsTable.selectAll()
.where { AdminUsersCreditAccountsTable.userId inList ids }
.map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] }
.toMap(),
ledger = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId inList ids }
.map(ResultRow::toUserLedgerRow),
ledger = ledger,
ledgerUsageTypes = loadLedgerUsageTypes(ledger),
usage = AdminUsersCreditUsageTable.selectAll()
.where { AdminUsersCreditUsageTable.userId inList ids }
.map(ResultRow::toUserUsageRow),
@@ -212,6 +267,23 @@ private fun loadSupport(userIds: Set<UUID>): UserSupportRows {
)
}
private fun loadLedgerUsageTypes(ledger: List<UserLedgerRow>): Map<UUID, String> {
val reservationIds = ledger.mapNotNull(UserLedgerRow::referenceId).distinct()
if (reservationIds.isEmpty()) return emptyMap()
return AdminUsersProviderRequestsTable.selectAll()
.where {
AdminUsersProviderRequestsTable.reservationId inList
reservationIds.map(UUID::toString)
}
.associate { row ->
UUID.fromString(requireNotNull(row[AdminUsersProviderRequestsTable.reservationId])) to
(
row[AdminUsersProviderRequestsTable.requestSource]
?: row[AdminUsersProviderRequestsTable.capability]
).lowercase()
}
}
private fun findReferralCode(userId: UUID): String? =
AdminUsersReferralCodesTable.selectAll()
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
@@ -268,6 +340,15 @@ private object AdminUsersCreditLedgerTable : Table("credit_ledger") {
val createdAt = timestamp("created_at")
}
private object AdminUsersProviderRequestsTable : Table("provider_requests") {
val accountId = varchar("account_id", 36)
val requestId = varchar("request_id", 64)
val reservationId = varchar("reservation_id", 36).nullable()
val capability = varchar("capability", 32)
val requestSource = varchar("request_source", 32).nullable()
override val primaryKey = PrimaryKey(accountId, requestId)
}
private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
val userId = varchar("user_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
@@ -303,12 +384,14 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
)
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
private fun UserLedgerRow.toDto(usageType: String?) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = type.name,
amountDelta = amountDelta,
balanceAfter = balanceAfter,
referenceId = referenceId?.toString(),
usageType = usageType,
createdAt = createdAt.toString(),
)
@@ -1,6 +1,7 @@
package com.osglab.account.features.admin.users.services
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.AdminUserCursor
@@ -17,11 +18,20 @@ class AdminUsersService(
private val repository: AdminUsersRepository,
) {
suspend fun searchByInternalId(query: String): AdminUserPageDto {
val userId = runCatching { UUID.fromString(query.trim()) }.getOrNull()
?: return AdminUserPageDto(emptyList(), null)
val normalized = query.trim()
val userId = runCatching { UUID.fromString(normalized) }.getOrNull()
if (userId != null) {
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
}
if (!SHORT_INTERNAL_ID.matches(normalized)) {
return AdminUserPageDto(emptyList(), null)
}
return AdminUserPageDto(
items = repository.findByIdSuffix(normalized.lowercase(), limit = MAX_SHORT_ID_MATCHES),
nextCursor = null,
)
}
suspend fun list(
limit: Int = 50,
@@ -58,11 +68,27 @@ class AdminUsersService(
userId: UUID,
limit: Int = 50,
cursor: String? = null,
): AdminUserLedgerPageDto {
return ledgerPage(limit, cursor) { pageSize, decodedCursor ->
if (!repository.exists(userId)) throw AdminUserNotFoundException()
repository.listLedger(userId, pageSize, decodedCursor)
}
}
suspend fun latestLedger(
limit: Int = 100,
cursor: String? = null,
): AdminUserLedgerPageDto =
ledgerPage(limit, cursor, repository::listLatestLedger)
private suspend fun ledgerPage(
limit: Int,
cursor: String?,
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
if (!repository.exists(userId)) throw AdminUserNotFoundException()
val results = repository.listLedger(userId, limit + 1, decodedCursor)
val results = load(limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
val nextCursor = if (hasMore) {
@@ -80,6 +106,9 @@ class AdminUsersService(
}
}
private val SHORT_INTERNAL_ID = Regex("^[A-Fa-f0-9]{8}$")
private const val MAX_SHORT_ID_MATCHES = 100
internal object AdminUserCursorCodec {
fun encode(cursor: AdminUserCursor): String {
val value = "${cursor.createdAt}|${cursor.userId}"
@@ -21,12 +21,14 @@ data class AppleSignInRequest(
val identityToken: String,
val authorizationCode: String,
val nonce: String,
val displayName: String? = null,
val deviceCheckToken: String? = null,
val appAttest: AppAttestRequest? = null,
) {
override fun toString(): String =
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
"nonce=[REDACTED], deviceCheckToken=[REDACTED], appAttest=[REDACTED])"
"nonce=[REDACTED], displayName=[REDACTED], deviceCheckToken=[REDACTED], " +
"appAttest=[REDACTED])"
}
@Serializable
@@ -73,6 +75,7 @@ class AuthRoutes(
identityToken = request.identityToken,
authorizationCode = request.authorizationCode,
nonce = request.nonce,
displayName = request.displayName,
integrityEvidence = IntegrityEvidence(
deviceCheckToken = request.deviceCheckToken,
appAttest = request.appAttest?.let {
@@ -34,7 +34,7 @@ data class SessionTokens(
}
fun interface AccountProvisioner {
suspend fun provision(accountId: UUID, deviceCheckToken: String?)
suspend fun provision(accountId: UUID, deviceCheckToken: String?, displayName: String?)
}
class SessionService(
@@ -46,7 +46,7 @@ class SessionService(
private val fieldEncryptor: FieldEncryptor,
private val identityFingerprint: IdentityFingerprint,
private val sessionConfig: SessionConfig,
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _ -> },
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _, _ -> },
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
private val clock: Clock = Clock.systemUTC(),
) {
@@ -55,6 +55,7 @@ class SessionService(
authorizationCode: String,
nonce: String,
integrityEvidence: IntegrityEvidence,
displayName: String? = null,
): SessionTokens {
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
@@ -90,6 +91,7 @@ class SessionService(
accountProvisioner.provision(
account.id,
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
displayName,
)
return createSession(account.id, now)
}
@@ -51,6 +51,11 @@ data class CreditAccount(
val updatedAt: Instant,
)
data class CreditAccountSummary(
val account: CreditAccount,
val lifetimeUsed: Long,
)
data class LedgerEntry(
val id: UUID,
val userId: UUID,
@@ -1,6 +1,6 @@
package com.osglab.account.features.credits.models
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditAccountSummary
import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.InvalidCreditRequest
@@ -64,13 +64,15 @@ data class SettleCreditsRequest(
data class CreditAccountDto(
val userId: String,
val balance: Long,
val lifetimeUsed: Long,
val updatedAt: String,
) {
companion object {
fun fromDomain(account: CreditAccount) = CreditAccountDto(
userId = account.userId.toString(),
balance = account.balance,
updatedAt = account.updatedAt.toString(),
fun fromDomain(summary: CreditAccountSummary) = CreditAccountDto(
userId = summary.account.userId.toString(),
balance = summary.account.balance,
lifetimeUsed = summary.lifetimeUsed,
updatedAt = summary.account.updatedAt.toString(),
)
}
}
@@ -8,6 +8,7 @@ import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.referrals.repositories.ReferralsRepository
import com.osglab.account.features.storekit.repositories.StoreKitRepository
import java.time.Instant
import java.util.UUID
@@ -28,6 +29,8 @@ interface CreditsRepository {
fun insertUsageRecord(record: CreditUsageRecord)
fun lifetimeUsedCredits(userId: UUID): Long
fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation?
fun lockReservation(id: UUID): CreditReservation?
@@ -52,6 +55,7 @@ interface BillingUnitOfWork {
val credits: CreditsRepository
val referrals: ReferralsRepository
val adminCreditGrants: AdminCreditGrantRepository
val storeKit: StoreKitRepository
}
interface BillingTransactionRunner {
@@ -19,6 +19,8 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import com.osglab.account.features.referrals.repositories.ReferralsRepository
import com.osglab.account.features.storekit.repositories.ExposedStoreKitRepository
import com.osglab.account.features.storekit.repositories.StoreKitRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.*
@@ -27,6 +29,7 @@ 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
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
@@ -184,6 +187,7 @@ private object ExposedBillingUnitOfWork : BillingUnitOfWork {
override val credits: CreditsRepository = ExposedCreditsRepository
override val referrals: ReferralsRepository = ExposedReferralsRepository
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
override val storeKit: StoreKitRepository = ExposedStoreKitRepository
}
private object ExposedCreditsRepository : CreditsRepository {
@@ -282,6 +286,25 @@ private object ExposedCreditsRepository : CreditsRepository {
}
}
override fun lifetimeUsedCredits(userId: UUID): Long {
val chargedTotal = CreditUsageRecords.chargedCredits.sum()
val charged = CreditUsageRecords
.select(chargedTotal)
.where { CreditUsageRecords.userId eq userId.toString() }
.single()[chargedTotal] ?: 0
val refundTotal = CreditLedger.amountDelta.sum()
val refunded = CreditLedger
.select(refundTotal)
.where {
(CreditLedger.userId eq userId.toString()) and
(CreditLedger.entryType eq LedgerEntryType.USAGE_REFUND)
}
.single()[refundTotal] ?: 0
return Math.subtractExact(charged, refunded).also {
check(it >= 0) { "Refunded credits exceed settled usage" }
}
}
override fun findReservationByReserveKey(
userId: UUID,
idempotencyKey: String,
@@ -36,7 +36,7 @@ class CreditRouteInstaller(
parent.route("/v1/credits") {
get("/balance") {
call.creditCall(authenticatedUser) { userId ->
CreditAccountDto.fromDomain(service.getAccount(userId))
CreditAccountDto.fromDomain(service.getAccountSummary(userId))
}
}
get("/ledger") {
@@ -4,6 +4,7 @@ import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditAccountSummary
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditCostCalculator
import com.osglab.account.features.credits.domain.CreditNotFound
@@ -46,6 +47,8 @@ data class ReferralRewardConfig(
interface CreditOperations {
suspend fun getAccount(userId: UUID): CreditAccount
suspend fun getAccountSummary(userId: UUID): CreditAccountSummary
suspend fun listEffectiveRates(): List<CreditRateVersion>
suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry>
@@ -107,6 +110,15 @@ class CreditService(
unit.credits.lockAccount(userId)
}
override suspend fun getAccountSummary(userId: UUID): CreditAccountSummary =
transactions.inTransaction { unit ->
unit.credits.createAccountIfAbsent(userId, clock.instant())
CreditAccountSummary(
account = unit.credits.lockAccount(userId),
lifetimeUsed = unit.credits.lifetimeUsedCredits(userId),
)
}
override suspend fun listEffectiveRates(): List<CreditRateVersion> =
transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) }
@@ -19,6 +19,12 @@ enum class GatewayCapability {
ASR,
}
@Serializable
enum class GatewayRequestSource {
@SerialName("hotword")
HOTWORD,
}
@Serializable
enum class UsageMeter {
@SerialName("llm_token")
@@ -53,8 +59,85 @@ data class TextGatewayRequest(
val maxOutputTokens: Int = 512,
val temperature: Double = 0.2,
val stream: Boolean = false,
val requestSource: GatewayRequestSource? = null,
val taskKind: GatewayTaskKind? = null,
)
@Serializable
enum class GatewayTaskKind {
@SerialName("dictation_polish")
DICTATION_POLISH,
@SerialName("translation")
TRANSLATION,
@SerialName("edit_last_input")
EDIT_LAST_INPUT,
@SerialName("ai_question")
AI_QUESTION,
@SerialName("clipboard_transform")
CLIPBOARD_TRANSFORM,
@SerialName("custom_skill")
CUSTOM_SKILL,
@SerialName("agent_planning")
AGENT_PLANNING,
}
enum class GatewayThinkingMode {
DISABLED,
ENABLED,
}
enum class GatewayReasoningEffort {
LOW,
HIGH,
MAX,
}
enum class GatewayWebSearchMode {
DISABLED,
ALLOWED,
REQUIRED,
}
enum class GatewayToolsMode {
DISABLED,
ALLOWED,
}
enum class GatewayModelProfile {
LOW_LATENCY,
REASONING,
}
/**
* Provider-independent policy selected exclusively from trusted server rules.
* Provider-specific request fields must be derived from this value.
*/
data class GatewayTaskExecutionPolicy(
val taskKind: GatewayTaskKind,
val modelProfile: GatewayModelProfile,
val thinking: GatewayThinkingMode,
val reasoningEffort: GatewayReasoningEffort?,
val webSearch: GatewayWebSearchMode,
val tools: GatewayToolsMode,
val allowEmptyContentRetry: Boolean,
val maxOutputTokens: Int,
) {
init {
require(maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS)
require(
(thinking == GatewayThinkingMode.ENABLED) == (reasoningEffort != null),
) {
"reasoning effort must be explicit exactly when thinking is enabled"
}
}
}
@Serializable
data class AsrGatewayOptions(
val format: String = "pcm",
@@ -69,16 +152,20 @@ data class AsrGatewayOptions(
sealed interface ProviderRequest {
val requestId: String
val capability: GatewayCapability
val requestSource: GatewayRequestSource?
get() = null
}
data class TextProviderRequest(
override val requestId: String,
override val capability: GatewayCapability,
val executionPolicy: GatewayTaskExecutionPolicy,
val input: String,
val context: String?,
val maxOutputTokens: Int,
val temperature: Double,
val stream: Boolean,
override val requestSource: GatewayRequestSource? = null,
) : ProviderRequest
data class AsrProviderRequest(
@@ -135,6 +222,9 @@ object TextRequestPolicy {
require(capability != GatewayCapability.AGENT || !request.stream) {
"agent requests must be non-streaming so the structured result can be validated"
}
require(request.requestSource == null || capability == GatewayCapability.AI) {
"requestSource is only supported for AI requests"
}
}
}
@@ -3,6 +3,7 @@ package com.osglab.account.features.gateway.ports
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewayRequestSource
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import io.ktor.server.application.ApplicationCall
@@ -139,6 +140,7 @@ data class ProviderRequestMetadata(
val reservationId: String,
val providerId: String,
val capability: GatewayCapability,
val requestSource: GatewayRequestSource?,
)
data class ProviderRefund(
@@ -3,6 +3,11 @@ package com.osglab.account.features.gateway.providers.deepseek
import com.osglab.account.features.gateway.agent.AgentPlan
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.GatewayModelProfile
import com.osglab.account.features.gateway.models.GatewayReasoningEffort
import com.osglab.account.features.gateway.models.GatewayThinkingMode
import com.osglab.account.features.gateway.models.GatewayToolsMode
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
@@ -34,11 +39,13 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.slf4j.LoggerFactory
data class DeepSeekConfig(
val endpoint: String,
val apiKey: String,
val model: String,
val reasoningModel: String = model,
) {
init {
val url = runCatching { Url(endpoint) }
@@ -52,6 +59,15 @@ data class DeepSeekConfig(
if (model.isBlank()) {
throw DeepSeekConfigurationException("DEEPSEEK_MODEL must not be blank")
}
if (reasoningModel.isBlank()) {
throw DeepSeekConfigurationException("DEEPSEEK_REASONING_MODEL must not be blank")
}
}
fun modelFor(profile: GatewayModelProfile): String =
when (profile) {
GatewayModelProfile.LOW_LATENCY -> model
GatewayModelProfile.REASONING -> reasoningModel
}
}
@@ -91,7 +107,30 @@ class DeepSeekProvider(
): ProviderUsage {
require(request is TextProviderRequest) { "DeepSeek only accepts text requests" }
validate(request)
var attempt = 1
while (true) {
try {
return upstream.complete(request, output)
} catch (failure: DeepSeekEmptyResultException) {
LOG.warn(
"DeepSeek returned empty content requestId={} capability={} attempt={} " +
"finishReason={} reasoningContentPresent={} usagePresent={}",
request.requestId,
request.capability.name,
attempt,
failure.finishReason,
failure.reasoningContentPresent,
failure.usagePresent,
)
if (request.stream ||
!request.executionPolicy.allowEmptyContentRetry ||
attempt >= MAX_BUFFERED_ATTEMPTS
) {
throw failure
}
attempt += 1
}
}
}
private fun validate(request: TextProviderRequest) {
@@ -103,6 +142,15 @@ class DeepSeekProvider(
require(request.maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
"maxOutputTokens is out of range"
}
require(request.maxOutputTokens == request.executionPolicy.maxOutputTokens) {
"maxOutputTokens must match the server execution policy"
}
require(request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) {
"DeepSeek web search is not configured"
}
require(request.executionPolicy.tools == GatewayToolsMode.DISABLED) {
"DeepSeek tools are not configured"
}
require(request.temperature in 0.0..1.0 && request.temperature.isFinite()) {
"temperature is out of range"
}
@@ -110,6 +158,11 @@ class DeepSeekProvider(
"agent requests must be non-streaming"
}
}
private companion object {
const val MAX_BUFFERED_ATTEMPTS = 2
val LOG = LoggerFactory.getLogger(DeepSeekProvider::class.java)
}
}
/**
@@ -129,11 +182,18 @@ class KtorDeepSeekClient(
output: ProviderOutput,
): ProviderUsage {
val payload = DeepSeekChatRequest(
model = config.model,
model = config.modelFor(request.executionPolicy.modelProfile),
messages = controlledMessages(request),
maxTokens = request.maxOutputTokens,
temperature = request.temperature,
stream = request.stream,
thinking = DeepSeekThinking(
type = when (request.executionPolicy.thinking) {
GatewayThinkingMode.DISABLED -> DeepSeekThinkingType.DISABLED
GatewayThinkingMode.ENABLED -> DeepSeekThinkingType.ENABLED
},
),
reasoningEffort = request.executionPolicy.reasoningEffort?.toDeepSeekReasoningEffort(),
streamOptions = if (request.stream) StreamOptions(includeUsage = true) else null,
responseFormat = if (request.capability == GatewayCapability.AGENT) {
ResponseFormat(type = "json_object")
@@ -182,8 +242,14 @@ class KtorDeepSeekClient(
): ProviderUsage {
val payload = bytes.decodeToString()
val content = extractAssistantContent(payload)
?: throw DeepSeekEmptyResultException()
if (content.isBlank()) throw DeepSeekEmptyResultException()
if (content.isNullOrBlank()) {
val metadata = emptyResultMetadata(payload)
throw DeepSeekEmptyResultException(
finishReason = metadata.finishReason,
reasoningContentPresent = metadata.reasoningContentPresent,
usagePresent = metadata.usagePresent,
)
}
if (request.capability == GatewayCapability.AGENT) validateAgentContent(content)
val usage = extractUsage(payload)?.toProviderUsageOrNull()
?: throw DeepSeekProviderException("DeepSeek response omitted token usage")
@@ -203,6 +269,8 @@ class KtorDeepSeekClient(
var contentBytes = 0L
var terminalChoiceSeen = false
var doneSeen = false
var finishReason = "missing"
var reasoningContentPresent = false
val assistantContent = StringBuilder()
while (true) {
val line = channel.readLineStrict(
@@ -237,6 +305,7 @@ class KtorDeepSeekClient(
}
val eventUsage = extractUsage(data)
eventUsage?.let { usage = it }
if (hasStreamReasoningContent(data)) reasoningContentPresent = true
val choices = runCatching {
event["choices"]?.jsonArray
?: throw IllegalArgumentException("choices is missing")
@@ -250,13 +319,14 @@ class KtorDeepSeekClient(
if (choice == null && eventUsage == null) {
throw DeepSeekProviderException("DeepSeek returned an empty non-usage event")
}
val finishReason = choice?.get("finish_reason")
if (finishReason != null && finishReason !is JsonNull) {
if (!finishReason.jsonPrimitive.isString ||
finishReason.jsonPrimitive.content.isBlank()
val finishReasonElement = choice?.get("finish_reason")
if (finishReasonElement != null && finishReasonElement !is JsonNull) {
if (!finishReasonElement.jsonPrimitive.isString ||
finishReasonElement.jsonPrimitive.content.isBlank()
) {
throw DeepSeekProviderException("DeepSeek returned an invalid finish reason")
}
finishReason = normalizeFinishReason(finishReasonElement.jsonPrimitive.content)
terminalChoiceSeen = true
}
extractStreamContent(data)?.let { chunk ->
@@ -270,7 +340,13 @@ class KtorDeepSeekClient(
output.emit(encoded)
}
if (!doneSeen) throw DeepSeekProviderException("DeepSeek stream closed before [DONE]")
if (assistantContent.isBlank()) throw DeepSeekEmptyResultException()
if (assistantContent.isBlank()) {
throw DeepSeekEmptyResultException(
finishReason = finishReason,
reasoningContentPresent = reasoningContentPresent,
usagePresent = usage != null,
)
}
if (request.capability == GatewayCapability.AGENT) {
validateAgentContent(assistantContent.toString())
}
@@ -354,6 +430,58 @@ class KtorDeepSeekClient(
?.content
}.getOrNull()
private fun hasStreamReasoningContent(payload: String): Boolean = runCatching {
json.parseToJsonElement(payload)
.jsonObject["choices"]
?.let { choices -> choices.jsonArray.firstOrNull() }
?.jsonObject
?.get("delta")
?.jsonObject
?.get("reasoning_content")
?.jsonPrimitive
?.takeIf { it.isString }
?.content
?.isNotBlank() == true
}.getOrDefault(false)
private fun emptyResultMetadata(payload: String): DeepSeekEmptyResultMetadata {
val root = runCatching { json.parseToJsonElement(payload).jsonObject }.getOrNull()
?: return DeepSeekEmptyResultMetadata()
val choice = runCatching {
root["choices"]?.jsonArray?.firstOrNull()?.jsonObject
}.getOrNull()
val message = runCatching { choice?.get("message")?.jsonObject }.getOrNull()
val rawFinishReason = runCatching {
choice?.get("finish_reason")
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.takeIf { it.isString }
?.content
}.getOrNull()
val reasoningContentPresent = runCatching {
message?.get("reasoning_content")
?.takeUnless { it is JsonNull }
?.jsonPrimitive
?.takeIf { it.isString }
?.content
?.isNotBlank() == true
}.getOrDefault(false)
return DeepSeekEmptyResultMetadata(
finishReason = normalizeFinishReason(rawFinishReason),
reasoningContentPresent = reasoningContentPresent,
usagePresent = root["usage"] != null && root["usage"] !is JsonNull,
)
}
private fun normalizeFinishReason(value: String?): String =
when (value?.trim()?.lowercase()) {
"stop", "length", "content_filter", "tool_calls", "insufficient_system_resource" ->
value.trim().lowercase()
null, "" -> "missing"
else -> "other"
}
private suspend fun ByteReadChannel.readBounded(): ByteArray {
val bytes = readRemaining(GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES.toLong() + 1L)
.readByteArray()
@@ -392,6 +520,13 @@ class KtorDeepSeekClient(
return listOf(ChatMessage("system", system), ChatMessage("user", userText))
}
private fun GatewayReasoningEffort.toDeepSeekReasoningEffort(): DeepSeekReasoningEffort =
when (this) {
GatewayReasoningEffort.LOW -> DeepSeekReasoningEffort.LOW
GatewayReasoningEffort.HIGH -> DeepSeekReasoningEffort.HIGH
GatewayReasoningEffort.MAX -> DeepSeekReasoningEffort.MAX
}
private companion object {
const val MAX_AGENT_ID_CHARS = 128
const val MAX_AGENT_FIELD_CHARS = 4_096
@@ -412,12 +547,41 @@ private data class DeepSeekChatRequest(
val maxTokens: Int,
val temperature: Double,
val stream: Boolean,
val thinking: DeepSeekThinking,
@SerialName("reasoning_effort")
val reasoningEffort: DeepSeekReasoningEffort?,
@SerialName("stream_options")
val streamOptions: StreamOptions?,
@SerialName("response_format")
val responseFormat: ResponseFormat?,
)
@Serializable
private data class DeepSeekThinking(
val type: DeepSeekThinkingType,
)
@Serializable
private enum class DeepSeekThinkingType {
@SerialName("disabled")
DISABLED,
@SerialName("enabled")
ENABLED,
}
@Serializable
private enum class DeepSeekReasoningEffort {
@SerialName("low")
LOW,
@SerialName("high")
HIGH,
@SerialName("max")
MAX,
}
@Serializable
private data class ChatMessage(
val role: String,
@@ -439,10 +603,20 @@ class DeepSeekConfigurationException(message: String) : IllegalStateException(me
class DeepSeekProviderException(message: String) : RuntimeException(message)
class DeepSeekUsageException(message: String) : ProviderCompletionException(message)
class DeepSeekEmptyResultException : RuntimeException("DeepSeek returned an empty result")
class DeepSeekEmptyResultException(
val finishReason: String = "missing",
val reasoningContentPresent: Boolean = false,
val usagePresent: Boolean = false,
) : RuntimeException("DeepSeek returned an empty result")
private data class DeepSeekUsage(
val total: Long,
val input: Long?,
val output: Long?,
)
private data class DeepSeekEmptyResultMetadata(
val finishReason: String = "missing",
val reasoningContentPresent: Boolean = false,
val usagePresent: Boolean = false,
)
@@ -231,7 +231,11 @@ class SaucSequenceValidator {
?: throw SaucProtocolException("SAUC server frame omitted its sequence")
val expected = Math.addExact(lastSequence, 1)
if (frame.isLast) {
if (sequence >= 0 || Math.abs(sequence.toLong()) != expected.toLong()) {
// Volcengine's protocol table documents a negative final sequence,
// while its examples and production service return the same
// strictly increasing sequence as a positive value. Accept both
// representations without weakening continuity validation.
if (Math.abs(sequence.toLong()) != expected.toLong()) {
throw SaucProtocolException("SAUC final sequence is invalid")
}
finalSeen = true
@@ -33,6 +33,7 @@ private object ProviderRequestsTable : Table("provider_requests") {
val reservationId = varchar("reservation_id", 36).nullable()
val providerId = varchar("provider_id", 64)
val capability = varchar("capability", 32)
val requestSource = varchar("request_source", 32).nullable()
val status = varchar("status", 24)
val providerRequestId = varchar("provider_request_id", 128).nullable()
val usageMeter = varchar("usage_meter", 32).nullable()
@@ -279,6 +280,7 @@ class ExposedGatewayRepository(
it[reservationId] = metadata.reservationId
it[providerId] = metadata.providerId
it[capability] = metadata.capability.name
it[requestSource] = metadata.requestSource?.name
it[status] = ProviderRequestState.CLAIMED.name
it[createdAt] = clock.instant()
}.insertedCount == 1
@@ -29,6 +29,7 @@ import com.osglab.account.features.gateway.services.GatewayGrantService
import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidException
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -67,6 +68,7 @@ fun Route.configureGatewayRoutes(
gatewayIdentity: GatewayAccessTokenPort,
grantService: GatewayGrantService? = null,
asrStreaming: AsrStreamingService? = null,
taskPolicyResolver: GatewayTaskPolicyResolver = GatewayTaskPolicyResolver(),
) {
route("/v1/gateway") {
if (grantService != null) {
@@ -240,10 +242,16 @@ fun Route.configureGatewayRoutes(
"Only polish, ai and agent are supported",
requestId,
)
val body = runCatching {
ROUTE_JSON.decodeFromString<TextGatewayRequest>(
val (body, executionPolicy) = runCatching {
val request = ROUTE_JSON.decodeFromString<TextGatewayRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
).also { TextRequestPolicy.validate(it, capability) }
)
TextRequestPolicy.validate(request, capability)
request to taskPolicyResolver.resolve(
capability = capability,
requestedTaskKind = request.taskKind,
requestedMaxOutputTokens = request.maxOutputTokens,
)
}
.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
@@ -259,11 +267,13 @@ fun Route.configureGatewayRoutes(
val providerRequest = TextProviderRequest(
requestId = requestId,
capability = capability,
executionPolicy = executionPolicy,
input = body.input,
context = body.context,
maxOutputTokens = body.maxOutputTokens,
maxOutputTokens = executionPolicy.maxOutputTokens,
temperature = body.temperature,
stream = body.stream,
requestSource = body.requestSource,
)
if (body.stream) {
@@ -74,6 +74,7 @@ class GatewayService(
reservationId = reservation.id,
providerId = provider.descriptor.id,
capability = request.capability,
requestSource = request.requestSource,
),
)
} catch (replay: GatewayRequestAlreadyClaimedException) {
@@ -0,0 +1,133 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayLimits
import com.osglab.account.features.gateway.models.GatewayModelProfile
import com.osglab.account.features.gateway.models.GatewayReasoningEffort
import com.osglab.account.features.gateway.models.GatewayTaskExecutionPolicy
import com.osglab.account.features.gateway.models.GatewayTaskKind
import com.osglab.account.features.gateway.models.GatewayThinkingMode
import com.osglab.account.features.gateway.models.GatewayToolsMode
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
data class GatewayTaskPolicyConfig(
val polishMaxOutputTokens: Int = 512,
val transformMaxOutputTokens: Int = 2_048,
val reasoningMaxOutputTokens: Int = GatewayLimits.MAX_OUTPUT_TOKENS,
val aiReasoningEffort: GatewayReasoningEffort = GatewayReasoningEffort.HIGH,
val agentReasoningEffort: GatewayReasoningEffort = GatewayReasoningEffort.HIGH,
) {
init {
require(polishMaxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS)
require(transformMaxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS)
require(reasoningMaxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS)
}
}
/**
* Deterministic server-side task policy. It never inspects user content and
* never accepts provider parameters from the client.
*/
class GatewayTaskPolicyResolver(
private val config: GatewayTaskPolicyConfig = GatewayTaskPolicyConfig(),
) {
fun resolve(
capability: GatewayCapability,
requestedTaskKind: GatewayTaskKind?,
requestedMaxOutputTokens: Int,
): GatewayTaskExecutionPolicy {
require(requestedMaxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
"maxOutputTokens is out of range"
}
val taskKind = requestedTaskKind ?: defaultTaskKind(capability)
require(taskKind in allowedTaskKinds(capability)) {
"taskKind is not supported for this capability"
}
return when (taskKind) {
GatewayTaskKind.DICTATION_POLISH,
GatewayTaskKind.EDIT_LAST_INPUT -> nonThinkingPolicy(
taskKind = taskKind,
maxOutputTokens = minOf(requestedMaxOutputTokens, config.polishMaxOutputTokens),
)
GatewayTaskKind.TRANSLATION,
GatewayTaskKind.CLIPBOARD_TRANSFORM,
GatewayTaskKind.CUSTOM_SKILL -> nonThinkingPolicy(
taskKind = taskKind,
maxOutputTokens = minOf(requestedMaxOutputTokens, config.transformMaxOutputTokens),
)
GatewayTaskKind.AI_QUESTION -> reasoningPolicy(
taskKind = taskKind,
effort = config.aiReasoningEffort,
maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens),
)
GatewayTaskKind.AGENT_PLANNING -> reasoningPolicy(
taskKind = taskKind,
effort = config.agentReasoningEffort,
maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens),
)
}
}
private fun nonThinkingPolicy(
taskKind: GatewayTaskKind,
maxOutputTokens: Int,
) = GatewayTaskExecutionPolicy(
taskKind = taskKind,
modelProfile = GatewayModelProfile.LOW_LATENCY,
thinking = GatewayThinkingMode.DISABLED,
reasoningEffort = null,
webSearch = GatewayWebSearchMode.DISABLED,
tools = GatewayToolsMode.DISABLED,
allowEmptyContentRetry = false,
maxOutputTokens = maxOutputTokens,
)
private fun reasoningPolicy(
taskKind: GatewayTaskKind,
effort: GatewayReasoningEffort,
maxOutputTokens: Int,
) = GatewayTaskExecutionPolicy(
taskKind = taskKind,
modelProfile = GatewayModelProfile.REASONING,
thinking = GatewayThinkingMode.ENABLED,
reasoningEffort = effort,
webSearch = GatewayWebSearchMode.DISABLED,
tools = GatewayToolsMode.DISABLED,
allowEmptyContentRetry = true,
maxOutputTokens = maxOutputTokens,
)
private fun defaultTaskKind(capability: GatewayCapability): GatewayTaskKind =
when (capability) {
GatewayCapability.POLISH -> GatewayTaskKind.DICTATION_POLISH
GatewayCapability.AI -> GatewayTaskKind.AI_QUESTION
GatewayCapability.AGENT -> GatewayTaskKind.AGENT_PLANNING
GatewayCapability.ASR -> throw IllegalArgumentException("ASR does not support text tasks")
}
private fun allowedTaskKinds(capability: GatewayCapability): Set<GatewayTaskKind> =
when (capability) {
GatewayCapability.POLISH -> POLISH_TASKS
GatewayCapability.AI -> AI_TASKS
GatewayCapability.AGENT -> AGENT_TASKS
GatewayCapability.ASR -> emptySet()
}
private companion object {
val POLISH_TASKS = setOf(
GatewayTaskKind.DICTATION_POLISH,
GatewayTaskKind.TRANSLATION,
GatewayTaskKind.EDIT_LAST_INPUT,
)
val AI_TASKS = setOf(
GatewayTaskKind.AI_QUESTION,
GatewayTaskKind.CLIPBOARD_TRANSFORM,
GatewayTaskKind.CUSTOM_SKILL,
)
val AGENT_TASKS = setOf(GatewayTaskKind.AGENT_PLANNING)
}
}
@@ -22,6 +22,7 @@ import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.slf4j.LoggerFactory
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Clock
@@ -283,10 +284,13 @@ class AppAttestService(
)
IntegrityVerification.Verified
} catch (exception: AppAttestRejectedException) {
APP_ATTEST_LOG.warn("App Attest assertion rejected: {}", exception.message)
IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion")
} catch (exception: InvalidRequestException) {
APP_ATTEST_LOG.warn("App Attest assertion request rejected: {}", exception.message)
IntegrityVerification.Rejected(exception.message)
} catch (exception: AppAttestUnavailableException) {
APP_ATTEST_LOG.error("App Attest assertion verification unavailable", exception)
IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable")
} catch (exception: CancellationException) {
throw exception
@@ -392,6 +396,7 @@ class AppAttestService(
const val SHA256_BYTES = 32
const val MAX_ATTESTATION_BYTES = 256 * 1024
const val MAX_ASSERTION_BYTES = 64 * 1024
val APP_ATTEST_LOG = LoggerFactory.getLogger(AppAttestService::class.java)
}
}
@@ -251,11 +251,11 @@ class LibraryAppAttestCrypto(
} catch (exception: Exception) {
throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception)
}
val signedBytes = authenticatorDataBytes + clientDataHash
val nonce = sha256(authenticatorDataBytes + clientDataHash)
val verified = try {
Signature.getInstance("SHA256withECDSA").run {
initVerify(key)
update(signedBytes)
update(nonce)
verify(signatureBytes)
}
} catch (exception: Exception) {
@@ -299,9 +299,10 @@ class LibraryAppAttestCrypto(
val rpHash = ByteArray(SHA256_BYTES).also(buffer::get)
val flags = buffer.get().toInt() and 0xff
val count = buffer.int.toLong() and UINT32_MASK
if ((flags and FLAG_ATTESTED_CREDENTIAL_DATA) != 0 ||
(flags and FLAG_EXTENSION_DATA) != 0
) {
// Production App Attest assertions can set AT while still using
// Apple's fixed 37-byte assertion profile. Exact-length validation
// above ensures no attested credential bytes are appended.
if ((flags and FLAG_EXTENSION_DATA) != 0) {
throw AppAttestRejectedException("App Attest assertion flags are invalid")
}
return AssertionAuthenticatorData(rpHash, flags, count)
@@ -32,6 +32,7 @@ import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.slf4j.LoggerFactory
import java.security.KeyFactory
import java.security.interfaces.ECPrivateKey
import java.security.spec.PKCS8EncodedKeySpec
@@ -233,7 +234,7 @@ class KtorAppleDeviceCheckClient(
private companion object {
const val MAX_DEVICE_TOKEN_LENGTH = 8_192
const val DEFAULT_TIMEOUT_MILLIS = 5_000L
const val DEFAULT_TIMEOUT_MILLIS = 15_000L
const val BIT_STATE_NOT_FOUND_RESPONSE = "Failed to find bit state"
val JSON = Json { ignoreUnknownKeys = true }
}
@@ -263,8 +264,13 @@ class RemoteDeviceCheckVerifier(
} catch (exception: DeviceCheckRejectedException) {
IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token")
} catch (exception: DeviceCheckUnavailableException) {
LOG.warn("DeviceCheck verification unavailable: {}", exception.message)
IntegrityVerification.Unavailable(exception.message ?: "DeviceCheck is unavailable")
}
private companion object {
val LOG = LoggerFactory.getLogger(RemoteDeviceCheckVerifier::class.java)
}
}
open class DeviceCheckException(message: String, cause: Throwable? = null) :
@@ -177,13 +177,15 @@ class ReferralService(
}
}
override suspend fun getProfile(userId: UUID): ReferralProfile =
transactions.inTransaction { unit ->
override suspend fun getProfile(userId: UUID): ReferralProfile {
val activeCode = getOrCreateCode(userId)
return transactions.inTransaction { unit ->
ReferralProfile(
code = unit.referrals.findCodeByOwner(userId),
code = activeCode,
binding = unit.referrals.findBinding(userId),
)
}
}
override suspend fun listActiveCampaigns(): List<ReferralCampaign> =
transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) }
@@ -0,0 +1,84 @@
package com.osglab.account.features.storekit.domain
import java.time.Instant
import java.util.UUID
enum class StoreKitEnvironment {
SANDBOX,
PRODUCTION,
}
data class StoreKitProduct(
val productId: String,
val credits: Long,
) {
init {
require(PRODUCT_ID.matches(productId)) { "StoreKit product ID is invalid" }
require(credits > 0) { "StoreKit product credits must be positive" }
}
private companion object {
val PRODUCT_ID = Regex("[A-Za-z0-9._-]{3,128}")
}
}
data class VerifiedStoreKitTransaction(
val transactionId: String,
val originalTransactionId: String,
val appAccountToken: UUID,
val productId: String,
val environment: StoreKitEnvironment,
val purchasedAt: Instant,
val signedAt: Instant,
val revokedAt: Instant?,
)
data class StoreKitCreditPurchase(
val id: UUID,
val transactionId: String,
val originalTransactionId: String,
val userId: UUID,
val appAccountToken: UUID,
val productId: String,
val environment: StoreKitEnvironment,
val creditsGranted: Long,
val ledgerEntryId: UUID,
val signedTransactionSha256: String,
val purchasedAt: Instant,
val signedAt: Instant,
val createdAt: Instant,
)
data class StoreKitPurchaseResult(
val purchase: StoreKitCreditPurchase,
val balanceAfter: Long,
val replayed: Boolean,
)
data class StoreKitTransactionCursor(
val purchasedAt: Instant,
val transactionId: String,
)
data class CreditedStoreKitTransaction(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val purchasedAt: Instant,
)
data class StoreKitTransactionPage(
val items: List<CreditedStoreKitTransaction>,
val nextCursor: String?,
)
sealed class StoreKitException(message: String) : RuntimeException(message)
class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable")
class InvalidStoreKitRequest(message: String) : StoreKitException(message)
class StoreKitVerificationFailed : StoreKitException("The App Store transaction could not be verified")
class StoreKitPurchaseConflict : StoreKitException("The App Store transaction conflicts with an existing purchase")
@@ -0,0 +1,85 @@
package com.osglab.account.features.storekit.models
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
@Serializable
data class StoreKitProductDto(
val productId: String,
val credits: Long,
) {
companion object {
fun fromDomain(product: StoreKitProduct): StoreKitProductDto =
StoreKitProductDto(product.productId, product.credits)
}
}
@Serializable
data class StoreKitSubmitRequest(
val signedTransaction: String,
)
@Serializable
data class StoreKitPurchaseResponse(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val replayed: Boolean,
) {
companion object {
fun fromDomain(result: StoreKitPurchaseResult): StoreKitPurchaseResponse =
StoreKitPurchaseResponse(
transactionId = result.purchase.transactionId,
productId = result.purchase.productId,
creditsGranted = result.purchase.creditsGranted,
balanceAfter = result.balanceAfter,
replayed = result.replayed,
)
}
}
@Serializable
data class StoreKitTransactionHistoryItemResponse(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val purchasedAt: String,
val status: String,
) {
companion object {
fun fromDomain(
transaction: CreditedStoreKitTransaction,
): StoreKitTransactionHistoryItemResponse =
StoreKitTransactionHistoryItemResponse(
transactionId = transaction.transactionId,
productId = transaction.productId,
creditsGranted = transaction.creditsGranted,
balanceAfter = transaction.balanceAfter,
purchasedAt = transaction.purchasedAt.toString(),
status = "credited",
)
}
}
@Serializable
data class StoreKitTransactionHistoryResponse(
val items: List<StoreKitTransactionHistoryItemResponse>,
val nextCursor: JsonElement,
) {
companion object {
fun fromDomain(page: StoreKitTransactionPage): StoreKitTransactionHistoryResponse =
StoreKitTransactionHistoryResponse(
items = page.items.map(StoreKitTransactionHistoryItemResponse::fromDomain),
nextCursor = page.nextCursor?.let(::JsonPrimitive) ?: JsonNull,
)
}
}
@@ -0,0 +1,161 @@
package com.osglab.account.features.storekit.repositories
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import java.util.UUID
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
interface StoreKitRepository {
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction>
fun insert(purchase: StoreKitCreditPurchase)
}
object ExposedStoreKitRepository : StoreKitRepository {
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
StoreKitCreditPurchases
.selectAll()
.where { StoreKitCreditPurchases.transactionId eq transactionId }
.singleOrNull()
?.toStoreKitCreditPurchase()
override fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction> {
val accountId = userId.toString()
val query = StoreKitCreditPurchases
.innerJoin(
otherTable = StoreKitPurchaseLedger,
onColumn = { ledgerEntryId },
otherColumn = { id },
)
.select(
StoreKitCreditPurchases.transactionId,
StoreKitCreditPurchases.productId,
StoreKitCreditPurchases.creditsGranted,
StoreKitCreditPurchases.purchasedAt,
StoreKitPurchaseLedger.balanceAfter,
)
query.where {
val accountAndCredited =
(StoreKitCreditPurchases.userId eq accountId) and
(StoreKitPurchaseLedger.userId eq accountId) and
(StoreKitPurchaseLedger.entryType eq LedgerEntryType.STOREKIT_PURCHASE) and
(
StoreKitPurchaseLedger.amountDelta eq
StoreKitCreditPurchases.creditsGranted
) and
(StoreKitPurchaseLedger.referenceId eq StoreKitCreditPurchases.id)
if (before == null) {
accountAndCredited
} else {
accountAndCredited and
(
(StoreKitCreditPurchases.purchasedAt less before.purchasedAt) or
(
(StoreKitCreditPurchases.purchasedAt eq before.purchasedAt) and
(
StoreKitCreditPurchases.transactionId less
before.transactionId
)
)
)
}
}
return query
.orderBy(
StoreKitCreditPurchases.purchasedAt to SortOrder.DESC,
StoreKitCreditPurchases.transactionId to SortOrder.DESC,
)
.limit(limit)
.map(ResultRow::toCreditedStoreKitTransaction)
}
override fun insert(purchase: StoreKitCreditPurchase) {
StoreKitCreditPurchases.insert {
it[id] = purchase.id.toString()
it[transactionId] = purchase.transactionId
it[originalTransactionId] = purchase.originalTransactionId
it[userId] = purchase.userId.toString()
it[appAccountToken] = purchase.appAccountToken.toString()
it[productId] = purchase.productId
it[environment] = purchase.environment
it[creditsGranted] = purchase.creditsGranted
it[ledgerEntryId] = purchase.ledgerEntryId.toString()
it[signedTransactionSha256] = purchase.signedTransactionSha256
it[purchasedAt] = purchase.purchasedAt
it[signedAt] = purchase.signedAt
it[createdAt] = purchase.createdAt
}
}
}
private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
val id = varchar("id", 36)
val transactionId = varchar("transaction_id", 64)
val originalTransactionId = varchar("original_transaction_id", 64)
val userId = varchar("user_id", 36)
val appAccountToken = varchar("app_account_token", 36)
val productId = varchar("product_id", 128)
val environment = enumerationByName<StoreKitEnvironment>("environment", 16)
val creditsGranted = long("credits_granted")
val ledgerEntryId = varchar("ledger_entry_id", 36)
val signedTransactionSha256 = char("signed_transaction_sha256", 64)
val purchasedAt = timestamp("purchased_at")
val signedAt = timestamp("signed_at")
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object StoreKitPurchaseLedger : Table("credit_ledger") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
val amountDelta = long("amount_delta")
val balanceAfter = long("balance_after")
val referenceId = varchar("reference_id", 36).nullable()
override val primaryKey = PrimaryKey(id)
}
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
StoreKitCreditPurchase(
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
transactionId = this[StoreKitCreditPurchases.transactionId],
originalTransactionId = this[StoreKitCreditPurchases.originalTransactionId],
userId = UUID.fromString(this[StoreKitCreditPurchases.userId]),
appAccountToken = UUID.fromString(this[StoreKitCreditPurchases.appAccountToken]),
productId = this[StoreKitCreditPurchases.productId],
environment = this[StoreKitCreditPurchases.environment],
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
ledgerEntryId = UUID.fromString(this[StoreKitCreditPurchases.ledgerEntryId]),
signedTransactionSha256 = this[StoreKitCreditPurchases.signedTransactionSha256],
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
signedAt = this[StoreKitCreditPurchases.signedAt],
createdAt = this[StoreKitCreditPurchases.createdAt],
)
private fun ResultRow.toCreditedStoreKitTransaction(): CreditedStoreKitTransaction =
CreditedStoreKitTransaction(
transactionId = this[StoreKitCreditPurchases.transactionId],
productId = this[StoreKitCreditPurchases.productId],
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
balanceAfter = this[StoreKitPurchaseLedger.balanceAfter],
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
)
@@ -0,0 +1,130 @@
package com.osglab.account.features.storekit.routes
import com.osglab.account.common.api.ApiError
import com.osglab.account.common.api.ApiErrorResponse
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.models.StoreKitProductDto
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
import com.osglab.account.features.storekit.models.StoreKitTransactionHistoryResponse
import com.osglab.account.features.storekit.services.StoreKitService
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
fun Route.storeKitRoutes(
service: StoreKitService,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
authenticate(SESSION_AUTH_NAME) {
route("/v1/storekit") {
get("/products") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
}
call.respond(service.products().map(StoreKitProductDto::fromDomain))
}
get("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
}
try {
if (call.request.queryParameters.contains("accountId")) {
throw InvalidStoreKitRequest("accountId is not accepted")
}
val rawLimit = call.request.queryParameters["limit"]
val limit = rawLimit?.toIntOrNull()
?: if (rawLimit == null) {
50
} else {
throw InvalidStoreKitRequest("limit must be an integer")
}
call.respond(
StoreKitTransactionHistoryResponse.fromDomain(
service.listTransactions(
userId = userId,
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
)
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
}
}
post("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@post
}
val request = call.receive<StoreKitSubmitRequest>()
try {
call.respond(
HttpStatusCode.OK,
StoreKitPurchaseResponse.fromDomain(
service.submit(userId, request.signedTransaction)
),
)
} catch (_: StoreKitUnavailable) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ApiErrorResponse(
ApiError("external_service_unavailable", "Credit purchases are unavailable")
),
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
} catch (_: StoreKitVerificationFailed) {
call.respond(
HttpStatusCode.UnprocessableEntity,
ApiErrorResponse(
ApiError("transaction_invalid", "The App Store transaction is invalid")
),
)
} catch (_: StoreKitPurchaseConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
} catch (_: CreditConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
}
}
}
}
}
@@ -0,0 +1,225 @@
package com.osglab.account.features.storekit.services
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.UUID
class StoreKitService(
products: List<StoreKitProduct>,
private val verifier: StoreKitTransactionVerifier,
private val transactions: BillingTransactionRunner,
private val clock: Clock = Clock.systemUTC(),
private val newId: () -> UUID = UUID::randomUUID,
) {
private val productsById = products.associateBy(StoreKitProduct::productId)
init {
require(productsById.size == products.size) { "StoreKit product IDs must be unique" }
}
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
suspend fun listTransactions(
userId: UUID,
limit: Int = DEFAULT_PAGE_SIZE,
cursor: String? = null,
): StoreKitTransactionPage {
if (limit !in 1..MAX_PAGE_SIZE) {
throw InvalidStoreKitRequest("limit must be between 1 and 100")
}
val decodedCursor = cursor?.let(StoreKitTransactionCursorCodec::decode)
val results = transactions.inTransaction { unit ->
unit.storeKit.listCreditedTransactions(userId, limit + 1, decodedCursor)
}
val items = results.take(limit)
return StoreKitTransactionPage(
items = items,
nextCursor = if (results.size > limit) {
items.lastOrNull()?.let {
StoreKitTransactionCursorCodec.encode(
StoreKitTransactionCursor(it.purchasedAt, it.transactionId)
)
}
} else {
null
},
)
}
suspend fun submit(
userId: UUID,
signedTransaction: String,
): StoreKitPurchaseResult {
if (productsById.isEmpty()) {
throw StoreKitUnavailable()
}
if (
signedTransaction.length !in MIN_SIGNED_TRANSACTION_LENGTH..MAX_SIGNED_TRANSACTION_LENGTH ||
signedTransaction != signedTransaction.trim()
) {
throw InvalidStoreKitRequest("signedTransaction is invalid")
}
val verified = verifier.verify(signedTransaction)
val product = productsById[verified.productId] ?: throw StoreKitPurchaseConflict()
validateTransaction(userId, verified)
val digest = signedTransaction.sha256Hex()
val idempotencyKey = "storekit:${verified.transactionId}"
return transactions.inTransaction { unit ->
val now = clock.instant()
unit.credits.createAccountIfAbsent(userId, now)
val account = unit.credits.lockAccount(userId)
unit.storeKit.findByTransactionId(verified.transactionId)?.let { existing ->
requireReplayMatches(existing, verified, product)
val ledger = unit.credits.findLedgerEntry(userId, idempotencyKey)
?: throw StoreKitPurchaseConflict()
if (
ledger.id != existing.ledgerEntryId ||
ledger.type != LedgerEntryType.STOREKIT_PURCHASE ||
ledger.amountDelta != existing.creditsGranted ||
ledger.referenceId != existing.id
) {
throw StoreKitPurchaseConflict()
}
return@inTransaction StoreKitPurchaseResult(existing, ledger.balanceAfter, replayed = true)
}
if (unit.credits.findLedgerEntry(userId, idempotencyKey) != null) {
throw StoreKitPurchaseConflict()
}
val balanceAfter = try {
Math.addExact(account.balance, product.credits)
} catch (_: ArithmeticException) {
throw CreditConflict("StoreKit credit balance overflow")
}
val purchaseId = newId()
val ledgerEntryId = newId()
val purchase = StoreKitCreditPurchase(
id = purchaseId,
transactionId = verified.transactionId,
originalTransactionId = verified.originalTransactionId,
userId = userId,
appAccountToken = verified.appAccountToken,
productId = product.productId,
environment = verified.environment,
creditsGranted = product.credits,
ledgerEntryId = ledgerEntryId,
signedTransactionSha256 = digest,
purchasedAt = verified.purchasedAt,
signedAt = verified.signedAt,
createdAt = now,
)
unit.credits.updateAccountBalance(userId, balanceAfter, now)
unit.credits.insertLedgerEntry(
LedgerEntry(
id = ledgerEntryId,
userId = userId,
type = LedgerEntryType.STOREKIT_PURCHASE,
amountDelta = product.credits,
balanceAfter = balanceAfter,
idempotencyKey = idempotencyKey,
referenceId = purchaseId,
createdAt = now,
)
)
unit.storeKit.insert(purchase)
StoreKitPurchaseResult(purchase, balanceAfter, replayed = false)
}
}
private fun validateTransaction(
userId: UUID,
transaction: VerifiedStoreKitTransaction,
) {
if (transaction.appAccountToken != userId || transaction.revokedAt != null) {
throw StoreKitPurchaseConflict()
}
val now = clock.instant()
if (
transaction.purchasedAt > transaction.signedAt.plus(MAX_CLOCK_SKEW) ||
transaction.signedAt > now.plus(MAX_CLOCK_SKEW)
) {
throw StoreKitPurchaseConflict()
}
}
private fun requireReplayMatches(
existing: StoreKitCreditPurchase,
verified: VerifiedStoreKitTransaction,
product: StoreKitProduct,
) {
if (
existing.userId != verified.appAccountToken ||
existing.originalTransactionId != verified.originalTransactionId ||
existing.productId != verified.productId ||
existing.environment != verified.environment ||
existing.creditsGranted != product.credits ||
existing.purchasedAt != verified.purchasedAt
) {
throw StoreKitPurchaseConflict()
}
}
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
private companion object {
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 100
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
}
}
internal object StoreKitTransactionCursorCodec {
private const val MAX_CURSOR_LENGTH = 256
private const val INVALID_CURSOR_MESSAGE = "cursor is invalid"
private val TRANSACTION_ID = Regex("[0-9]{1,64}")
fun encode(cursor: StoreKitTransactionCursor): String {
val value = "${cursor.purchasedAt}|${cursor.transactionId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): StoreKitTransactionCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH || value != value.trim()) {
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
}
return try {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2 && TRANSACTION_ID.matches(parts[1]))
StoreKitTransactionCursor(
purchasedAt = Instant.parse(parts[0]),
transactionId = parts[1],
)
} catch (_: IllegalArgumentException) {
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
}
}
}
@@ -0,0 +1,119 @@
package com.osglab.account.features.storekit.verification
import com.apple.itunes.storekit.model.Environment
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload
import com.apple.itunes.storekit.model.Type
import com.apple.itunes.storekit.verification.SignedDataVerifier
import com.apple.itunes.storekit.verification.VerificationException
import com.apple.itunes.storekit.verification.VerificationStatus
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import java.io.InputStream
import java.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
fun interface StoreKitTransactionVerifier {
suspend fun verify(signedTransaction: String): VerifiedStoreKitTransaction
}
class AppleStoreKitTransactionVerifier(
bundleId: String,
appAppleId: Long,
rootCertificateLoader: (String) -> InputStream? = {
AppleStoreKitTransactionVerifier::class.java.getResourceAsStream(it)
},
enableOnlineChecks: Boolean = true,
) : StoreKitTransactionVerifier {
private val production: SignedDataVerifier
private val sandbox: SignedDataVerifier
init {
val certificateBytes = ROOT_CERTIFICATES.map { path ->
rootCertificateLoader(path)?.use(InputStream::readAllBytes)
?: error("Missing Apple root certificate: $path")
}
fun verifier(environment: Environment): SignedDataVerifier {
val streams = certificateBytes.map(ByteArray::inputStream).toSet()
return SignedDataVerifier(
streams,
bundleId,
if (environment == Environment.PRODUCTION) appAppleId else null,
environment,
enableOnlineChecks,
).also { streams.forEach(InputStream::close) }
}
production = verifier(Environment.PRODUCTION)
sandbox = verifier(Environment.SANDBOX)
}
override suspend fun verify(
signedTransaction: String
): VerifiedStoreKitTransaction = withContext(Dispatchers.IO) {
if (signedTransaction.length !in MIN_JWS_LENGTH..MAX_JWS_LENGTH) {
throw StoreKitVerificationFailed()
}
var retryableFailure = false
val payload = try {
production.verifyAndDecodeTransaction(signedTransaction)
} catch (exception: VerificationException) {
retryableFailure = exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
null
} ?: try {
sandbox.verifyAndDecodeTransaction(signedTransaction)
} catch (exception: VerificationException) {
retryableFailure = retryableFailure ||
exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
null
}
if (payload == null && retryableFailure) {
throw StoreKitUnavailable()
}
if (payload == null) {
throw StoreKitVerificationFailed()
}
payload.toVerifiedTransaction()
}
private fun JWSTransactionDecodedPayload.toVerifiedTransaction(): VerifiedStoreKitTransaction {
val transaction = transactionId?.takeIf(TRANSACTION_ID::matches)
?: throw StoreKitVerificationFailed()
val original = originalTransactionId?.takeIf(TRANSACTION_ID::matches)
?: throw StoreKitVerificationFailed()
val accountToken = appAccountToken ?: throw StoreKitVerificationFailed()
val product = productId?.takeIf { it.length in 3..128 }
?: throw StoreKitVerificationFailed()
val purchaseMillis = purchaseDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
val signedMillis = signedDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
if (type != Type.CONSUMABLE || quantity != 1) {
throw StoreKitVerificationFailed()
}
val verifiedEnvironment = when (environment) {
Environment.PRODUCTION -> StoreKitEnvironment.PRODUCTION
Environment.SANDBOX -> StoreKitEnvironment.SANDBOX
else -> throw StoreKitVerificationFailed()
}
return VerifiedStoreKitTransaction(
transactionId = transaction,
originalTransactionId = original,
appAccountToken = accountToken,
productId = product,
environment = verifiedEnvironment,
purchasedAt = Instant.ofEpochMilli(purchaseMillis),
signedAt = Instant.ofEpochMilli(signedMillis),
revokedAt = revocationDate?.let(Instant::ofEpochMilli),
)
}
private companion object {
const val MIN_JWS_LENGTH = 100
const val MAX_JWS_LENGTH = 32_768
val TRANSACTION_ID = Regex("[0-9]{1,64}")
val ROOT_CERTIFICATES = listOf(
"/apple-pki/AppleRootCA-G2.cer",
"/apple-pki/AppleRootCA-G3.cer",
)
}
}
Binary file not shown.
Binary file not shown.
+8 -2
View File
@@ -49,9 +49,14 @@ app:
revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke"
credits:
signupTrial: "$SIGNUP_TRIAL_CREDITS:1000"
referralInviter: "$REFERRAL_INVITER_CREDITS:3000"
referralInvitee: "$REFERRAL_INVITEE_CREDITS:3000"
referralInviter: "$REFERRAL_INVITER_CREDITS:1000"
referralInvitee: "$REFERRAL_INVITEE_CREDITS:1000"
referralBindingDays: "$REFERRAL_BINDING_DAYS:7"
storeKit:
enabled: "$STOREKIT_ENABLED:false"
bundleId: "$STOREKIT_BUNDLE_ID:com.osgkeyboard.ios"
appAppleId: "$STOREKIT_APP_APPLE_ID:"
products: "$STOREKIT_PRODUCTS:"
providers:
volcengine:
endpoint: "$VOLCENGINE_ASR_ENDPOINT:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
@@ -63,6 +68,7 @@ app:
endpoint: "$DEEPSEEK_ENDPOINT:https://api.deepseek.com/v1"
apiKey: "$DEEPSEEK_API_KEY:"
model: "$DEEPSEEK_MODEL:deepseek-v4-flash"
reasoningModel: "$DEEPSEEK_REASONING_MODEL:"
integrity:
enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false"
enforceAppAttest: "$ENFORCE_APP_ATTEST:false"
@@ -0,0 +1,60 @@
-- Close the initial immutable rates and activate the smaller credit unit.
-- ASR bills one credit per started three-second interval.
-- DeepSeek bills input/output dimensions independently and rounds each upward.
SET @new_credit_rate_effective_from = UTC_TIMESTAMP(6);
UPDATE credit_rate_versions
SET effective_until = @new_credit_rate_effective_from
WHERE id IN (
'10000000-0000-0000-0000-000000000001',
'10000000-0000-0000-0000-000000000002'
)
AND effective_until IS NULL;
INSERT INTO credit_rate_versions (
id,
kind,
provider,
model,
effective_from,
effective_until,
asr_credits_numerator,
asr_millis_denominator,
created_at
) VALUES (
'10000000-0000-0000-0000-000000000003',
'ASR',
'volcengine-sauc-v3',
'volc.seedasr.sauc.duration',
@new_credit_rate_effective_from,
NULL,
1,
3000,
@new_credit_rate_effective_from
);
INSERT INTO credit_rate_versions (
id,
kind,
provider,
model,
effective_from,
effective_until,
input_credits_numerator,
input_tokens_denominator,
output_credits_numerator,
output_tokens_denominator,
created_at
) VALUES (
'10000000-0000-0000-0000-000000000004',
'LLM',
'deepseek',
'deepseek-v4-flash',
@new_credit_rate_effective_from,
NULL,
1,
1000,
1,
400,
@new_credit_rate_effective_from
);
@@ -0,0 +1,9 @@
CREATE TABLE account_profiles (
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
encrypted_display_name MEDIUMTEXT NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (account_id),
CONSTRAINT fk_account_profiles_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
) ENGINE = InnoDB;
@@ -0,0 +1,4 @@
UPDATE referral_campaigns
SET inviter_reward_credits = 1000,
invitee_reward_credits = 1000
WHERE id = '00000000-0000-0000-0000-000000000001';
@@ -0,0 +1,2 @@
CREATE INDEX idx_credit_ledger_created
ON credit_ledger (created_at, id);
@@ -0,0 +1,5 @@
ALTER TABLE provider_requests
ADD COLUMN request_source VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER capability,
ADD CONSTRAINT chk_provider_request_source CHECK (
request_source IS NULL OR request_source IN ('HOTWORD')
);
@@ -0,0 +1,2 @@
CREATE INDEX idx_storekit_purchase_user_purchased_transaction
ON storekit_credit_purchases (user_id, purchased_at, transaction_id);
@@ -0,0 +1,24 @@
CREATE TABLE storekit_credit_purchases (
id CHAR(36) NOT NULL,
transaction_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
original_transaction_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
app_account_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
product_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
environment VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
credits_granted BIGINT NOT NULL,
ledger_entry_id CHAR(36) NOT NULL,
signed_transaction_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
purchased_at DATETIME(6) NOT NULL,
signed_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_storekit_purchase_transaction (transaction_id),
UNIQUE KEY uk_storekit_purchase_ledger (ledger_entry_id),
INDEX idx_storekit_purchase_user_created (user_id, created_at, id),
CONSTRAINT chk_storekit_purchase_credits CHECK (credits_granted > 0),
CONSTRAINT chk_storekit_purchase_environment
CHECK (environment IN ('SANDBOX', 'PRODUCTION')),
CONSTRAINT fk_storekit_purchase_ledger
FOREIGN KEY (ledger_entry_id) REFERENCES credit_ledger(id)
) ENGINE = InnoDB;
@@ -14,6 +14,9 @@ class AppConfigTest : FunSpec({
config.environment shouldBe Environment.TEST
config.apple.clientCredentialsAvailable shouldBe false
config.encryption.key.size shouldBe 32
config.credits.signupTrial shouldBe 1_000
config.credits.referralInviter shouldBe 1_000
config.credits.referralInvitee shouldBe 1_000
}
test("production rejects placeholder secrets") {
@@ -76,6 +79,43 @@ class AppConfigTest : FunSpec({
}.message.orEmpty() shouldContain "bootstrapEnabled requires"
}
test("StoreKit requires an app identifier and dedicated credit product when enabled") {
val missingAppId = validProductionConfig().apply {
put("app.storeKit.enabled", "true")
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(missingAppId)
}.message.orEmpty() shouldContain "appAppleId is required"
val enabled = validProductionConfig().apply {
put("app.storeKit.enabled", "true")
put("app.storeKit.appAppleId", "6781553267")
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
}
val storeKit = AppConfig.from(enabled).storeKit
storeKit.enabled shouldBe true
storeKit.appAppleId shouldBe 6_781_553_267
storeKit.products.map { it.productId to it.credits } shouldBe listOf(
"500tks" to 500,
"1500tks" to 1_500,
"3000tks" to 3_000,
)
}
test("StoreKit rejects duplicate product mappings") {
val config = validProductionConfig().apply {
put("app.storeKit.enabled", "true")
put("app.storeKit.appAppleId", "6781553267")
put("app.storeKit.products", "500tks:500,500tks:3000")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "duplicate product IDs"
}
test("production fails fast when Apple signing credentials are missing") {
val config = validProductionConfig().apply {
put("app.apple.keyId", "")
@@ -61,6 +61,68 @@ class DeploymentConsistencyTest : FunSpec({
openApi shouldContain "unpadded Base64URL"
}
test("StoreKit catalog and smaller immutable rates stay aligned") {
listOf(root.read(".env.example"), root.read("compose.yaml")).forEach { configuration ->
configuration shouldContain "STOREKIT_PRODUCTS"
configuration shouldContain "500tks:500,1500tks:1500,3000tks:3000"
configuration shouldNotContain "STOREKIT_PRODUCT_CREDITS"
configuration shouldContain "SIGNUP_TRIAL_CREDITS"
configuration shouldContain "REFERRAL_INVITER_CREDITS"
}
val rates = root.read("src/main/resources/db/migration/V10__smaller_credit_units.sql")
rates shouldContain "'10000000-0000-0000-0000-000000000003'"
rates shouldContain "'10000000-0000-0000-0000-000000000004'"
rates shouldContain "1,\n 3000,"
rates shouldContain "1,\n 1000,\n 1,\n 400,"
}
test("StoreKit history remains ledger backed, indexed, and privacy minimized") {
val historyIndex = root.read(
"src/main/resources/db/migration/V15__storekit_purchase_history_index.sql",
)
historyIndex shouldContain
"ON storekit_credit_purchases (user_id, purchased_at, transaction_id)"
val openApi = root.read("docs/openapi.yaml")
val historySchema = openApi
.substringAfter(" StoreKitTransactionHistoryItem:")
.substringBefore(" AppleAppSiteAssociation:")
historySchema shouldContain "purchasedAt"
historySchema shouldContain "status"
historySchema shouldContain "nextCursor"
historySchema shouldNotContain "signedTransaction"
historySchema shouldNotContain "appAccountToken"
historySchema shouldNotContain "userId"
historySchema shouldNotContain "accountId"
}
test("account profiles cascade on deletion and grants stay aligned") {
val profileMigration = root.read(
"src/main/resources/db/migration/V11__account_profiles.sql",
)
val referralMigration = root.read(
"src/main/resources/db/migration/V12__align_referral_rewards.sql",
)
val ledgerTimelineMigration = root.read(
"src/main/resources/db/migration/V13__credit_ledger_global_timeline.sql",
)
profileMigration shouldContain "encrypted_display_name MEDIUMTEXT NOT NULL"
profileMigration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE"
referralMigration shouldContain "inviter_reward_credits = 1000"
referralMigration shouldContain "invitee_reward_credits = 1000"
ledgerTimelineMigration shouldContain "ON credit_ledger (created_at, id)"
listOf(
root.read("src/main/resources/application.yaml"),
root.read(".env.example"),
root.read("compose.yaml"),
).forEach { configuration ->
configuration shouldContain "1000"
configuration shouldNotContain "SIGNUP_TRIAL_CREDITS=334"
}
}
test("production Compose reuses private MySQL and hardens the application container") {
val compose = root.read("compose.yaml")
@@ -81,6 +143,7 @@ class DeploymentConsistencyTest : FunSpec({
test("admin bootstrap is one-time and runtime database grants stay explicit") {
val compose = root.read("compose.yaml")
val privileges = root.read("docs/mysql-minimum-privileges.sql")
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}"
privileges shouldContain "GRANT SELECT ON osg_account.admin_operators"
@@ -91,10 +154,16 @@ class DeploymentConsistencyTest : FunSpec({
privileges shouldContain "GRANT INSERT ON osg_account.gateway_grant_scopes"
privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens"
privileges shouldContain "GRANT SELECT ON osg_account.account_profiles"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.account_profiles"
privileges shouldContain "GRANT INSERT ON osg_account.admin_audit_log"
privileges shouldContain "GRANT INSERT ON osg_account.admin_credit_grants"
privileges shouldContain "GRANT SELECT ON osg_account.storekit_credit_purchases"
privileges shouldContain "GRANT INSERT ON osg_account.storekit_credit_purchases"
privileges shouldNotContain "UPDATE ON osg_account.admin_audit_log"
privileges shouldNotContain "DELETE ON osg_account.admin_credit_grants"
smokePrivileges shouldContain "GRANT SELECT ON osg_account_smoke.account_profiles"
smokePrivileges shouldContain "GRANT INSERT, UPDATE ON osg_account_smoke.account_profiles"
}
test("container image remains non-root and read-only compatible") {
@@ -162,6 +231,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/credits/balance",
"/v1/credits/ledger",
"/v1/credits/rates",
"/v1/storekit/products",
"/v1/storekit/transactions",
"/v1/referrals",
"/v1/referrals/me",
"/v1/referrals/code",
@@ -187,6 +258,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/admin/users",
"/v1/admin/users/{userId}",
"/v1/admin/users/{userId}/ledger",
"/v1/admin/credits/ledger",
"/v1/admin/credits/grants",
"/v1/admin/operators/summary",
"/v1/admin/operators",
@@ -37,9 +37,13 @@ class SmokeDeploymentTest : FunSpec({
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/"
runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/"
runner shouldContain "Flyway history was not exactly successful V1-V8"
runner shouldContain "Flyway history was not exactly successful V1-V12"
runner shouldContain "default referral rewards were not 1000 credits for both accounts"
runner shouldContain "active smaller credit rates did not match the V10 contract"
runner shouldContain "first ledger page omitted nextCursor"
runner shouldContain "DELETE FROM admin_sessions WHERE expires_at < UTC_TIMESTAMP()"
runner shouldContain "UPDATE storekit_credit_purchases SET credits_granted = credits_granted"
runner shouldContain "DELETE FROM storekit_credit_purchases WHERE 1 = 0"
runner shouldNotContain "appleid.apple.com"
runner shouldNotContain "api.deepseek.com"
runner shouldNotContain "openspeech.bytedance.com"
@@ -47,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..8)
val migrationTables = (1..13)
.flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") }

Some files were not shown because too many files have changed in this diff Show More