Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 231c5040a5 | |||
| 11ec34dacb | |||
| 3edc86a9a0 | |||
| e1fd35b1ff | |||
| 2194e69bb8 | |||
| 75c046d91d | |||
| 58445dd880 | |||
| c592f426be | |||
| 1737106560 | |||
| 25cfbfa4e6 |
+11
-2
@@ -61,13 +61,22 @@ VOLCENGINE_ASR_ENDPOINT=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel
|
|||||||
|
|
||||||
DEEPSEEK_API_KEY=replace-with-deepseek-api-key
|
DEEPSEEK_API_KEY=replace-with-deepseek-api-key
|
||||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||||
|
# Optional; defaults to DEEPSEEK_MODEL when omitted.
|
||||||
|
DEEPSEEK_REASONING_MODEL=
|
||||||
DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1
|
DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1
|
||||||
|
|
||||||
SIGNUP_TRIAL_CREDITS=1000
|
SIGNUP_TRIAL_CREDITS=1000
|
||||||
REFERRAL_INVITER_CREDITS=3000
|
REFERRAL_INVITER_CREDITS=1000
|
||||||
REFERRAL_INVITEE_CREDITS=3000
|
REFERRAL_INVITEE_CREDITS=1000
|
||||||
REFERRAL_BINDING_DAYS=7
|
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.
|
# Production startup requires both flags and the production Apple environment.
|
||||||
ENFORCE_DEVICE_CHECK=false
|
ENFORCE_DEVICE_CHECK=false
|
||||||
ENFORCE_APP_ATTEST=false
|
ENFORCE_APP_ATTEST=false
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ Gateway execution and settlement rules:
|
|||||||
|
|
||||||
Existing `application.yaml` values can be mapped into:
|
Existing `application.yaml` values can be mapped into:
|
||||||
|
|
||||||
- `DeepSeekConfig(endpoint, apiKey, model)`
|
- `DeepSeekConfig(endpoint, apiKey, model, reasoningModel)`
|
||||||
- `VolcengineAsrConfig(endpoint, resourceId, appId, accessToken)`
|
- `VolcengineAsrConfig(endpoint, resourceId, appId, accessToken)`
|
||||||
- `InviteWebConfig(appStoreUrl, appleAppId, universalLinkBaseUrl)`
|
- `InviteWebConfig(appStoreUrl, appleAppId, universalLinkBaseUrl)`
|
||||||
|
|
||||||
@@ -152,8 +152,16 @@ Configuration ownership:
|
|||||||
`APPLE_INTEGRITY_ENVIRONMENT`, plus the two integrity enforcement flags.
|
`APPLE_INTEGRITY_ENVIRONMENT`, plus the two integrity enforcement flags.
|
||||||
- Volcengine: prefer `VOLCENGINE_API_KEY`; set the SAUC v3 `VOLCENGINE_RESOURCE_ID` and WSS
|
- 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.
|
`VOLCENGINE_ASR_ENDPOINT`. The legacy app ID/access token pair remains optional.
|
||||||
- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned `DEEPSEEK_MODEL`, and HTTPS
|
- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned low-latency `DEEPSEEK_MODEL`,
|
||||||
`DEEPSEEK_ENDPOINT`.
|
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
|
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.
|
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: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`,
|
||||||
`APPLE_INTEGRITY_ENVIRONMENT`.
|
`APPLE_INTEGRITY_ENVIRONMENT`.
|
||||||
- Providers: `VOLCENGINE_API_KEY`, `VOLCENGINE_RESOURCE_ID`, `DEEPSEEK_API_KEY`,
|
- 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`,
|
- Production controls: `APP_ENV=production`, `ENFORCE_DEVICE_CHECK=true`,
|
||||||
`ENFORCE_APP_ATTEST=true`.
|
`ENFORCE_APP_ATTEST=true`.
|
||||||
- Optional tuning: token lifetimes, gateway grant days, credit values, binding window and pool size;
|
- Optional tuning: token lifetimes, gateway grant days, credit values, binding window and pool size;
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="theme-color" content="#f5f7fb" />
|
||||||
<meta name="referrer" content="same-origin" />
|
<meta name="referrer" content="same-origin" />
|
||||||
<title>OSG 运营后台</title>
|
<title>OSG 运营后台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+1628
-329
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -9,13 +9,29 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/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",
|
"jsdom": "^27.0.1",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^7.1.2",
|
"vite": "^7.1.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export const adminApi = {
|
|||||||
referrals: (range: string) =>
|
referrals: (range: string) =>
|
||||||
request<ReferralOverview>(`/referrals${query({ range })}`),
|
request<ReferralOverview>(`/referrals${query({ range })}`),
|
||||||
|
|
||||||
users: (search: string, cursor?: string) =>
|
users: (search = "", cursor?: string) =>
|
||||||
request<PageResult<UserSummary>>(
|
request<PageResult<UserSummary>>(
|
||||||
`/users${query({ q: search.trim(), cursor })}`,
|
`/users${query({ q: search.trim(), cursor })}`,
|
||||||
),
|
),
|
||||||
@@ -173,6 +173,9 @@ export const adminApi = {
|
|||||||
user: (userId: string) =>
|
user: (userId: string) =>
|
||||||
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
|
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
|
||||||
|
|
||||||
|
latestLedger: (cursor?: string) =>
|
||||||
|
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
|
||||||
|
|
||||||
ledger: (userId: string, cursor?: string) =>
|
ledger: (userId: string, cursor?: string) =>
|
||||||
request<PageResult<LedgerEntry>>(
|
request<PageResult<LedgerEntry>>(
|
||||||
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
|
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface UserSummary {
|
|||||||
maskedEmail?: string;
|
maskedEmail?: string;
|
||||||
status: "active" | "suspended" | "closed";
|
status: "active" | "suspended" | "closed";
|
||||||
creditBalance: number;
|
creditBalance: number;
|
||||||
|
consumedCredits: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,12 +98,16 @@ export type LedgerEntryType =
|
|||||||
| "refund"
|
| "refund"
|
||||||
| "adjustment";
|
| "adjustment";
|
||||||
|
|
||||||
|
export type UsageType = "polish" | "asr" | "ai" | "agent" | "hotword";
|
||||||
|
|
||||||
export interface LedgerEntry {
|
export interface LedgerEntry {
|
||||||
entryId: string;
|
entryId: string;
|
||||||
|
userId: string;
|
||||||
type: LedgerEntryType;
|
type: LedgerEntryType;
|
||||||
amount: number;
|
amount: number;
|
||||||
balanceAfter: number;
|
balanceAfter: number;
|
||||||
reasonCode: string;
|
reasonCode: string;
|
||||||
|
usageType?: UsageType;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,17 @@ export function statusLabel(status: string): string {
|
|||||||
return labels[status] ?? status;
|
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 {
|
export function createIdempotencyKey(): string {
|
||||||
return globalThis.crypto?.randomUUID?.() ??
|
return globalThis.crypto?.randomUUID?.() ??
|
||||||
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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="');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -90,13 +90,17 @@ describe("adminApi", () => {
|
|||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await adminApi.latestLedger("latest+/=");
|
||||||
await adminApi.ledger("user/with space", "ledger+/=");
|
await adminApi.ledger("user/with space", "ledger+/=");
|
||||||
await adminApi.operators("operator+/=");
|
await adminApi.operators("operator+/=");
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
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(
|
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",
|
"/v1/admin/operators?cursor=operator%2B%2F%3D",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
formatDateTime,
|
formatDateTime,
|
||||||
formatSignedCredits,
|
formatSignedCredits,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
|
usageTypeLabel,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
|
|
||||||
describe("format helpers", () => {
|
describe("format helpers", () => {
|
||||||
@@ -26,4 +27,10 @@ describe("format helpers", () => {
|
|||||||
it("未知状态保持原值", () => {
|
it("未知状态保持原值", () => {
|
||||||
expect(statusLabel("custom")).toBe("custom");
|
expect(statusLabel("custom")).toBe("custom");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("消费类型使用清晰的中文标签", () => {
|
||||||
|
expect(usageTypeLabel("polish")).toBe("润色");
|
||||||
|
expect(usageTypeLabel("hotword")).toBe("热词");
|
||||||
|
expect(usageTypeLabel()).toBe("—");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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("提交");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: "/admin/",
|
base: "/admin/",
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/v1/admin": {
|
"/v1/admin": {
|
||||||
@@ -12,6 +15,21 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: "es2022",
|
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: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation("com.auth0:java-jwt:4.6.0")
|
implementation("com.auth0:java-jwt:4.6.0")
|
||||||
implementation("com.nimbusds:nimbus-jose-jwt:10.9.1")
|
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("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
|
||||||
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
|
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
|
||||||
implementation("com.upokecenter:cbor:4.5.6")
|
implementation("com.upokecenter:cbor:4.5.6")
|
||||||
|
|||||||
+7
-2
@@ -54,12 +54,17 @@ services:
|
|||||||
VOLCENGINE_ASR_ENDPOINT: ${VOLCENGINE_ASR_ENDPOINT:-wss://openspeech.bytedance.com/api/v3/sauc/bigmodel}
|
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_API_KEY: ${DEEPSEEK_API_KEY:?set DeepSeek API key}
|
||||||
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash}
|
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash}
|
||||||
|
DEEPSEEK_REASONING_MODEL: ${DEEPSEEK_REASONING_MODEL:-}
|
||||||
DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1}
|
DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1}
|
||||||
|
|
||||||
SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000}
|
SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000}
|
||||||
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-3000}
|
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-1000}
|
||||||
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-3000}
|
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-1000}
|
||||||
REFERRAL_BINDING_DAYS: ${REFERRAL_BINDING_DAYS:-7}
|
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:
|
ports:
|
||||||
- "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080"
|
- "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080"
|
||||||
read_only: true
|
read_only: true
|
||||||
|
|||||||
+39
-2
@@ -319,6 +319,12 @@ verify_immutable_history_denials() {
|
|||||||
expect_runtime_denied \
|
expect_runtime_denied \
|
||||||
"admin grant DELETE" \
|
"admin grant DELETE" \
|
||||||
"DELETE FROM admin_credit_grants WHERE 1 = 0"
|
"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 \
|
expect_runtime_denied \
|
||||||
"Flyway metadata read" \
|
"Flyway metadata read" \
|
||||||
"SELECT version FROM flyway_schema_history LIMIT 1"
|
"SELECT version FROM flyway_schema_history LIMIT 1"
|
||||||
@@ -464,9 +470,40 @@ WHERE version IS NOT NULL
|
|||||||
ORDER BY installed_rank;
|
ORDER BY installed_rank;
|
||||||
SQL
|
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" ]] ||
|
[[ "$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
|
compose --profile setup stop schema-migrator >/dev/null
|
||||||
|
|
||||||
log "installing exact runtime grants and disposable fixture"
|
log "installing exact runtime grants and disposable fixture"
|
||||||
|
|||||||
@@ -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.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.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.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_operators TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.admin_sessions 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_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.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, DELETE ON osg_account_smoke.accounts TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT, UPDATE ON osg_account_smoke.apple_credentials 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.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.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.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 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, 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_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.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 1–100).
|
||||||
|
- Responses omit JWS data, Apple identifiers, account IDs, and internal record IDs.
|
||||||
|
- History queries never call the App Store.
|
||||||
@@ -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.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.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.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_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_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_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.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, 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.%';
|
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.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.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.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
|
-- Operators and sessions are mutable authentication state. Audit and grant
|
||||||
-- records remain append-only and deliberately receive no UPDATE or DELETE.
|
-- 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 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, 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_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.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,
|
-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
|
||||||
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
|
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
|
||||||
|
|||||||
+217
-8
@@ -75,7 +75,7 @@ paths:
|
|||||||
default: { $ref: "#/components/responses/Error" }
|
default: { $ref: "#/components/responses/Error" }
|
||||||
/v1/account:
|
/v1/account:
|
||||||
get:
|
get:
|
||||||
summary: Return the minimal account profile
|
summary: Return the account profile
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Account profile
|
description: Account profile
|
||||||
@@ -83,6 +83,20 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: "#/components/schemas/AccountEnvelope" }
|
schema: { $ref: "#/components/schemas/AccountEnvelope" }
|
||||||
default: { $ref: "#/components/responses/Error" }
|
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:
|
delete:
|
||||||
summary: Reauthenticate with Apple, delete the account, and revoke authorization
|
summary: Reauthenticate with Apple, delete the account, and revoke authorization
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -112,7 +126,7 @@ paths:
|
|||||||
default: { $ref: "#/components/responses/Error" }
|
default: { $ref: "#/components/responses/Error" }
|
||||||
/v1/credits/balance:
|
/v1/credits/balance:
|
||||||
get:
|
get:
|
||||||
summary: Return available integer credits
|
summary: Return available and consumed integer credits
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Credit account
|
description: Credit account
|
||||||
@@ -146,6 +160,66 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items: { type: object, additionalProperties: true }
|
items: { type: object, additionalProperties: true }
|
||||||
default: { $ref: "#/components/responses/Error" }
|
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:
|
/v1/referrals:
|
||||||
get:
|
get:
|
||||||
summary: List invitees without exposing their Apple identity
|
summary: List invitees without exposing their Apple identity
|
||||||
@@ -162,7 +236,7 @@ paths:
|
|||||||
default: { $ref: "#/components/responses/Error" }
|
default: { $ref: "#/components/responses/Error" }
|
||||||
/v1/referrals/me:
|
/v1/referrals/me:
|
||||||
get:
|
get:
|
||||||
summary: Return the current referral code and binding
|
summary: Return the referral profile and idempotently provision its invite code
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Referral profile
|
description: Referral profile
|
||||||
@@ -304,6 +378,11 @@ paths:
|
|||||||
/v1/gateway/llm/{capability}:
|
/v1/gateway/llm/{capability}:
|
||||||
post:
|
post:
|
||||||
summary: Run a metered polish, AI, or agent request
|
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:
|
parameters:
|
||||||
- $ref: "#/components/parameters/RequestId"
|
- $ref: "#/components/parameters/RequestId"
|
||||||
- name: capability
|
- name: capability
|
||||||
@@ -497,7 +576,7 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- adminMtls: []
|
- adminMtls: []
|
||||||
adminSession: []
|
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:
|
parameters:
|
||||||
- name: q
|
- name: q
|
||||||
in: query
|
in: query
|
||||||
@@ -552,6 +631,25 @@ paths:
|
|||||||
"400": { description: Cursor is malformed }
|
"400": { description: Cursor is malformed }
|
||||||
"403": { description: ANALYST role cannot access credit ledger records }
|
"403": { description: ANALYST role cannot access credit ledger records }
|
||||||
"404": { description: User was not found }
|
"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:
|
/v1/admin/credits/grants:
|
||||||
post:
|
post:
|
||||||
security:
|
security:
|
||||||
@@ -907,12 +1005,13 @@ components:
|
|||||||
AdminUserSummary:
|
AdminUserSummary:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [userId, displayName, status, creditBalance, createdAt]
|
required: [userId, displayName, status, creditBalance, consumedCredits, createdAt]
|
||||||
properties:
|
properties:
|
||||||
userId: { type: string, format: uuid }
|
userId: { type: string, format: uuid }
|
||||||
displayName: { type: string }
|
displayName: { type: string }
|
||||||
status: { type: string, enum: [active, suspended, closed] }
|
status: { type: string, enum: [active, suspended, closed] }
|
||||||
creditBalance: { type: integer, format: int64, minimum: 0 }
|
creditBalance: { type: integer, format: int64, minimum: 0 }
|
||||||
|
consumedCredits: { type: integer, format: int64, minimum: 0 }
|
||||||
createdAt: { type: string, format: date-time }
|
createdAt: { type: string, format: date-time }
|
||||||
AdminUserPage:
|
AdminUserPage:
|
||||||
type: object
|
type: object
|
||||||
@@ -939,6 +1038,7 @@ components:
|
|||||||
- displayName
|
- displayName
|
||||||
- status
|
- status
|
||||||
- creditBalance
|
- creditBalance
|
||||||
|
- consumedCredits
|
||||||
- createdAt
|
- createdAt
|
||||||
- qualifiedUsage
|
- qualifiedUsage
|
||||||
- usage
|
- usage
|
||||||
@@ -948,6 +1048,7 @@ components:
|
|||||||
displayName: { type: string }
|
displayName: { type: string }
|
||||||
status: { type: string, enum: [active, suspended, closed] }
|
status: { type: string, enum: [active, suspended, closed] }
|
||||||
creditBalance: { type: integer, format: int64, minimum: 0 }
|
creditBalance: { type: integer, format: int64, minimum: 0 }
|
||||||
|
consumedCredits: { type: integer, format: int64, minimum: 0 }
|
||||||
createdAt: { type: string, format: date-time }
|
createdAt: { type: string, format: date-time }
|
||||||
lastActiveAt: { type: ["string", "null"], format: date-time }
|
lastActiveAt: { type: ["string", "null"], format: date-time }
|
||||||
qualifiedUsage: { type: boolean }
|
qualifiedUsage: { type: boolean }
|
||||||
@@ -960,13 +1061,18 @@ components:
|
|||||||
AdminLedgerEntry:
|
AdminLedgerEntry:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [entryId, type, amount, balanceAfter, reasonCode, createdAt]
|
required: [entryId, userId, type, amount, balanceAfter, reasonCode, createdAt]
|
||||||
properties:
|
properties:
|
||||||
entryId: { type: string, format: uuid }
|
entryId: { type: string, format: uuid }
|
||||||
|
userId: { type: string, format: uuid }
|
||||||
type: { type: string, enum: [grant, reserve, settle, refund, adjustment] }
|
type: { type: string, enum: [grant, reserve, settle, refund, adjustment] }
|
||||||
amount: { type: integer, format: int64 }
|
amount: { type: integer, format: int64 }
|
||||||
balanceAfter: { type: integer, format: int64, minimum: 0 }
|
balanceAfter: { type: integer, format: int64, minimum: 0 }
|
||||||
reasonCode: { type: string }
|
reasonCode: { 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 }
|
createdAt: { type: string, format: date-time }
|
||||||
AdminLedgerPage:
|
AdminLedgerPage:
|
||||||
type: object
|
type: object
|
||||||
@@ -1088,6 +1194,10 @@ components:
|
|||||||
nonce:
|
nonce:
|
||||||
type: string
|
type: string
|
||||||
description: Raw nonce whose lowercase SHA-256 hex digest was sent to Apple.
|
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:
|
deviceCheckToken:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
description: Ephemeral DeviceCheck token; never persisted in plaintext.
|
description: Ephemeral DeviceCheck token; never persisted in plaintext.
|
||||||
@@ -1143,12 +1253,24 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: { type: string, format: uuid }
|
id: { type: string, format: uuid }
|
||||||
createdAtEpochSeconds: { type: integer, format: int64 }
|
createdAtEpochSeconds: { type: integer, format: int64 }
|
||||||
|
displayName:
|
||||||
|
type: ["string", "null"]
|
||||||
|
maxLength: 64
|
||||||
AccountEnvelope:
|
AccountEnvelope:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [data]
|
required: [data]
|
||||||
properties:
|
properties:
|
||||||
data: { $ref: "#/components/schemas/Account" }
|
data: { $ref: "#/components/schemas/Account" }
|
||||||
|
UpdateAccountProfileRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [displayName]
|
||||||
|
properties:
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 64
|
||||||
DeleteAccountRequest:
|
DeleteAccountRequest:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
@@ -1160,10 +1282,15 @@ components:
|
|||||||
CreditAccount:
|
CreditAccount:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
required: [userId, balance]
|
required: [userId, balance, lifetimeUsed]
|
||||||
properties:
|
properties:
|
||||||
userId: { type: string, format: uuid }
|
userId: { type: string, format: uuid }
|
||||||
balance: { type: integer, format: int64, minimum: 0 }
|
balance: { type: integer, format: int64, minimum: 0 }
|
||||||
|
lifetimeUsed:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
description: Settled usage minus credits returned by refunds
|
||||||
LedgerEntry:
|
LedgerEntry:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
@@ -1172,6 +1299,58 @@ components:
|
|||||||
id: { type: string, format: uuid }
|
id: { type: string, format: uuid }
|
||||||
amountDelta: { type: integer, format: int64 }
|
amountDelta: { type: integer, format: int64 }
|
||||||
balanceAfter: { type: integer, format: int64, minimum: 0 }
|
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:
|
AppleAppSiteAssociation:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
@@ -1245,9 +1424,39 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
input: { type: string, minLength: 1, maxLength: 32000 }
|
input: { type: string, minLength: 1, maxLength: 32000 }
|
||||||
context: { type: ["string", "null"], 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 }
|
temperature: { type: number, minimum: 0, maximum: 1, default: 0.2 }
|
||||||
stream: { type: boolean, default: false }
|
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:
|
CreateGatewayGrantRequest:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
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.AdminUsersRepository
|
||||||
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
|
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
|
||||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
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.AccountReauthenticator
|
||||||
|
import com.osglab.account.features.account.AccountRepository
|
||||||
import com.osglab.account.features.account.AccountService
|
import com.osglab.account.features.account.AccountService
|
||||||
import com.osglab.account.features.account.AppleAccountReauthenticator
|
import com.osglab.account.features.account.AppleAccountReauthenticator
|
||||||
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
|
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.InviteWebConfig
|
||||||
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
||||||
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
||||||
|
import com.osglab.account.features.referrals.domain.ReferralException
|
||||||
import com.osglab.account.features.referrals.routes.referralRoutes
|
import com.osglab.account.features.referrals.routes.referralRoutes
|
||||||
import com.osglab.account.features.referrals.services.ReferralOperations
|
import com.osglab.account.features.referrals.services.ReferralOperations
|
||||||
import com.osglab.account.features.referrals.services.ReferralService
|
import com.osglab.account.features.referrals.services.ReferralService
|
||||||
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
|
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
|
||||||
import com.osglab.account.features.referrals.services.ReferralRiskProvider
|
import com.osglab.account.features.referrals.services.ReferralRiskProvider
|
||||||
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
|
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.HttpClient
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
|
||||||
@@ -288,6 +295,7 @@ fun Application.module() {
|
|||||||
accountRoutes(koin.get())
|
accountRoutes(koin.get())
|
||||||
creditRoutes(koin.get(), koin.get())
|
creditRoutes(koin.get(), koin.get())
|
||||||
referralRoutes(koin.get(), koin.get())
|
referralRoutes(koin.get(), koin.get())
|
||||||
|
storeKitRoutes(koin.get())
|
||||||
}
|
}
|
||||||
rateLimit(GATEWAY_RATE_LIMIT) {
|
rateLimit(GATEWAY_RATE_LIMIT) {
|
||||||
configureGatewayRoutes(
|
configureGatewayRoutes(
|
||||||
@@ -431,6 +439,23 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
single<CreditOperations> { get<CreditService>() }
|
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> {
|
single<TrialCreditGranter> {
|
||||||
TrialCreditGranter { accountId ->
|
TrialCreditGranter { accountId ->
|
||||||
get<CreditService>().grantSignupTrial(
|
get<CreditService>().grantSignupTrial(
|
||||||
@@ -454,7 +479,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
|
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
|
||||||
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
|
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
|
||||||
single<AccountProvisioner> {
|
single<AccountProvisioner> {
|
||||||
AccountProvisioner { accountId, deviceCheckToken ->
|
AccountProvisioner { accountId, deviceCheckToken, displayName ->
|
||||||
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
||||||
if (deviceCheckToken != null && !granted) {
|
if (deviceCheckToken != null && !granted) {
|
||||||
get<AuthRepository>().restrictAccountForAntiAbuse(
|
get<AuthRepository>().restrictAccountForAntiAbuse(
|
||||||
@@ -462,6 +487,14 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
java.time.Instant.now(),
|
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 {
|
single {
|
||||||
@@ -494,6 +527,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
reauthenticator = get(),
|
reauthenticator = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
single<AccountOperations> { get<AccountService>() }
|
||||||
|
|
||||||
single<UserRegistrationTimeProvider> {
|
single<UserRegistrationTimeProvider> {
|
||||||
UserRegistrationTimeProvider { accountId ->
|
UserRegistrationTimeProvider { accountId ->
|
||||||
@@ -575,6 +609,7 @@ private fun configuredProviders(config: AppConfig, client: HttpClient): List<Gat
|
|||||||
endpoint = config.providers.deepSeek.endpoint,
|
endpoint = config.providers.deepSeek.endpoint,
|
||||||
apiKey = apiKey,
|
apiKey = apiKey,
|
||||||
model = config.providers.deepSeek.model,
|
model = config.providers.deepSeek.model,
|
||||||
|
reasoningModel = config.providers.deepSeek.reasoningModel,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.osglab.account.config
|
package com.osglab.account.config
|
||||||
|
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||||
import io.ktor.server.config.ApplicationConfig
|
import io.ktor.server.config.ApplicationConfig
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
@@ -16,6 +17,7 @@ data class AppConfig(
|
|||||||
val antiAbuse: AntiAbuseConfig,
|
val antiAbuse: AntiAbuseConfig,
|
||||||
val apple: AppleConfig,
|
val apple: AppleConfig,
|
||||||
val credits: CreditsConfig,
|
val credits: CreditsConfig,
|
||||||
|
val storeKit: StoreKitConfig = StoreKitConfig(),
|
||||||
val providers: ProvidersConfig,
|
val providers: ProvidersConfig,
|
||||||
val integrity: IntegrityConfig,
|
val integrity: IntegrityConfig,
|
||||||
val admin: AdminConfig = AdminConfig(),
|
val admin: AdminConfig = AdminConfig(),
|
||||||
@@ -76,10 +78,36 @@ data class AppConfig(
|
|||||||
)
|
)
|
||||||
val credits = CreditsConfig(
|
val credits = CreditsConfig(
|
||||||
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
|
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
|
||||||
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000),
|
referralInviter = config.positiveLong("app.credits.referralInviter", 1_000),
|
||||||
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_000),
|
referralInvitee = config.positiveLong("app.credits.referralInvitee", 1_000),
|
||||||
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
|
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(
|
val providers = ProvidersConfig(
|
||||||
volcengine = VolcengineConfig(
|
volcengine = VolcengineConfig(
|
||||||
endpoint = config.valueOrDefault(
|
endpoint = config.valueOrDefault(
|
||||||
@@ -100,7 +128,9 @@ data class AppConfig(
|
|||||||
"https://api.deepseek.com/v1",
|
"https://api.deepseek.com/v1",
|
||||||
),
|
),
|
||||||
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
|
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(
|
val integrity = IntegrityConfig(
|
||||||
@@ -294,6 +324,7 @@ data class AppConfig(
|
|||||||
antiAbuse = antiAbuse,
|
antiAbuse = antiAbuse,
|
||||||
apple = apple,
|
apple = apple,
|
||||||
credits = credits,
|
credits = credits,
|
||||||
|
storeKit = storeKit,
|
||||||
providers = providers,
|
providers = providers,
|
||||||
integrity = integrity,
|
integrity = integrity,
|
||||||
admin = admin,
|
admin = admin,
|
||||||
@@ -359,6 +390,13 @@ data class CreditsConfig(
|
|||||||
val referralBindingDays: Long,
|
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(
|
data class ProvidersConfig(
|
||||||
val volcengine: VolcengineConfig,
|
val volcengine: VolcengineConfig,
|
||||||
val deepSeek: DeepSeekConfig,
|
val deepSeek: DeepSeekConfig,
|
||||||
@@ -379,6 +417,7 @@ data class DeepSeekConfig(
|
|||||||
val endpoint: String,
|
val endpoint: String,
|
||||||
val apiKey: String?,
|
val apiKey: String?,
|
||||||
val model: String,
|
val model: String,
|
||||||
|
val reasoningModel: String = model,
|
||||||
) {
|
) {
|
||||||
val credentialsAvailable: Boolean
|
val credentialsAvailable: Boolean
|
||||||
get() = !apiKey.isNullOrBlank()
|
get() = !apiKey.isNullOrBlank()
|
||||||
@@ -492,6 +531,24 @@ private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
|
|||||||
?: throw ConfigValidationException("$path must be a positive integer")
|
?: throw ConfigValidationException("$path must be a positive integer")
|
||||||
} ?: default
|
} ?: 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 =
|
private fun ApplicationConfig.boolean(path: String): Boolean =
|
||||||
required(path).let {
|
required(path).let {
|
||||||
when (it.lowercase()) {
|
when (it.lowercase()) {
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ data class AccountRecord(
|
|||||||
val identityFingerprint: String,
|
val identityFingerprint: String,
|
||||||
val antiAbuseRestricted: Boolean,
|
val antiAbuseRestricted: Boolean,
|
||||||
val encryptedAppleRefreshToken: String?,
|
val encryptedAppleRefreshToken: String?,
|
||||||
|
val encryptedDisplayName: String? = null,
|
||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
) {
|
) {
|
||||||
override fun toString(): String =
|
override fun toString(): String =
|
||||||
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
|
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
|
||||||
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
|
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
|
||||||
|
"encryptedDisplayName=[REDACTED], " +
|
||||||
"createdAt=$createdAt)"
|
"createdAt=$createdAt)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +63,18 @@ internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
|
|||||||
override val primaryKey = PrimaryKey(id)
|
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 {
|
interface AccountRepository {
|
||||||
suspend fun findById(accountId: UUID): AccountRecord?
|
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(
|
suspend fun deleteById(
|
||||||
accountId: UUID,
|
accountId: UUID,
|
||||||
deletedAt: Instant,
|
deletedAt: Instant,
|
||||||
@@ -89,17 +101,58 @@ class ExposedAccountRepository(
|
|||||||
.where { AppleCredentialsTable.accountId eq accountId.toString() }
|
.where { AppleCredentialsTable.accountId eq accountId.toString() }
|
||||||
.singleOrNull()
|
.singleOrNull()
|
||||||
?.get(AppleCredentialsTable.encryptedRefreshToken)
|
?.get(AppleCredentialsTable.encryptedRefreshToken)
|
||||||
|
val encryptedDisplayName = AccountProfilesTable.selectAll()
|
||||||
|
.where { AccountProfilesTable.accountId eq accountId.toString() }
|
||||||
|
.singleOrNull()
|
||||||
|
?.get(AccountProfilesTable.encryptedDisplayName)
|
||||||
row.let {
|
row.let {
|
||||||
AccountRecord(
|
AccountRecord(
|
||||||
id = UUID.fromString(it[AccountsTable.id]),
|
id = UUID.fromString(it[AccountsTable.id]),
|
||||||
identityFingerprint = fingerprint,
|
identityFingerprint = fingerprint,
|
||||||
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
|
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
|
||||||
encryptedAppleRefreshToken = encryptedRefreshToken,
|
encryptedAppleRefreshToken = encryptedRefreshToken,
|
||||||
|
encryptedDisplayName = encryptedDisplayName,
|
||||||
createdAt = it[AccountsTable.createdAt],
|
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(
|
override suspend fun deleteById(
|
||||||
accountId: UUID,
|
accountId: UUID,
|
||||||
deletedAt: Instant,
|
deletedAt: Instant,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import io.ktor.server.response.respond
|
|||||||
import io.ktor.server.routing.Route
|
import io.ktor.server.routing.Route
|
||||||
import io.ktor.server.routing.delete
|
import io.ktor.server.routing.delete
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.patch
|
||||||
import io.ktor.server.routing.route
|
import io.ktor.server.routing.route
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -19,8 +20,12 @@ import kotlinx.serialization.Serializable
|
|||||||
data class AccountResponse(
|
data class AccountResponse(
|
||||||
val id: String,
|
val id: String,
|
||||||
val createdAtEpochSeconds: Long,
|
val createdAtEpochSeconds: Long,
|
||||||
|
val displayName: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpdateAccountProfileRequest(val displayName: String)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DeleteAccountRequest(
|
data class DeleteAccountRequest(
|
||||||
val identityToken: String,
|
val identityToken: String,
|
||||||
@@ -39,7 +44,7 @@ data class DeleteAccountRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AccountRoutes(
|
class AccountRoutes(
|
||||||
private val accountService: AccountService,
|
private val accountService: AccountOperations,
|
||||||
) {
|
) {
|
||||||
fun register(parent: Route) {
|
fun register(parent: Route) {
|
||||||
with(parent) {
|
with(parent) {
|
||||||
@@ -49,14 +54,17 @@ class AccountRoutes(
|
|||||||
val principal = call.principal<AccountPrincipal>()
|
val principal = call.principal<AccountPrincipal>()
|
||||||
?: throw UnauthorizedException()
|
?: throw UnauthorizedException()
|
||||||
val account = accountService.get(principal.userId)
|
val account = accountService.get(principal.userId)
|
||||||
call.respond(
|
call.respond(ApiResponse(data = account.toResponse()))
|
||||||
ApiResponse(
|
}
|
||||||
data = AccountResponse(
|
patch {
|
||||||
id = account.id.toString(),
|
val principal = call.principal<AccountPrincipal>()
|
||||||
createdAtEpochSeconds = account.createdAt.epochSecond,
|
?: throw UnauthorizedException()
|
||||||
),
|
val request = call.receive<UpdateAccountProfileRequest>()
|
||||||
),
|
val account = accountService.updateDisplayName(
|
||||||
|
principal.userId,
|
||||||
|
request.displayName,
|
||||||
)
|
)
|
||||||
|
call.respond(ApiResponse(data = account.toResponse()))
|
||||||
}
|
}
|
||||||
delete {
|
delete {
|
||||||
val principal = call.principal<AccountPrincipal>()
|
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)
|
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
|
package com.osglab.account.features.account
|
||||||
|
|
||||||
import com.osglab.account.common.errors.ExternalServiceUnavailableException
|
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.errors.UnauthorizedException
|
||||||
import com.osglab.account.common.security.FieldDecryptionException
|
import com.osglab.account.common.security.FieldDecryptionException
|
||||||
import com.osglab.account.common.security.FieldEncryptor
|
import com.osglab.account.common.security.FieldEncryptor
|
||||||
@@ -16,11 +17,13 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.text.Normalizer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class AccountView(
|
data class AccountView(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
|
val displayName: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AppleReauthenticationProof(
|
data class AppleReauthenticationProof(
|
||||||
@@ -41,6 +44,13 @@ fun interface AccountReauthenticator {
|
|||||||
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
|
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(
|
class AppleAccountReauthenticator(
|
||||||
private val identityVerifier: AppleIdentityTokenVerifier,
|
private val identityVerifier: AppleIdentityTokenVerifier,
|
||||||
private val appleTokenClient: AppleTokenClient,
|
private val appleTokenClient: AppleTokenClient,
|
||||||
@@ -94,13 +104,34 @@ class AccountService(
|
|||||||
private val revocationProcessor: AppleRevocationOutboxProcessor,
|
private val revocationProcessor: AppleRevocationOutboxProcessor,
|
||||||
private val reauthenticator: AccountReauthenticator,
|
private val reauthenticator: AccountReauthenticator,
|
||||||
private val clock: Clock = Clock.systemUTC(),
|
private val clock: Clock = Clock.systemUTC(),
|
||||||
) {
|
) : AccountOperations {
|
||||||
suspend fun get(accountId: UUID): AccountView {
|
override suspend fun get(accountId: UUID): AccountView {
|
||||||
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
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 account = repository.findById(accountId) ?: return
|
||||||
val now = clock.instant()
|
val now = clock.instant()
|
||||||
val currentRefreshToken = reauthenticator.verify(account, proof)
|
val currentRefreshToken = reauthenticator.verify(account, proof)
|
||||||
@@ -126,6 +157,14 @@ class AccountService(
|
|||||||
// Local deletion is final. The durable outbox retry loop handles Apple outages.
|
// 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(
|
class AppleRevocationOutboxProcessor(
|
||||||
@@ -175,3 +214,24 @@ class AppleRevocationOutboxProcessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
|
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") {
|
post("/credits/grants") {
|
||||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||||
@@ -646,9 +673,10 @@ private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
|
|||||||
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
|
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
|
||||||
AdminUserSummaryResponse(
|
AdminUserSummaryResponse(
|
||||||
userId = id,
|
userId = id,
|
||||||
displayName = "用户 ${id.take(8)}",
|
displayName = "用户 ${id.takeLast(8).uppercase()}",
|
||||||
status = if (antiAbuseRestricted) "suspended" else "active",
|
status = if (antiAbuseRestricted) "suspended" else "active",
|
||||||
creditBalance = creditBalance,
|
creditBalance = creditBalance,
|
||||||
|
consumedCredits = consumedCredits,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -666,6 +694,7 @@ private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
|
|||||||
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
||||||
AdminLedgerResponse(
|
AdminLedgerResponse(
|
||||||
entryId = id,
|
entryId = id,
|
||||||
|
userId = userId,
|
||||||
type = when (type) {
|
type = when (type) {
|
||||||
"USAGE_RESERVE" -> "reserve"
|
"USAGE_RESERVE" -> "reserve"
|
||||||
"USAGE_SETTLE" -> "settle"
|
"USAGE_SETTLE" -> "settle"
|
||||||
@@ -677,6 +706,7 @@ private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
|||||||
amount = amountDelta,
|
amount = amountDelta,
|
||||||
balanceAfter = balanceAfter,
|
balanceAfter = balanceAfter,
|
||||||
reasonCode = type,
|
reasonCode = type,
|
||||||
|
usageType = usageType,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -817,6 +847,7 @@ private data class AdminUserSummaryResponse(
|
|||||||
val displayName: String,
|
val displayName: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val creditBalance: Long,
|
val creditBalance: Long,
|
||||||
|
val consumedCredits: Long,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -826,6 +857,7 @@ private data class AdminUserDetailResponse(
|
|||||||
val displayName: String,
|
val displayName: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val creditBalance: Long,
|
val creditBalance: Long,
|
||||||
|
val consumedCredits: Long,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val lastActiveAt: String?,
|
val lastActiveAt: String?,
|
||||||
val qualifiedUsage: Boolean,
|
val qualifiedUsage: Boolean,
|
||||||
@@ -847,6 +879,7 @@ private data class AdminUserDetailResponse(
|
|||||||
displayName = summary.displayName,
|
displayName = summary.displayName,
|
||||||
status = summary.status,
|
status = summary.status,
|
||||||
creditBalance = summary.creditBalance,
|
creditBalance = summary.creditBalance,
|
||||||
|
consumedCredits = summary.consumedCredits,
|
||||||
createdAt = summary.createdAt,
|
createdAt = summary.createdAt,
|
||||||
lastActiveAt = lastActiveAt,
|
lastActiveAt = lastActiveAt,
|
||||||
qualifiedUsage = qualifiedUsage,
|
qualifiedUsage = qualifiedUsage,
|
||||||
@@ -860,10 +893,12 @@ private data class AdminUserDetailResponse(
|
|||||||
@Serializable
|
@Serializable
|
||||||
private data class AdminLedgerResponse(
|
private data class AdminLedgerResponse(
|
||||||
val entryId: String,
|
val entryId: String,
|
||||||
|
val userId: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
val amount: Long,
|
val amount: Long,
|
||||||
val balanceAfter: Long,
|
val balanceAfter: Long,
|
||||||
val reasonCode: String,
|
val reasonCode: String,
|
||||||
|
val usageType: String?,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ data class AdminUserPageDto(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class AdminUserLedgerEntryDto(
|
data class AdminUserLedgerEntryDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val userId: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
val amountDelta: Long,
|
val amountDelta: Long,
|
||||||
val balanceAfter: Long,
|
val balanceAfter: Long,
|
||||||
val referenceId: String?,
|
val referenceId: String?,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
|
val usageType: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
+91
-8
@@ -16,6 +16,7 @@ import org.jetbrains.exposed.v1.core.and
|
|||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.core.inList
|
import org.jetbrains.exposed.v1.core.inList
|
||||||
import org.jetbrains.exposed.v1.core.less
|
import org.jetbrains.exposed.v1.core.less
|
||||||
|
import org.jetbrains.exposed.v1.core.like
|
||||||
import org.jetbrains.exposed.v1.core.or
|
import org.jetbrains.exposed.v1.core.or
|
||||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
@@ -35,6 +36,8 @@ data class AdminUserLedgerCursor(
|
|||||||
interface AdminUsersRepository {
|
interface AdminUsersRepository {
|
||||||
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
|
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 exists(userId: UUID): Boolean
|
||||||
|
|
||||||
suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto?
|
suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto?
|
||||||
@@ -44,6 +47,11 @@ interface AdminUsersRepository {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
cursor: AdminUserLedgerCursor?,
|
cursor: AdminUserLedgerCursor?,
|
||||||
): List<AdminUserLedgerEntryDto>
|
): List<AdminUserLedgerEntryDto>
|
||||||
|
|
||||||
|
suspend fun listLatestLedger(
|
||||||
|
limit: Int,
|
||||||
|
cursor: AdminUserLedgerCursor?,
|
||||||
|
): List<AdminUserLedgerEntryDto>
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExposedAdminUsersRepository(
|
class ExposedAdminUsersRepository(
|
||||||
@@ -74,6 +82,22 @@ class ExposedAdminUsersRepository(
|
|||||||
accountRows.map { it.toSummary(support) }
|
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 {
|
override suspend fun exists(userId: UUID): Boolean = databaseFactory.query {
|
||||||
AdminUsersAccountsTable.selectAll()
|
AdminUsersAccountsTable.selectAll()
|
||||||
.where { AdminUsersAccountsTable.id eq userId.toString() }
|
.where { AdminUsersAccountsTable.id eq userId.toString() }
|
||||||
@@ -111,7 +135,7 @@ class ExposedAdminUsersRepository(
|
|||||||
.thenByDescending { it.id.toString() },
|
.thenByDescending { it.id.toString() },
|
||||||
)
|
)
|
||||||
.take(ledgerLimit)
|
.take(ledgerLimit)
|
||||||
.map(UserLedgerRow::toDto)
|
.map { it.toDto(support.ledgerUsageTypes[it.referenceId]) }
|
||||||
AdminUserDetailDto(
|
AdminUserDetailDto(
|
||||||
summary = account.toSummary(support),
|
summary = account.toSummary(support),
|
||||||
referralCode = findReferralCode(userId),
|
referralCode = findReferralCode(userId),
|
||||||
@@ -147,18 +171,45 @@ class ExposedAdminUsersRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
query.orderBy(
|
val ledger = query.orderBy(
|
||||||
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
|
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
|
||||||
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
|
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
|
||||||
)
|
)
|
||||||
.limit(limit)
|
.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(
|
private data class UserSupportRows(
|
||||||
val balances: Map<UUID, Long>,
|
val balances: Map<UUID, Long>,
|
||||||
val ledger: List<UserLedgerRow>,
|
val ledger: List<UserLedgerRow>,
|
||||||
|
val ledgerUsageTypes: Map<UUID, String>,
|
||||||
val usage: List<UserUsageRow>,
|
val usage: List<UserUsageRow>,
|
||||||
val bindings: List<UserReferralBindingRow>,
|
val bindings: List<UserReferralBindingRow>,
|
||||||
)
|
)
|
||||||
@@ -190,16 +241,20 @@ private data class UserReferralBindingRow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private fun loadSupport(userIds: Set<UUID>): UserSupportRows {
|
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 ids = userIds.map(UUID::toString)
|
||||||
|
val ledger = AdminUsersCreditLedgerTable.selectAll()
|
||||||
|
.where { AdminUsersCreditLedgerTable.userId inList ids }
|
||||||
|
.map(ResultRow::toUserLedgerRow)
|
||||||
return UserSupportRows(
|
return UserSupportRows(
|
||||||
balances = AdminUsersCreditAccountsTable.selectAll()
|
balances = AdminUsersCreditAccountsTable.selectAll()
|
||||||
.where { AdminUsersCreditAccountsTable.userId inList ids }
|
.where { AdminUsersCreditAccountsTable.userId inList ids }
|
||||||
.map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] }
|
.map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] }
|
||||||
.toMap(),
|
.toMap(),
|
||||||
ledger = AdminUsersCreditLedgerTable.selectAll()
|
ledger = ledger,
|
||||||
.where { AdminUsersCreditLedgerTable.userId inList ids }
|
ledgerUsageTypes = loadLedgerUsageTypes(ledger),
|
||||||
.map(ResultRow::toUserLedgerRow),
|
|
||||||
usage = AdminUsersCreditUsageTable.selectAll()
|
usage = AdminUsersCreditUsageTable.selectAll()
|
||||||
.where { AdminUsersCreditUsageTable.userId inList ids }
|
.where { AdminUsersCreditUsageTable.userId inList ids }
|
||||||
.map(ResultRow::toUserUsageRow),
|
.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? =
|
private fun findReferralCode(userId: UUID): String? =
|
||||||
AdminUsersReferralCodesTable.selectAll()
|
AdminUsersReferralCodesTable.selectAll()
|
||||||
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
|
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
|
||||||
@@ -268,6 +340,15 @@ private object AdminUsersCreditLedgerTable : Table("credit_ledger") {
|
|||||||
val createdAt = timestamp("created_at")
|
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") {
|
private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
|
||||||
val userId = varchar("user_id", 36)
|
val userId = varchar("user_id", 36)
|
||||||
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
|
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
|
||||||
@@ -303,12 +384,14 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
|
|||||||
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
|
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
|
private fun UserLedgerRow.toDto(usageType: String?) = AdminUserLedgerEntryDto(
|
||||||
id = id.toString(),
|
id = id.toString(),
|
||||||
|
userId = userId.toString(),
|
||||||
type = type.name,
|
type = type.name,
|
||||||
amountDelta = amountDelta,
|
amountDelta = amountDelta,
|
||||||
balanceAfter = balanceAfter,
|
balanceAfter = balanceAfter,
|
||||||
referenceId = referenceId?.toString(),
|
referenceId = referenceId?.toString(),
|
||||||
|
usageType = usageType,
|
||||||
createdAt = createdAt.toString(),
|
createdAt = createdAt.toString(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+35
-6
@@ -1,6 +1,7 @@
|
|||||||
package com.osglab.account.features.admin.users.services
|
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.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.AdminUserLedgerPageDto
|
||||||
import com.osglab.account.features.admin.users.models.AdminUserPageDto
|
import com.osglab.account.features.admin.users.models.AdminUserPageDto
|
||||||
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
|
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
|
||||||
@@ -17,10 +18,19 @@ class AdminUsersService(
|
|||||||
private val repository: AdminUsersRepository,
|
private val repository: AdminUsersRepository,
|
||||||
) {
|
) {
|
||||||
suspend fun searchByInternalId(query: String): AdminUserPageDto {
|
suspend fun searchByInternalId(query: String): AdminUserPageDto {
|
||||||
val userId = runCatching { UUID.fromString(query.trim()) }.getOrNull()
|
val normalized = query.trim()
|
||||||
?: return AdminUserPageDto(emptyList(), null)
|
val userId = runCatching { UUID.fromString(normalized) }.getOrNull()
|
||||||
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
|
if (userId != null) {
|
||||||
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), 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(
|
suspend fun list(
|
||||||
@@ -58,11 +68,27 @@ class AdminUsersService(
|
|||||||
userId: UUID,
|
userId: UUID,
|
||||||
limit: Int = 50,
|
limit: Int = 50,
|
||||||
cursor: String? = null,
|
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 {
|
): AdminUserLedgerPageDto {
|
||||||
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
|
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
|
||||||
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
|
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
|
||||||
if (!repository.exists(userId)) throw AdminUserNotFoundException()
|
val results = load(limit + 1, decodedCursor)
|
||||||
val results = repository.listLedger(userId, limit + 1, decodedCursor)
|
|
||||||
val hasMore = results.size > limit
|
val hasMore = results.size > limit
|
||||||
val items = results.take(limit)
|
val items = results.take(limit)
|
||||||
val nextCursor = if (hasMore) {
|
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 {
|
internal object AdminUserCursorCodec {
|
||||||
fun encode(cursor: AdminUserCursor): String {
|
fun encode(cursor: AdminUserCursor): String {
|
||||||
val value = "${cursor.createdAt}|${cursor.userId}"
|
val value = "${cursor.createdAt}|${cursor.userId}"
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ data class AppleSignInRequest(
|
|||||||
val identityToken: String,
|
val identityToken: String,
|
||||||
val authorizationCode: String,
|
val authorizationCode: String,
|
||||||
val nonce: String,
|
val nonce: String,
|
||||||
|
val displayName: String? = null,
|
||||||
val deviceCheckToken: String? = null,
|
val deviceCheckToken: String? = null,
|
||||||
val appAttest: AppAttestRequest? = null,
|
val appAttest: AppAttestRequest? = null,
|
||||||
) {
|
) {
|
||||||
override fun toString(): String =
|
override fun toString(): String =
|
||||||
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
|
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
|
||||||
"nonce=[REDACTED], deviceCheckToken=[REDACTED], appAttest=[REDACTED])"
|
"nonce=[REDACTED], displayName=[REDACTED], deviceCheckToken=[REDACTED], " +
|
||||||
|
"appAttest=[REDACTED])"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -73,6 +75,7 @@ class AuthRoutes(
|
|||||||
identityToken = request.identityToken,
|
identityToken = request.identityToken,
|
||||||
authorizationCode = request.authorizationCode,
|
authorizationCode = request.authorizationCode,
|
||||||
nonce = request.nonce,
|
nonce = request.nonce,
|
||||||
|
displayName = request.displayName,
|
||||||
integrityEvidence = IntegrityEvidence(
|
integrityEvidence = IntegrityEvidence(
|
||||||
deviceCheckToken = request.deviceCheckToken,
|
deviceCheckToken = request.deviceCheckToken,
|
||||||
appAttest = request.appAttest?.let {
|
appAttest = request.appAttest?.let {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ data class SessionTokens(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun interface AccountProvisioner {
|
fun interface AccountProvisioner {
|
||||||
suspend fun provision(accountId: UUID, deviceCheckToken: String?)
|
suspend fun provision(accountId: UUID, deviceCheckToken: String?, displayName: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
class SessionService(
|
class SessionService(
|
||||||
@@ -46,7 +46,7 @@ class SessionService(
|
|||||||
private val fieldEncryptor: FieldEncryptor,
|
private val fieldEncryptor: FieldEncryptor,
|
||||||
private val identityFingerprint: IdentityFingerprint,
|
private val identityFingerprint: IdentityFingerprint,
|
||||||
private val sessionConfig: SessionConfig,
|
private val sessionConfig: SessionConfig,
|
||||||
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _ -> },
|
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _, _ -> },
|
||||||
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
|
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
|
||||||
private val clock: Clock = Clock.systemUTC(),
|
private val clock: Clock = Clock.systemUTC(),
|
||||||
) {
|
) {
|
||||||
@@ -55,6 +55,7 @@ class SessionService(
|
|||||||
authorizationCode: String,
|
authorizationCode: String,
|
||||||
nonce: String,
|
nonce: String,
|
||||||
integrityEvidence: IntegrityEvidence,
|
integrityEvidence: IntegrityEvidence,
|
||||||
|
displayName: String? = null,
|
||||||
): SessionTokens {
|
): SessionTokens {
|
||||||
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
|
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
|
||||||
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
|
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
|
||||||
@@ -90,6 +91,7 @@ class SessionService(
|
|||||||
accountProvisioner.provision(
|
accountProvisioner.provision(
|
||||||
account.id,
|
account.id,
|
||||||
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
|
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
|
||||||
|
displayName,
|
||||||
)
|
)
|
||||||
return createSession(account.id, now)
|
return createSession(account.id, now)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ data class CreditAccount(
|
|||||||
val updatedAt: Instant,
|
val updatedAt: Instant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class CreditAccountSummary(
|
||||||
|
val account: CreditAccount,
|
||||||
|
val lifetimeUsed: Long,
|
||||||
|
)
|
||||||
|
|
||||||
data class LedgerEntry(
|
data class LedgerEntry(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val userId: UUID,
|
val userId: UUID,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.osglab.account.features.credits.models
|
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.CreditRateVersion
|
||||||
import com.osglab.account.features.credits.domain.CreditReservation
|
import com.osglab.account.features.credits.domain.CreditReservation
|
||||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||||
@@ -64,13 +64,15 @@ data class SettleCreditsRequest(
|
|||||||
data class CreditAccountDto(
|
data class CreditAccountDto(
|
||||||
val userId: String,
|
val userId: String,
|
||||||
val balance: Long,
|
val balance: Long,
|
||||||
|
val lifetimeUsed: Long,
|
||||||
val updatedAt: String,
|
val updatedAt: String,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromDomain(account: CreditAccount) = CreditAccountDto(
|
fun fromDomain(summary: CreditAccountSummary) = CreditAccountDto(
|
||||||
userId = account.userId.toString(),
|
userId = summary.account.userId.toString(),
|
||||||
balance = account.balance,
|
balance = summary.account.balance,
|
||||||
updatedAt = account.updatedAt.toString(),
|
lifetimeUsed = summary.lifetimeUsed,
|
||||||
|
updatedAt = summary.account.updatedAt.toString(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -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.LedgerEntry
|
||||||
import com.osglab.account.features.credits.domain.UsageKind
|
import com.osglab.account.features.credits.domain.UsageKind
|
||||||
import com.osglab.account.features.referrals.repositories.ReferralsRepository
|
import com.osglab.account.features.referrals.repositories.ReferralsRepository
|
||||||
|
import com.osglab.account.features.storekit.repositories.StoreKitRepository
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ interface CreditsRepository {
|
|||||||
|
|
||||||
fun insertUsageRecord(record: CreditUsageRecord)
|
fun insertUsageRecord(record: CreditUsageRecord)
|
||||||
|
|
||||||
|
fun lifetimeUsedCredits(userId: UUID): Long
|
||||||
|
|
||||||
fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation?
|
fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation?
|
||||||
|
|
||||||
fun lockReservation(id: UUID): CreditReservation?
|
fun lockReservation(id: UUID): CreditReservation?
|
||||||
@@ -52,6 +55,7 @@ interface BillingUnitOfWork {
|
|||||||
val credits: CreditsRepository
|
val credits: CreditsRepository
|
||||||
val referrals: ReferralsRepository
|
val referrals: ReferralsRepository
|
||||||
val adminCreditGrants: AdminCreditGrantRepository
|
val adminCreditGrants: AdminCreditGrantRepository
|
||||||
|
val storeKit: StoreKitRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BillingTransactionRunner {
|
interface BillingTransactionRunner {
|
||||||
|
|||||||
+23
@@ -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.ReferralCode
|
||||||
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
|
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
|
||||||
import com.osglab.account.features.referrals.repositories.ReferralsRepository
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.exposed.v1.core.*
|
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.andWhere
|
||||||
import org.jetbrains.exposed.v1.jdbc.insert
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.select
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
@@ -184,6 +187,7 @@ private object ExposedBillingUnitOfWork : BillingUnitOfWork {
|
|||||||
override val credits: CreditsRepository = ExposedCreditsRepository
|
override val credits: CreditsRepository = ExposedCreditsRepository
|
||||||
override val referrals: ReferralsRepository = ExposedReferralsRepository
|
override val referrals: ReferralsRepository = ExposedReferralsRepository
|
||||||
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
|
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
|
||||||
|
override val storeKit: StoreKitRepository = ExposedStoreKitRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
private object ExposedCreditsRepository : CreditsRepository {
|
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(
|
override fun findReservationByReserveKey(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
idempotencyKey: String,
|
idempotencyKey: String,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class CreditRouteInstaller(
|
|||||||
parent.route("/v1/credits") {
|
parent.route("/v1/credits") {
|
||||||
get("/balance") {
|
get("/balance") {
|
||||||
call.creditCall(authenticatedUser) { userId ->
|
call.creditCall(authenticatedUser) { userId ->
|
||||||
CreditAccountDto.fromDomain(service.getAccount(userId))
|
CreditAccountDto.fromDomain(service.getAccountSummary(userId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
get("/ledger") {
|
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.AdminAuditOutcome
|
||||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||||
import com.osglab.account.features.credits.domain.CreditAccount
|
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.CreditConflict
|
||||||
import com.osglab.account.features.credits.domain.CreditCostCalculator
|
import com.osglab.account.features.credits.domain.CreditCostCalculator
|
||||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||||
@@ -46,6 +47,8 @@ data class ReferralRewardConfig(
|
|||||||
interface CreditOperations {
|
interface CreditOperations {
|
||||||
suspend fun getAccount(userId: UUID): CreditAccount
|
suspend fun getAccount(userId: UUID): CreditAccount
|
||||||
|
|
||||||
|
suspend fun getAccountSummary(userId: UUID): CreditAccountSummary
|
||||||
|
|
||||||
suspend fun listEffectiveRates(): List<CreditRateVersion>
|
suspend fun listEffectiveRates(): List<CreditRateVersion>
|
||||||
|
|
||||||
suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry>
|
suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry>
|
||||||
@@ -107,6 +110,15 @@ class CreditService(
|
|||||||
unit.credits.lockAccount(userId)
|
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> =
|
override suspend fun listEffectiveRates(): List<CreditRateVersion> =
|
||||||
transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) }
|
transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) }
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ enum class GatewayCapability {
|
|||||||
ASR,
|
ASR,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class GatewayRequestSource {
|
||||||
|
@SerialName("hotword")
|
||||||
|
HOTWORD,
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class UsageMeter {
|
enum class UsageMeter {
|
||||||
@SerialName("llm_token")
|
@SerialName("llm_token")
|
||||||
@@ -53,8 +59,85 @@ data class TextGatewayRequest(
|
|||||||
val maxOutputTokens: Int = 512,
|
val maxOutputTokens: Int = 512,
|
||||||
val temperature: Double = 0.2,
|
val temperature: Double = 0.2,
|
||||||
val stream: Boolean = false,
|
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
|
@Serializable
|
||||||
data class AsrGatewayOptions(
|
data class AsrGatewayOptions(
|
||||||
val format: String = "pcm",
|
val format: String = "pcm",
|
||||||
@@ -69,16 +152,20 @@ data class AsrGatewayOptions(
|
|||||||
sealed interface ProviderRequest {
|
sealed interface ProviderRequest {
|
||||||
val requestId: String
|
val requestId: String
|
||||||
val capability: GatewayCapability
|
val capability: GatewayCapability
|
||||||
|
val requestSource: GatewayRequestSource?
|
||||||
|
get() = null
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TextProviderRequest(
|
data class TextProviderRequest(
|
||||||
override val requestId: String,
|
override val requestId: String,
|
||||||
override val capability: GatewayCapability,
|
override val capability: GatewayCapability,
|
||||||
|
val executionPolicy: GatewayTaskExecutionPolicy,
|
||||||
val input: String,
|
val input: String,
|
||||||
val context: String?,
|
val context: String?,
|
||||||
val maxOutputTokens: Int,
|
val maxOutputTokens: Int,
|
||||||
val temperature: Double,
|
val temperature: Double,
|
||||||
val stream: Boolean,
|
val stream: Boolean,
|
||||||
|
override val requestSource: GatewayRequestSource? = null,
|
||||||
) : ProviderRequest
|
) : ProviderRequest
|
||||||
|
|
||||||
data class AsrProviderRequest(
|
data class AsrProviderRequest(
|
||||||
@@ -135,6 +222,9 @@ object TextRequestPolicy {
|
|||||||
require(capability != GatewayCapability.AGENT || !request.stream) {
|
require(capability != GatewayCapability.AGENT || !request.stream) {
|
||||||
"agent requests must be non-streaming so the structured result can be validated"
|
"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.GatewayCapability
|
||||||
import com.osglab.account.features.gateway.models.GatewayGrant
|
import com.osglab.account.features.gateway.models.GatewayGrant
|
||||||
import com.osglab.account.features.gateway.models.GatewayPrincipal
|
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.ProviderUsage
|
||||||
import com.osglab.account.features.gateway.models.UsageMeter
|
import com.osglab.account.features.gateway.models.UsageMeter
|
||||||
import io.ktor.server.application.ApplicationCall
|
import io.ktor.server.application.ApplicationCall
|
||||||
@@ -139,6 +140,7 @@ data class ProviderRequestMetadata(
|
|||||||
val reservationId: String,
|
val reservationId: String,
|
||||||
val providerId: String,
|
val providerId: String,
|
||||||
val capability: GatewayCapability,
|
val capability: GatewayCapability,
|
||||||
|
val requestSource: GatewayRequestSource?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ProviderRefund(
|
data class ProviderRefund(
|
||||||
|
|||||||
+184
-10
@@ -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.agent.AgentPlan
|
||||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||||
import com.osglab.account.features.gateway.models.GatewayLimits
|
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.ProviderDescriptor
|
||||||
import com.osglab.account.features.gateway.models.ProviderOutput
|
import com.osglab.account.features.gateway.models.ProviderOutput
|
||||||
import com.osglab.account.features.gateway.models.ProviderRequest
|
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.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
data class DeepSeekConfig(
|
data class DeepSeekConfig(
|
||||||
val endpoint: String,
|
val endpoint: String,
|
||||||
val apiKey: String,
|
val apiKey: String,
|
||||||
val model: String,
|
val model: String,
|
||||||
|
val reasoningModel: String = model,
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
val url = runCatching { Url(endpoint) }
|
val url = runCatching { Url(endpoint) }
|
||||||
@@ -52,7 +59,16 @@ data class DeepSeekConfig(
|
|||||||
if (model.isBlank()) {
|
if (model.isBlank()) {
|
||||||
throw DeepSeekConfigurationException("DEEPSEEK_MODEL must not be blank")
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun interface DeepSeekClient {
|
fun interface DeepSeekClient {
|
||||||
@@ -91,7 +107,30 @@ class DeepSeekProvider(
|
|||||||
): ProviderUsage {
|
): ProviderUsage {
|
||||||
require(request is TextProviderRequest) { "DeepSeek only accepts text requests" }
|
require(request is TextProviderRequest) { "DeepSeek only accepts text requests" }
|
||||||
validate(request)
|
validate(request)
|
||||||
return upstream.complete(request, output)
|
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) {
|
private fun validate(request: TextProviderRequest) {
|
||||||
@@ -103,6 +142,15 @@ class DeepSeekProvider(
|
|||||||
require(request.maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
|
require(request.maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
|
||||||
"maxOutputTokens is out of range"
|
"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()) {
|
require(request.temperature in 0.0..1.0 && request.temperature.isFinite()) {
|
||||||
"temperature is out of range"
|
"temperature is out of range"
|
||||||
}
|
}
|
||||||
@@ -110,6 +158,11 @@ class DeepSeekProvider(
|
|||||||
"agent requests must be non-streaming"
|
"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,
|
output: ProviderOutput,
|
||||||
): ProviderUsage {
|
): ProviderUsage {
|
||||||
val payload = DeepSeekChatRequest(
|
val payload = DeepSeekChatRequest(
|
||||||
model = config.model,
|
model = config.modelFor(request.executionPolicy.modelProfile),
|
||||||
messages = controlledMessages(request),
|
messages = controlledMessages(request),
|
||||||
maxTokens = request.maxOutputTokens,
|
maxTokens = request.maxOutputTokens,
|
||||||
temperature = request.temperature,
|
temperature = request.temperature,
|
||||||
stream = request.stream,
|
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,
|
streamOptions = if (request.stream) StreamOptions(includeUsage = true) else null,
|
||||||
responseFormat = if (request.capability == GatewayCapability.AGENT) {
|
responseFormat = if (request.capability == GatewayCapability.AGENT) {
|
||||||
ResponseFormat(type = "json_object")
|
ResponseFormat(type = "json_object")
|
||||||
@@ -182,8 +242,14 @@ class KtorDeepSeekClient(
|
|||||||
): ProviderUsage {
|
): ProviderUsage {
|
||||||
val payload = bytes.decodeToString()
|
val payload = bytes.decodeToString()
|
||||||
val content = extractAssistantContent(payload)
|
val content = extractAssistantContent(payload)
|
||||||
?: throw DeepSeekEmptyResultException()
|
if (content.isNullOrBlank()) {
|
||||||
if (content.isBlank()) throw DeepSeekEmptyResultException()
|
val metadata = emptyResultMetadata(payload)
|
||||||
|
throw DeepSeekEmptyResultException(
|
||||||
|
finishReason = metadata.finishReason,
|
||||||
|
reasoningContentPresent = metadata.reasoningContentPresent,
|
||||||
|
usagePresent = metadata.usagePresent,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (request.capability == GatewayCapability.AGENT) validateAgentContent(content)
|
if (request.capability == GatewayCapability.AGENT) validateAgentContent(content)
|
||||||
val usage = extractUsage(payload)?.toProviderUsageOrNull()
|
val usage = extractUsage(payload)?.toProviderUsageOrNull()
|
||||||
?: throw DeepSeekProviderException("DeepSeek response omitted token usage")
|
?: throw DeepSeekProviderException("DeepSeek response omitted token usage")
|
||||||
@@ -203,6 +269,8 @@ class KtorDeepSeekClient(
|
|||||||
var contentBytes = 0L
|
var contentBytes = 0L
|
||||||
var terminalChoiceSeen = false
|
var terminalChoiceSeen = false
|
||||||
var doneSeen = false
|
var doneSeen = false
|
||||||
|
var finishReason = "missing"
|
||||||
|
var reasoningContentPresent = false
|
||||||
val assistantContent = StringBuilder()
|
val assistantContent = StringBuilder()
|
||||||
while (true) {
|
while (true) {
|
||||||
val line = channel.readLineStrict(
|
val line = channel.readLineStrict(
|
||||||
@@ -237,6 +305,7 @@ class KtorDeepSeekClient(
|
|||||||
}
|
}
|
||||||
val eventUsage = extractUsage(data)
|
val eventUsage = extractUsage(data)
|
||||||
eventUsage?.let { usage = it }
|
eventUsage?.let { usage = it }
|
||||||
|
if (hasStreamReasoningContent(data)) reasoningContentPresent = true
|
||||||
val choices = runCatching {
|
val choices = runCatching {
|
||||||
event["choices"]?.jsonArray
|
event["choices"]?.jsonArray
|
||||||
?: throw IllegalArgumentException("choices is missing")
|
?: throw IllegalArgumentException("choices is missing")
|
||||||
@@ -250,13 +319,14 @@ class KtorDeepSeekClient(
|
|||||||
if (choice == null && eventUsage == null) {
|
if (choice == null && eventUsage == null) {
|
||||||
throw DeepSeekProviderException("DeepSeek returned an empty non-usage event")
|
throw DeepSeekProviderException("DeepSeek returned an empty non-usage event")
|
||||||
}
|
}
|
||||||
val finishReason = choice?.get("finish_reason")
|
val finishReasonElement = choice?.get("finish_reason")
|
||||||
if (finishReason != null && finishReason !is JsonNull) {
|
if (finishReasonElement != null && finishReasonElement !is JsonNull) {
|
||||||
if (!finishReason.jsonPrimitive.isString ||
|
if (!finishReasonElement.jsonPrimitive.isString ||
|
||||||
finishReason.jsonPrimitive.content.isBlank()
|
finishReasonElement.jsonPrimitive.content.isBlank()
|
||||||
) {
|
) {
|
||||||
throw DeepSeekProviderException("DeepSeek returned an invalid finish reason")
|
throw DeepSeekProviderException("DeepSeek returned an invalid finish reason")
|
||||||
}
|
}
|
||||||
|
finishReason = normalizeFinishReason(finishReasonElement.jsonPrimitive.content)
|
||||||
terminalChoiceSeen = true
|
terminalChoiceSeen = true
|
||||||
}
|
}
|
||||||
extractStreamContent(data)?.let { chunk ->
|
extractStreamContent(data)?.let { chunk ->
|
||||||
@@ -270,7 +340,13 @@ class KtorDeepSeekClient(
|
|||||||
output.emit(encoded)
|
output.emit(encoded)
|
||||||
}
|
}
|
||||||
if (!doneSeen) throw DeepSeekProviderException("DeepSeek stream closed before [DONE]")
|
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) {
|
if (request.capability == GatewayCapability.AGENT) {
|
||||||
validateAgentContent(assistantContent.toString())
|
validateAgentContent(assistantContent.toString())
|
||||||
}
|
}
|
||||||
@@ -354,6 +430,58 @@ class KtorDeepSeekClient(
|
|||||||
?.content
|
?.content
|
||||||
}.getOrNull()
|
}.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 {
|
private suspend fun ByteReadChannel.readBounded(): ByteArray {
|
||||||
val bytes = readRemaining(GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES.toLong() + 1L)
|
val bytes = readRemaining(GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES.toLong() + 1L)
|
||||||
.readByteArray()
|
.readByteArray()
|
||||||
@@ -392,6 +520,13 @@ class KtorDeepSeekClient(
|
|||||||
return listOf(ChatMessage("system", system), ChatMessage("user", userText))
|
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 {
|
private companion object {
|
||||||
const val MAX_AGENT_ID_CHARS = 128
|
const val MAX_AGENT_ID_CHARS = 128
|
||||||
const val MAX_AGENT_FIELD_CHARS = 4_096
|
const val MAX_AGENT_FIELD_CHARS = 4_096
|
||||||
@@ -412,12 +547,41 @@ private data class DeepSeekChatRequest(
|
|||||||
val maxTokens: Int,
|
val maxTokens: Int,
|
||||||
val temperature: Double,
|
val temperature: Double,
|
||||||
val stream: Boolean,
|
val stream: Boolean,
|
||||||
|
val thinking: DeepSeekThinking,
|
||||||
|
@SerialName("reasoning_effort")
|
||||||
|
val reasoningEffort: DeepSeekReasoningEffort?,
|
||||||
@SerialName("stream_options")
|
@SerialName("stream_options")
|
||||||
val streamOptions: StreamOptions?,
|
val streamOptions: StreamOptions?,
|
||||||
@SerialName("response_format")
|
@SerialName("response_format")
|
||||||
val responseFormat: ResponseFormat?,
|
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
|
@Serializable
|
||||||
private data class ChatMessage(
|
private data class ChatMessage(
|
||||||
val role: String,
|
val role: String,
|
||||||
@@ -439,10 +603,20 @@ class DeepSeekConfigurationException(message: String) : IllegalStateException(me
|
|||||||
|
|
||||||
class DeepSeekProviderException(message: String) : RuntimeException(message)
|
class DeepSeekProviderException(message: String) : RuntimeException(message)
|
||||||
class DeepSeekUsageException(message: String) : ProviderCompletionException(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(
|
private data class DeepSeekUsage(
|
||||||
val total: Long,
|
val total: Long,
|
||||||
val input: Long?,
|
val input: Long?,
|
||||||
val output: Long?,
|
val output: Long?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class DeepSeekEmptyResultMetadata(
|
||||||
|
val finishReason: String = "missing",
|
||||||
|
val reasoningContentPresent: Boolean = false,
|
||||||
|
val usagePresent: Boolean = false,
|
||||||
|
)
|
||||||
|
|||||||
+5
-1
@@ -231,7 +231,11 @@ class SaucSequenceValidator {
|
|||||||
?: throw SaucProtocolException("SAUC server frame omitted its sequence")
|
?: throw SaucProtocolException("SAUC server frame omitted its sequence")
|
||||||
val expected = Math.addExact(lastSequence, 1)
|
val expected = Math.addExact(lastSequence, 1)
|
||||||
if (frame.isLast) {
|
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")
|
throw SaucProtocolException("SAUC final sequence is invalid")
|
||||||
}
|
}
|
||||||
finalSeen = true
|
finalSeen = true
|
||||||
|
|||||||
+2
@@ -33,6 +33,7 @@ private object ProviderRequestsTable : Table("provider_requests") {
|
|||||||
val reservationId = varchar("reservation_id", 36).nullable()
|
val reservationId = varchar("reservation_id", 36).nullable()
|
||||||
val providerId = varchar("provider_id", 64)
|
val providerId = varchar("provider_id", 64)
|
||||||
val capability = varchar("capability", 32)
|
val capability = varchar("capability", 32)
|
||||||
|
val requestSource = varchar("request_source", 32).nullable()
|
||||||
val status = varchar("status", 24)
|
val status = varchar("status", 24)
|
||||||
val providerRequestId = varchar("provider_request_id", 128).nullable()
|
val providerRequestId = varchar("provider_request_id", 128).nullable()
|
||||||
val usageMeter = varchar("usage_meter", 32).nullable()
|
val usageMeter = varchar("usage_meter", 32).nullable()
|
||||||
@@ -279,6 +280,7 @@ class ExposedGatewayRepository(
|
|||||||
it[reservationId] = metadata.reservationId
|
it[reservationId] = metadata.reservationId
|
||||||
it[providerId] = metadata.providerId
|
it[providerId] = metadata.providerId
|
||||||
it[capability] = metadata.capability.name
|
it[capability] = metadata.capability.name
|
||||||
|
it[requestSource] = metadata.requestSource?.name
|
||||||
it[status] = ProviderRequestState.CLAIMED.name
|
it[status] = ProviderRequestState.CLAIMED.name
|
||||||
it[createdAt] = clock.instant()
|
it[createdAt] = clock.instant()
|
||||||
}.insertedCount == 1
|
}.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.GatewayRefreshTokenInvalidException
|
||||||
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
|
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
|
||||||
import com.osglab.account.features.gateway.services.GatewayService
|
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.ContentType
|
||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
@@ -67,6 +68,7 @@ fun Route.configureGatewayRoutes(
|
|||||||
gatewayIdentity: GatewayAccessTokenPort,
|
gatewayIdentity: GatewayAccessTokenPort,
|
||||||
grantService: GatewayGrantService? = null,
|
grantService: GatewayGrantService? = null,
|
||||||
asrStreaming: AsrStreamingService? = null,
|
asrStreaming: AsrStreamingService? = null,
|
||||||
|
taskPolicyResolver: GatewayTaskPolicyResolver = GatewayTaskPolicyResolver(),
|
||||||
) {
|
) {
|
||||||
route("/v1/gateway") {
|
route("/v1/gateway") {
|
||||||
if (grantService != null) {
|
if (grantService != null) {
|
||||||
@@ -240,10 +242,16 @@ fun Route.configureGatewayRoutes(
|
|||||||
"Only polish, ai and agent are supported",
|
"Only polish, ai and agent are supported",
|
||||||
requestId,
|
requestId,
|
||||||
)
|
)
|
||||||
val body = runCatching {
|
val (body, executionPolicy) = runCatching {
|
||||||
ROUTE_JSON.decodeFromString<TextGatewayRequest>(
|
val request = ROUTE_JSON.decodeFromString<TextGatewayRequest>(
|
||||||
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
|
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 {
|
.getOrElse {
|
||||||
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
|
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
|
||||||
@@ -259,11 +267,13 @@ fun Route.configureGatewayRoutes(
|
|||||||
val providerRequest = TextProviderRequest(
|
val providerRequest = TextProviderRequest(
|
||||||
requestId = requestId,
|
requestId = requestId,
|
||||||
capability = capability,
|
capability = capability,
|
||||||
|
executionPolicy = executionPolicy,
|
||||||
input = body.input,
|
input = body.input,
|
||||||
context = body.context,
|
context = body.context,
|
||||||
maxOutputTokens = body.maxOutputTokens,
|
maxOutputTokens = executionPolicy.maxOutputTokens,
|
||||||
temperature = body.temperature,
|
temperature = body.temperature,
|
||||||
stream = body.stream,
|
stream = body.stream,
|
||||||
|
requestSource = body.requestSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (body.stream) {
|
if (body.stream) {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class GatewayService(
|
|||||||
reservationId = reservation.id,
|
reservationId = reservation.id,
|
||||||
providerId = provider.descriptor.id,
|
providerId = provider.descriptor.id,
|
||||||
capability = request.capability,
|
capability = request.capability,
|
||||||
|
requestSource = request.requestSource,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} catch (replay: GatewayRequestAlreadyClaimedException) {
|
} catch (replay: GatewayRequestAlreadyClaimedException) {
|
||||||
|
|||||||
+133
@@ -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.insertIgnore
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
@@ -283,10 +284,13 @@ class AppAttestService(
|
|||||||
)
|
)
|
||||||
IntegrityVerification.Verified
|
IntegrityVerification.Verified
|
||||||
} catch (exception: AppAttestRejectedException) {
|
} catch (exception: AppAttestRejectedException) {
|
||||||
|
APP_ATTEST_LOG.warn("App Attest assertion rejected: {}", exception.message)
|
||||||
IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion")
|
IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion")
|
||||||
} catch (exception: InvalidRequestException) {
|
} catch (exception: InvalidRequestException) {
|
||||||
|
APP_ATTEST_LOG.warn("App Attest assertion request rejected: {}", exception.message)
|
||||||
IntegrityVerification.Rejected(exception.message)
|
IntegrityVerification.Rejected(exception.message)
|
||||||
} catch (exception: AppAttestUnavailableException) {
|
} catch (exception: AppAttestUnavailableException) {
|
||||||
|
APP_ATTEST_LOG.error("App Attest assertion verification unavailable", exception)
|
||||||
IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable")
|
IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable")
|
||||||
} catch (exception: CancellationException) {
|
} catch (exception: CancellationException) {
|
||||||
throw exception
|
throw exception
|
||||||
@@ -392,6 +396,7 @@ class AppAttestService(
|
|||||||
const val SHA256_BYTES = 32
|
const val SHA256_BYTES = 32
|
||||||
const val MAX_ATTESTATION_BYTES = 256 * 1024
|
const val MAX_ATTESTATION_BYTES = 256 * 1024
|
||||||
const val MAX_ASSERTION_BYTES = 64 * 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) {
|
} catch (exception: Exception) {
|
||||||
throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception)
|
throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception)
|
||||||
}
|
}
|
||||||
val signedBytes = authenticatorDataBytes + clientDataHash
|
val nonce = sha256(authenticatorDataBytes + clientDataHash)
|
||||||
val verified = try {
|
val verified = try {
|
||||||
Signature.getInstance("SHA256withECDSA").run {
|
Signature.getInstance("SHA256withECDSA").run {
|
||||||
initVerify(key)
|
initVerify(key)
|
||||||
update(signedBytes)
|
update(nonce)
|
||||||
verify(signatureBytes)
|
verify(signatureBytes)
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
@@ -299,9 +299,10 @@ class LibraryAppAttestCrypto(
|
|||||||
val rpHash = ByteArray(SHA256_BYTES).also(buffer::get)
|
val rpHash = ByteArray(SHA256_BYTES).also(buffer::get)
|
||||||
val flags = buffer.get().toInt() and 0xff
|
val flags = buffer.get().toInt() and 0xff
|
||||||
val count = buffer.int.toLong() and UINT32_MASK
|
val count = buffer.int.toLong() and UINT32_MASK
|
||||||
if ((flags and FLAG_ATTESTED_CREDENTIAL_DATA) != 0 ||
|
// Production App Attest assertions can set AT while still using
|
||||||
(flags and FLAG_EXTENSION_DATA) != 0
|
// 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")
|
throw AppAttestRejectedException("App Attest assertion flags are invalid")
|
||||||
}
|
}
|
||||||
return AssertionAuthenticatorData(rpHash, flags, count)
|
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.insertIgnore
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import java.security.KeyFactory
|
import java.security.KeyFactory
|
||||||
import java.security.interfaces.ECPrivateKey
|
import java.security.interfaces.ECPrivateKey
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
@@ -233,7 +234,7 @@ class KtorAppleDeviceCheckClient(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MAX_DEVICE_TOKEN_LENGTH = 8_192
|
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"
|
const val BIT_STATE_NOT_FOUND_RESPONSE = "Failed to find bit state"
|
||||||
val JSON = Json { ignoreUnknownKeys = true }
|
val JSON = Json { ignoreUnknownKeys = true }
|
||||||
}
|
}
|
||||||
@@ -263,8 +264,13 @@ class RemoteDeviceCheckVerifier(
|
|||||||
} catch (exception: DeviceCheckRejectedException) {
|
} catch (exception: DeviceCheckRejectedException) {
|
||||||
IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token")
|
IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token")
|
||||||
} catch (exception: DeviceCheckUnavailableException) {
|
} catch (exception: DeviceCheckUnavailableException) {
|
||||||
|
LOG.warn("DeviceCheck verification unavailable: {}", exception.message)
|
||||||
IntegrityVerification.Unavailable(exception.message ?: "DeviceCheck is unavailable")
|
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) :
|
open class DeviceCheckException(message: String, cause: Throwable? = null) :
|
||||||
|
|||||||
@@ -177,13 +177,15 @@ class ReferralService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getProfile(userId: UUID): ReferralProfile =
|
override suspend fun getProfile(userId: UUID): ReferralProfile {
|
||||||
transactions.inTransaction { unit ->
|
val activeCode = getOrCreateCode(userId)
|
||||||
|
return transactions.inTransaction { unit ->
|
||||||
ReferralProfile(
|
ReferralProfile(
|
||||||
code = unit.referrals.findCodeByOwner(userId),
|
code = activeCode,
|
||||||
binding = unit.referrals.findBinding(userId),
|
binding = unit.referrals.findBinding(userId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun listActiveCampaigns(): List<ReferralCampaign> =
|
override suspend fun listActiveCampaigns(): List<ReferralCampaign> =
|
||||||
transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) }
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+161
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -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.
@@ -49,9 +49,14 @@ app:
|
|||||||
revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke"
|
revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke"
|
||||||
credits:
|
credits:
|
||||||
signupTrial: "$SIGNUP_TRIAL_CREDITS:1000"
|
signupTrial: "$SIGNUP_TRIAL_CREDITS:1000"
|
||||||
referralInviter: "$REFERRAL_INVITER_CREDITS:3000"
|
referralInviter: "$REFERRAL_INVITER_CREDITS:1000"
|
||||||
referralInvitee: "$REFERRAL_INVITEE_CREDITS:3000"
|
referralInvitee: "$REFERRAL_INVITEE_CREDITS:1000"
|
||||||
referralBindingDays: "$REFERRAL_BINDING_DAYS:7"
|
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:
|
providers:
|
||||||
volcengine:
|
volcengine:
|
||||||
endpoint: "$VOLCENGINE_ASR_ENDPOINT:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
|
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"
|
endpoint: "$DEEPSEEK_ENDPOINT:https://api.deepseek.com/v1"
|
||||||
apiKey: "$DEEPSEEK_API_KEY:"
|
apiKey: "$DEEPSEEK_API_KEY:"
|
||||||
model: "$DEEPSEEK_MODEL:deepseek-v4-flash"
|
model: "$DEEPSEEK_MODEL:deepseek-v4-flash"
|
||||||
|
reasoningModel: "$DEEPSEEK_REASONING_MODEL:"
|
||||||
integrity:
|
integrity:
|
||||||
enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false"
|
enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false"
|
||||||
enforceAppAttest: "$ENFORCE_APP_ATTEST: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.environment shouldBe Environment.TEST
|
||||||
config.apple.clientCredentialsAvailable shouldBe false
|
config.apple.clientCredentialsAvailable shouldBe false
|
||||||
config.encryption.key.size shouldBe 32
|
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") {
|
test("production rejects placeholder secrets") {
|
||||||
@@ -76,6 +79,43 @@ class AppConfigTest : FunSpec({
|
|||||||
}.message.orEmpty() shouldContain "bootstrapEnabled requires"
|
}.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") {
|
test("production fails fast when Apple signing credentials are missing") {
|
||||||
val config = validProductionConfig().apply {
|
val config = validProductionConfig().apply {
|
||||||
put("app.apple.keyId", "")
|
put("app.apple.keyId", "")
|
||||||
|
|||||||
@@ -61,6 +61,68 @@ class DeploymentConsistencyTest : FunSpec({
|
|||||||
openApi shouldContain "unpadded Base64URL"
|
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") {
|
test("production Compose reuses private MySQL and hardens the application container") {
|
||||||
val compose = root.read("compose.yaml")
|
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") {
|
test("admin bootstrap is one-time and runtime database grants stay explicit") {
|
||||||
val compose = root.read("compose.yaml")
|
val compose = root.read("compose.yaml")
|
||||||
val privileges = root.read("docs/mysql-minimum-privileges.sql")
|
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}"
|
compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}"
|
||||||
privileges shouldContain "GRANT SELECT ON osg_account.admin_operators"
|
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 INSERT ON osg_account.gateway_grant_scopes"
|
||||||
privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens"
|
privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens"
|
||||||
privileges shouldContain "GRANT INSERT, UPDATE 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_audit_log"
|
||||||
privileges shouldContain "GRANT INSERT ON osg_account.admin_credit_grants"
|
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 "UPDATE ON osg_account.admin_audit_log"
|
||||||
privileges shouldNotContain "DELETE ON osg_account.admin_credit_grants"
|
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") {
|
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/balance",
|
||||||
"/v1/credits/ledger",
|
"/v1/credits/ledger",
|
||||||
"/v1/credits/rates",
|
"/v1/credits/rates",
|
||||||
|
"/v1/storekit/products",
|
||||||
|
"/v1/storekit/transactions",
|
||||||
"/v1/referrals",
|
"/v1/referrals",
|
||||||
"/v1/referrals/me",
|
"/v1/referrals/me",
|
||||||
"/v1/referrals/code",
|
"/v1/referrals/code",
|
||||||
@@ -187,6 +258,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
|||||||
"/v1/admin/users",
|
"/v1/admin/users",
|
||||||
"/v1/admin/users/{userId}",
|
"/v1/admin/users/{userId}",
|
||||||
"/v1/admin/users/{userId}/ledger",
|
"/v1/admin/users/{userId}/ledger",
|
||||||
|
"/v1/admin/credits/ledger",
|
||||||
"/v1/admin/credits/grants",
|
"/v1/admin/credits/grants",
|
||||||
"/v1/admin/operators/summary",
|
"/v1/admin/operators/summary",
|
||||||
"/v1/admin/operators",
|
"/v1/admin/operators",
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ class SmokeDeploymentTest : FunSpec({
|
|||||||
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
|
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
|
||||||
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://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 "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 "first ledger page omitted nextCursor"
|
||||||
runner shouldContain "DELETE FROM admin_sessions WHERE expires_at < UTC_TIMESTAMP()"
|
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 "appleid.apple.com"
|
||||||
runner shouldNotContain "api.deepseek.com"
|
runner shouldNotContain "api.deepseek.com"
|
||||||
runner shouldNotContain "openspeech.bytedance.com"
|
runner shouldNotContain "openspeech.bytedance.com"
|
||||||
@@ -47,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
|
|||||||
|
|
||||||
test("runtime grants cover every migrated table without mutable history privileges") {
|
test("runtime grants cover every migrated table without mutable history privileges") {
|
||||||
val grants = root.read("deploy/smoke/runtime-grants.sql")
|
val grants = root.read("deploy/smoke/runtime-grants.sql")
|
||||||
val migrationTables = (1..8)
|
val migrationTables = (1..13)
|
||||||
.flatMap { version ->
|
.flatMap { version ->
|
||||||
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
|
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
|
||||||
paths.filter { it.fileName.toString().startsWith("V${version}__") }
|
paths.filter { it.fileName.toString().startsWith("V${version}__") }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user