Compare commits
37 Commits
971b7e26c6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 505515f746 | |||
| 4c9e5feec0 | |||
| 36a926f12f | |||
| e522788867 | |||
| 51c37e6206 | |||
| 03eac71905 | |||
| 4f5b6eafbd | |||
| f95d09f303 | |||
| b4064126fe | |||
| 544e0d7356 | |||
| 636a8541bc | |||
| 10ba4f0a0c | |||
| 9fb947aa7d | |||
| f8fa93dc48 | |||
| 0d236f57fb | |||
| edd0d9feca | |||
| b25f5ae6e9 | |||
| 454ba8ddc5 | |||
| d0abe27623 | |||
| b5212dcdc2 | |||
| 74c3fcd45f | |||
| 034a3e8745 | |||
| 0b4acb5978 | |||
| 4b465e0e5e | |||
| 231c5040a5 | |||
| 11ec34dacb | |||
| 3edc86a9a0 | |||
| e1fd35b1ff | |||
| 2194e69bb8 | |||
| 75c046d91d | |||
| 58445dd880 | |||
| c592f426be | |||
| 1737106560 | |||
| 25cfbfa4e6 | |||
| babc80044d | |||
| 0dff35a0f7 | |||
| e6ce70117b |
+22
-2
@@ -23,6 +23,7 @@ JWT_AUDIENCE=osgkeyboard-ios
|
||||
JWT_SECRET=replace-with-at-least-32-random-bytes
|
||||
ACCESS_TOKEN_MINUTES=15
|
||||
REFRESH_TOKEN_DAYS=30
|
||||
LEGACY_REFRESH_REPLAY_SECONDS=30
|
||||
GATEWAY_GRANT_DAYS=30
|
||||
FIELD_ENCRYPTION_KEY=replace-with-exactly-32-random-bytes-as-base64
|
||||
IDENTITY_HMAC_KEY=replace-with-a-distinct-32-random-bytes-as-base64
|
||||
@@ -31,6 +32,8 @@ IDENTITY_TOMBSTONE_RETENTION_DAYS=365
|
||||
# Admin console. Enable bootstrap for the first successful startup only, then
|
||||
# set it back to false and remove all four ADMIN_BOOTSTRAP_* credential values.
|
||||
ADMIN_ENABLED=false
|
||||
# Keep true by default. Set false only for a deliberate temporary mTLS bypass.
|
||||
ADMIN_MTLS_REQUIRED=true
|
||||
ADMIN_BOOTSTRAP_ENABLED=false
|
||||
ADMIN_BOOTSTRAP_OPERATOR_ID=replace-with-random-uuid
|
||||
ADMIN_BOOTSTRAP_USERNAME=owner
|
||||
@@ -39,6 +42,12 @@ ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32=replace-with-random-base32-secret
|
||||
ADMIN_SESSION_HOURS=8
|
||||
ADMIN_MAXIMUM_MANUAL_GRANT=100000
|
||||
|
||||
# AI Hint Feed runs in this service without changing the legacy key.osglab.com deployment.
|
||||
HINT_FEED_ENABLED=false
|
||||
HINT_FEED_ZONE_ID=UTC
|
||||
# Optional paid fallback. Keep provider keys in environment-backed secret storage.
|
||||
TOPHUB_API_KEY=
|
||||
|
||||
# Apple identifiers are not secrets, but use the values from your own developer account.
|
||||
APPLE_TEAM_ID=replace-with-apple-team-id
|
||||
APPLE_KEY_ID=replace-with-apple-key-id
|
||||
@@ -50,6 +59,8 @@ APPLE_TOKEN_URL=https://appleid.apple.com/auth/token
|
||||
APPLE_REVOKE_URL=https://appleid.apple.com/auth/revoke
|
||||
APPLE_INTEGRITY_ENVIRONMENT=development
|
||||
APP_ATTEST_CHALLENGE_TTL_SECONDS=300
|
||||
# Temporary production-device testing only; keep false for normal deployments.
|
||||
ALLOW_DEVELOPMENT_APP_ATTEST=false
|
||||
# DeviceCheck reuses the configured Apple Team ID, Key ID and ES256 private key.
|
||||
|
||||
# Prefer the newer Volcengine API key. The legacy app ID/access token pair is optional.
|
||||
@@ -61,13 +72,22 @@ VOLCENGINE_ASR_ENDPOINT=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel
|
||||
|
||||
DEEPSEEK_API_KEY=replace-with-deepseek-api-key
|
||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
# Optional; defaults to DEEPSEEK_MODEL when omitted.
|
||||
DEEPSEEK_REASONING_MODEL=
|
||||
DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1
|
||||
|
||||
SIGNUP_TRIAL_CREDITS=1000
|
||||
REFERRAL_INVITER_CREDITS=3000
|
||||
REFERRAL_INVITEE_CREDITS=3000
|
||||
REFERRAL_INVITER_CREDITS=1000
|
||||
REFERRAL_INVITEE_CREDITS=1000
|
||||
REFERRAL_BINDING_DAYS=7
|
||||
|
||||
# Keep voluntary tips separate. Every entry must be a dedicated consumable in
|
||||
# productId:credits format and use an appAccountToken supplied by the app.
|
||||
STOREKIT_ENABLED=false
|
||||
STOREKIT_BUNDLE_ID=com.osgkeyboard.ios
|
||||
STOREKIT_APP_APPLE_ID=6781553267
|
||||
STOREKIT_PRODUCTS=500tks:500,1500tks:1500,3000tks:3000
|
||||
|
||||
# Production startup requires both flags and the production Apple environment.
|
||||
ENFORCE_DEVICE_CHECK=false
|
||||
ENFORCE_APP_ATTEST=false
|
||||
|
||||
@@ -15,17 +15,17 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: admin-web/package-lock.json
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: gradle/actions/setup-gradle@v6
|
||||
- name: Verify Docker for MySQL integration tests
|
||||
run: docker info
|
||||
- name: Validate production Compose
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -89,6 +89,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
build-args: |
|
||||
APP_BUILD_SHA=${{ github.sha }}
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
@@ -10,6 +10,7 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
|
||||
./gradlew --no-daemon --no-configuration-cache --stacktrace installDist
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
ARG APP_BUILD_SHA=unknown
|
||||
RUN addgroup -S -g 10001 app \
|
||||
&& adduser -S -D -H -u 10001 -G app -h /app app
|
||||
WORKDIR /app
|
||||
@@ -17,6 +18,7 @@ WORKDIR /app
|
||||
COPY --from=build --chown=app:app /workspace/build/install/OSGAccountServer/ /app/
|
||||
|
||||
ENV HOME=/tmp \
|
||||
APP_BUILD_SHA=$APP_BUILD_SHA \
|
||||
JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=/tmp -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
|
||||
|
||||
USER 10001:10001
|
||||
|
||||
@@ -8,7 +8,10 @@ Kotlin 2.4.10 / Ktor 3.5.2 managed AI gateway and invitation page.
|
||||
- Volcengine SAUC v3 ASR over WebSocket with a strict binary frame codec.
|
||||
- Credit reservation, settlement, release, identity, grant, and metadata persistence ports.
|
||||
- Apple DeviceCheck trial enforcement and App Attest attestation/assertion validation.
|
||||
- Bilingual invitation page at `GET /i/{code}` with no analytics, tracking, or fingerprinting.
|
||||
- Bilingual invitation page at `GET /i/{code}` with no third-party analytics,
|
||||
tracking scripts, or fingerprinting.
|
||||
- Privacy-minimized, idempotent product event ingestion and an internal growth,
|
||||
retention, AI usage, monetization, and referral dashboard.
|
||||
- Flyway migrations for provider metadata, metered usage, gateway grants, and integrity state.
|
||||
|
||||
Prompts, audio, transcripts, and provider response bodies are never sent to the usage persistence port.
|
||||
@@ -26,6 +29,7 @@ Application logging must also keep request/response body logging disabled.
|
||||
- `POST /v1/integrity/assert`
|
||||
- `POST /v1/auth/apple`
|
||||
- `POST /v1/auth/refresh`, `POST /v1/auth/logout`
|
||||
- `POST /v1/analytics/events`
|
||||
- `GET/DELETE /v1/account`
|
||||
- `POST /v1/apple/events`
|
||||
- `GET /v1/credits/{balance|ledger|rates}`
|
||||
@@ -34,6 +38,7 @@ Application logging must also keep request/response body logging disabled.
|
||||
- `GET /i/{code}`
|
||||
- `GET /.well-known/apple-app-site-association`
|
||||
- `GET /apple-app-site-association`
|
||||
- `GET /v1/admin/analytics` through the private mTLS-protected admin edge
|
||||
|
||||
See `docs/openapi.yaml` for request limits and response formats.
|
||||
|
||||
@@ -87,6 +92,11 @@ receipt, assertion, and certificate-chain validation use the
|
||||
official Apple App Attestation Root CA bundled from Apple Certificate Authority. The server stores the
|
||||
validated public key, receipt, and strictly increasing assertion counter.
|
||||
|
||||
Production accepts only production App Attest AAGUIDs by default. For a time-bounded physical-device
|
||||
test against the production service, set `ALLOW_DEVELOPMENT_APP_ATTEST=true` to admit development
|
||||
AAGUIDs from registered development builds. Disable the flag again after testing; TestFlight and App
|
||||
Store builds do not require it.
|
||||
|
||||
Request an `attestation` challenge after `generateKey`, then call `/attest` with the resulting CBOR
|
||||
object. For login assertions, request an `assertion` challenge and generate the assertion over SHA-256
|
||||
of the canonical UTF-8 payload documented in `docs/openapi.yaml`. Challenges are single-use and expire
|
||||
@@ -136,7 +146,7 @@ Gateway execution and settlement rules:
|
||||
|
||||
Existing `application.yaml` values can be mapped into:
|
||||
|
||||
- `DeepSeekConfig(endpoint, apiKey, model)`
|
||||
- `DeepSeekConfig(endpoint, apiKey, model, reasoningModel)`
|
||||
- `VolcengineAsrConfig(endpoint, resourceId, appId, accessToken)`
|
||||
- `InviteWebConfig(appStoreUrl, appleAppId, universalLinkBaseUrl)`
|
||||
|
||||
@@ -152,8 +162,18 @@ Configuration ownership:
|
||||
`APPLE_INTEGRITY_ENVIRONMENT`, plus the two integrity enforcement flags.
|
||||
- Volcengine: prefer `VOLCENGINE_API_KEY`; set the SAUC v3 `VOLCENGINE_RESOURCE_ID` and WSS
|
||||
`VOLCENGINE_ASR_ENDPOINT`. The legacy app ID/access token pair remains optional.
|
||||
- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned `DEEPSEEK_MODEL`, and HTTPS
|
||||
`DEEPSEEK_ENDPOINT`.
|
||||
- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned low-latency `DEEPSEEK_MODEL`,
|
||||
and HTTPS `DEEPSEEK_ENDPOINT`. `DEEPSEEK_REASONING_MODEL` is optional and
|
||||
falls back to `DEEPSEEK_MODEL`.
|
||||
|
||||
Gateway text requests may include the optional stable `taskKind` values documented in
|
||||
`docs/openapi.yaml`. The server maps `capability + taskKind` to a deterministic execution policy;
|
||||
it never infers task type from user content. Polish and transform tasks explicitly disable DeepSeek
|
||||
thinking and do not retry an empty buffered result. AI questions and agent planning explicitly use
|
||||
high-effort thinking. Ordinary AI questions allow model-selected DeepSeek Responses web search;
|
||||
`current_information_question` requires web search. Search failures fall back to Chat Completions
|
||||
before any result is emitted. Other tools remain disabled, and search usage is billed only through
|
||||
the provider-reported LLM input and output Token counts.
|
||||
|
||||
Store production values in 1Panel's secret/environment facility. The Compose environment receives
|
||||
them at runtime because this application does not read Docker `/run/secrets/*` files directly.
|
||||
@@ -236,7 +256,7 @@ Internet.
|
||||
- Apple: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`,
|
||||
`APPLE_INTEGRITY_ENVIRONMENT`.
|
||||
- Providers: `VOLCENGINE_API_KEY`, `VOLCENGINE_RESOURCE_ID`, `DEEPSEEK_API_KEY`,
|
||||
`DEEPSEEK_MODEL`.
|
||||
`DEEPSEEK_MODEL`; optionally `DEEPSEEK_REASONING_MODEL`.
|
||||
- Production controls: `APP_ENV=production`, `ENFORCE_DEVICE_CHECK=true`,
|
||||
`ENFORCE_APP_ATTEST=true`.
|
||||
- Optional tuning: token lifetimes, gateway grant days, credit values, binding window and pool size;
|
||||
@@ -290,9 +310,13 @@ Gateway tests cover account-scoped replay exclusion, cross-account isolation, pr
|
||||
settlement pending behavior, reconciliation, whole-call timeout, forged audio duration, malformed SAUC
|
||||
sequences, and final-frame-only duration.
|
||||
|
||||
The MySQL Testcontainers suite validates all migrations, account-deletion cascades, stale access-token
|
||||
rejection, real InnoDB concurrent balance locking, and exactly-once referral rewards. It is automatically
|
||||
skipped when Docker is unavailable; CI requires Docker and runs it on every build.
|
||||
The MySQL Testcontainers suite validates all migrations, account-deletion
|
||||
cascades (including linked analytics events), stale access-token rejection, real
|
||||
InnoDB concurrent balance locking, and exactly-once referral rewards. Analytics
|
||||
tests additionally cover event validation, idempotent replay, conflict handling,
|
||||
offline timestamps, and privacy-minimized responses. It is automatically
|
||||
skipped when Docker is unavailable; CI requires Docker and runs it on every
|
||||
build.
|
||||
|
||||
## Staged acceptance
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#f5f7fb" />
|
||||
<meta name="referrer" content="same-origin" />
|
||||
<title>OSG 运营后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1629
-330
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -9,13 +9,29 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.5",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"jsdom": "^27.0.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@awesome.me/webawesome": "^3.11.0"
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
+149
-14
@@ -3,17 +3,39 @@ import type {
|
||||
AdminOperatorCreateRequest,
|
||||
AdminOperatorProvisioning,
|
||||
AdminSecuritySummary,
|
||||
AdminHintPack,
|
||||
HintFeedGenerationResponse,
|
||||
HintFeedGenerationStatus,
|
||||
HintFeedSettings,
|
||||
AdminLoginResponse,
|
||||
AuditQuery,
|
||||
AuditLogEntry,
|
||||
CreditGrantRequest,
|
||||
CreditGrantResponse,
|
||||
LedgerQuery,
|
||||
LedgerEntry,
|
||||
ManagedProviderId,
|
||||
ManagedProviderOverview,
|
||||
ManagedProviderStatus,
|
||||
OperatorsQuery,
|
||||
Overview,
|
||||
PageResult,
|
||||
ProductAnalyticsOverview,
|
||||
CreateOfficialSkillRequest,
|
||||
OfficialSkill,
|
||||
OfficialSkillCatalog,
|
||||
RevealProviderApiKeyRequest,
|
||||
RevealProviderApiKeyResponse,
|
||||
ReferralsQuery,
|
||||
ReferralOverview,
|
||||
SessionResponse,
|
||||
UserDetail,
|
||||
UsersQuery,
|
||||
UserSummary,
|
||||
UpdateHintPackRequest,
|
||||
UpdateHintFeedSettingsRequest,
|
||||
UpdateOfficialSkillRequest,
|
||||
UpdateProviderApiKeyRequest,
|
||||
} from "./types";
|
||||
|
||||
const API_BASE = "/v1/admin";
|
||||
@@ -67,6 +89,15 @@ function safeMessage(status: number, code?: string): string {
|
||||
ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在",
|
||||
CANNOT_DISABLE_SELF: "不能停用当前登录的管理员",
|
||||
LAST_SUPER_ADMIN_REQUIRED: "必须至少保留一名启用的超级管理员",
|
||||
CONTENT_SKILL_NOT_FOUND: "未找到该官方 Skill",
|
||||
CONTENT_SKILL_CONFLICT: "该官方 Skill ID 已存在",
|
||||
CONTENT_HINT_PACK_NOT_FOUND: "该语言的 Hint pack 尚未发布",
|
||||
HINT_FEED_GENERATION_IN_PROGRESS: "Hint 提示包正在生成,请稍后刷新",
|
||||
HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求",
|
||||
HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用",
|
||||
PROVIDER_API_KEY_INVALID: "API Key 不符合要求",
|
||||
PROVIDER_API_KEY_NOT_CONFIGURED: "该 Provider 尚未配置可读取的 API Key",
|
||||
PROVIDER_NOT_FOUND: "不支持该 Provider",
|
||||
RATE_LIMITED: "操作过于频繁,请稍后再试",
|
||||
};
|
||||
if (code && messages[code]) return messages[code];
|
||||
@@ -139,15 +170,26 @@ async function request<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function query(params: Record<string, string | undefined>): string {
|
||||
export function encodeQuery<T extends object>(params: T): string {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) search.set(key, value);
|
||||
if (value !== undefined && value !== "") search.set(key, String(value));
|
||||
});
|
||||
const result = search.toString();
|
||||
return result ? `?${result}` : "";
|
||||
}
|
||||
|
||||
function cursorQuery<T extends CursorQuery>(value?: string | T): CursorQuery | T {
|
||||
return typeof value === "string" ? { cursor: value } : (value ?? {});
|
||||
}
|
||||
|
||||
type CursorQuery = { cursor?: string };
|
||||
|
||||
function ledgerQuery(value?: string | LedgerQuery): CursorQuery | LedgerQuery {
|
||||
if (typeof value === "string") return { cursor: value };
|
||||
return value ? { ...value, referenceId: value.referenceId?.trim() } : {};
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
session: () => request<SessionResponse>("/auth/session"),
|
||||
|
||||
@@ -160,22 +202,85 @@ export const adminApi = {
|
||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||
|
||||
overview: (range: string) =>
|
||||
request<Overview>(`/overview${query({ range })}`),
|
||||
request<Overview>(`/overview${encodeQuery({ range })}`),
|
||||
|
||||
referrals: (range: string) =>
|
||||
request<ReferralOverview>(`/referrals${query({ range })}`),
|
||||
referrals: (value: string | ReferralsQuery) => {
|
||||
const params = typeof value === "string" ? { range: value } : value;
|
||||
return request<ReferralOverview>(`/referrals${encodeQuery(params)}`);
|
||||
},
|
||||
|
||||
users: (search: string, cursor?: string) =>
|
||||
request<PageResult<UserSummary>>(
|
||||
`/users${query({ q: search.trim(), cursor })}`,
|
||||
productAnalytics: (range: string) =>
|
||||
request<ProductAnalyticsOverview>(`/analytics${encodeQuery({ range })}`),
|
||||
|
||||
contentSkills: () => request<OfficialSkillCatalog>("/content/skills"),
|
||||
|
||||
createContentSkill: (payload: CreateOfficialSkillRequest) =>
|
||||
request<OfficialSkill>("/content/skills", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
updateContentSkill: (id: string, payload: UpdateOfficialSkillRequest) =>
|
||||
request<OfficialSkill>(`/content/skills/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
setContentSkillEnabled: (id: string, enabled: boolean) =>
|
||||
request<void>(
|
||||
`/content/skills/${encodeURIComponent(id)}/${enabled ? "enable" : "disable"}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
contentHintPack: (locale: "zh" | "en") =>
|
||||
request<AdminHintPack>(`/content/hints/${locale}`),
|
||||
|
||||
updateContentHintPack: (
|
||||
locale: "zh" | "en",
|
||||
payload: UpdateHintPackRequest,
|
||||
) =>
|
||||
request<AdminHintPack>(`/content/hints/${locale}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
hintFeedSettings: () =>
|
||||
request<HintFeedSettings>("/content/hints/generation/settings"),
|
||||
|
||||
updateHintFeedSettings: (payload: UpdateHintFeedSettingsRequest) =>
|
||||
request<HintFeedSettings>("/content/hints/generation/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
hintFeedStatus: () =>
|
||||
request<HintFeedGenerationStatus>("/content/hints/generation/status"),
|
||||
|
||||
regenerateHintFeed: () =>
|
||||
request<HintFeedGenerationResponse>("/content/hints/generation/regenerate", {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(130_000),
|
||||
}),
|
||||
|
||||
users: (value: string | UsersQuery = "", legacyCursor?: string) => {
|
||||
const params =
|
||||
typeof value === "string"
|
||||
? { q: value.trim(), cursor: legacyCursor }
|
||||
: { ...value, q: value.q?.trim() };
|
||||
return request<PageResult<UserSummary>>(`/users${encodeQuery(params)}`);
|
||||
},
|
||||
|
||||
user: (userId: string) =>
|
||||
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
|
||||
|
||||
ledger: (userId: string, cursor?: string) =>
|
||||
latestLedger: (value?: string | LedgerQuery) =>
|
||||
request<PageResult<LedgerEntry>>(
|
||||
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
|
||||
`/credits/ledger${encodeQuery(ledgerQuery(value))}`,
|
||||
),
|
||||
|
||||
ledger: (userId: string, value?: string | LedgerQuery) =>
|
||||
request<PageResult<LedgerEntry>>(
|
||||
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(ledgerQuery(value))}`,
|
||||
),
|
||||
|
||||
grantCredits: (payload: CreditGrantRequest) =>
|
||||
@@ -189,11 +294,41 @@ export const adminApi = {
|
||||
headers: { "Idempotency-Key": payload.idempotencyKey },
|
||||
}),
|
||||
|
||||
auditLogs: (cursor?: string) =>
|
||||
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`),
|
||||
providers: () => request<ManagedProviderOverview>("/providers"),
|
||||
|
||||
operators: (cursor?: string) =>
|
||||
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`),
|
||||
revealProviderApiKey: (
|
||||
providerId: ManagedProviderId,
|
||||
payload: RevealProviderApiKeyRequest,
|
||||
) =>
|
||||
request<RevealProviderApiKeyResponse>(
|
||||
`/providers/${encodeURIComponent(providerId)}/api-key/reveal`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
|
||||
updateProviderApiKey: (
|
||||
providerId: ManagedProviderId,
|
||||
payload: UpdateProviderApiKeyRequest,
|
||||
) =>
|
||||
request<ManagedProviderStatus>(
|
||||
`/providers/${encodeURIComponent(providerId)}/api-key`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
|
||||
auditLogs: (value?: string | AuditQuery) =>
|
||||
request<PageResult<AuditLogEntry>>(
|
||||
`/audit${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
operators: (value?: string | OperatorsQuery) =>
|
||||
request<PageResult<AdminOperator>>(
|
||||
`/operators${encodeQuery(cursorQuery(value))}`,
|
||||
),
|
||||
|
||||
operatorSummary: () =>
|
||||
request<AdminSecuritySummary>("/operators/summary"),
|
||||
|
||||
+357
-4
@@ -1,5 +1,197 @@
|
||||
export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST";
|
||||
|
||||
export type SortOrder = "asc" | "desc";
|
||||
export type AdminAuditAction =
|
||||
| "LOGIN_SUCCEEDED"
|
||||
| "LOGIN_FAILED"
|
||||
| "SESSION_REVOKED"
|
||||
| "OPERATOR_CREATED"
|
||||
| "OPERATOR_ENABLED"
|
||||
| "OPERATOR_DISABLED"
|
||||
| "OPERATOR_UNLOCKED"
|
||||
| "OPERATOR_CREDENTIALS_RESET"
|
||||
| "OPERATOR_SESSIONS_REVOKED"
|
||||
| "MANUAL_CREDIT_GRANTED"
|
||||
| "CONTENT_SKILL_CREATED"
|
||||
| "CONTENT_SKILL_UPDATED"
|
||||
| "CONTENT_SKILL_ENABLED"
|
||||
| "CONTENT_SKILL_DISABLED"
|
||||
| "CONTENT_HINT_PACK_PUBLISHED"
|
||||
| "CONTENT_HINT_PACK_SAVED"
|
||||
| "CONTENT_HINT_FEED_SETTINGS_UPDATED"
|
||||
| "CONTENT_HINT_FEED_GENERATED"
|
||||
| "PROVIDER_API_KEY_UPDATED"
|
||||
| "PROVIDER_API_KEY_REVEALED";
|
||||
|
||||
export interface SkillLocalization {
|
||||
name: string;
|
||||
summary: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface OfficialSkill {
|
||||
id: string;
|
||||
systemImage: string;
|
||||
sortOrder: number;
|
||||
kind: "transform";
|
||||
thinkingEnabled: boolean;
|
||||
enabled: boolean;
|
||||
localizations: {
|
||||
"zh-Hans": SkillLocalization;
|
||||
en: SkillLocalization;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OfficialSkillCatalog {
|
||||
revision: number;
|
||||
generatedAt?: string;
|
||||
skills: OfficialSkill[];
|
||||
}
|
||||
|
||||
export type CreateOfficialSkillRequest = Omit<
|
||||
OfficialSkill,
|
||||
"kind" | "enabled"
|
||||
>;
|
||||
|
||||
export type UpdateOfficialSkillRequest = Omit<
|
||||
CreateOfficialSkillRequest,
|
||||
"id"
|
||||
>;
|
||||
|
||||
export interface AIHintCard {
|
||||
id: string;
|
||||
displayText?: string;
|
||||
text?: string;
|
||||
prompt: string;
|
||||
category: string;
|
||||
priority: number;
|
||||
source: string;
|
||||
locale: "zh" | "en";
|
||||
conditions: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
taskKind?: "ai_question" | "current_information_question";
|
||||
}
|
||||
|
||||
export interface AdminHintPack {
|
||||
locale: "zh" | "en";
|
||||
generatedAt?: string;
|
||||
expiresAt?: string;
|
||||
intervalHours?: number;
|
||||
version: number;
|
||||
cards: AIHintCard[];
|
||||
}
|
||||
|
||||
export interface UpdateHintPackRequest {
|
||||
generatedAt?: string;
|
||||
expiresAt?: string;
|
||||
intervalHours?: number;
|
||||
cards: AIHintCard[];
|
||||
}
|
||||
|
||||
export interface HintFeedSettings {
|
||||
enabled: boolean;
|
||||
topHubApiKeyConfigured: boolean;
|
||||
generationIntervalHours: number;
|
||||
holidayCountriesZh: string;
|
||||
holidayCountriesEn: string;
|
||||
weatherCitiesZh: string;
|
||||
weatherCitiesEn: string;
|
||||
googleTrendsGeos: string;
|
||||
}
|
||||
|
||||
export type UpdateHintFeedSettingsRequest = Omit<
|
||||
HintFeedSettings,
|
||||
"enabled" | "topHubApiKeyConfigured"
|
||||
>;
|
||||
|
||||
export interface HintFeedGenerationStatus {
|
||||
enabled: boolean;
|
||||
outcome: "IDLE" | "RUNNING" | "SUCCEEDED" | "FAILED";
|
||||
intervalHours: number;
|
||||
lastStartedAt?: string;
|
||||
lastCompletedAt?: string;
|
||||
lastErrorCode?: string;
|
||||
nextScheduledAt?: string;
|
||||
topHubApiKeyConfigured: boolean;
|
||||
zhVersion?: number;
|
||||
zhCardCount?: number;
|
||||
enVersion?: number;
|
||||
enCardCount?: number;
|
||||
}
|
||||
|
||||
export interface HintFeedGenerationResponse {
|
||||
generationId: string;
|
||||
generatedAt: string;
|
||||
zh: { version: number; cardCount: number };
|
||||
en: { version: number; cardCount: number };
|
||||
}
|
||||
|
||||
export type ManagedProviderId = "deepseek" | "volcengine";
|
||||
|
||||
export type ProviderCredentialSource = "ENVIRONMENT" | "RUNTIME_OVERRIDE";
|
||||
|
||||
export interface ManagedProviderStatus {
|
||||
providerId: ManagedProviderId;
|
||||
configured: boolean;
|
||||
source: ProviderCredentialSource;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export type ManagedProviderOverview = ManagedProviderStatus[];
|
||||
|
||||
export interface UpdateProviderApiKeyRequest {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface RevealProviderApiKeyRequest {
|
||||
totpCode: string;
|
||||
}
|
||||
|
||||
export interface RevealProviderApiKeyResponse {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface CursorPageQuery {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
from?: string;
|
||||
until?: string;
|
||||
sort?: "createdAt";
|
||||
order?: SortOrder;
|
||||
}
|
||||
|
||||
export interface UsersQuery extends CursorPageQuery {
|
||||
q?: string;
|
||||
status?: UserSummary["status"];
|
||||
}
|
||||
|
||||
export interface LedgerQuery extends Omit<CursorPageQuery, "sort"> {
|
||||
type?: LedgerAction;
|
||||
entryType?: LedgerEntryType;
|
||||
usageType?: UsageType;
|
||||
referenceId?: string;
|
||||
sort?: "createdAt" | "amount";
|
||||
}
|
||||
|
||||
export interface AuditQuery extends CursorPageQuery {
|
||||
action?: AdminAuditAction;
|
||||
result?: AuditLogEntry["result"];
|
||||
}
|
||||
|
||||
export interface OperatorsQuery extends Omit<CursorPageQuery, "sort"> {
|
||||
role?: AdminRole;
|
||||
enabled?: boolean;
|
||||
locked?: boolean;
|
||||
sort?: "createdAt" | "username" | "lastLoginAt";
|
||||
}
|
||||
|
||||
export interface ReferralsQuery {
|
||||
range: string;
|
||||
sort?: "invited" | "qualified" | "creditsEarned";
|
||||
order?: SortOrder;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type AuthState =
|
||||
| { status: "anonymous" }
|
||||
| { status: "authenticated"; operatorName: string; role: AdminRole };
|
||||
@@ -23,6 +215,10 @@ export interface TrendPoint {
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
period: {
|
||||
from: string;
|
||||
until: string;
|
||||
};
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
newUsers: number;
|
||||
@@ -46,18 +242,133 @@ export interface ReferralRankingItem {
|
||||
}
|
||||
|
||||
export interface ReferralOverview {
|
||||
period: {
|
||||
from: string;
|
||||
until: string;
|
||||
};
|
||||
pendingBindings: number;
|
||||
ineligibleBindings: number;
|
||||
funnel: FunnelStep[];
|
||||
ranking: ReferralRankingItem[];
|
||||
}
|
||||
|
||||
export interface AnalyticsRate {
|
||||
numerator: number;
|
||||
denominator: number;
|
||||
percent?: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsChannel {
|
||||
channel: string;
|
||||
installations: number;
|
||||
activated: number;
|
||||
activationRate: AnalyticsRate;
|
||||
}
|
||||
|
||||
export interface AnalyticsCohort {
|
||||
cohortDate: string;
|
||||
size: number;
|
||||
d1?: AnalyticsRate;
|
||||
d7?: AnalyticsRate;
|
||||
d30?: AnalyticsRate;
|
||||
}
|
||||
|
||||
export interface AnalyticsFeatureUsage {
|
||||
feature: string;
|
||||
executionMode: string;
|
||||
users: number;
|
||||
successes: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsLatencyBucket {
|
||||
bucket: string;
|
||||
successful: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface ProductAnalyticsOverview {
|
||||
period: {
|
||||
from: string;
|
||||
until: string;
|
||||
};
|
||||
northStar: {
|
||||
weeklyAiActiveUsers: number;
|
||||
previousWeeklyAiActiveUsers: number;
|
||||
weekOverWeekPercent?: number;
|
||||
};
|
||||
growth: {
|
||||
newInstallations: number;
|
||||
newAccounts: number;
|
||||
activation24h: AnalyticsRate;
|
||||
medianTimeToValueMinutes?: number;
|
||||
channels: AnalyticsChannel[];
|
||||
};
|
||||
activity: {
|
||||
dau: number;
|
||||
wau: number;
|
||||
mau: number;
|
||||
stickinessPercent?: number;
|
||||
successfulAiRequests: number;
|
||||
successfulRequestsPerActiveUser?: number;
|
||||
};
|
||||
consumption: {
|
||||
totalCredits: number;
|
||||
averageDailyCreditsPerActiveUser?: number;
|
||||
medianUserDailyCredits?: number;
|
||||
averageCreditsPerManagedRequest?: number;
|
||||
};
|
||||
monetization: {
|
||||
payingUsers: number;
|
||||
purchases: number;
|
||||
creditsPurchased: number;
|
||||
conversion7d: AnalyticsRate;
|
||||
conversion30d: AnalyticsRate;
|
||||
repeatPurchaseRate: AnalyticsRate;
|
||||
purchaseFunnel: FunnelStep[];
|
||||
cancelledUsers: number;
|
||||
};
|
||||
growthFunnel: FunnelStep[];
|
||||
retention: AnalyticsCohort[];
|
||||
aiFeatures: AnalyticsFeatureUsage[];
|
||||
keyboardUsage: {
|
||||
activeUsers: number;
|
||||
activationToInput: AnalyticsRate;
|
||||
chineseActiveUsers: number;
|
||||
englishActiveUsers: number;
|
||||
bilingualActiveUsers: number;
|
||||
totalCharacters: number;
|
||||
chineseCharacters: number;
|
||||
englishCharacters: number;
|
||||
otherCharacters: number;
|
||||
chineseSharePercent?: number;
|
||||
englishSharePercent?: number;
|
||||
inputSessions: number;
|
||||
averageCharactersPerInputSession?: number;
|
||||
chineseOnlySessions: number;
|
||||
englishOnlySessions: number;
|
||||
mixedLanguageSessions: number;
|
||||
otherOnlySessions: number;
|
||||
};
|
||||
referralSignals: {
|
||||
shared: number;
|
||||
opened: number;
|
||||
};
|
||||
referralFunnel: FunnelStep[];
|
||||
guardrails: {
|
||||
clientAiSuccessRate: AnalyticsRate;
|
||||
managedSuccessRate: AnalyticsRate;
|
||||
creditBlockedUsers: number;
|
||||
latencyBuckets: AnalyticsLatencyBucket[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
maskedEmail?: string;
|
||||
status: "active" | "suspended" | "closed";
|
||||
creditBalance: number;
|
||||
consumedCredits: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -90,19 +401,61 @@ export interface PageResult<T> {
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export type LedgerEntryType =
|
||||
export type LedgerAction =
|
||||
| "grant"
|
||||
| "reserve"
|
||||
| "settle"
|
||||
| "refund"
|
||||
| "adjustment";
|
||||
| "refund";
|
||||
|
||||
export type LedgerEntryType =
|
||||
| "SIGNUP_TRIAL"
|
||||
| "MANUAL_GRANT"
|
||||
| "USAGE_RESERVE"
|
||||
| "USAGE_SETTLE"
|
||||
| "USAGE_RELEASE"
|
||||
| "USAGE_REFUND"
|
||||
| "REFERRAL_INVITER"
|
||||
| "REFERRAL_INVITEE"
|
||||
| "STOREKIT_PURCHASE"
|
||||
| "SUBSCRIPTION_GRANT";
|
||||
|
||||
export type UsageType = "polish" | "asr" | "ai" | "agent" | "hotword";
|
||||
|
||||
export type LedgerEntryDetails =
|
||||
| {
|
||||
kind: "manualGrant";
|
||||
reason?: string;
|
||||
operatorName?: string;
|
||||
}
|
||||
| {
|
||||
kind: "storeKit";
|
||||
productId?: string;
|
||||
transactionId?: string;
|
||||
originalTransactionId?: string;
|
||||
environment?: string;
|
||||
purchasedAt?: string;
|
||||
}
|
||||
| {
|
||||
kind: "referral";
|
||||
role?: string;
|
||||
relatedUserId?: string;
|
||||
}
|
||||
| {
|
||||
kind: "usage";
|
||||
reservationId?: string;
|
||||
};
|
||||
|
||||
export interface LedgerEntry {
|
||||
entryId: string;
|
||||
type: LedgerEntryType;
|
||||
userId: string;
|
||||
type: LedgerAction;
|
||||
entryType: LedgerEntryType;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
reasonCode: string;
|
||||
usageType?: UsageType;
|
||||
referenceId?: string;
|
||||
details?: LedgerEntryDetails;
|
||||
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,512 @@
|
||||
import {
|
||||
Activity,
|
||||
BookOpenCheck,
|
||||
ChartNoAxesCombined,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
LibraryBig,
|
||||
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 AnalyticsPage = lazy(() =>
|
||||
import("./features/analytics/analytics-page").then((module) => ({
|
||||
default: module.AnalyticsPage,
|
||||
})),
|
||||
);
|
||||
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 ContentPage = lazy(() =>
|
||||
import("./features/content/content-page").then((module) => ({
|
||||
default: module.ContentPage,
|
||||
})),
|
||||
);
|
||||
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,
|
||||
})),
|
||||
);
|
||||
const ProvidersPage = lazy(() =>
|
||||
import("./features/providers/providers-page").then((module) => ({
|
||||
default: module.ProvidersPage,
|
||||
})),
|
||||
);
|
||||
|
||||
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: "/analytics",
|
||||
label: "产品分析",
|
||||
description: "增长、留存与付费",
|
||||
icon: ChartNoAxesCombined,
|
||||
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: "/content",
|
||||
label: "内容管理",
|
||||
description: "Skill 与 Hint",
|
||||
icon: LibraryBig,
|
||||
roles: supportRoles,
|
||||
},
|
||||
{
|
||||
path: "/audit",
|
||||
label: "审计日志",
|
||||
description: "操作追踪",
|
||||
icon: BookOpenCheck,
|
||||
roles: ["SUPER_ADMIN"],
|
||||
},
|
||||
{
|
||||
path: "/security",
|
||||
label: "安全中心",
|
||||
description: "权限与会话",
|
||||
icon: ShieldCheck,
|
||||
roles: ["SUPER_ADMIN"],
|
||||
},
|
||||
{
|
||||
path: "/providers",
|
||||
label: "Provider 配置",
|
||||
description: "上游密钥管理",
|
||||
icon: KeyRound,
|
||||
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="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/referrals" element={<ReferralsPage />} />
|
||||
{supportRoles.includes(role) ? (
|
||||
<>
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/credits" element={<CreditsPage />} />
|
||||
<Route path="/content" element={<ContentPage />} />
|
||||
</>
|
||||
) : null}
|
||||
{role === "SUPER_ADMIN" ? (
|
||||
<>
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
<Route path="/security" element={<SecurityPage />} />
|
||||
<Route path="/providers" element={<ProvidersPage />} />
|
||||
</>
|
||||
) : 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];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ChartToolbar({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 px-5 py-4"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,16 @@
|
||||
export function ChartLegend({
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ label: string; tone: "primary" | "violet" | "success" | "warning" }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
|
||||
{items.map((item) => (
|
||||
<span className="flex items-center gap-2" key={`${item.label}-${item.tone}`}>
|
||||
<i className={`chart-legend-dot chart-legend-dot--${item.tone}`} aria-hidden />
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { AnalyticsCohort, AnalyticsRate } from "../../api/types";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
|
||||
export function CohortHeatmap({ cohorts }: { cohorts: AnalyticsCohort[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] border-separate border-spacing-x-1 border-spacing-y-1.5 px-4 py-4 text-sm">
|
||||
<caption className="sr-only">D1、D7、D30 价值留存 cohort 热力图</caption>
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted">
|
||||
<th className="px-2 py-2 font-semibold" scope="col">激活日期</th>
|
||||
<th className="px-2 py-2 text-right font-semibold" scope="col">用户</th>
|
||||
<th className="px-2 py-2 text-center font-semibold" scope="col">D1</th>
|
||||
<th className="px-2 py-2 text-center font-semibold" scope="col">D7</th>
|
||||
<th className="px-2 py-2 text-center font-semibold" scope="col">D30</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cohorts.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-2 py-10 text-center text-muted" colSpan={5}>
|
||||
当前周期暂无已激活 cohort
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
cohorts.map((cohort) => (
|
||||
<tr key={cohort.cohortDate}>
|
||||
<th className="px-2 py-2 text-left font-medium" scope="row">
|
||||
{cohort.cohortDate}
|
||||
</th>
|
||||
<td className="px-2 py-2 text-right tabular-nums">{formatNumber(cohort.size)}</td>
|
||||
<HeatmapCell rate={cohort.d1} />
|
||||
<HeatmapCell rate={cohort.d7} />
|
||||
<HeatmapCell rate={cohort.d30} />
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 px-5 pb-5 text-xs text-muted" aria-hidden>
|
||||
<span>低</span>
|
||||
<i className="heatmap-key heatmap-key--low" />
|
||||
<i className="heatmap-key heatmap-key--medium" />
|
||||
<i className="heatmap-key heatmap-key--high" />
|
||||
<span>高</span>
|
||||
<i className="heatmap-key heatmap-key--unavailable" />
|
||||
<span>窗口未成熟</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatmapCell({ rate }: { rate?: AnalyticsRate }) {
|
||||
const label = rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`;
|
||||
return (
|
||||
<td className="p-0.5 text-center">
|
||||
<span
|
||||
className={`heatmap-cell ${heatmapTone(rate?.percent)}`}
|
||||
title={
|
||||
rate == null
|
||||
? "留存窗口尚未成熟"
|
||||
: `${formatNumber(rate.numerator)} / ${formatNumber(rate.denominator)}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function heatmapTone(percent?: number): string {
|
||||
if (percent == null) return "heatmap-cell--unavailable";
|
||||
if (percent >= 40) return "heatmap-cell--high";
|
||||
if (percent >= 20) return "heatmap-cell--medium";
|
||||
return "heatmap-cell--low";
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { formatNumber } from "../../lib/format";
|
||||
import { ChartLegend } from "./chart-legend";
|
||||
|
||||
type ChartTone = "primary" | "violet" | "success" | "warning";
|
||||
|
||||
export interface ComparisonBarItem {
|
||||
id?: string;
|
||||
label: string;
|
||||
value: number | null;
|
||||
secondaryValue?: number | null;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function ComparisonBarChart({
|
||||
items,
|
||||
primaryLabel,
|
||||
secondaryLabel,
|
||||
primaryTone = "primary",
|
||||
secondaryTone = "violet",
|
||||
valueFormatter = formatNumber,
|
||||
emptyText = "当前周期暂无可比较数据",
|
||||
}: {
|
||||
items: ComparisonBarItem[];
|
||||
primaryLabel: string;
|
||||
secondaryLabel?: string;
|
||||
primaryTone?: ChartTone;
|
||||
secondaryTone?: ChartTone;
|
||||
valueFormatter?: (value: number) => string;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <p className="p-8 text-center text-sm text-muted">{emptyText}</p>;
|
||||
}
|
||||
|
||||
const maximum = Math.max(
|
||||
...items.flatMap((item) => [item.value ?? 0, item.secondaryValue ?? 0]),
|
||||
1,
|
||||
);
|
||||
const legend = [
|
||||
{ label: primaryLabel, tone: primaryTone },
|
||||
...(secondaryLabel ? [{ label: secondaryLabel, tone: secondaryTone }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-5 sm:p-6">
|
||||
<ChartLegend items={legend} />
|
||||
<div className="mt-6 space-y-5">
|
||||
{items.map((item, index) => (
|
||||
<div key={item.id ?? `${item.label}-${index}`}>
|
||||
<div className="mb-2 flex items-baseline justify-between gap-4">
|
||||
<span className="text-sm font-medium text-foreground">{item.label}</span>
|
||||
{item.hint ? <span className="text-xs text-muted">{item.hint}</span> : null}
|
||||
</div>
|
||||
<Bar
|
||||
label={`${item.label} ${primaryLabel}`}
|
||||
maximum={maximum}
|
||||
tone={primaryTone}
|
||||
value={item.value}
|
||||
valueLabel={item.value == null ? "—" : valueFormatter(item.value)}
|
||||
/>
|
||||
{secondaryLabel != null && item.secondaryValue !== undefined ? (
|
||||
<Bar
|
||||
label={`${item.label} ${secondaryLabel}`}
|
||||
maximum={maximum}
|
||||
tone={secondaryTone}
|
||||
value={item.secondaryValue}
|
||||
valueLabel={
|
||||
item.secondaryValue == null ? "—" : valueFormatter(item.secondaryValue)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bar({
|
||||
label,
|
||||
maximum,
|
||||
tone,
|
||||
value,
|
||||
valueLabel,
|
||||
}: {
|
||||
label: string;
|
||||
maximum: number;
|
||||
tone: ChartTone;
|
||||
value: number | null;
|
||||
valueLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
<progress
|
||||
className={`bar-progress bar-progress--${tone} block h-2.5 min-w-0 flex-1 overflow-hidden rounded-full`}
|
||||
max={maximum}
|
||||
value={value ?? 0}
|
||||
aria-label={value == null ? `${label}:暂无数据` : `${label}:${valueLabel}`}
|
||||
/>
|
||||
<strong className="w-16 shrink-0 text-right text-xs font-semibold tabular-nums text-foreground">
|
||||
{valueLabel}
|
||||
</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { FunnelStep } from "../../api/types";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
|
||||
export function FunnelChart({
|
||||
steps,
|
||||
emptyText = "当前周期暂无漏斗数据",
|
||||
}: {
|
||||
steps: FunnelStep[];
|
||||
emptyText?: string;
|
||||
}) {
|
||||
if (steps.length === 0) {
|
||||
return <p className="p-8 text-center text-sm text-muted">{emptyText}</p>;
|
||||
}
|
||||
|
||||
const maximum = Math.max(steps[0]?.count ?? 0, 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-5 sm:p-6">
|
||||
{steps.map((step, index) => {
|
||||
const previous = steps[index - 1]?.count;
|
||||
const stepConversion =
|
||||
previous == null || previous === 0 ? undefined : (step.count / previous) * 100;
|
||||
const totalConversion = maximum === 0 ? 0 : (step.count / maximum) * 100;
|
||||
|
||||
return (
|
||||
<div key={`${step.label}-${index}`}>
|
||||
<div className="mb-2 flex items-baseline justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{step.label}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted">
|
||||
{index === 0
|
||||
? "漏斗起点"
|
||||
: `上一步转化 ${stepConversion == null ? "—" : `${stepConversion.toFixed(1)}%`}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<strong className="block text-sm tabular-nums">{formatNumber(step.count)}</strong>
|
||||
<span className="text-xs tabular-nums text-muted">
|
||||
占起点 {totalConversion.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<progress
|
||||
className="funnel-progress block h-3 w-full overflow-hidden rounded-full"
|
||||
max={maximum}
|
||||
value={step.count}
|
||||
aria-label={`${step.label}:${formatNumber(step.count)},占起点 ${totalConversion.toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
type RadialTone = "primary" | "success" | "violet" | "warning";
|
||||
|
||||
export function RadialMetric({
|
||||
label,
|
||||
percent,
|
||||
detail,
|
||||
tone = "primary",
|
||||
}: {
|
||||
label: string;
|
||||
percent: number | null;
|
||||
detail?: string;
|
||||
tone?: RadialTone;
|
||||
}) {
|
||||
const value = percent == null ? null : Math.min(Math.max(percent, 0), 100);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-5">
|
||||
<svg
|
||||
className="size-28 shrink-0"
|
||||
viewBox="0 0 120 120"
|
||||
role="img"
|
||||
aria-label={value == null ? `${label}:暂无数据` : `${label}:${value.toFixed(1)}%`}
|
||||
>
|
||||
<circle className="radial-track" cx="60" cy="60" r="48" pathLength="100" />
|
||||
<circle
|
||||
className={`radial-value radial-value--${tone}`}
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="48"
|
||||
pathLength="100"
|
||||
strokeDasharray={`${value ?? 0} ${100 - (value ?? 0)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
/>
|
||||
<text className="radial-label" x="60" y="65" textAnchor="middle">
|
||||
{value == null ? "—" : `${Math.round(value)}%`}
|
||||
</text>
|
||||
</svg>
|
||||
<div>
|
||||
<strong className="text-sm text-foreground">{label}</strong>
|
||||
{detail ? <p className="mt-1 text-xs leading-5 text-muted">{detail}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { TrendPoint } from "../../api/types";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
|
||||
export function TrendChart({
|
||||
points,
|
||||
showRegistrations = true,
|
||||
showCredits = true,
|
||||
}: {
|
||||
points: TrendPoint[];
|
||||
showRegistrations?: boolean;
|
||||
showCredits?: boolean;
|
||||
}) {
|
||||
if (points.length === 0) {
|
||||
return <div className="grid h-full place-items-center text-sm text-muted">暂无趋势数据</div>;
|
||||
}
|
||||
if (!showRegistrations && !showCredits) {
|
||||
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="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"
|
||||
/>
|
||||
))}
|
||||
{showRegistrations ? (
|
||||
<text x={paddingX} y={paddingY - 9} className="chart-label">
|
||||
{formatNumber(registrationMax)} 用户
|
||||
</text>
|
||||
) : null}
|
||||
{showCredits ? (
|
||||
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
|
||||
{formatNumber(creditMax)} 积分
|
||||
</text>
|
||||
) : null}
|
||||
{showRegistrations ? (
|
||||
<polyline points={registrationLine} className="chart-line chart-line--primary" />
|
||||
) : null}
|
||||
{showCredits ? (
|
||||
<polyline points={creditLine} className="chart-line chart-line--violet" />
|
||||
) : null}
|
||||
{coordinates.map(({ point, x, registrationY, creditY }, index) => (
|
||||
<g key={point.date}>
|
||||
{showRegistrations ? (
|
||||
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
|
||||
<title>
|
||||
{point.date}:新增 {formatNumber(point.registrations)} 位用户
|
||||
</title>
|
||||
</circle>
|
||||
) : null}
|
||||
{showCredits ? (
|
||||
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
|
||||
<title>
|
||||
{point.date}:消耗 {formatNumber(point.creditsUsed)} 积分
|
||||
</title>
|
||||
</circle>
|
||||
) : null}
|
||||
{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>
|
||||
{showRegistrations ? <th scope="col">新增用户</th> : null}
|
||||
{showCredits ? <th scope="col">消耗积分</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{points.map((point) => (
|
||||
<tr key={point.date}>
|
||||
<td>{point.date}</td>
|
||||
{showRegistrations ? <td>{formatNumber(point.registrations)}</td> : null}
|
||||
{showCredits ? <td>{formatNumber(point.creditsUsed)}</td> : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import type { RowData } from "@tanstack/table-core";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
type LegacyColumnDef,
|
||||
useLegacyTable,
|
||||
} from "@tanstack/react-table/legacy";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Fragment, type ReactNode, useState } from "react";
|
||||
import type { SortOrder } from "../api/types";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EmptyState } from "./primitives";
|
||||
|
||||
export type DataColumn<T extends RowData> = LegacyColumnDef<T, unknown>;
|
||||
|
||||
export function DataTable<T extends RowData>({
|
||||
data,
|
||||
columns,
|
||||
caption,
|
||||
emptyTitle = "暂无数据",
|
||||
footer,
|
||||
className,
|
||||
sort,
|
||||
sortableColumns,
|
||||
onSortChange,
|
||||
renderExpandedRow,
|
||||
getRowId,
|
||||
expandLabel = "详情",
|
||||
}: {
|
||||
data: T[];
|
||||
columns: DataColumn<T>[];
|
||||
caption: string;
|
||||
emptyTitle?: string;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
sort?: { key: string; order: SortOrder };
|
||||
sortableColumns?: Record<string, string>;
|
||||
onSortChange?: (key: string, order: SortOrder) => void;
|
||||
renderExpandedRow?: (row: T) => ReactNode;
|
||||
getRowId?: (row: T) => string;
|
||||
expandLabel?: string;
|
||||
}) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(() => new Set());
|
||||
const table = useLegacyTable({
|
||||
data,
|
||||
columns,
|
||||
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) => {
|
||||
const sortKey = sortableColumns?.[header.column.id];
|
||||
const active = sortKey != null && sort?.key === sortKey;
|
||||
const ariaSort = active
|
||||
? sort.order === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: sortKey
|
||||
? "none"
|
||||
: undefined;
|
||||
return (
|
||||
<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"
|
||||
aria-sort={ariaSort}
|
||||
>
|
||||
{header.isPlaceholder ? null : sortKey && onSortChange ? (
|
||||
<button
|
||||
className="inline-flex min-h-8 items-center gap-1.5 rounded-lg px-1 text-left transition hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSortChange(
|
||||
sortKey,
|
||||
active && sort.order === "desc" ? "asc" : "desc",
|
||||
)
|
||||
}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{active ? (
|
||||
sort.order === "asc" ? (
|
||||
<ArrowUp className="size-3.5" aria-hidden />
|
||||
) : (
|
||||
<ArrowDown className="size-3.5" aria-hidden />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 opacity-60" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{renderExpandedRow ? (
|
||||
<th
|
||||
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
|
||||
scope="col"
|
||||
>
|
||||
{expandLabel}
|
||||
</th>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const expansionKey = getRowId?.(row.original) ?? row.id;
|
||||
const expanded = expandedRows.has(expansionKey);
|
||||
const panelId = `expanded-row-${expansionKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<tr className="group transition-colors hover:bg-surface-muted/70">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
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>
|
||||
))}
|
||||
{renderExpandedRow ? (
|
||||
<td className="border-b border-border/70 px-5 py-4 text-right align-middle">
|
||||
<button
|
||||
className="inline-flex min-h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-semibold text-primary transition hover:bg-primary-soft focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={panelId}
|
||||
onClick={() =>
|
||||
setExpandedRows((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(expansionKey)) next.delete(expansionKey);
|
||||
else next.add(expansionKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3.5" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
)}
|
||||
{expanded ? "收起" : "展开"}
|
||||
</button>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
{renderExpandedRow && expanded ? (
|
||||
<tr id={panelId}>
|
||||
<td
|
||||
className="border-b border-border bg-surface-muted/45 px-5 py-4"
|
||||
colSpan={row.getVisibleCells().length + 1}
|
||||
>
|
||||
{renderExpandedRow(row.original)}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface DateRangeValue {
|
||||
from?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
function datePart(value?: string, exclusiveEnd = false): string {
|
||||
if (!value) return "";
|
||||
if (!exclusiveEnd) return value.slice(0, 10);
|
||||
const date = new Date(value);
|
||||
date.setUTCDate(date.getUTCDate() - 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function utcDate(value: string, exclusiveEnd: boolean): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
if (exclusiveEnd) date.setUTCDate(date.getUTCDate() + 1);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function DateRangeControl({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
|
||||
<legend className="sr-only">日期范围</legend>
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
type="date"
|
||||
value={datePart(value.from)}
|
||||
max={datePart(value.until, true) || undefined}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, from: utcDate(event.target.value, false) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
type="date"
|
||||
value={datePart(value.until, true)}
|
||||
min={datePart(value.from) || undefined}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, until: utcDate(event.target.value, true) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -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,55 @@
|
||||
export interface FilterOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function FilterControl<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly FilterOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value as T)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToggleFilter({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-h-9 cursor-pointer items-center gap-2 rounded-lg border border-border bg-input px-3 text-xs font-semibold text-foreground">
|
||||
<input
|
||||
className="size-4 accent-primary"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function PeriodCaption({
|
||||
from,
|
||||
until,
|
||||
}: {
|
||||
from: string;
|
||||
until: string;
|
||||
}) {
|
||||
return (
|
||||
<p className="text-xs text-muted" aria-label="实际统计周期">
|
||||
统计周期:{utcLabel(from)} 至 {utcLabel(until)}(UTC,包含今日未完整数据)
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function utcLabel(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toISOString().replace("T", " ").slice(0, 16);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const ranges = [
|
||||
{ value: "7d", label: "7 天" },
|
||||
{ value: "30d", label: "30 天" },
|
||||
{ value: "90d", label: "90 天" },
|
||||
] as const;
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SortOrder } from "../api/types";
|
||||
|
||||
export interface SortOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SortControl<T extends string>({
|
||||
value,
|
||||
order,
|
||||
options,
|
||||
onChange,
|
||||
label = "排序",
|
||||
}: {
|
||||
value: T;
|
||||
order: SortOrder;
|
||||
options: readonly SortOption<T>[];
|
||||
onChange: (value: T, order: SortOrder) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
|
||||
<legend className="sr-only">{label}</legend>
|
||||
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value as T, order)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
||||
{(["desc", "asc"] as const).map((direction) => (
|
||||
<button
|
||||
key={direction}
|
||||
className={`min-h-7 rounded-lg px-3 text-xs font-semibold transition ${
|
||||
order === direction
|
||||
? "bg-surface text-foreground shadow-sm"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
type="button"
|
||||
aria-pressed={order === direction}
|
||||
onClick={() => onChange(value, direction)}
|
||||
>
|
||||
{direction === "desc" ? "降序" : "升序"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
export function TableToolbar({
|
||||
children,
|
||||
active,
|
||||
onClear,
|
||||
clearLabel = "清除筛选",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
active: boolean;
|
||||
onClear: () => void;
|
||||
clearLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 p-4 sm:p-5"
|
||||
aria-label="表格筛选与排序"
|
||||
>
|
||||
{children}
|
||||
<Button
|
||||
className="sm:ml-auto"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
disabled={!active}
|
||||
onClick={onClear}
|
||||
>
|
||||
{clearLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,601 @@
|
||||
import {
|
||||
Activity,
|
||||
BadgeDollarSign,
|
||||
BrainCircuit,
|
||||
ChartNoAxesCombined,
|
||||
Gauge,
|
||||
Keyboard,
|
||||
Repeat2,
|
||||
Sparkles,
|
||||
Target,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type {
|
||||
AnalyticsRate,
|
||||
ProductAnalyticsOverview,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { CohortHeatmap } from "../../components/charts/cohort-heatmap";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||
import { FilterControl, ToggleFilter } from "../../components/filter-control";
|
||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||
import { PeriodCaption } from "../../components/period-caption";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
import { stableSort } from "../../lib/sort";
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [data, setData] = useState<ProductAnalyticsOverview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [executionMode, setExecutionMode] = useState("");
|
||||
const [aiSort, setAiSort] = useState<"successes" | "users">("successes");
|
||||
const [aiOrder, setAiOrder] = useState<SortOrder>("desc");
|
||||
const [channelSort, setChannelSort] = useState<"installs" | "activated" | "rate">("installs");
|
||||
const [channelOrder, setChannelOrder] = useState<SortOrder>("desc");
|
||||
const [hideUnknown, setHideUnknown] = useState(false);
|
||||
const [cohortOrder, setCohortOrder] = useState<SortOrder>("desc");
|
||||
const [minimumCohortSize, setMinimumCohortSize] = useState(0);
|
||||
const [matureOnly, setMatureOnly] = useState(false);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await adminApi.productAnalytics(range);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const visibleAiFeatures = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.aiFeatures ?? []).filter(
|
||||
(item) => !executionMode || item.executionMode === executionMode,
|
||||
),
|
||||
(item) => (aiSort === "successes" ? item.successes : item.users),
|
||||
aiOrder,
|
||||
),
|
||||
[aiOrder, aiSort, data?.aiFeatures, executionMode],
|
||||
);
|
||||
const visibleChannels = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.growth.channels ?? []).filter(
|
||||
(channel) => !hideUnknown || channel.channel !== "UNKNOWN",
|
||||
),
|
||||
(channel) =>
|
||||
channelSort === "installs"
|
||||
? channel.installations
|
||||
: channelSort === "activated"
|
||||
? channel.activated
|
||||
: channel.activationRate.percent ?? -1,
|
||||
channelOrder,
|
||||
),
|
||||
[channelOrder, channelSort, data?.growth.channels, hideUnknown],
|
||||
);
|
||||
const visibleCohorts = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
(data?.retention ?? []).filter(
|
||||
(cohort) =>
|
||||
cohort.size >= minimumCohortSize &&
|
||||
(!matureOnly || cohort.d30?.percent != null),
|
||||
),
|
||||
(cohort) => cohort.cohortDate,
|
||||
cohortOrder,
|
||||
),
|
||||
[cohortOrder, data?.retention, matureOnly, minimumCohortSize],
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||
if (!data) return <LoadingState label="加载产品数据" />;
|
||||
|
||||
const weeklyGrowth = data.northStar.weekOverWeekPercent;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="CEO Dashboard"
|
||||
title="产品增长与留存"
|
||||
description="围绕成功使用 AI 的核心价值事件,观察增长质量、留存、消耗与付费。"
|
||||
actions={
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<RangeControl value={range} onChange={setRange} />
|
||||
<PeriodCaption from={data.period.from} until={data.period.until} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="北极星指标">
|
||||
<StatCard
|
||||
label="周 AI 活跃用户"
|
||||
value={formatNumber(data.northStar.weeklyAiActiveUsers)}
|
||||
hint={
|
||||
weeklyGrowth == null
|
||||
? "暂无可比上周数据"
|
||||
: `较上周 ${signedPercent(weeklyGrowth)}`
|
||||
}
|
||||
icon={Sparkles}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label="24 小时激活率"
|
||||
value={rateLabel(data.growth.activation24h)}
|
||||
hint={`${formatNumber(data.growth.activation24h.numerator)} / ${formatNumber(data.growth.activation24h.denominator)} 位新用户`}
|
||||
icon={Target}
|
||||
/>
|
||||
<StatCard
|
||||
label="D7 价值留存"
|
||||
value={latestMatureRetention(data, "d7")}
|
||||
hint="激活后第 7 天再次成功使用 AI"
|
||||
icon={Repeat2}
|
||||
tone="violet"
|
||||
/>
|
||||
<StatCard
|
||||
label="每活跃用户日均消耗"
|
||||
value={optionalNumber(data.consumption.averageDailyCreditsPerActiveUser)}
|
||||
hint={`周期总消耗 ${formatNumber(data.consumption.totalCredits)} 积分`}
|
||||
icon={Gauge}
|
||||
tone="warning"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="增长激活漏斗"
|
||||
description="同一批已完成 24 小时观察的新安装,所有步骤必须在首次启动后 24 小时内完成"
|
||||
icon={Target}
|
||||
/>
|
||||
<FunnelChart steps={data.growthFunnel} />
|
||||
</Card>
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="留存 Cohort 热力图"
|
||||
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
||||
icon={ChartNoAxesCombined}
|
||||
/>
|
||||
<ChartToolbar label="留存 cohort 筛选与排序">
|
||||
<SortControl
|
||||
label="激活日期"
|
||||
value="date"
|
||||
order={cohortOrder}
|
||||
options={[{ value: "date", label: "激活日期" }]}
|
||||
onChange={(_, order) => setCohortOrder(order)}
|
||||
/>
|
||||
<FilterControl
|
||||
label="最小样本量"
|
||||
value={String(minimumCohortSize)}
|
||||
options={[
|
||||
{ value: "0", label: "不限" },
|
||||
{ value: "10", label: "至少 10 人" },
|
||||
{ value: "50", label: "至少 50 人" },
|
||||
{ value: "100", label: "至少 100 人" },
|
||||
]}
|
||||
onChange={(value) => setMinimumCohortSize(Number(value))}
|
||||
/>
|
||||
<ToggleFilter label="仅显示 D30 已成熟" checked={matureOnly} onChange={setMatureOnly} />
|
||||
</ChartToolbar>
|
||||
<CohortHeatmap cohorts={visibleCohorts} />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="AI 使用结构"
|
||||
description="客户端功能与执行模式,仅包含白名单元数据"
|
||||
icon={BrainCircuit}
|
||||
/>
|
||||
<ChartToolbar label="AI 使用结构筛选与排序">
|
||||
<FilterControl
|
||||
label="执行模式"
|
||||
value={executionMode}
|
||||
options={[
|
||||
{ value: "", label: "全部模式" },
|
||||
{ value: "MANAGED", label: "托管" },
|
||||
{ value: "LOCAL", label: "本地" },
|
||||
{ value: "BYOK", label: "BYOK" },
|
||||
]}
|
||||
onChange={setExecutionMode}
|
||||
/>
|
||||
<SortControl
|
||||
value={aiSort}
|
||||
order={aiOrder}
|
||||
options={[
|
||||
{ value: "successes", label: "成功次数" },
|
||||
{ value: "users", label: "成功用户" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setAiSort(value);
|
||||
setAiOrder(order);
|
||||
}}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
{visibleAiFeatures.length === 0 ? (
|
||||
<p className="p-8 text-center text-sm text-muted">当前筛选条件下暂无 AI 使用数据</p>
|
||||
) : (
|
||||
<ComparisonBarChart
|
||||
items={visibleAiFeatures.map((item) => ({
|
||||
id: `${item.feature}-${item.executionMode}`,
|
||||
label: featureLabel(item.feature),
|
||||
value: item.successes,
|
||||
secondaryValue: item.users,
|
||||
hint: executionModeLabel(item.executionMode),
|
||||
}))}
|
||||
primaryLabel="成功次数"
|
||||
secondaryLabel="成功用户"
|
||||
primaryTone="violet"
|
||||
secondaryTone="success"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="活跃与消耗"
|
||||
description="DAU / WAU / MAU 为截至统计截止时刻的滚动 1 / 7 / 30 日 AI 价值活跃用户"
|
||||
icon={Activity}
|
||||
/>
|
||||
<ComparisonBarChart
|
||||
items={[
|
||||
{ label: "DAU", value: data.activity.dau },
|
||||
{ label: "WAU", value: data.activity.wau },
|
||||
{ label: "MAU", value: data.activity.mau },
|
||||
]}
|
||||
primaryLabel="AI 活跃用户"
|
||||
primaryTone="success"
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
["DAU / MAU", optionalPercent(data.activity.stickinessPercent)],
|
||||
["成功 AI 次数", formatNumber(data.activity.successfulAiRequests)],
|
||||
["人均成功次数", optionalDecimal(data.activity.successfulRequestsPerActiveUser)],
|
||||
["用户日消耗中位数", optionalNumber(data.consumption.medianUserDailyCredits)],
|
||||
["单次托管请求积分", optionalDecimal(data.consumption.averageCreditsPerManagedRequest)],
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-2" aria-label="键盘输入统计">
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="键盘中英文输入"
|
||||
description="仅统计 OSGKeyboard 本地聚合后的手动提交字符,不包含输入内容"
|
||||
icon={Keyboard}
|
||||
/>
|
||||
<ComparisonBarChart
|
||||
items={[
|
||||
{
|
||||
label: "中文",
|
||||
value: data.keyboardUsage.chineseCharacters,
|
||||
hint: `占中英文 ${optionalPercent(data.keyboardUsage.chineseSharePercent)}`,
|
||||
},
|
||||
{
|
||||
label: "英文",
|
||||
value: data.keyboardUsage.englishCharacters,
|
||||
hint: `占中英文 ${optionalPercent(data.keyboardUsage.englishSharePercent)}`,
|
||||
},
|
||||
{
|
||||
label: "其他",
|
||||
value: data.keyboardUsage.otherCharacters,
|
||||
hint: "数字、标点与 Emoji",
|
||||
},
|
||||
]}
|
||||
primaryLabel="提交字符数"
|
||||
primaryTone="primary"
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
["输入活跃用户", formatNumber(data.keyboardUsage.activeUsers)],
|
||||
["激活到输入转化", rateLabel(data.keyboardUsage.activationToInput)],
|
||||
["中文活跃用户", formatNumber(data.keyboardUsage.chineseActiveUsers)],
|
||||
["英文活跃用户", formatNumber(data.keyboardUsage.englishActiveUsers)],
|
||||
["中英双语用户", formatNumber(data.keyboardUsage.bilingualActiveUsers)],
|
||||
["总提交字符", formatNumber(data.keyboardUsage.totalCharacters)],
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="输入会话结构"
|
||||
description="一次键盘激活期间至少提交一个字符才计为输入会话"
|
||||
icon={Activity}
|
||||
/>
|
||||
<ComparisonBarChart
|
||||
items={[
|
||||
{ label: "仅中文", value: data.keyboardUsage.chineseOnlySessions },
|
||||
{ label: "仅英文", value: data.keyboardUsage.englishOnlySessions },
|
||||
{ label: "中英混合", value: data.keyboardUsage.mixedLanguageSessions },
|
||||
{ label: "仅其他", value: data.keyboardUsage.otherOnlySessions },
|
||||
]}
|
||||
primaryLabel="输入会话"
|
||||
primaryTone="success"
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
["输入会话总数", formatNumber(data.keyboardUsage.inputSessions)],
|
||||
[
|
||||
"平均每会话字符",
|
||||
optionalDecimal(data.keyboardUsage.averageCharactersPerInputSession),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-3">
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="付费转化"
|
||||
description="所选周期内完成 7 / 30 天观察窗的注册 cohort;购买以 StoreKit 验证为准"
|
||||
icon={BadgeDollarSign}
|
||||
/>
|
||||
<ComparisonBarChart
|
||||
items={[
|
||||
{
|
||||
label: "7 天免费转付费",
|
||||
value: data.monetization.conversion7d.percent ?? null,
|
||||
},
|
||||
{
|
||||
label: "30 天免费转付费",
|
||||
value: data.monetization.conversion30d.percent ?? null,
|
||||
},
|
||||
{
|
||||
label: "复购率",
|
||||
value: data.monetization.repeatPurchaseRate.percent ?? null,
|
||||
},
|
||||
]}
|
||||
primaryLabel="转化率"
|
||||
primaryTone="success"
|
||||
valueFormatter={(value) => `${value.toFixed(1)}%`}
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
["付费用户", formatNumber(data.monetization.payingUsers)],
|
||||
["购买次数", formatNumber(data.monetization.purchases)],
|
||||
["购买积分", formatNumber(data.monetization.creditsPurchased)],
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="推荐增长漏斗"
|
||||
description="严格绑定 cohort;分享和打开仅作为独立方向信号"
|
||||
icon={Target}
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
["客户端分享信号", formatNumber(data.referralSignals.shared)],
|
||||
["邀请打开信号", formatNumber(data.referralSignals.opened)],
|
||||
]}
|
||||
/>
|
||||
<FunnelChart steps={data.referralFunnel} />
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader title="体验护栏" description="避免增长被失败体验抵消" icon={Users} />
|
||||
<ComparisonBarChart
|
||||
items={[
|
||||
{
|
||||
label: "客户端 AI",
|
||||
value: data.guardrails.clientAiSuccessRate.percent ?? null,
|
||||
},
|
||||
{
|
||||
label: "托管请求",
|
||||
value: data.guardrails.managedSuccessRate.percent ?? null,
|
||||
},
|
||||
]}
|
||||
primaryLabel="成功率"
|
||||
primaryTone="primary"
|
||||
valueFormatter={(value) => `${value.toFixed(1)}%`}
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
["积分不足阻断用户", formatNumber(data.guardrails.creditBlockedUsers)],
|
||||
["首次价值耗时中位数", optionalMinutes(data.growth.medianTimeToValueMinutes)],
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="购买意向漏斗"
|
||||
description="同一安装依次浏览、发起购买,并由 StoreKit 服务端验证"
|
||||
icon={BadgeDollarSign}
|
||||
/>
|
||||
<FunnelChart
|
||||
steps={data.monetization.purchaseFunnel}
|
||||
emptyText="客户端购买事件暂无样本"
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[["取消购买用户", formatNumber(data.monetization.cancelledUsers)]]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader
|
||||
title="AI 终态延迟分布"
|
||||
description="按客户端白名单耗时桶聚合;不推算虚假的精确分位数"
|
||||
icon={Gauge}
|
||||
/>
|
||||
<ComparisonBarChart
|
||||
items={data.guardrails.latencyBuckets.map((item) => ({
|
||||
id: item.bucket,
|
||||
label: latencyBucketLabel(item.bucket),
|
||||
value: item.successful,
|
||||
secondaryValue: item.failed,
|
||||
}))}
|
||||
primaryLabel="成功"
|
||||
secondaryLabel="失败"
|
||||
primaryTone="success"
|
||||
secondaryTone="warning"
|
||||
emptyText="客户端 AI 终态事件暂无样本"
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
||||
<ChartToolbar label="渠道质量筛选与排序">
|
||||
<SortControl
|
||||
value={channelSort}
|
||||
order={channelOrder}
|
||||
options={[
|
||||
{ value: "installs", label: "已完成观察安装" },
|
||||
{ value: "activated", label: "激活人数" },
|
||||
{ value: "rate", label: "激活率" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setChannelSort(value);
|
||||
setChannelOrder(order);
|
||||
}}
|
||||
/>
|
||||
<ToggleFilter label="隐藏未知来源" checked={hideUnknown} onChange={setHideUnknown} />
|
||||
</ChartToolbar>
|
||||
<div className="grid gap-0 xl:grid-cols-[1fr_260px]">
|
||||
<ComparisonBarChart
|
||||
items={visibleChannels.map((channel) => ({
|
||||
label: channelLabel(channel.channel),
|
||||
value: channel.installations,
|
||||
secondaryValue: channel.activated,
|
||||
hint: `激活率 ${rateLabel(channel.activationRate)}`,
|
||||
}))}
|
||||
primaryLabel="已完成 24h 观察安装"
|
||||
secondaryLabel="24 小时激活"
|
||||
emptyText="当前筛选条件下暂无渠道归因数据"
|
||||
/>
|
||||
<MetricRows
|
||||
rows={[
|
||||
["新增安装", formatNumber(data.growth.newInstallations)],
|
||||
["新增账号", formatNumber(data.growth.newAccounts)],
|
||||
["整体激活率", rateLabel(data.growth.activation24h)],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof Activity;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-5">
|
||||
<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 MetricRows({ rows }: { rows: Array<[string, string]> }) {
|
||||
return (
|
||||
<dl className="divide-y divide-border">
|
||||
{rows.map(([label, value]) => (
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-3.5" key={label}>
|
||||
<dt className="text-sm text-muted">{label}</dt>
|
||||
<dd className="text-right text-sm font-semibold tabular-nums">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function rateLabel(rate?: AnalyticsRate): string {
|
||||
return rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function optionalPercent(value?: number): string {
|
||||
return value == null ? "—" : `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function optionalDecimal(value?: number): string {
|
||||
return value == null ? "—" : value.toFixed(2);
|
||||
}
|
||||
|
||||
function optionalNumber(value?: number): string {
|
||||
return value == null ? "—" : formatNumber(Math.round(value));
|
||||
}
|
||||
|
||||
function optionalMinutes(value?: number): string {
|
||||
return value == null ? "—" : `${Math.round(value)} 分钟`;
|
||||
}
|
||||
|
||||
function signedPercent(value: number): string {
|
||||
return `${value >= 0 ? "+" : ""}${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function latestMatureRetention(
|
||||
data: ProductAnalyticsOverview,
|
||||
key: "d1" | "d7" | "d30",
|
||||
): string {
|
||||
return rateLabel(data.retention.find((cohort) => cohort[key]?.percent != null)?.[key]);
|
||||
}
|
||||
|
||||
function channelLabel(channel: string): string {
|
||||
return {
|
||||
APP_STORE_ORGANIC: "App Store 自然量",
|
||||
REFERRAL: "用户邀请",
|
||||
SOCIAL_CONTENT: "社交 / 内容",
|
||||
UNKNOWN: "未知来源",
|
||||
}[channel] ?? channel;
|
||||
}
|
||||
|
||||
function featureLabel(feature: string): string {
|
||||
return {
|
||||
TRANSCRIPTION: "语音转写",
|
||||
POLISH: "文字润色",
|
||||
AI_ASSISTANT: "AI 助手",
|
||||
AGENT: "Agent",
|
||||
HOTWORD: "快捷指令",
|
||||
OTHER: "其他",
|
||||
}[feature] ?? feature;
|
||||
}
|
||||
|
||||
function executionModeLabel(mode: string): string {
|
||||
return {
|
||||
MANAGED: "托管",
|
||||
LOCAL: "本地",
|
||||
BYOK: "BYOK",
|
||||
}[mode] ?? mode;
|
||||
}
|
||||
|
||||
function latencyBucketLabel(bucket: string): string {
|
||||
return {
|
||||
LT_1S: "< 1 秒",
|
||||
S1_TO_3: "1–3 秒",
|
||||
S3_TO_10: "3–10 秒",
|
||||
S10_TO_30: "10–30 秒",
|
||||
GTE_30S: "≥ 30 秒",
|
||||
}[bucket] ?? bucket;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { FileCheck2, ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type {
|
||||
AdminAuditAction,
|
||||
AuditLogEntry,
|
||||
AuditQuery,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { DateRangeControl } from "../../components/date-range-control";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import { formatDateTime, statusLabel } from "../../lib/format";
|
||||
|
||||
export function AuditPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [action, setAction] = useState<"" | AdminAuditAction>(
|
||||
(searchParams.get("action") as AdminAuditAction | null) ?? "",
|
||||
);
|
||||
const [result, setResult] = useState<"" | AuditLogEntry["result"]>(
|
||||
(searchParams.get("result") as AuditLogEntry["result"] | null) ?? "",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const auditQuery = useMemo<AuditQuery>(
|
||||
() => ({
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
action: action || undefined,
|
||||
result: result || undefined,
|
||||
sort: "createdAt",
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[action, from, order, result, until],
|
||||
);
|
||||
const fetchAudit = useCallback((value: AuditQuery) => adminApi.auditLogs(value), []);
|
||||
const {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(auditQuery, fetchAudit);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (action) params.set("action", action);
|
||||
if (result) params.set("result", result);
|
||||
params.set("sort", "createdAt");
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [action, from, order, result, setSearchParams, until]);
|
||||
|
||||
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">
|
||||
<TableToolbar
|
||||
active={Boolean(from || until || action || result || order !== "desc")}
|
||||
onClear={() => {
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setAction("");
|
||||
setResult("");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="操作类型"
|
||||
value={action}
|
||||
options={[
|
||||
{ value: "", label: "全部操作" },
|
||||
{ value: "LOGIN_SUCCEEDED", label: "登录成功" },
|
||||
{ value: "LOGIN_FAILED", label: "登录失败" },
|
||||
{ value: "SESSION_REVOKED", label: "会话撤销" },
|
||||
{ value: "OPERATOR_CREATED", label: "创建管理员" },
|
||||
{ value: "OPERATOR_ENABLED", label: "启用管理员" },
|
||||
{ value: "OPERATOR_DISABLED", label: "停用管理员" },
|
||||
{ value: "OPERATOR_UNLOCKED", label: "解锁管理员" },
|
||||
{ value: "OPERATOR_CREDENTIALS_RESET", label: "重置管理员凭据" },
|
||||
{ value: "OPERATOR_SESSIONS_REVOKED", label: "撤销管理员会话" },
|
||||
{ value: "MANUAL_CREDIT_GRANTED", label: "人工赠送积分" },
|
||||
]}
|
||||
onChange={setAction}
|
||||
/>
|
||||
<FilterControl
|
||||
label="执行结果"
|
||||
value={result}
|
||||
options={[
|
||||
{ value: "", label: "全部结果" },
|
||||
{ value: "success", label: "成功" },
|
||||
{ value: "rejected", label: "已拒绝" },
|
||||
]}
|
||||
onChange={setResult}
|
||||
/>
|
||||
</TableToolbar>
|
||||
{error ? (
|
||||
<div className="p-6">
|
||||
<ErrorState error={error} retry={reload} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载审计日志" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption="管理员审计事件"
|
||||
emptyTitle={
|
||||
from || until || action || result
|
||||
? "没有符合当前筛选条件的审计记录"
|
||||
: "暂无审计记录"
|
||||
}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
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,510 @@
|
||||
import { FileJson, Pencil, Plus, Power, RefreshCw, Sparkles } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
AdminHintPack,
|
||||
CreateOfficialSkillRequest,
|
||||
OfficialSkill,
|
||||
OfficialSkillCatalog,
|
||||
SkillLocalization,
|
||||
UpdateHintPackRequest,
|
||||
} from "../../api/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Input,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from "../../components/primitives";
|
||||
import { useAuth } from "../auth/auth-context";
|
||||
import { HintAutoSection } from "./hint-auto-section";
|
||||
|
||||
type HintLocale = "zh" | "en";
|
||||
|
||||
export function ContentPage() {
|
||||
const { auth } = useAuth();
|
||||
const canEdit = auth.status === "authenticated" && auth.role === "SUPER_ADMIN";
|
||||
const [catalog, setCatalog] = useState<OfficialSkillCatalog>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [editingSkill, setEditingSkill] = useState<OfficialSkill | "new">();
|
||||
const [busySkillId, setBusySkillId] = useState<string>();
|
||||
const [locale, setLocale] = useState<HintLocale>("zh");
|
||||
const [hintText, setHintText] = useState("");
|
||||
const [hintVersion, setHintVersion] = useState(0);
|
||||
const [hintLoading, setHintLoading] = useState(true);
|
||||
const [hintSaving, setHintSaving] = useState(false);
|
||||
|
||||
const loadSkills = useCallback(async () => {
|
||||
setError(undefined);
|
||||
try {
|
||||
setCatalog(await adminApi.contentSkills());
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadHint = useCallback(async (nextLocale: HintLocale) => {
|
||||
setHintLoading(true);
|
||||
try {
|
||||
const pack = await adminApi.contentHintPack(nextLocale);
|
||||
setHintVersion(pack.version);
|
||||
setHintText(formatHintPack(pack));
|
||||
} catch (requestError) {
|
||||
toast.error(errorMessage(requestError, "Hint pack 加载失败"));
|
||||
} finally {
|
||||
setHintLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHint(locale);
|
||||
}, [loadHint, locale]);
|
||||
|
||||
async function setSkillEnabled(skill: OfficialSkill) {
|
||||
setBusySkillId(skill.id);
|
||||
try {
|
||||
await adminApi.setContentSkillEnabled(skill.id, !skill.enabled);
|
||||
toast.success(skill.enabled ? "Skill 已停用" : "Skill 已启用");
|
||||
await loadSkills();
|
||||
} catch (requestError) {
|
||||
toast.error(errorMessage(requestError, "Skill 状态更新失败"));
|
||||
} finally {
|
||||
setBusySkillId(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveHint() {
|
||||
let payload: UpdateHintPackRequest;
|
||||
try {
|
||||
const parsed = JSON.parse(hintText) as Partial<UpdateHintPackRequest>;
|
||||
if (!Array.isArray(parsed.cards)) throw new Error("cards 必须是数组");
|
||||
payload = {
|
||||
generatedAt: optionalString(parsed.generatedAt),
|
||||
expiresAt: optionalString(parsed.expiresAt),
|
||||
intervalHours:
|
||||
parsed.intervalHours === undefined ? undefined : Number(parsed.intervalHours),
|
||||
cards: parsed.cards,
|
||||
};
|
||||
} catch (parseError) {
|
||||
toast.error(parseError instanceof Error ? parseError.message : "JSON 格式无效");
|
||||
return;
|
||||
}
|
||||
setHintSaving(true);
|
||||
try {
|
||||
const saved = await adminApi.updateContentHintPack(locale, payload);
|
||||
setHintVersion(saved.version);
|
||||
setHintText(formatHintPack(saved));
|
||||
toast.success(`${locale} Hint pack 已保存并生效`);
|
||||
} catch (requestError) {
|
||||
toast.error(errorMessage(requestError, "Hint pack 保存失败"));
|
||||
} finally {
|
||||
setHintSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState error={error} retry={() => void loadSkills()} />;
|
||||
if (loading || !catalog) return <LoadingState label="加载内容管理" />;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Official Content"
|
||||
title="内容管理"
|
||||
description="维护客户端官方 Skill、AI Hint 自动生成与 zh/en 内容。所有变更都会写入审计。"
|
||||
actions={
|
||||
canEdit ? (
|
||||
<Button onClick={() => setEditingSkill("new")}>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
新增 Skill
|
||||
</Button>
|
||||
) : (
|
||||
<Badge tone="info">只读访问</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<section aria-labelledby="skill-heading" className="space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="skill-heading" className="text-xl font-bold">
|
||||
官方 Skill
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
当前 revision {catalog.revision} · 共 {catalog.skills.length} 项
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => void loadSkills()}>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{catalog.skills.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState title="暂无官方 Skill" description="新增后可单独启用发布。" />
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{catalog.skills.map((skill) => (
|
||||
<Card key={skill.id} className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary-soft text-primary">
|
||||
<Sparkles className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold">{skill.localizations["zh-Hans"].name}</h3>
|
||||
<Badge tone={skill.enabled ? "success" : "neutral"}>
|
||||
{skill.enabled ? "已启用" : "已停用"}
|
||||
</Badge>
|
||||
{skill.thinkingEnabled ? <Badge tone="violet">思考</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{skill.localizations["zh-Hans"].summary}
|
||||
</p>
|
||||
<p className="mt-3 break-all font-mono text-[11px] text-muted">
|
||||
{skill.id} · {skill.systemImage} · 排序 {skill.sortOrder}
|
||||
</p>
|
||||
{canEdit ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setEditingSkill(skill)}
|
||||
>
|
||||
<Pencil className="size-3.5" aria-hidden />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={skill.enabled ? "danger" : "primary"}
|
||||
loading={busySkillId === skill.id}
|
||||
onClick={() => void setSkillEnabled(skill)}
|
||||
>
|
||||
<Power className="size-3.5" aria-hidden />
|
||||
{skill.enabled ? "停用" : "启用"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<HintAutoSection canEdit={canEdit} />
|
||||
|
||||
<section aria-labelledby="hint-heading" className="space-y-4">
|
||||
<div>
|
||||
<h2 id="hint-heading" className="text-xl font-bold">
|
||||
AI Hint packs
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
可查看并编辑当前生效的 AIHintPack;保存后立即生效,服务端自动递增 version。
|
||||
</p>
|
||||
</div>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-4">
|
||||
{(["zh", "en"] as HintLocale[]).map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
size="sm"
|
||||
variant={locale === item ? "primary" : "secondary"}
|
||||
onClick={() => setLocale(item)}
|
||||
aria-pressed={locale === item}
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
))}
|
||||
<Badge className="ml-auto" tone="info">
|
||||
version {hintVersion}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="p-4 sm:p-6">
|
||||
{hintLoading ? (
|
||||
<LoadingState label={`加载 ${locale} Hint pack`} />
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-sm font-semibold" htmlFor="hint-pack-json">
|
||||
<span className="mb-2 flex items-center gap-2">
|
||||
<FileJson className="size-4 text-primary" aria-hidden />
|
||||
{locale} JSON
|
||||
</span>
|
||||
<Textarea
|
||||
id="hint-pack-json"
|
||||
className="min-h-[420px] font-mono text-xs leading-5"
|
||||
value={hintText}
|
||||
onChange={(event) => setHintText(event.target.value)}
|
||||
readOnly={!canEdit}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
{canEdit ? (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button loading={hintSaving} onClick={() => void saveHint()}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<SkillDialog
|
||||
skill={editingSkill}
|
||||
onClose={() => setEditingSkill(undefined)}
|
||||
onSaved={async () => {
|
||||
setEditingSkill(undefined);
|
||||
await loadSkills();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDialog({
|
||||
skill,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
skill?: OfficialSkill | "new";
|
||||
onClose: () => void;
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<CreateOfficialSkillRequest>(() => emptySkill());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(skill && skill !== "new" ? skillToRequest(skill) : emptySkill());
|
||||
}, [skill]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (skill === "new") {
|
||||
await adminApi.createContentSkill(form);
|
||||
} else if (skill) {
|
||||
const { id: _id, ...payload } = form;
|
||||
await adminApi.updateContentSkill(skill.id, payload);
|
||||
}
|
||||
toast.success(skill === "new" ? "Skill 已创建(默认停用)" : "Skill 已更新");
|
||||
await onSaved();
|
||||
} catch (requestError) {
|
||||
toast.error(errorMessage(requestError, "Skill 保存失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(skill)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
title={skill === "new" ? "新增官方 Skill" : "编辑官方 Skill"}
|
||||
description="ID 创建后不可修改;新 Skill 默认停用,确认内容后再启用。"
|
||||
preventClose={saving}
|
||||
className="max-w-3xl"
|
||||
>
|
||||
<form className="space-y-5" onSubmit={(event) => void submit(event)}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Skill ID">
|
||||
<Input
|
||||
required
|
||||
pattern={"official\\.[a-z0-9._-]+"}
|
||||
minLength={10}
|
||||
maxLength={100}
|
||||
value={form.id}
|
||||
disabled={skill !== "new"}
|
||||
onChange={(event) => setForm({ ...form, id: event.target.value })}
|
||||
placeholder="official.polish"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="SF Symbol">
|
||||
<Input
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={100}
|
||||
value={form.systemImage}
|
||||
onChange={(event) => setForm({ ...form, systemImage: event.target.value })}
|
||||
placeholder="wand.and.sparkles"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="排序">
|
||||
<Input
|
||||
required
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000}
|
||||
value={form.sortOrder}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, sortOrder: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex min-h-11 items-center gap-3 self-end rounded-xl border border-border px-4 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.thinkingEnabled}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, thinkingEnabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
启用思考模式
|
||||
</label>
|
||||
</div>
|
||||
<LocalizationFields
|
||||
title="简体中文"
|
||||
value={form.localizations["zh-Hans"]}
|
||||
onChange={(value) =>
|
||||
setForm({
|
||||
...form,
|
||||
localizations: { ...form.localizations, "zh-Hans": value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<LocalizationFields
|
||||
title="English"
|
||||
value={form.localizations.en}
|
||||
onChange={(value) =>
|
||||
setForm({
|
||||
...form,
|
||||
localizations: { ...form.localizations, en: value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" loading={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalizationFields({
|
||||
title,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
value: SkillLocalization;
|
||||
onChange: (value: SkillLocalization) => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="space-y-3 rounded-2xl border border-border p-4">
|
||||
<legend className="px-2 text-sm font-semibold">{title}</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="名称">
|
||||
<Input
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={40}
|
||||
value={value.name}
|
||||
onChange={(event) => onChange({ ...value, name: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="摘要">
|
||||
<Input
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={200}
|
||||
value={value.summary}
|
||||
onChange={(event) => onChange({ ...value, summary: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Prompt">
|
||||
<Textarea
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={6000}
|
||||
value={value.prompt}
|
||||
onChange={(event) => onChange({ ...value, prompt: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block text-sm font-medium">
|
||||
<span className="mb-1.5 block">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function emptySkill(): CreateOfficialSkillRequest {
|
||||
return {
|
||||
id: "official.",
|
||||
systemImage: "sparkles",
|
||||
sortOrder: 0,
|
||||
thinkingEnabled: false,
|
||||
localizations: {
|
||||
"zh-Hans": { name: "", summary: "", prompt: "" },
|
||||
en: { name: "", summary: "", prompt: "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function skillToRequest(skill: OfficialSkill): CreateOfficialSkillRequest {
|
||||
return {
|
||||
id: skill.id,
|
||||
systemImage: skill.systemImage,
|
||||
sortOrder: skill.sortOrder,
|
||||
thinkingEnabled: skill.thinkingEnabled,
|
||||
localizations: skill.localizations,
|
||||
};
|
||||
}
|
||||
|
||||
function formatHintPack(pack: AdminHintPack): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
generatedAt: pack.generatedAt,
|
||||
expiresAt: pack.expiresAt,
|
||||
intervalHours: pack.intervalHours,
|
||||
cards: pack.cards,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof ApiError ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { Play, RefreshCw, Save, Settings2 } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
HintFeedGenerationStatus,
|
||||
HintFeedSettings,
|
||||
UpdateHintFeedSettingsRequest,
|
||||
} from "../../api/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
LoadingState,
|
||||
Textarea,
|
||||
} from "../../components/primitives";
|
||||
|
||||
interface HintAutoSectionProps {
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function HintAutoSection({ canEdit }: HintAutoSectionProps) {
|
||||
const [settings, setSettings] = useState<HintFeedSettings>();
|
||||
const [status, setStatus] = useState<HintFeedGenerationStatus>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextSettings, nextStatus] = await Promise.all([
|
||||
adminApi.hintFeedSettings(),
|
||||
adminApi.hintFeedStatus(),
|
||||
]);
|
||||
setSettings(nextSettings);
|
||||
setStatus(nextStatus);
|
||||
} catch (error) {
|
||||
toast.error(message(error, "Hint 自动生成状态加载失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: UpdateHintFeedSettingsRequest = {
|
||||
generationIntervalHours: settings.generationIntervalHours,
|
||||
holidayCountriesZh: settings.holidayCountriesZh,
|
||||
holidayCountriesEn: settings.holidayCountriesEn,
|
||||
weatherCitiesZh: settings.weatherCitiesZh,
|
||||
weatherCitiesEn: settings.weatherCitiesEn,
|
||||
googleTrendsGeos: settings.googleTrendsGeos,
|
||||
};
|
||||
setSettings(await adminApi.updateHintFeedSettings(payload));
|
||||
setStatus(await adminApi.hintFeedStatus());
|
||||
toast.success("Hint 自动生成配置已保存");
|
||||
} catch (error) {
|
||||
toast.error(message(error, "Hint 自动生成配置保存失败"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerate() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"将立即抓取外部数据,并原子覆盖 zh/en 提示包。旧版本会保留到新一代全部生成成功。是否继续?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await adminApi.regenerateHintFeed();
|
||||
toast.success(
|
||||
`生成完成:zh ${result.zh.cardCount} 条,en ${result.en.cardCount} 条`,
|
||||
);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(message(error, "Hint 提示包生成失败,旧版本仍保持可用"));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !settings || !status) {
|
||||
return <LoadingState label="加载 Hint 自动生成配置" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="hint-auto-heading" className="space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="hint-auto-heading" className="text-xl font-bold">
|
||||
Hint 自动生成
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
从 TopHub、Google Trends、Google News、节日和天气来源生成双语提示包。
|
||||
任一来源失败不会影响其他来源,整代生成失败时继续提供旧版本。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => void load()}>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
刷新状态
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button loading={generating} onClick={() => void regenerate()}>
|
||||
<Play className="size-4" aria-hidden />
|
||||
立即生成
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatusCard
|
||||
label="调度"
|
||||
value={settings.enabled ? `每 ${status.intervalHours} 小时` : "未启用"}
|
||||
tone={settings.enabled ? "success" : "neutral"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="上次结果"
|
||||
value={outcomeLabel(status.outcome)}
|
||||
detail={formatInstant(status.lastCompletedAt ?? status.lastStartedAt)}
|
||||
tone={outcomeTone(status.outcome)}
|
||||
/>
|
||||
<StatusCard
|
||||
label="中文包"
|
||||
value={status.zhVersion ? `v${status.zhVersion}` : "未发布"}
|
||||
detail={
|
||||
status.zhCardCount === undefined ? undefined : `${status.zhCardCount} 条`
|
||||
}
|
||||
tone={status.zhVersion ? "success" : "warning"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="英文包"
|
||||
value={status.enVersion ? `v${status.enVersion}` : "未发布"}
|
||||
detail={
|
||||
status.enCardCount === undefined ? undefined : `${status.enCardCount} 条`
|
||||
}
|
||||
tone={status.enVersion ? "success" : "warning"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="p-5 sm:p-6">
|
||||
<form className="space-y-5" onSubmit={(event) => void save(event)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="size-4 text-primary" aria-hidden />
|
||||
<h3 className="font-semibold">生成设置</h3>
|
||||
<Badge className="ml-auto" tone={settings.topHubApiKeyConfigured ? "success" : "neutral"}>
|
||||
TopHub Key {settings.topHubApiKeyConfigured ? "已配置" : "未配置"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Field label="生成间隔(小时)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
value={settings.generationIntervalHours}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
generationIntervalHours: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="中文节日国家">
|
||||
<Input
|
||||
value={settings.holidayCountriesZh}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, holidayCountriesZh: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="英文节日国家">
|
||||
<Input
|
||||
value={settings.holidayCountriesEn}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, holidayCountriesEn: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Google Trends 地区(逗号分隔)">
|
||||
<Input
|
||||
value={settings.googleTrendsGeos}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, googleTrendsGeos: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Field label="中文天气城市(城市:纬度,经度;…)">
|
||||
<Textarea
|
||||
className="min-h-24 font-mono text-xs"
|
||||
value={settings.weatherCitiesZh}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, weatherCitiesZh: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="英文天气城市(城市:纬度,经度;…)">
|
||||
<Textarea
|
||||
className="min-h-24 font-mono text-xs"
|
||||
value={settings.weatherCitiesEn}
|
||||
readOnly={!canEdit}
|
||||
onChange={(event) =>
|
||||
setSettings({ ...settings, weatherCitiesEn: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
TopHub API Key 仅从服务器环境变量读取,不会保存到数据库或返回浏览器。
|
||||
手动 JSON 编辑与保存仍保留;下一次自动生成会更新双语版本。
|
||||
</p>
|
||||
|
||||
{canEdit ? (
|
||||
<Button type="submit" loading={saving}>
|
||||
<Save className="size-4" aria-hidden />
|
||||
保存生成设置
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
tone: "success" | "warning" | "danger" | "neutral" | "info" | "violet";
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="font-semibold">{value}</span>
|
||||
<Badge tone={tone}>{detail ?? value}</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block text-sm font-semibold">
|
||||
<span className="mb-2 block">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function outcomeLabel(outcome: HintFeedGenerationStatus["outcome"]): string {
|
||||
return {
|
||||
IDLE: "尚未运行",
|
||||
RUNNING: "生成中",
|
||||
SUCCEEDED: "成功",
|
||||
FAILED: "失败",
|
||||
}[outcome];
|
||||
}
|
||||
|
||||
function outcomeTone(
|
||||
outcome: HintFeedGenerationStatus["outcome"],
|
||||
): "success" | "warning" | "danger" | "neutral" {
|
||||
return {
|
||||
IDLE: "neutral",
|
||||
RUNNING: "warning",
|
||||
SUCCEEDED: "success",
|
||||
FAILED: "danger",
|
||||
}[outcome] as "success" | "warning" | "danger" | "neutral";
|
||||
}
|
||||
|
||||
function formatInstant(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function message(error: unknown, fallback: string): string {
|
||||
return error instanceof ApiError ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ArrowDownUp, Coins, Search } from "lucide-react";
|
||||
import { useCallback, useState, type FormEvent } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { LedgerQuery } from "../../api/types";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorState,
|
||||
Input,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
} from "../../components/primitives";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
import { LedgerFilters } from "./ledger-filters";
|
||||
import { LedgerTable } from "./ledger-table";
|
||||
import { useLedgerFilters } from "./use-ledger-filters";
|
||||
|
||||
export function CreditsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialUserId = searchParams.get("userId") ?? "";
|
||||
const [query, setQuery] = useState(initialUserId);
|
||||
const [activeUserId, setActiveUserId] = useState(initialUserId);
|
||||
const filters = useLedgerFilters();
|
||||
const fetchLedger = useCallback(
|
||||
(value: LedgerQuery) =>
|
||||
activeUserId ? adminApi.ledger(activeUserId, value) : adminApi.latestLedger(value),
|
||||
[activeUserId],
|
||||
);
|
||||
const {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(filters.query, fetchLedger);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const userId = query.trim();
|
||||
setActiveUserId(userId);
|
||||
setSearchParams((current) => {
|
||||
const params = new URLSearchParams(current);
|
||||
if (userId) params.set("userId", userId);
|
||||
else params.delete("userId");
|
||||
return params;
|
||||
}, { replace: true });
|
||||
}
|
||||
|
||||
const hasBusinessFilter = Boolean(
|
||||
filters.state.from ||
|
||||
filters.state.until ||
|
||||
filters.state.type ||
|
||||
filters.state.entryType ||
|
||||
filters.state.usageType ||
|
||||
filters.state.referenceId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="积分账本"
|
||||
title="积分流水"
|
||||
description="从账务动作、业务来源和消费能力三个维度查看不可变积分账本。"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<LedgerFilters
|
||||
value={filters.state}
|
||||
onChange={filters.update}
|
||||
onClear={filters.clear}
|
||||
/>
|
||||
|
||||
<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={reload} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载积分流水" />
|
||||
) : (
|
||||
<LedgerTable
|
||||
entries={items}
|
||||
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
|
||||
emptyTitle={
|
||||
activeUserId || hasBusinessFilter
|
||||
? "没有符合当前用户和筛选条件的流水"
|
||||
: "暂无积分流水"
|
||||
}
|
||||
showUser={!activeUserId}
|
||||
sort={filters.state.sort}
|
||||
order={filters.state.order}
|
||||
onSortChange={(sort, order) => filters.update({ sort, order })}
|
||||
onReferenceFilter={(referenceId) => filters.update({ referenceId })}
|
||||
footer={
|
||||
nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
|
||||
加载更多流水
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { CalendarRange, Link2 } from "lucide-react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { DateRangeControl } from "../../components/date-range-control";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import { Button, Input } from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import {
|
||||
ledgerActionLabel,
|
||||
ledgerActionOptions,
|
||||
ledgerEntryTypeLabel,
|
||||
ledgerEntryTypeOptions,
|
||||
ledgerUsageLabel,
|
||||
ledgerUsageOptions,
|
||||
} from "./ledger-format";
|
||||
import {
|
||||
ledgerDatePreset,
|
||||
type LedgerDatePreset,
|
||||
type LedgerFilterState,
|
||||
} from "./use-ledger-filters";
|
||||
|
||||
export const ledgerDatePresetOptions: ReadonlyArray<{
|
||||
value: LedgerDatePreset;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "all", label: "全部时间" },
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "7d", label: "最近 7 天" },
|
||||
{ value: "30d", label: "最近 30 天" },
|
||||
{ value: "90d", label: "最近 90 天" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
];
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function LedgerFilters({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
}: {
|
||||
value: LedgerFilterState;
|
||||
onChange: (patch: Partial<LedgerFilterState>) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [referenceDraft, setReferenceDraft] = useState(value.referenceId);
|
||||
const normalizedReference = referenceDraft.trim();
|
||||
const referenceValid =
|
||||
normalizedReference.length === 0 || UUID_PATTERN.test(normalizedReference);
|
||||
|
||||
useEffect(() => {
|
||||
setReferenceDraft(value.referenceId);
|
||||
}, [value.referenceId]);
|
||||
|
||||
function applyReference(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (referenceValid) onChange({ referenceId: normalizedReference });
|
||||
}
|
||||
|
||||
const active =
|
||||
value.range !== "all" ||
|
||||
Boolean(value.type || value.entryType || value.usageType || value.referenceId) ||
|
||||
value.sort !== "createdAt" ||
|
||||
value.order !== "desc";
|
||||
const summary = [
|
||||
value.type ? `账务动作:${ledgerActionLabel(value.type)}` : "",
|
||||
value.entryType ? `业务来源:${ledgerEntryTypeLabel(value.entryType)}` : "",
|
||||
value.usageType ? `消费能力:${ledgerUsageLabel(value.usageType)}` : "",
|
||||
value.referenceId ? `关联 ID:${value.referenceId}` : "",
|
||||
value.range !== "all"
|
||||
? `时间:${ledgerDatePresetOptions.find((option) => option.value === value.range)?.label}`
|
||||
: "",
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableToolbar
|
||||
active={active}
|
||||
onClear={onClear}
|
||||
clearLabel="清除全部"
|
||||
>
|
||||
<FilterControl
|
||||
label="时间范围"
|
||||
value={value.range}
|
||||
options={ledgerDatePresetOptions}
|
||||
onChange={(range) => {
|
||||
if (range === "custom") onChange({ range });
|
||||
else onChange(ledgerDatePreset(range));
|
||||
}}
|
||||
/>
|
||||
{value.range === "custom" ? (
|
||||
<DateRangeControl
|
||||
value={{ from: value.from || undefined, until: value.until || undefined }}
|
||||
onChange={(range) =>
|
||||
onChange({
|
||||
from: range.from ?? "",
|
||||
until: range.until ?? "",
|
||||
range: "custom",
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<FilterControl
|
||||
label="账务动作"
|
||||
value={value.type}
|
||||
options={ledgerActionOptions}
|
||||
onChange={(type) => onChange({ type })}
|
||||
/>
|
||||
<FilterControl
|
||||
label="业务来源"
|
||||
value={value.entryType}
|
||||
options={ledgerEntryTypeOptions}
|
||||
onChange={(entryType) => onChange({ entryType })}
|
||||
/>
|
||||
<FilterControl
|
||||
label="消费能力"
|
||||
value={value.usageType}
|
||||
options={ledgerUsageOptions}
|
||||
onChange={(usageType) => onChange({ usageType })}
|
||||
/>
|
||||
<form className="grid min-w-64 flex-1 gap-1" onSubmit={applyReference}>
|
||||
<label className="text-xs font-semibold text-muted" htmlFor="ledger-reference-id">
|
||||
关联 ID
|
||||
</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="relative flex-1">
|
||||
<Link2
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
id="ledger-reference-id"
|
||||
className="h-9 rounded-lg pl-9 font-mono text-xs"
|
||||
value={referenceDraft}
|
||||
onChange={(event) => setReferenceDraft(event.target.value)}
|
||||
placeholder="输入 referenceId"
|
||||
maxLength={36}
|
||||
autoComplete="off"
|
||||
aria-invalid={!referenceValid}
|
||||
aria-describedby={!referenceValid ? "ledger-reference-error" : undefined}
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
disabled={!referenceValid || normalizedReference === value.referenceId}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</span>
|
||||
{!referenceValid ? (
|
||||
<span id="ledger-reference-error" className="text-xs font-medium text-danger">
|
||||
请输入完整 UUID
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
</TableToolbar>
|
||||
<div
|
||||
className="flex items-start gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6"
|
||||
aria-live="polite"
|
||||
>
|
||||
<CalendarRange className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden />
|
||||
<span className="font-semibold text-foreground">筛选摘要</span>
|
||||
<span className="min-w-0 break-all">
|
||||
{summary.length > 0 ? summary.join(" · ") : "全部流水"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
LedgerAction,
|
||||
LedgerEntryType,
|
||||
UsageType,
|
||||
} from "../../api/types";
|
||||
|
||||
export type LedgerTone = "neutral" | "success" | "danger" | "warning" | "info" | "violet";
|
||||
|
||||
export const ledgerActionOptions: ReadonlyArray<{
|
||||
value: "" | LedgerAction;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "", label: "全部账务动作" },
|
||||
{ value: "grant", label: "入账" },
|
||||
{ value: "reserve", label: "预留" },
|
||||
{ value: "settle", label: "结算" },
|
||||
{ value: "refund", label: "退还" },
|
||||
];
|
||||
|
||||
export const ledgerEntryTypeOptions: ReadonlyArray<{
|
||||
value: "" | LedgerEntryType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "", label: "全部业务来源" },
|
||||
{ value: "SIGNUP_TRIAL", label: "注册试用赠送" },
|
||||
{ value: "MANUAL_GRANT", label: "人工赠送" },
|
||||
{ value: "USAGE_RESERVE", label: "消费预留" },
|
||||
{ value: "USAGE_SETTLE", label: "消费结算" },
|
||||
{ value: "USAGE_RELEASE", label: "消费释放" },
|
||||
{ value: "USAGE_REFUND", label: "消费退款" },
|
||||
{ value: "REFERRAL_INVITER", label: "邀请人奖励" },
|
||||
{ value: "REFERRAL_INVITEE", label: "受邀人奖励" },
|
||||
{ value: "STOREKIT_PURCHASE", label: "App Store 购买" },
|
||||
{ value: "SUBSCRIPTION_GRANT", label: "订阅赠送" },
|
||||
];
|
||||
|
||||
export const ledgerUsageOptions: ReadonlyArray<{
|
||||
value: "" | UsageType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "", label: "全部消费能力" },
|
||||
{ value: "polish", label: "文字润色" },
|
||||
{ value: "asr", label: "语音转写" },
|
||||
{ value: "ai", label: "AI 助手" },
|
||||
{ value: "agent", label: "智能代理" },
|
||||
{ value: "hotword", label: "热词" },
|
||||
];
|
||||
|
||||
const entryTypeLabels = Object.fromEntries(
|
||||
ledgerEntryTypeOptions
|
||||
.filter((option) => option.value)
|
||||
.map((option) => [option.value, option.label]),
|
||||
) as Record<LedgerEntryType, string>;
|
||||
|
||||
const usageLabels = Object.fromEntries(
|
||||
ledgerUsageOptions
|
||||
.filter((option) => option.value)
|
||||
.map((option) => [option.value, option.label]),
|
||||
) as Record<UsageType, string>;
|
||||
|
||||
const actionPresentation: Record<LedgerAction, { label: string; tone: LedgerTone }> = {
|
||||
grant: { label: "入账", tone: "success" },
|
||||
reserve: { label: "预留", tone: "warning" },
|
||||
settle: { label: "结算", tone: "info" },
|
||||
refund: { label: "退还", tone: "violet" },
|
||||
};
|
||||
|
||||
const usageClasses: Record<UsageType, string> = {
|
||||
polish: "bg-violet-soft text-violet",
|
||||
asr: "bg-primary-soft text-primary",
|
||||
ai: "bg-success-soft text-success",
|
||||
agent: "bg-warning-soft text-warning",
|
||||
hotword: "bg-pink-500/10 text-pink-700 dark:text-pink-300",
|
||||
};
|
||||
|
||||
export function ledgerEntryTypeLabel(value?: string): string {
|
||||
if (!value) return "未知类型(空值)";
|
||||
return entryTypeLabels[value as LedgerEntryType] ?? `未知类型(${value})`;
|
||||
}
|
||||
|
||||
export function ledgerActionLabel(value?: string): string {
|
||||
return actionPresentation[value as LedgerAction]?.label ?? `未知动作(${value || "空值"})`;
|
||||
}
|
||||
|
||||
export function ledgerActionTone(value?: string): LedgerTone {
|
||||
return actionPresentation[value as LedgerAction]?.tone ?? "neutral";
|
||||
}
|
||||
|
||||
export function ledgerUsageLabel(value?: string): string {
|
||||
if (!value) return "—";
|
||||
return usageLabels[value as UsageType] ?? `未知能力(${value})`;
|
||||
}
|
||||
|
||||
export function ledgerUsageClass(value?: string): string {
|
||||
return usageClasses[value as UsageType] ?? "bg-surface-muted text-muted";
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Clipboard, Filter, UserRound } from "lucide-react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { LedgerEntry, LedgerQuery, SortOrder } from "../../api/types";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { Badge, Button } from "../../components/primitives";
|
||||
import { formatDateTime, formatNumber, formatSignedCredits } from "../../lib/format";
|
||||
import {
|
||||
ledgerActionLabel,
|
||||
ledgerActionTone,
|
||||
ledgerEntryTypeLabel,
|
||||
ledgerUsageClass,
|
||||
ledgerUsageLabel,
|
||||
} from "./ledger-format";
|
||||
|
||||
export function LedgerTable({
|
||||
entries,
|
||||
caption,
|
||||
emptyTitle,
|
||||
showUser = false,
|
||||
sort,
|
||||
order,
|
||||
onSortChange,
|
||||
onReferenceFilter,
|
||||
footer,
|
||||
}: {
|
||||
entries: LedgerEntry[];
|
||||
caption: string;
|
||||
emptyTitle: string;
|
||||
showUser?: boolean;
|
||||
sort: NonNullable<LedgerQuery["sort"]>;
|
||||
order: SortOrder;
|
||||
onSortChange: (sort: NonNullable<LedgerQuery["sort"]>, order: SortOrder) => void;
|
||||
onReferenceFilter: (referenceId: string) => void;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
const columns = useMemo<DataColumn<LedgerEntry>[]>(() => {
|
||||
const result: DataColumn<LedgerEntry>[] = [
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "时间",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted">
|
||||
{formatDateTime(String(getValue()))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
if (showUser) {
|
||||
result.push({
|
||||
accessorKey: "userId",
|
||||
header: "用户",
|
||||
cell: ({ getValue }) => {
|
||||
const userId = String(getValue());
|
||||
return (
|
||||
<a
|
||||
className="group/user inline-flex max-w-52 items-center gap-2 text-left text-primary"
|
||||
href={`#/users?userId=${encodeURIComponent(userId)}`}
|
||||
title={userId}
|
||||
>
|
||||
<UserRound className="size-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate font-mono text-xs underline-offset-4 group-hover/user:underline">
|
||||
{userId}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
result.push(
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "账务动作",
|
||||
cell: ({ getValue }) => (
|
||||
<Badge tone={ledgerActionTone(String(getValue()))}>
|
||||
{ledgerActionLabel(String(getValue()))}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "entryType",
|
||||
header: "业务来源",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="whitespace-nowrap text-xs font-semibold">
|
||||
{ledgerEntryTypeLabel(String(getValue() ?? ""))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "usageType",
|
||||
header: "消费能力",
|
||||
cell: ({ getValue }) => {
|
||||
const usageType = getValue() ? String(getValue()) : undefined;
|
||||
return usageType ? (
|
||||
<Badge className={ledgerUsageClass(usageType)}>
|
||||
{ledgerUsageLabel(usageType)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: "变动",
|
||||
cell: ({ getValue }) => {
|
||||
const value = Number(getValue());
|
||||
return (
|
||||
<span
|
||||
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
|
||||
>
|
||||
{formatSignedCredits(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "balanceAfter",
|
||||
header: "结余",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
|
||||
),
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}, [showUser]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={entries}
|
||||
columns={columns}
|
||||
caption={caption}
|
||||
emptyTitle={emptyTitle}
|
||||
sort={{ key: sort, order }}
|
||||
sortableColumns={{ createdAt: "createdAt", amount: "amount" }}
|
||||
onSortChange={(nextSort, nextOrder) =>
|
||||
onSortChange(nextSort as NonNullable<LedgerQuery["sort"]>, nextOrder)
|
||||
}
|
||||
renderExpandedRow={(entry) => (
|
||||
<LedgerDetails entry={entry} onReferenceFilter={onReferenceFilter} />
|
||||
)}
|
||||
getRowId={(entry) => entry.entryId}
|
||||
footer={footer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerDetails({
|
||||
entry,
|
||||
onReferenceFilter,
|
||||
}: {
|
||||
entry: LedgerEntry;
|
||||
onReferenceFilter: (referenceId: string) => void;
|
||||
}) {
|
||||
const details = entry.details;
|
||||
return (
|
||||
<section className="space-y-4" aria-label="流水安全详情">
|
||||
<dl className="grid gap-x-8 gap-y-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DetailItem label="流水 ID">
|
||||
<Identifier value={entry.entryId} />
|
||||
</DetailItem>
|
||||
<DetailItem label="关联 ID">
|
||||
{entry.referenceId ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Identifier value={entry.referenceId} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onReferenceFilter(entry.referenceId!)}
|
||||
>
|
||||
<Filter className="size-3.5" aria-hidden />
|
||||
筛选同笔生命周期
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</DetailItem>
|
||||
<DetailItem label="原因代码">
|
||||
<span className="font-mono text-[11px] text-muted">{entry.reasonCode || "—"}</span>
|
||||
</DetailItem>
|
||||
{details?.kind === "manualGrant" ? (
|
||||
<>
|
||||
<DetailItem label="人工原因">{details.reason || "—"}</DetailItem>
|
||||
<DetailItem label="操作员">{details.operatorName || "—"}</DetailItem>
|
||||
</>
|
||||
) : null}
|
||||
{details?.kind === "storeKit" ? (
|
||||
<>
|
||||
<DetailItem label="商品">{details.productId || "—"}</DetailItem>
|
||||
<DetailItem label="交易 ID">
|
||||
<Identifier value={details.transactionId} />
|
||||
</DetailItem>
|
||||
<DetailItem label="原始交易 ID">
|
||||
<Identifier value={details.originalTransactionId} />
|
||||
</DetailItem>
|
||||
<DetailItem label="环境">{details.environment || "—"}</DetailItem>
|
||||
<DetailItem label="购买时间">{formatDateTime(details.purchasedAt)}</DetailItem>
|
||||
</>
|
||||
) : null}
|
||||
{details?.kind === "referral" ? (
|
||||
<>
|
||||
<DetailItem label="裂变角色">{referralRoleLabel(details.role)}</DetailItem>
|
||||
<DetailItem label="关联用户">
|
||||
<Identifier value={details.relatedUserId} />
|
||||
</DetailItem>
|
||||
</>
|
||||
) : null}
|
||||
{details?.kind === "usage" ? (
|
||||
<DetailItem label="预留 ID">
|
||||
<Identifier value={details.reservationId} />
|
||||
</DetailItem>
|
||||
) : null}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[11px] font-bold uppercase tracking-wide text-muted">{label}</dt>
|
||||
<dd className="mt-1.5 break-words text-xs font-medium text-foreground">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Identifier({ value }: { value?: string }) {
|
||||
if (!value) return <span className="text-muted">—</span>;
|
||||
return (
|
||||
<span className="inline-flex max-w-full items-center gap-1.5">
|
||||
<code className="break-all text-[11px]">{value}</code>
|
||||
<button
|
||||
className="grid size-7 shrink-0 place-items-center rounded-lg text-muted transition hover:bg-surface hover:text-primary focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||
type="button"
|
||||
title="复制 ID"
|
||||
aria-label={`复制 ID ${value}`}
|
||||
onClick={() => void copyIdentifier(value)}
|
||||
>
|
||||
<Clipboard className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyIdentifier(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success("ID 已复制");
|
||||
} catch {
|
||||
toast.error("无法复制,请手动选择 ID");
|
||||
}
|
||||
}
|
||||
|
||||
function referralRoleLabel(role?: string): string {
|
||||
if (!role) return "—";
|
||||
const labels: Record<string, string> = {
|
||||
inviter: "邀请人",
|
||||
invitee: "受邀人",
|
||||
INVITER: "邀请人",
|
||||
INVITEE: "受邀人",
|
||||
};
|
||||
return labels[role] ?? role;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type {
|
||||
LedgerAction,
|
||||
LedgerEntryType,
|
||||
LedgerQuery,
|
||||
SortOrder,
|
||||
UsageType,
|
||||
} from "../../api/types";
|
||||
import {
|
||||
ledgerActionOptions,
|
||||
ledgerEntryTypeOptions,
|
||||
ledgerUsageOptions,
|
||||
} from "./ledger-format";
|
||||
|
||||
export type LedgerDatePreset = "all" | "today" | "7d" | "30d" | "90d" | "custom";
|
||||
|
||||
export interface LedgerFilterState {
|
||||
from: string;
|
||||
until: string;
|
||||
type: "" | LedgerAction;
|
||||
entryType: "" | LedgerEntryType;
|
||||
usageType: "" | UsageType;
|
||||
referenceId: string;
|
||||
range: LedgerDatePreset;
|
||||
sort: NonNullable<LedgerQuery["sort"]>;
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
const defaultState: LedgerFilterState = {
|
||||
from: "",
|
||||
until: "",
|
||||
type: "",
|
||||
entryType: "",
|
||||
usageType: "",
|
||||
referenceId: "",
|
||||
range: "all",
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
};
|
||||
|
||||
const ranges = new Set<LedgerDatePreset>(["all", "today", "7d", "30d", "90d", "custom"]);
|
||||
const sorts = new Set<LedgerFilterState["sort"]>(["createdAt", "amount"]);
|
||||
const orders = new Set<SortOrder>(["asc", "desc"]);
|
||||
|
||||
function parameterName(prefix: string, name: string): string {
|
||||
if (!prefix) return name;
|
||||
return `${prefix}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
|
||||
}
|
||||
|
||||
function validOption<T extends string>(
|
||||
value: string | null,
|
||||
options: ReadonlyArray<{ value: "" | T }>,
|
||||
): "" | T {
|
||||
return options.some((option) => option.value === value) ? (value as T) : "";
|
||||
}
|
||||
|
||||
export function readLedgerFilterState(
|
||||
searchParams: URLSearchParams,
|
||||
prefix = "",
|
||||
): LedgerFilterState {
|
||||
const read = (name: string) => searchParams.get(parameterName(prefix, name));
|
||||
const from = read("from") ?? "";
|
||||
const until = read("until") ?? "";
|
||||
const storedRange = read("range") as LedgerDatePreset | null;
|
||||
return {
|
||||
from,
|
||||
until,
|
||||
type: validOption(read("type"), ledgerActionOptions),
|
||||
entryType: validOption(read("entryType"), ledgerEntryTypeOptions),
|
||||
usageType: validOption(read("usageType"), ledgerUsageOptions),
|
||||
referenceId: read("referenceId") ?? "",
|
||||
range: storedRange && ranges.has(storedRange) ? storedRange : from || until ? "custom" : "all",
|
||||
sort: sorts.has(read("sort") as LedgerFilterState["sort"])
|
||||
? (read("sort") as LedgerFilterState["sort"])
|
||||
: "createdAt",
|
||||
order: orders.has(read("order") as SortOrder) ? (read("order") as SortOrder) : "desc",
|
||||
};
|
||||
}
|
||||
|
||||
export function ledgerDatePreset(
|
||||
preset: Exclude<LedgerDatePreset, "custom">,
|
||||
now = new Date(),
|
||||
): Pick<LedgerFilterState, "from" | "until" | "range"> {
|
||||
if (preset === "all") return { from: "", until: "", range: "all" };
|
||||
const today = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||
);
|
||||
const until = new Date(today);
|
||||
until.setUTCDate(until.getUTCDate() + 1);
|
||||
const from = new Date(today);
|
||||
const days = preset === "today" ? 1 : Number.parseInt(preset, 10);
|
||||
from.setUTCDate(from.getUTCDate() - (days - 1));
|
||||
return { from: from.toISOString(), until: until.toISOString(), range: preset };
|
||||
}
|
||||
|
||||
export function useLedgerFilters(prefix = "") {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [state, setState] = useState<LedgerFilterState>(() =>
|
||||
readLedgerFilterState(searchParams, prefix),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParams((current) => {
|
||||
const params = new URLSearchParams(current);
|
||||
const keys: Array<keyof LedgerFilterState> = [
|
||||
"from",
|
||||
"until",
|
||||
"type",
|
||||
"entryType",
|
||||
"usageType",
|
||||
"referenceId",
|
||||
"range",
|
||||
"sort",
|
||||
"order",
|
||||
];
|
||||
keys.forEach((key) => params.delete(parameterName(prefix, key)));
|
||||
if (state.from) params.set(parameterName(prefix, "from"), state.from);
|
||||
if (state.until) params.set(parameterName(prefix, "until"), state.until);
|
||||
if (state.type) params.set(parameterName(prefix, "type"), state.type);
|
||||
if (state.entryType) params.set(parameterName(prefix, "entryType"), state.entryType);
|
||||
if (state.usageType) params.set(parameterName(prefix, "usageType"), state.usageType);
|
||||
if (state.referenceId) {
|
||||
params.set(parameterName(prefix, "referenceId"), state.referenceId);
|
||||
}
|
||||
if (state.range !== "all") params.set(parameterName(prefix, "range"), state.range);
|
||||
if (state.sort !== "createdAt") params.set(parameterName(prefix, "sort"), state.sort);
|
||||
if (state.order !== "desc") params.set(parameterName(prefix, "order"), state.order);
|
||||
return params;
|
||||
}, { replace: true });
|
||||
}, [prefix, setSearchParams, state]);
|
||||
|
||||
const query = useMemo<LedgerQuery>(
|
||||
() => ({
|
||||
from: state.from || undefined,
|
||||
until: state.until || undefined,
|
||||
type: state.type || undefined,
|
||||
entryType: state.entryType || undefined,
|
||||
usageType: state.usageType || undefined,
|
||||
referenceId: state.referenceId.trim() || undefined,
|
||||
sort: state.sort,
|
||||
order: state.order,
|
||||
limit: 50,
|
||||
}),
|
||||
[state],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
query,
|
||||
setState,
|
||||
update: (patch: Partial<LedgerFilterState>) =>
|
||||
setState((current) => ({ ...current, ...patch })),
|
||||
clear: () => setState(defaultState),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
Activity,
|
||||
Coins,
|
||||
CreditCard,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type { Overview, SortOrder } from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { ChartLegend } from "../../components/charts/chart-legend";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||
import { TrendChart } from "../../components/charts/trend-chart";
|
||||
import { ToggleFilter } from "../../components/filter-control";
|
||||
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||
import { PeriodCaption } from "../../components/period-caption";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber, usageTypeLabel } from "../../lib/format";
|
||||
import { stableSort } from "../../lib/sort";
|
||||
|
||||
export function OverviewPage() {
|
||||
const [range, setRange] = useState("30d");
|
||||
const [data, setData] = useState<Overview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [showRegistrations, setShowRegistrations] = useState(true);
|
||||
const [showCredits, setShowCredits] = useState(true);
|
||||
const [usageSort, setUsageSort] = useState<"credits" | "requests">("credits");
|
||||
const [usageOrder, setUsageOrder] = useState<SortOrder>("desc");
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await adminApi.overview(range);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const sortedUsage = useMemo(
|
||||
() =>
|
||||
stableSort(
|
||||
data?.usage ?? [],
|
||||
(item) => (usageSort === "credits" ? item.chargedCredits : item.requests),
|
||||
usageOrder,
|
||||
),
|
||||
[data?.usage, usageOrder, usageSort],
|
||||
);
|
||||
|
||||
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) : null;
|
||||
const usageRequests = data.usage.reduce((sum, item) => sum + item.requests, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="核心指标"
|
||||
title="运营总览"
|
||||
description="活跃用户指周期内成功使用 AI 或产生手动键盘输入的注册用户。"
|
||||
actions={
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<RangeControl value={range} onChange={setRange} />
|
||||
<PeriodCaption from={data.period.from} until={data.period.until} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<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 == null ? "—" : `${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>
|
||||
<ChartLegend
|
||||
items={[
|
||||
...(showRegistrations ? [{ label: "新增用户", tone: "primary" as const }] : []),
|
||||
...(showCredits ? [{ label: "积分消耗", tone: "violet" as const }] : []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex flex-wrap gap-2" aria-label="趋势系列显隐">
|
||||
<ToggleFilter
|
||||
label="新增用户"
|
||||
checked={showRegistrations}
|
||||
onChange={setShowRegistrations}
|
||||
/>
|
||||
<ToggleFilter
|
||||
label="积分消耗"
|
||||
checked={showCredits}
|
||||
onChange={setShowCredits}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[320px] w-full">
|
||||
<TrendChart
|
||||
points={data.trend}
|
||||
showRegistrations={showRegistrations}
|
||||
showCredits={showCredits}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="border-b border-border p-5 sm:p-6">
|
||||
<h2 className="text-base font-bold text-foreground">运营效率</h2>
|
||||
<p className="mt-1 text-xs text-muted">活跃覆盖与积分流转对比</p>
|
||||
<div className="mt-5">
|
||||
<RadialMetric
|
||||
label="用户活跃率"
|
||||
percent={activeRate}
|
||||
detail={`${formatNumber(data.activeUsers)} / ${formatNumber(data.totalUsers)} 位用户活跃`}
|
||||
tone="success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ComparisonBarChart
|
||||
items={[
|
||||
{ label: "周期赠送", value: data.creditsGranted },
|
||||
{ label: "周期消耗", value: data.creditsUsed },
|
||||
]}
|
||||
primaryLabel="积分"
|
||||
primaryTone="warning"
|
||||
/>
|
||||
</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>
|
||||
<ChartToolbar label="使用构成排序">
|
||||
<SortControl
|
||||
value={usageSort}
|
||||
order={usageOrder}
|
||||
options={[
|
||||
{ value: "credits", label: "消耗积分" },
|
||||
{ value: "requests", label: "请求次数" },
|
||||
]}
|
||||
onChange={(value, order) => {
|
||||
setUsageSort(value);
|
||||
setUsageOrder(order);
|
||||
}}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
{data.usage.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-muted">当前周期暂无使用记录</div>
|
||||
) : (
|
||||
<ComparisonBarChart
|
||||
items={sortedUsage.map((item) => ({
|
||||
label: usageTypeLabel(item.kind),
|
||||
value: item.chargedCredits,
|
||||
hint: `${formatNumber(item.requests)} 次请求`,
|
||||
}))}
|
||||
primaryLabel="已消耗积分"
|
||||
primaryTone="violet"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { Eye, EyeOff, KeyRound, RefreshCw, ServerCog } from "lucide-react";
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
ManagedProviderId,
|
||||
ManagedProviderOverview,
|
||||
ManagedProviderStatus,
|
||||
} from "../../api/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
ErrorState,
|
||||
Input,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
} from "../../components/primitives";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
|
||||
const PROVIDERS: Array<{
|
||||
id: ManagedProviderId;
|
||||
name: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
description: "用于润色、AI 和 Agent 等托管文本能力。",
|
||||
},
|
||||
{
|
||||
id: "volcengine",
|
||||
name: "火山引擎",
|
||||
description: "用于托管语音识别能力,仅替换 API Key。",
|
||||
},
|
||||
];
|
||||
|
||||
export function ProvidersPage() {
|
||||
const [overview, setOverview] = useState<ManagedProviderOverview>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setOverview(await adminApi.providers());
|
||||
} catch (cause) {
|
||||
setError(cause);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const updateStatus = useCallback((updated: ManagedProviderStatus) => {
|
||||
setOverview((current) => {
|
||||
if (!current) return current;
|
||||
const providers = current.filter(
|
||||
(item) => item.providerId !== updated.providerId,
|
||||
);
|
||||
return [...providers, updated];
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) return <LoadingState label="加载 Provider 配置" />;
|
||||
if (error || !overview) return <ErrorState error={error} retry={() => void load()} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Managed Providers"
|
||||
title="Provider 配置"
|
||||
description="查看或替换托管网关使用的上游 API Key。新配置只影响之后开始的请求。"
|
||||
actions={
|
||||
<Button variant="secondary" onClick={() => void load()}>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
刷新状态
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="border-warning/25 bg-warning-soft/30 p-5 text-sm leading-6 text-foreground sm:p-6">
|
||||
<div className="flex gap-3">
|
||||
<span className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-xl bg-warning-soft text-warning">
|
||||
<KeyRound className="size-4" aria-hidden />
|
||||
</span>
|
||||
<p>
|
||||
查看当前 Key 需要再次验证动态验证码;查看与替换都会写入审计日志,但不会记录密钥内容。
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
{PROVIDERS.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
providerId={provider.id}
|
||||
name={provider.name}
|
||||
description={provider.description}
|
||||
status={overview.find((item) => item.providerId === provider.id)}
|
||||
onUpdated={updateStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({
|
||||
providerId,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
onUpdated,
|
||||
}: {
|
||||
providerId: ManagedProviderId;
|
||||
name: string;
|
||||
description: string;
|
||||
status?: ManagedProviderStatus;
|
||||
onUpdated: (status: ManagedProviderStatus) => void;
|
||||
}) {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [currentApiKey, setCurrentApiKey] = useState<string>();
|
||||
const [revealOpen, setRevealOpen] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [revealError, setRevealError] = useState("");
|
||||
const [revealing, setRevealing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function toggleCurrentApiKey() {
|
||||
if (currentApiKey) {
|
||||
setCurrentApiKey(undefined);
|
||||
return;
|
||||
}
|
||||
setTotpCode("");
|
||||
setRevealError("");
|
||||
setRevealOpen(true);
|
||||
}
|
||||
|
||||
function handleRevealOpenChange(open: boolean) {
|
||||
setRevealOpen(open);
|
||||
if (!open) {
|
||||
setTotpCode("");
|
||||
setRevealError("");
|
||||
}
|
||||
}
|
||||
|
||||
async function reveal(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!/^\d{6}$/.test(totpCode)) {
|
||||
setRevealError("请输入 6 位动态验证码");
|
||||
return;
|
||||
}
|
||||
|
||||
setRevealing(true);
|
||||
setRevealError("");
|
||||
try {
|
||||
const response = await adminApi.revealProviderApiKey(providerId, { totpCode });
|
||||
setCurrentApiKey(response.apiKey);
|
||||
handleRevealOpenChange(false);
|
||||
} catch (error) {
|
||||
setRevealError(error instanceof ApiError ? error.message : "当前 API Key 读取失败");
|
||||
} finally {
|
||||
setRevealing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const normalized = apiKey.trim();
|
||||
if (!validApiKey(normalized)) {
|
||||
toast.error("API Key 必须为 1–512 个字符,且不能包含换行");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确定替换 ${name} API Key?保存后,新请求将立即使用该 Key。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await adminApi.updateProviderApiKey(providerId, {
|
||||
apiKey: normalized,
|
||||
});
|
||||
setApiKey("");
|
||||
setCurrentApiKey((current) => (current ? normalized : undefined));
|
||||
onUpdated(updated);
|
||||
toast.success(`${name} API Key 已替换`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : `${name} API Key 替换失败`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="border-b border-border bg-surface-muted/35 p-5 sm:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-primary-soft text-primary">
|
||||
<ServerCog className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-bold">{name}</h2>
|
||||
<Badge tone={status?.configured ? "success" : "danger"}>
|
||||
{status?.configured ? "已配置" : "未配置"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<StatusItem label="当前来源" value={sourceLabel(status)} />
|
||||
<StatusItem
|
||||
label="最后替换"
|
||||
value={status?.updatedAt ? formatDateTime(status.updatedAt) : "随服务器部署"}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border p-5 sm:p-6">
|
||||
<label
|
||||
className="block text-sm font-semibold"
|
||||
htmlFor={`${providerId}-current-api-key`}
|
||||
>
|
||||
<span className="mb-2 block">{name} 当前 API Key</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${providerId}-current-api-key`}
|
||||
className="pr-12 font-mono"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
readOnly
|
||||
spellCheck={false}
|
||||
value={
|
||||
status?.configured
|
||||
? currentApiKey ?? "••••••••••••••••"
|
||||
: "未配置"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
className="absolute right-0.5 top-0.5"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!status?.configured || revealing}
|
||||
aria-label={currentApiKey ? `隐藏 ${name} 当前 API Key` : `显示 ${name} 当前 API Key`}
|
||||
onClick={toggleCurrentApiKey}
|
||||
>
|
||||
{currentApiKey ? (
|
||||
<EyeOff className="size-4" aria-hidden />
|
||||
) : (
|
||||
<Eye className="size-4" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-muted">
|
||||
点击眼睛并通过动态验证码验证后显示;再次点击或离开页面时隐藏。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4 p-5 sm:p-6" onSubmit={(event) => void submit(event)}>
|
||||
<label className="block text-sm font-semibold" htmlFor={`${providerId}-api-key`}>
|
||||
<span className="mb-2 block">{name} 新 API Key</span>
|
||||
<Input
|
||||
id={`${providerId}-api-key`}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
maxLength={512}
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={`输入新的 ${name} API Key`}
|
||||
aria-describedby={`${providerId}-api-key-help`}
|
||||
/>
|
||||
</label>
|
||||
<p id={`${providerId}-api-key-help`} className="text-xs leading-5 text-muted">
|
||||
留空不会修改配置。若当前 Key 已显示,替换成功后会同步显示新 Key。
|
||||
</p>
|
||||
<Button type="submit" loading={saving} disabled={!apiKey.trim()}>
|
||||
<KeyRound className="size-4" aria-hidden />
|
||||
替换 {name} API Key
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Dialog
|
||||
open={revealOpen}
|
||||
onOpenChange={handleRevealOpenChange}
|
||||
title={`验证后显示 ${name} API Key`}
|
||||
description="请输入当前管理员的 6 位动态验证码。本次查看会写入审计日志。"
|
||||
>
|
||||
<form className="space-y-5" onSubmit={(event) => void reveal(event)} noValidate>
|
||||
<label className="block text-sm font-semibold" htmlFor={`${providerId}-reveal-totp`}>
|
||||
<span className="mb-2 block">动态验证码</span>
|
||||
<Input
|
||||
id={`${providerId}-reveal-totp`}
|
||||
className="text-center text-lg font-semibold tracking-[0.35em]"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, ""))}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{revealError ? (
|
||||
<p className="text-sm text-danger" role="alert">
|
||||
{revealError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => handleRevealOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" loading={revealing} disabled={totpCode.length !== 6}>
|
||||
验证并显示
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-semibold text-muted">{label}</dt>
|
||||
<dd className="mt-1 font-medium text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceLabel(status?: ManagedProviderStatus): string {
|
||||
if (!status?.configured) return "尚未配置";
|
||||
return status.source === "RUNTIME_OVERRIDE" ? "管理后台" : "环境变量";
|
||||
}
|
||||
|
||||
function validApiKey(value: string): boolean {
|
||||
return (
|
||||
value.length >= 1 &&
|
||||
value.length <= 512 &&
|
||||
!value.includes("\n") &&
|
||||
!value.includes("\r")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { adminApi } from "../../api/client";
|
||||
import type {
|
||||
ReferralOverview,
|
||||
ReferralRankingItem,
|
||||
ReferralsQuery,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Card,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
StatCard,
|
||||
} from "../../components/primitives";
|
||||
import { PeriodCaption } from "../../components/period-caption";
|
||||
import { RangeControl } from "../../components/range-control";
|
||||
import { SortControl } from "../../components/sort-control";
|
||||
import { formatNumber } from "../../lib/format";
|
||||
|
||||
export function ReferralsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [range, setRange] = useState(searchParams.get("range") ?? "30d");
|
||||
const [sort, setSort] = useState<NonNullable<ReferralsQuery["sort"]>>(
|
||||
(searchParams.get("sort") as ReferralsQuery["sort"] | null) ?? "qualified",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const [limit, setLimit] = useState(() => {
|
||||
const value = Number(searchParams.get("limit"));
|
||||
return [20, 50, 100].includes(value) ? value : 20;
|
||||
});
|
||||
const [data, setData] = useState<ReferralOverview>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const requestVersion = useRef(0);
|
||||
const query = useMemo<ReferralsQuery>(
|
||||
() => ({ range, sort, order, limit }),
|
||||
[limit, order, range, sort],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setError(undefined);
|
||||
setData(undefined);
|
||||
try {
|
||||
const response = await adminApi.referrals(query);
|
||||
if (requestVersion.current === version) setData(response);
|
||||
} catch (requestError) {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({ range, sort, order, limit: String(limit) });
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [limit, order, range, setSearchParams, sort]);
|
||||
|
||||
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) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="增长分析"
|
||||
title="裂变与排行"
|
||||
description="奖励以有效使用为前提,关注真实转化而不是单纯注册量。"
|
||||
actions={
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<RangeControl value={range} onChange={setRange} />
|
||||
<PeriodCaption from={data.period.from} until={data.period.until} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<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 == null ? "—" : `${conversion}%`}
|
||||
hint="首环节至最终有效使用"
|
||||
icon={TrendingUp}
|
||||
tone="success"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<ChartToolbar label="邀请排行设置">
|
||||
<SortControl
|
||||
label="排行指标"
|
||||
value={sort}
|
||||
order={order}
|
||||
options={[
|
||||
{ value: "invited", label: "邀请人数" },
|
||||
{ value: "qualified", label: "有效邀请" },
|
||||
{ value: "creditsEarned", label: "奖励积分" },
|
||||
]}
|
||||
onChange={(value, nextOrder) => {
|
||||
setSort(value);
|
||||
setOrder(nextOrder);
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="排行数量"
|
||||
value={String(limit)}
|
||||
options={[
|
||||
{ value: "20", label: "前 20 名" },
|
||||
{ value: "50", label: "前 50 名" },
|
||||
{ value: "100", label: "前 100 名" },
|
||||
]}
|
||||
onChange={(value) => setLimit(Number(value))}
|
||||
/>
|
||||
</ChartToolbar>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-border p-5 sm:p-6">
|
||||
<div>
|
||||
<h2 className="text-base font-bold">裂变漏斗</h2>
|
||||
<p className="mt-1 text-xs text-muted">同一批绑定用户从首次 AI 成功到完成奖励</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="border-b border-border p-5 sm:p-6">
|
||||
<RadialMetric
|
||||
label="整体有效转化"
|
||||
percent={conversion}
|
||||
detail={`${formatNumber(first)} 个绑定,最终形成 ${formatNumber(last)} 次奖励`}
|
||||
tone="success"
|
||||
/>
|
||||
</div>
|
||||
<FunnelChart steps={data.funnel} />
|
||||
</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">
|
||||
当前请求结果 Top 8;完整 {limit} 名见下方明细
|
||||
</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>
|
||||
<ComparisonBarChart
|
||||
items={data.ranking.slice(0, 8).map((item, index) => ({
|
||||
label: `第 ${index + 1} 名 · ${shortUserId(item.userId)}`,
|
||||
value: item.invited,
|
||||
secondaryValue: item.qualified,
|
||||
hint: `${formatNumber(item.creditsEarned)} 奖励积分`,
|
||||
}))}
|
||||
primaryLabel="邀请"
|
||||
secondaryLabel="有效"
|
||||
primaryTone="warning"
|
||||
secondaryTone="success"
|
||||
emptyText="当前周期暂无排行数据"
|
||||
/>
|
||||
</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">邀请排行明细</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="当前周期暂无排行数据"
|
||||
sort={{ key: sort, order }}
|
||||
sortableColumns={{
|
||||
invited: "invited",
|
||||
qualified: "qualified",
|
||||
creditsEarned: "creditsEarned",
|
||||
}}
|
||||
onSortChange={(key, nextOrder) => {
|
||||
setSort(key as NonNullable<ReferralsQuery["sort"]>);
|
||||
setOrder(nextOrder);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<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 shortUserId(userId: string): string {
|
||||
return userId.length <= 12 ? userId : `${userId.slice(0, 6)}…${userId.slice(-4)}`;
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
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 { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
AdminOperator,
|
||||
AdminOperatorProvisioning,
|
||||
AdminRole,
|
||||
AdminSecuritySummary,
|
||||
OperatorsQuery,
|
||||
SortOrder,
|
||||
} from "../../api/types";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { DateRangeControl } from "../../components/date-range-control";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
ErrorState,
|
||||
Input,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
StatCard,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
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 [searchParams, setSearchParams] = useSearchParams();
|
||||
const [summary, setSummary] = useState<AdminSecuritySummary>();
|
||||
const [summaryError, setSummaryError] = useState<unknown>();
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [role, setRole] = useState<"" | AdminRole>(
|
||||
(searchParams.get("role") as AdminRole | null) ?? "",
|
||||
);
|
||||
const [enabled, setEnabled] = useState(searchParams.get("enabled") ?? "");
|
||||
const [locked, setLocked] = useState(searchParams.get("locked") ?? "");
|
||||
const [sort, setSort] = useState<NonNullable<OperatorsQuery["sort"]>>(
|
||||
(searchParams.get("sort") as OperatorsQuery["sort"] | null) ?? "createdAt",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [resetOperator, setResetOperator] = useState<AdminOperator>();
|
||||
const [confirmation, setConfirmation] = useState<ConfirmationState>();
|
||||
const [provisioning, setProvisioning] = useState<{
|
||||
data: AdminOperatorProvisioning;
|
||||
title: string;
|
||||
}>();
|
||||
const operatorsQuery = useMemo<OperatorsQuery>(
|
||||
() => ({
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
role: role || undefined,
|
||||
enabled: enabled === "" ? undefined : enabled === "true",
|
||||
locked: locked === "" ? undefined : locked === "true",
|
||||
sort,
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[enabled, from, locked, order, role, sort, until],
|
||||
);
|
||||
const fetchOperators = useCallback(
|
||||
(value: OperatorsQuery) => adminApi.operators(value),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
items: operators,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(operatorsQuery, fetchOperators);
|
||||
const loadSummary = useCallback(async () => {
|
||||
setSummaryError(undefined);
|
||||
try {
|
||||
setSummary(await adminApi.operatorSummary());
|
||||
} catch (requestError) {
|
||||
setSummaryError(requestError);
|
||||
}
|
||||
}, []);
|
||||
const load = useCallback(async () => {
|
||||
reload();
|
||||
await loadSummary();
|
||||
}, [loadSummary, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (role) params.set("role", role);
|
||||
if (enabled) params.set("enabled", enabled);
|
||||
if (locked) params.set("locked", locked);
|
||||
params.set("sort", sort);
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [enabled, from, locked, order, role, setSearchParams, sort, until]);
|
||||
|
||||
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: "username",
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "创建时间",
|
||||
cell: ({ getValue }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted">
|
||||
{formatDateTime(String(getValue()))}
|
||||
</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 || summaryError) {
|
||||
return <ErrorState error={error ?? summaryError} 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>
|
||||
<TableToolbar
|
||||
active={Boolean(
|
||||
from ||
|
||||
until ||
|
||||
role ||
|
||||
enabled ||
|
||||
locked ||
|
||||
sort !== "createdAt" ||
|
||||
order !== "desc",
|
||||
)}
|
||||
onClear={() => {
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setRole("");
|
||||
setEnabled("");
|
||||
setLocked("");
|
||||
setSort("createdAt");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="角色"
|
||||
value={role}
|
||||
options={[
|
||||
{ value: "", label: "全部角色" },
|
||||
{ value: "SUPER_ADMIN", label: "超级管理员" },
|
||||
{ value: "SUPPORT", label: "支持人员" },
|
||||
{ value: "ANALYST", label: "分析员" },
|
||||
]}
|
||||
onChange={setRole}
|
||||
/>
|
||||
<FilterControl
|
||||
label="启用状态"
|
||||
value={enabled}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "true", label: "已启用" },
|
||||
{ value: "false", label: "已停用" },
|
||||
]}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
<FilterControl
|
||||
label="锁定状态"
|
||||
value={locked}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "true", label: "已锁定" },
|
||||
{ value: "false", label: "未锁定" },
|
||||
]}
|
||||
onChange={setLocked}
|
||||
/>
|
||||
</TableToolbar>
|
||||
<DataTable
|
||||
data={operators}
|
||||
columns={columns}
|
||||
caption="管理员账户与安全状态"
|
||||
emptyTitle={
|
||||
from || until || role || enabled || locked
|
||||
? "没有符合当前筛选条件的管理员"
|
||||
: "暂无管理员账户"
|
||||
}
|
||||
sort={{ key: sort, order }}
|
||||
sortableColumns={{
|
||||
username: "username",
|
||||
lastLoginAt: "lastLoginAt",
|
||||
createdAt: "createdAt",
|
||||
}}
|
||||
onSortChange={(key, nextOrder) => {
|
||||
setSort(key as NonNullable<OperatorsQuery["sort"]>);
|
||||
setOrder(nextOrder);
|
||||
}}
|
||||
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,751 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Coins,
|
||||
Gift,
|
||||
Search,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { adminApi, ApiError } from "../../api/client";
|
||||
import type {
|
||||
LedgerQuery,
|
||||
SortOrder,
|
||||
UserDetail,
|
||||
UsersQuery,
|
||||
UserSummary,
|
||||
UserUsageAggregate,
|
||||
} from "../../api/types";
|
||||
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||
import { DateRangeControl } from "../../components/date-range-control";
|
||||
import { FilterControl } from "../../components/filter-control";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
ErrorState,
|
||||
Input,
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from "../../components/primitives";
|
||||
import { TableToolbar } from "../../components/table-toolbar";
|
||||
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
formatDateTime,
|
||||
formatNumber,
|
||||
statusLabel,
|
||||
usageTypeLabel,
|
||||
} from "../../lib/format";
|
||||
import { useAuth } from "../auth/auth-context";
|
||||
import { LedgerFilters } from "../credits/ledger-filters";
|
||||
import { LedgerTable } from "../credits/ledger-table";
|
||||
import { useLedgerFilters } from "../credits/use-ledger-filters";
|
||||
|
||||
export function UsersPage() {
|
||||
const { auth } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
|
||||
const selectedUserId = searchParams.get("userId") ?? undefined;
|
||||
|
||||
function selectUser(userId?: string) {
|
||||
setSearchParams((current) => {
|
||||
const params = new URLSearchParams(current);
|
||||
if (userId) params.set("userId", userId);
|
||||
else params.delete("userId");
|
||||
return params;
|
||||
});
|
||||
}
|
||||
|
||||
return selectedUserId ? (
|
||||
<UserDetailView
|
||||
userId={selectedUserId}
|
||||
canGrant={role === "SUPER_ADMIN"}
|
||||
onBack={() => selectUser()}
|
||||
/>
|
||||
) : (
|
||||
<UserList onSelect={selectUser} />
|
||||
);
|
||||
}
|
||||
|
||||
function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialQuery = searchParams.get("q") ?? "";
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [activeQuery, setActiveQuery] = useState(initialQuery);
|
||||
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||
const [status, setStatus] = useState<"" | UserSummary["status"]>(
|
||||
(searchParams.get("status") as UserSummary["status"] | null) ?? "",
|
||||
);
|
||||
const [order, setOrder] = useState<SortOrder>(
|
||||
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||
);
|
||||
const usersQuery = useMemo<UsersQuery>(
|
||||
() => ({
|
||||
q: activeQuery || undefined,
|
||||
from: from || undefined,
|
||||
until: until || undefined,
|
||||
status: status || undefined,
|
||||
sort: "createdAt",
|
||||
order,
|
||||
limit: 50,
|
||||
}),
|
||||
[activeQuery, from, order, status, until],
|
||||
);
|
||||
const fetchUsers = useCallback((value: UsersQuery) => adminApi.users(value), []);
|
||||
const {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload,
|
||||
} = useCursorPage(usersQuery, fetchUsers);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (activeQuery) params.set("q", activeQuery);
|
||||
if (from) params.set("from", from);
|
||||
if (until) params.set("until", until);
|
||||
if (status) params.set("status", status);
|
||||
params.set("sort", "createdAt");
|
||||
params.set("order", order);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [activeQuery, from, order, setSearchParams, status, until]);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const search = query.trim();
|
||||
setActiveQuery(search);
|
||||
}
|
||||
|
||||
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>
|
||||
<TableToolbar
|
||||
active={Boolean(activeQuery || from || until || status || order !== "desc")}
|
||||
onClear={() => {
|
||||
setQuery("");
|
||||
setActiveQuery("");
|
||||
setFrom("");
|
||||
setUntil("");
|
||||
setStatus("");
|
||||
setOrder("desc");
|
||||
}}
|
||||
>
|
||||
<DateRangeControl
|
||||
value={{ from: from || undefined, until: until || undefined }}
|
||||
onChange={(value) => {
|
||||
setFrom(value.from ?? "");
|
||||
setUntil(value.until ?? "");
|
||||
}}
|
||||
/>
|
||||
<FilterControl
|
||||
label="账户状态"
|
||||
value={status}
|
||||
options={[
|
||||
{ value: "", label: "全部状态" },
|
||||
{ value: "active", label: "正常" },
|
||||
{ value: "suspended", label: "反滥用受限" },
|
||||
]}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
</TableToolbar>
|
||||
<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={reload} />
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingState label="加载用户列表" />
|
||||
) : (
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
caption={activeQuery ? "用户查询结果" : "全部用户"}
|
||||
emptyTitle={
|
||||
activeQuery || from || until || status
|
||||
? "没有符合当前筛选条件的用户"
|
||||
: "暂无用户"
|
||||
}
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
const filters = useLedgerFilters("ledger");
|
||||
const requestVersion = useRef(0);
|
||||
const fetchLedger = useCallback(
|
||||
(value: LedgerQuery) => adminApi.ledger(userId, value),
|
||||
[userId],
|
||||
);
|
||||
const ledgerPage = useCursorPage(filters.query, fetchLedger);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const version = ++requestVersion.current;
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const detail = await adminApi.user(userId);
|
||||
if (requestVersion.current !== version) return;
|
||||
setUser(detail);
|
||||
} catch (requestError) {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
} finally {
|
||||
if (requestVersion.current === version) setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (error || ledgerPage.error) {
|
||||
return (
|
||||
<ErrorState
|
||||
error={error ?? ledgerPage.error}
|
||||
retry={() => {
|
||||
void load();
|
||||
ledgerPage.reload();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (loading || ledgerPage.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="所有变动均来自不可变账本"
|
||||
/>
|
||||
<LedgerFilters
|
||||
value={filters.state}
|
||||
onChange={filters.update}
|
||||
onClear={filters.clear}
|
||||
/>
|
||||
<LedgerTable
|
||||
entries={ledgerPage.items}
|
||||
caption={`${user.displayName || user.userId} 的积分流水`}
|
||||
emptyTitle={
|
||||
filters.state.from ||
|
||||
filters.state.until ||
|
||||
filters.state.type ||
|
||||
filters.state.entryType ||
|
||||
filters.state.usageType ||
|
||||
filters.state.referenceId
|
||||
? "没有符合当前筛选条件的流水"
|
||||
: "暂无积分流水"
|
||||
}
|
||||
sort={filters.state.sort}
|
||||
order={filters.state.order}
|
||||
onSortChange={(sort, order) => filters.update({ sort, order })}
|
||||
onReferenceFilter={(referenceId) => filters.update({ referenceId })}
|
||||
footer={
|
||||
ledgerPage.nextCursor ? (
|
||||
<div className="flex justify-center border-t border-border p-5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void ledgerPage.loadMore()}
|
||||
loading={ledgerPage.loadingMore}
|
||||
>
|
||||
加载更多流水
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{usage.length > 0 ? <UsagePanel usage={usage} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canGrant ? (
|
||||
<GrantDialog
|
||||
open={grantOpen}
|
||||
user={user}
|
||||
onOpenChange={setGrantOpen}
|
||||
onSuccess={async () => {
|
||||
await load();
|
||||
ledgerPage.reload();
|
||||
}}
|
||||
/>
|
||||
) : 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 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PageResult } from "../api/types";
|
||||
|
||||
export function useCursorPage<T, Q extends { cursor?: string }>(
|
||||
query: Q,
|
||||
fetchPage: (query: Q) => Promise<PageResult<T>>,
|
||||
) {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const requestVersion = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const version = ++requestVersion.current;
|
||||
setItems([]);
|
||||
setNextCursor(undefined);
|
||||
setLoading(true);
|
||||
setLoadingMore(false);
|
||||
setError(undefined);
|
||||
void fetchPage(query)
|
||||
.then((page) => {
|
||||
if (requestVersion.current !== version) return;
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
})
|
||||
.catch((requestError) => {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestVersion.current === version) setLoading(false);
|
||||
});
|
||||
}, [fetchPage, query, refreshKey]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
const version = requestVersion.current;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await fetchPage({ ...query, cursor: nextCursor });
|
||||
if (requestVersion.current !== version) return;
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (requestError) {
|
||||
if (requestVersion.current === version) setError(requestError);
|
||||
} finally {
|
||||
if (requestVersion.current === version) setLoadingMore(false);
|
||||
}
|
||||
}, [fetchPage, loadingMore, nextCursor, query]);
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
loadMore,
|
||||
reload: useCallback(() => setRefreshKey((current) => current + 1), []),
|
||||
};
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function escapeHtml(value: unknown): string {
|
||||
export function statusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
active: "正常",
|
||||
suspended: "已停用",
|
||||
suspended: "反滥用受限",
|
||||
closed: "已关闭",
|
||||
success: "成功",
|
||||
rejected: "已拒绝",
|
||||
@@ -43,11 +43,23 @@ export function statusLabel(status: string): string {
|
||||
reserve: "预留",
|
||||
settle: "结算",
|
||||
refund: "退还",
|
||||
adjustment: "调整",
|
||||
};
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
export function usageTypeLabel(usageType?: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
polish: "润色",
|
||||
asr: "ASR",
|
||||
ASR: "语音转写",
|
||||
ai: "AI",
|
||||
LLM: "AI 文本",
|
||||
agent: "Agent",
|
||||
hotword: "热词",
|
||||
};
|
||||
return usageType ? (labels[usageType] ?? usageType) : "—";
|
||||
}
|
||||
|
||||
export function createIdempotencyKey(): string {
|
||||
return globalThis.crypto?.randomUUID?.() ??
|
||||
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SortOrder } from "../api/types";
|
||||
|
||||
export function stableSort<T>(
|
||||
items: readonly T[],
|
||||
value: (item: T) => number | string,
|
||||
order: SortOrder,
|
||||
): T[] {
|
||||
const direction = order === "asc" ? 1 : -1;
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((left, right) => {
|
||||
const leftValue = value(left.item);
|
||||
const rightValue = value(right.item);
|
||||
const compared =
|
||||
typeof leftValue === "string" && typeof rightValue === "string"
|
||||
? leftValue.localeCompare(rightValue)
|
||||
: Number(leftValue) - Number(rightValue);
|
||||
return compared === 0 ? left.index - right.index : compared * direction;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
+385
-1346
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="');
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,23 @@ describe("adminApi", () => {
|
||||
expect(request.credentials).toBe("include");
|
||||
});
|
||||
|
||||
it("内容发布请求携带 CSRF 并编码 Skill ID", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(null, { status: 204 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setCsrfToken("csrf-content");
|
||||
|
||||
await adminApi.setContentSkillEnabled("official.skill/with space", true);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"/v1/admin/content/skills/official.skill%2Fwith%20space/enable",
|
||||
);
|
||||
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-content");
|
||||
expect(request.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("登录请求不依赖已有会话 CSRF", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
@@ -90,17 +107,87 @@ describe("adminApi", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await adminApi.latestLedger("latest+/=");
|
||||
await adminApi.ledger("user/with space", "ledger+/=");
|
||||
await adminApi.operators("operator+/=");
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
|
||||
"/v1/admin/credits/ledger?cursor=latest%2B%2F%3D",
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
|
||||
);
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe(
|
||||
"/v1/admin/operators?cursor=operator%2B%2F%3D",
|
||||
);
|
||||
});
|
||||
|
||||
it("typed query 编码日期、筛选、排序与布尔值", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(async () =>
|
||||
new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await adminApi.users({
|
||||
q: "user + value",
|
||||
from: "2026-08-01T00:00:00.000Z",
|
||||
until: "2026-08-20T23:59:59.999Z",
|
||||
status: "suspended",
|
||||
sort: "createdAt",
|
||||
order: "asc",
|
||||
limit: 25,
|
||||
});
|
||||
await adminApi.operators({
|
||||
enabled: false,
|
||||
locked: true,
|
||||
sort: "username",
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toContain(
|
||||
"q=user+%2B+value&from=2026-08-01T00%3A00%3A00.000Z",
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toContain(
|
||||
"status=suspended&sort=createdAt&order=asc&limit=25",
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
"/v1/admin/operators?enabled=false&locked=true&sort=username&order=desc",
|
||||
);
|
||||
});
|
||||
|
||||
it("流水查询编码三维筛选、关联 ID 与金额排序", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await adminApi.latestLedger({
|
||||
cursor: "next+/=",
|
||||
limit: 50,
|
||||
from: "2026-08-01T00:00:00.000Z",
|
||||
until: "2026-08-21T00:00:00.000Z",
|
||||
type: "settle",
|
||||
entryType: "USAGE_SETTLE",
|
||||
usageType: "hotword",
|
||||
referenceId: "11111111-1111-4111-8111-111111111111",
|
||||
sort: "amount",
|
||||
order: "asc",
|
||||
});
|
||||
|
||||
const url = String(fetchMock.mock.calls[0]?.[0]);
|
||||
expect(url).toContain("cursor=next%2B%2F%3D&limit=50");
|
||||
expect(url).toContain("type=settle&entryType=USAGE_SETTLE&usageType=hotword");
|
||||
expect(url).toContain(
|
||||
"referenceId=11111111-1111-4111-8111-111111111111&sort=amount&order=asc",
|
||||
);
|
||||
});
|
||||
|
||||
it("缺少 CSRF 时在发送变更请求前失败", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -189,4 +276,54 @@ describe("adminApi", () => {
|
||||
);
|
||||
expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP");
|
||||
});
|
||||
|
||||
it("Provider API Key 替换请求携带 CSRF 且只提交新 Key", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
providerId: "deepseek",
|
||||
configured: true,
|
||||
source: "RUNTIME_OVERRIDE",
|
||||
updatedAt: "2026-08-22T08:00:00Z",
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setCsrfToken("csrf-provider");
|
||||
|
||||
await adminApi.updateProviderApiKey("deepseek", { apiKey: "new-provider-key" });
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"/v1/admin/providers/deepseek/api-key",
|
||||
);
|
||||
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(request.method).toBe("PUT");
|
||||
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-provider");
|
||||
expect(request.body).toBe(JSON.stringify({ apiKey: "new-provider-key" }));
|
||||
});
|
||||
|
||||
it("Provider API Key 查看请求携带 CSRF 且只提交动态验证码", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ apiKey: "current-provider-key" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setCsrfToken("csrf-reveal");
|
||||
|
||||
const result = await adminApi.revealProviderApiKey("volcengine", {
|
||||
totpCode: "123456",
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"/v1/admin/providers/volcengine/api-key/reveal",
|
||||
);
|
||||
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(request.method).toBe("POST");
|
||||
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-reveal");
|
||||
expect(request.body).toBe(JSON.stringify({ totpCode: "123456" }));
|
||||
expect(result.apiKey).toBe("current-provider-key");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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 } from "../api/client";
|
||||
import type { AdminRole, OfficialSkillCatalog } from "../api/types";
|
||||
import { App } from "../app";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
describe("内容管理", () => {
|
||||
it("SUPPORT 可查看但不能修改 Skill 与 Hint", async () => {
|
||||
mockSession("SUPPORT");
|
||||
mockContent();
|
||||
window.location.hash = "#/content";
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "内容管理" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /内容管理/ })).toBeTruthy();
|
||||
expect(await screen.findByText("润色")).toBeTruthy();
|
||||
expect(screen.getByText("只读访问")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "新增 Skill" })).toBeNull();
|
||||
const editor = await screen.findByLabelText("zh JSON");
|
||||
expect(editor).toHaveProperty("readOnly", true);
|
||||
expect(screen.queryByRole("button", { name: "保存" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "立即生成" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "保存生成设置" })).toBeNull();
|
||||
});
|
||||
|
||||
it("SUPER_ADMIN 可启停官方 Skill", async () => {
|
||||
mockSession("SUPER_ADMIN");
|
||||
mockContent();
|
||||
const mutation = vi
|
||||
.spyOn(adminApi, "setContentSkillEnabled")
|
||||
.mockResolvedValue(undefined);
|
||||
window.location.hash = "#/content";
|
||||
|
||||
render(<App />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "停用" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mutation).toHaveBeenCalledWith("official.polish", false);
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "新增 Skill" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Skill 编辑器约束与客户端边界一致", async () => {
|
||||
mockSession("SUPER_ADMIN");
|
||||
mockContent();
|
||||
window.location.hash = "#/content";
|
||||
render(<App />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "新增 Skill" }));
|
||||
|
||||
expect(screen.getByLabelText("Skill ID").getAttribute("maxlength")).toBe("100");
|
||||
expect(screen.getByLabelText("SF Symbol").getAttribute("maxlength")).toBe("100");
|
||||
const sortOrder = screen.getByLabelText("排序");
|
||||
expect(sortOrder.getAttribute("min")).toBe("0");
|
||||
expect(sortOrder.getAttribute("max")).toBe("100000");
|
||||
screen.getAllByLabelText("名称").forEach((input) => {
|
||||
expect(input.getAttribute("maxlength")).toBe("40");
|
||||
});
|
||||
screen.getAllByLabelText("摘要").forEach((input) => {
|
||||
expect(input.getAttribute("maxlength")).toBe("200");
|
||||
});
|
||||
screen.getAllByLabelText("Prompt").forEach((input) => {
|
||||
expect(input.getAttribute("maxlength")).toBe("6000");
|
||||
});
|
||||
});
|
||||
|
||||
it("SUPER_ADMIN 可手动触发双语 Hint 原子生成", async () => {
|
||||
mockSession("SUPER_ADMIN");
|
||||
mockContent();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const regenerate = vi.spyOn(adminApi, "regenerateHintFeed").mockResolvedValue({
|
||||
generationId: "00000000-0000-0000-0000-000000000001",
|
||||
generatedAt: "2026-08-21T06:00:00Z",
|
||||
zh: { version: 3, cardCount: 20 },
|
||||
en: { version: 3, cardCount: 25 },
|
||||
});
|
||||
window.location.hash = "#/content";
|
||||
render(<App />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "立即生成" }));
|
||||
|
||||
await waitFor(() => expect(regenerate).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("SUPER_ADMIN 可编辑并保存当前生效的 Hint pack", async () => {
|
||||
mockSession("SUPER_ADMIN");
|
||||
mockContent();
|
||||
const save = vi.spyOn(adminApi, "updateContentHintPack").mockResolvedValue({
|
||||
locale: "zh",
|
||||
generatedAt: "2026-08-21T06:00:00Z",
|
||||
intervalHours: 12,
|
||||
version: 3,
|
||||
cards: [],
|
||||
});
|
||||
window.location.hash = "#/content";
|
||||
render(<App />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "保存" }));
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalledOnce());
|
||||
expect(await screen.findByText("version 3")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
function mockSession(role: AdminRole) {
|
||||
vi.spyOn(adminApi, "session").mockResolvedValue({
|
||||
authenticated: true,
|
||||
operatorName: "owner",
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
function mockContent() {
|
||||
vi.spyOn(adminApi, "contentSkills").mockResolvedValue(catalog());
|
||||
vi.spyOn(adminApi, "contentHintPack").mockResolvedValue({
|
||||
locale: "zh",
|
||||
generatedAt: "2026-08-21T04:00:00Z",
|
||||
intervalHours: 12,
|
||||
version: 2,
|
||||
cards: [],
|
||||
});
|
||||
vi.spyOn(adminApi, "hintFeedSettings").mockResolvedValue({
|
||||
enabled: true,
|
||||
topHubApiKeyConfigured: false,
|
||||
generationIntervalHours: 12,
|
||||
holidayCountriesZh: "CN",
|
||||
holidayCountriesEn: "US,GB",
|
||||
weatherCitiesZh: "北京:39.90,116.40",
|
||||
weatherCitiesEn: "London:51.51,-0.13",
|
||||
googleTrendsGeos: "US,GB",
|
||||
});
|
||||
vi.spyOn(adminApi, "hintFeedStatus").mockResolvedValue({
|
||||
enabled: true,
|
||||
outcome: "SUCCEEDED",
|
||||
intervalHours: 12,
|
||||
topHubApiKeyConfigured: false,
|
||||
zhVersion: 2,
|
||||
zhCardCount: 20,
|
||||
enVersion: 2,
|
||||
enCardCount: 25,
|
||||
});
|
||||
}
|
||||
|
||||
function catalog(): OfficialSkillCatalog {
|
||||
return {
|
||||
revision: 3,
|
||||
generatedAt: "2026-08-21T04:00:00Z",
|
||||
skills: [
|
||||
{
|
||||
id: "official.polish",
|
||||
systemImage: "wand.and.sparkles",
|
||||
sortOrder: 10,
|
||||
kind: "transform",
|
||||
thinkingEnabled: false,
|
||||
enabled: true,
|
||||
localizations: {
|
||||
"zh-Hans": {
|
||||
name: "润色",
|
||||
summary: "优化表达",
|
||||
prompt: "请润色",
|
||||
},
|
||||
en: {
|
||||
name: "Polish",
|
||||
summary: "Improve wording",
|
||||
prompt: "Please polish",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { DataTable, type DataColumn } from "../components/data-table";
|
||||
import { DateRangeControl } from "../components/date-range-control";
|
||||
import type { SortOrder } from "../api/types";
|
||||
import { stableSort } from "../lib/sort";
|
||||
import { LedgerFilters } from "../features/credits/ledger-filters";
|
||||
import {
|
||||
ledgerDatePreset,
|
||||
readLedgerFilterState,
|
||||
} from "../features/credits/use-ledger-filters";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("筛选与排序基础能力", () => {
|
||||
it("DataTable 暴露受控 aria-sort 并通过表头切换方向", async () => {
|
||||
function TableHarness() {
|
||||
const [order, setOrder] = useState<SortOrder>("desc");
|
||||
const columns: DataColumn<{ createdAt: string }>[] = [
|
||||
{ accessorKey: "createdAt", header: "时间" },
|
||||
];
|
||||
return (
|
||||
<DataTable
|
||||
data={[{ createdAt: "2026-08-20T00:00:00Z" }]}
|
||||
columns={columns}
|
||||
caption="排序测试"
|
||||
sort={{ key: "createdAt", order }}
|
||||
sortableColumns={{ createdAt: "createdAt" }}
|
||||
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TableHarness />);
|
||||
const header = screen.getByRole("columnheader", { name: /时间/ });
|
||||
expect(header.getAttribute("aria-sort")).toBe("descending");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /时间/ }));
|
||||
|
||||
expect(header.getAttribute("aria-sort")).toBe("ascending");
|
||||
});
|
||||
|
||||
it("稳定排序不修改输入并保留同值项目顺序", () => {
|
||||
const source = [
|
||||
{ id: "first", value: 2 },
|
||||
{ id: "second", value: 2 },
|
||||
{ id: "third", value: 1 },
|
||||
];
|
||||
const result = stableSort(source, (item) => item.value, "desc");
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(["first", "second", "third"]);
|
||||
expect(source.map((item) => item.id)).toEqual(["first", "second", "third"]);
|
||||
expect(result).not.toBe(source);
|
||||
});
|
||||
|
||||
it("日期范围将结束日期转换为次日 UTC 半开边界", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DateRangeControl value={{}} onChange={onChange} />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("结束日期"), "2026-08-20");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
until: "2026-08-21T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("流水时间预设生成 UTC 半开区间", () => {
|
||||
const now = new Date("2026-08-20T21:30:00+08:00");
|
||||
|
||||
expect(ledgerDatePreset("today", now)).toEqual({
|
||||
from: "2026-08-20T00:00:00.000Z",
|
||||
until: "2026-08-21T00:00:00.000Z",
|
||||
range: "today",
|
||||
});
|
||||
expect(ledgerDatePreset("7d", now)).toEqual({
|
||||
from: "2026-08-14T00:00:00.000Z",
|
||||
until: "2026-08-21T00:00:00.000Z",
|
||||
range: "7d",
|
||||
});
|
||||
expect(ledgerDatePreset("all", now)).toEqual({
|
||||
from: "",
|
||||
until: "",
|
||||
range: "all",
|
||||
});
|
||||
});
|
||||
|
||||
it("关联 ID 完整且合法后才应用筛选", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<LedgerFilters
|
||||
value={readLedgerFilterState(new URLSearchParams())}
|
||||
onChange={onChange}
|
||||
onClear={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("关联 ID");
|
||||
await userEvent.type(input, "invalid");
|
||||
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByText("请输入完整 UUID")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "应用" }).hasAttribute("disabled")).toBe(true);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||
await userEvent.click(screen.getByRole("button", { name: "应用" }));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
referenceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
formatDateTime,
|
||||
formatSignedCredits,
|
||||
statusLabel,
|
||||
usageTypeLabel,
|
||||
} from "../lib/format";
|
||||
import {
|
||||
ledgerEntryTypeLabel,
|
||||
ledgerUsageClass,
|
||||
} from "../features/credits/ledger-format";
|
||||
|
||||
describe("format helpers", () => {
|
||||
it("转义服务端文本以阻止 HTML 注入", () => {
|
||||
@@ -26,4 +31,49 @@ describe("format helpers", () => {
|
||||
it("未知状态保持原值", () => {
|
||||
expect(statusLabel("custom")).toBe("custom");
|
||||
});
|
||||
|
||||
it("反滥用限制不会误显示为账户停用", () => {
|
||||
expect(statusLabel("suspended")).toBe("反滥用受限");
|
||||
});
|
||||
|
||||
it("消费类型使用清晰的中文标签", () => {
|
||||
expect(usageTypeLabel("polish")).toBe("润色");
|
||||
expect(usageTypeLabel("hotword")).toBe("热词");
|
||||
expect(usageTypeLabel()).toBe("—");
|
||||
});
|
||||
|
||||
it("十种流水业务来源使用稳定中文标签,未知值保留原值", () => {
|
||||
expect([
|
||||
"SIGNUP_TRIAL",
|
||||
"MANUAL_GRANT",
|
||||
"USAGE_RESERVE",
|
||||
"USAGE_SETTLE",
|
||||
"USAGE_RELEASE",
|
||||
"USAGE_REFUND",
|
||||
"REFERRAL_INVITER",
|
||||
"REFERRAL_INVITEE",
|
||||
"STOREKIT_PURCHASE",
|
||||
"SUBSCRIPTION_GRANT",
|
||||
].map(ledgerEntryTypeLabel)).toEqual([
|
||||
"注册试用赠送",
|
||||
"人工赠送",
|
||||
"消费预留",
|
||||
"消费结算",
|
||||
"消费释放",
|
||||
"消费退款",
|
||||
"邀请人奖励",
|
||||
"受邀人奖励",
|
||||
"App Store 购买",
|
||||
"订阅赠送",
|
||||
]);
|
||||
expect(ledgerEntryTypeLabel("FUTURE_TYPE")).toBe("未知类型(FUTURE_TYPE)");
|
||||
});
|
||||
|
||||
it("五种消费能力使用固定且可读的类别色", () => {
|
||||
expect(ledgerUsageClass("polish")).toContain("text-violet");
|
||||
expect(ledgerUsageClass("asr")).toContain("text-primary");
|
||||
expect(ledgerUsageClass("ai")).toContain("text-success");
|
||||
expect(ledgerUsageClass("agent")).toContain("text-warning");
|
||||
expect(ledgerUsageClass("hotword")).toContain("text-pink");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,410 @@
|
||||
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,
|
||||
ProductAnalyticsOverview,
|
||||
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,
|
||||
expect.objectContaining({ cursor: "user-next", sort: "createdAt", order: "desc" }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("第二位用户")).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("用户排序变更会清空旧游标并重新请求", async () => {
|
||||
mockSession("SUPPORT");
|
||||
vi.spyOn(adminApi, "users")
|
||||
.mockResolvedValueOnce({
|
||||
items: [user("第一页", userId)],
|
||||
nextCursor: "stale-cursor",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [user("旧游标结果", "22222222-2222-4222-8222-222222222222")],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [user("升序结果", "33333333-3333-4333-8333-333333333333")],
|
||||
});
|
||||
window.location.hash = "#/users";
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByText("第一页")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
|
||||
expect(await screen.findByText("旧游标结果")).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /注册时间/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(adminApi.users).toHaveBeenCalledTimes(3);
|
||||
const refreshedQuery = vi.mocked(adminApi.users).mock.calls[2]?.[0];
|
||||
expect(refreshedQuery).toMatchObject({ order: "asc" });
|
||||
expect(refreshedQuery).not.toHaveProperty("cursor");
|
||||
});
|
||||
expect(await screen.findByText("升序结果")).toBeTruthy();
|
||||
expect(screen.queryByText("旧游标结果")).toBeNull();
|
||||
expect(window.location.hash).toContain("order=asc");
|
||||
expect(window.location.hash).not.toContain("cursor");
|
||||
});
|
||||
|
||||
it("积分页自动显示最新不可变流水", async () => {
|
||||
mockSession("SUPPORT");
|
||||
const entry: LedgerEntry = {
|
||||
entryId: "ledger-1",
|
||||
userId,
|
||||
type: "settle",
|
||||
entryType: "USAGE_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("消费结算")).toBeTruthy();
|
||||
expect(screen.getAllByText("热词")).toHaveLength(2);
|
||||
expect(screen.getByText("-18")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("积分流水支持金额排序、展开安全详情、关联筛选与用户跳转", async () => {
|
||||
mockSession("SUPPORT");
|
||||
const referenceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const entry: LedgerEntry = {
|
||||
entryId: "ledger-manual-1",
|
||||
userId,
|
||||
type: "grant",
|
||||
entryType: "MANUAL_GRANT",
|
||||
amount: 50,
|
||||
balanceAfter: 170,
|
||||
reasonCode: "MANUAL_CREDIT_GRANT",
|
||||
referenceId,
|
||||
details: {
|
||||
kind: "manualGrant",
|
||||
reason: "客服补偿",
|
||||
operatorName: "support",
|
||||
},
|
||||
createdAt: "2026-08-20T09:00:00Z",
|
||||
};
|
||||
vi.spyOn(adminApi, "latestLedger").mockResolvedValue({ items: [entry] });
|
||||
window.location.hash = "#/credits";
|
||||
|
||||
render(<App />);
|
||||
|
||||
const userLink = await screen.findByRole("link", { name: userId });
|
||||
expect(screen.getAllByText("人工赠送")).toHaveLength(2);
|
||||
expect(screen.queryByText("MANUAL_CREDIT_GRANT")).toBeNull();
|
||||
expect(userLink.getAttribute("href")).toBe(`#/users?userId=${userId}`);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /变动/ }));
|
||||
await waitFor(() => {
|
||||
expect(adminApi.latestLedger).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sort: "amount", order: "desc" }),
|
||||
);
|
||||
});
|
||||
expect(window.location.hash).toContain("sort=amount");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "展开" }));
|
||||
expect(screen.getByText("客服补偿")).toBeTruthy();
|
||||
expect(screen.getByText("support")).toBeTruthy();
|
||||
expect(screen.getByText("MANUAL_CREDIT_GRANT")).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "筛选同笔生命周期" }));
|
||||
await waitFor(() => {
|
||||
expect(adminApi.latestLedger).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ referenceId }),
|
||||
);
|
||||
});
|
||||
expect(window.location.hash).toContain(`referenceId=${referenceId}`);
|
||||
});
|
||||
|
||||
it("用户 URL 可直接打开详情,ledger 前缀不覆盖列表筛选", async () => {
|
||||
mockSession("SUPPORT");
|
||||
vi.spyOn(adminApi, "user").mockResolvedValue({
|
||||
...user("链接用户", userId),
|
||||
qualifiedUsage: false,
|
||||
});
|
||||
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
|
||||
vi.spyOn(adminApi, "users").mockResolvedValue({ items: [] });
|
||||
window.location.hash =
|
||||
`#/users?q=11111111&status=active&order=asc&userId=${userId}` +
|
||||
"&ledgerType=reserve&ledgerEntryType=USAGE_RESERVE&ledgerUsageType=ai";
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "链接用户" })).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(adminApi.ledger).toHaveBeenCalledWith(
|
||||
userId,
|
||||
expect.objectContaining({
|
||||
type: "reserve",
|
||||
entryType: "USAGE_RESERVE",
|
||||
usageType: "ai",
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(window.location.hash).toContain("q=11111111");
|
||||
expect(window.location.hash).toContain("ledgerType=reserve");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "返回用户列表" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).not.toContain("userId=");
|
||||
expect(window.location.hash).toContain("q=11111111");
|
||||
expect(window.location.hash).toContain("status=active");
|
||||
expect(window.location.hash).toContain("order=asc");
|
||||
});
|
||||
});
|
||||
|
||||
it("安全中心保留管理员游标分页", async () => {
|
||||
mockSession("SUPER_ADMIN");
|
||||
const first = operator("operator-1", "owner");
|
||||
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,
|
||||
expect.objectContaining({ cursor: "operator-next", sort: "createdAt", order: "desc" }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("support")).toBeTruthy();
|
||||
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("产品分析页展示北极星、留存与付费护栏", async () => {
|
||||
mockSession("SUPPORT");
|
||||
vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(analyticsOverview());
|
||||
window.location.hash = "#/analytics";
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "产品增长与留存" })).toBeTruthy();
|
||||
expect(screen.getByText("周 AI 活跃用户")).toBeTruthy();
|
||||
expect(screen.getByText(/较上周 \+12\.5%/)).toBeTruthy();
|
||||
expect(screen.getByText("2026-08-01")).toBeTruthy();
|
||||
expect(screen.getByText("文字润色")).toBeTruthy();
|
||||
expect(screen.getByText("键盘中英文输入")).toBeTruthy();
|
||||
expect(screen.getByText("中文活跃用户")).toBeTruthy();
|
||||
expect(screen.getByText("中英混合")).toBeTruthy();
|
||||
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
|
||||
expect(screen.getByText("购买意向漏斗")).toBeTruthy();
|
||||
expect(screen.getByText("AI 终态延迟分布")).toBeTruthy();
|
||||
expect(screen.getByText("客户端分享信号")).toBeTruthy();
|
||||
expect(screen.getByLabelText("实际统计周期").textContent).toContain("包含今日未完整数据");
|
||||
});
|
||||
|
||||
it("产品图表可按执行模式筛选并按用户数稳定排序", async () => {
|
||||
mockSession("SUPPORT");
|
||||
const overview = analyticsOverview();
|
||||
overview.aiFeatures = [
|
||||
{ feature: "POLISH", executionMode: "MANAGED", users: 10, successes: 100 },
|
||||
{ feature: "AI_ASSISTANT", executionMode: "MANAGED", users: 40, successes: 50 },
|
||||
{ feature: "HOTWORD", executionMode: "LOCAL", users: 80, successes: 200 },
|
||||
];
|
||||
vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(overview);
|
||||
window.location.hash = "#/analytics";
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByText("快捷指令")).toBeTruthy();
|
||||
await userEvent.selectOptions(screen.getByLabelText("执行模式"), "MANAGED");
|
||||
expect(screen.queryByText("快捷指令")).toBeNull();
|
||||
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("成功次数"), "users");
|
||||
|
||||
const labels = screen
|
||||
.getAllByRole("progressbar")
|
||||
.map((element) => element.getAttribute("aria-label"));
|
||||
expect(labels.indexOf("AI 助手 成功次数:50")).toBeLessThan(
|
||||
labels.indexOf("文字润色 成功次数:100"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
|
||||
vi.spyOn(adminApi, "session").mockResolvedValue({
|
||||
authenticated: true,
|
||||
operatorName: "owner",
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
function analyticsOverview(): ProductAnalyticsOverview {
|
||||
const rate = (numerator: number, denominator: number, percent: number) => ({
|
||||
numerator,
|
||||
denominator,
|
||||
percent,
|
||||
});
|
||||
return {
|
||||
period: { from: "2026-08-01T00:00:00Z", until: "2026-08-20T00:00:00Z" },
|
||||
northStar: {
|
||||
weeklyAiActiveUsers: 90,
|
||||
previousWeeklyAiActiveUsers: 80,
|
||||
weekOverWeekPercent: 12.5,
|
||||
},
|
||||
growth: {
|
||||
newInstallations: 100,
|
||||
newAccounts: 80,
|
||||
activation24h: rate(60, 100, 60),
|
||||
medianTimeToValueMinutes: 8,
|
||||
channels: [
|
||||
{
|
||||
channel: "APP_STORE_ORGANIC",
|
||||
installations: 100,
|
||||
activated: 60,
|
||||
activationRate: rate(60, 100, 60),
|
||||
},
|
||||
],
|
||||
},
|
||||
activity: {
|
||||
dau: 20,
|
||||
wau: 90,
|
||||
mau: 150,
|
||||
stickinessPercent: 13.3,
|
||||
successfulAiRequests: 500,
|
||||
successfulRequestsPerActiveUser: 3.3,
|
||||
},
|
||||
consumption: {
|
||||
totalCredits: 1_200,
|
||||
averageDailyCreditsPerActiveUser: 12,
|
||||
medianUserDailyCredits: 8,
|
||||
averageCreditsPerManagedRequest: 2.4,
|
||||
},
|
||||
monetization: {
|
||||
payingUsers: 10,
|
||||
purchases: 12,
|
||||
creditsPurchased: 8_000,
|
||||
conversion7d: rate(8, 70, 11.4),
|
||||
conversion30d: rate(10, 50, 20),
|
||||
repeatPurchaseRate: rate(2, 10, 20),
|
||||
purchaseFunnel: [
|
||||
{ label: "浏览购买页", count: 30 },
|
||||
{ label: "发起购买", count: 15 },
|
||||
{ label: "StoreKit 验证完成", count: 10 },
|
||||
],
|
||||
cancelledUsers: 4,
|
||||
},
|
||||
growthFunnel: [
|
||||
{ label: "首次启动", count: 100 },
|
||||
{ label: "24 小时内首次 AI 成功", count: 60 },
|
||||
],
|
||||
retention: [
|
||||
{
|
||||
cohortDate: "2026-08-01",
|
||||
size: 20,
|
||||
d1: rate(10, 20, 50),
|
||||
d7: rate(6, 20, 30),
|
||||
},
|
||||
],
|
||||
aiFeatures: [
|
||||
{ feature: "POLISH", executionMode: "MANAGED", users: 30, successes: 100 },
|
||||
],
|
||||
keyboardUsage: {
|
||||
activeUsers: 40,
|
||||
activationToInput: rate(40, 50, 80),
|
||||
chineseActiveUsers: 30,
|
||||
englishActiveUsers: 20,
|
||||
bilingualActiveUsers: 10,
|
||||
totalCharacters: 12_000,
|
||||
chineseCharacters: 7_000,
|
||||
englishCharacters: 4_000,
|
||||
otherCharacters: 1_000,
|
||||
chineseSharePercent: 63.6,
|
||||
englishSharePercent: 36.4,
|
||||
inputSessions: 200,
|
||||
averageCharactersPerInputSession: 60,
|
||||
chineseOnlySessions: 100,
|
||||
englishOnlySessions: 60,
|
||||
mixedLanguageSessions: 30,
|
||||
otherOnlySessions: 10,
|
||||
},
|
||||
referralSignals: { shared: 20, opened: 15 },
|
||||
referralFunnel: [
|
||||
{ label: "完成绑定", count: 10 },
|
||||
{ label: "绑定后首次 AI 成功", count: 8 },
|
||||
{ label: "完成奖励", count: 5 },
|
||||
],
|
||||
guardrails: {
|
||||
clientAiSuccessRate: rate(90, 100, 90),
|
||||
managedSuccessRate: rate(95, 100, 95),
|
||||
creditBlockedUsers: 3,
|
||||
latencyBuckets: [{ bucket: "S1_TO_3", successful: 80, failed: 5 }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,110 @@
|
||||
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 } from "../api/client";
|
||||
import type { ManagedProviderOverview } from "../api/types";
|
||||
import { App } from "../app";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
describe("Provider 配置", () => {
|
||||
it("默认仅展示配置状态,不会回显现有 API Key", async () => {
|
||||
mockSession();
|
||||
mockProviders();
|
||||
window.location.hash = "#/providers";
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Provider 配置" })).toBeTruthy();
|
||||
expect(screen.getByText("DeepSeek")).toBeTruthy();
|
||||
expect(screen.getByText("火山引擎")).toBeTruthy();
|
||||
expect(screen.getByText("管理后台")).toBeTruthy();
|
||||
expect(screen.getByText("环境变量")).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain("existing-secret");
|
||||
});
|
||||
|
||||
it("通过动态验证码显示当前 Key,并可再次点击眼睛隐藏", async () => {
|
||||
mockSession();
|
||||
mockProviders();
|
||||
const reveal = vi.spyOn(adminApi, "revealProviderApiKey").mockResolvedValue({
|
||||
apiKey: "existing-secret",
|
||||
});
|
||||
window.location.hash = "#/providers";
|
||||
render(<App />);
|
||||
|
||||
const currentKey = await screen.findByLabelText("DeepSeek 当前 API Key");
|
||||
expect(currentKey).toHaveProperty("value", "••••••••••••••••");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "显示 DeepSeek 当前 API Key" }),
|
||||
);
|
||||
await userEvent.type(screen.getByLabelText("动态验证码"), "123456");
|
||||
await userEvent.click(screen.getByRole("button", { name: "验证并显示" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reveal).toHaveBeenCalledWith("deepseek", { totpCode: "123456" }),
|
||||
);
|
||||
await waitFor(() => expect(currentKey).toHaveProperty("value", "existing-secret"));
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "隐藏 DeepSeek 当前 API Key" }),
|
||||
);
|
||||
expect(currentKey).toHaveProperty("value", "••••••••••••••••");
|
||||
});
|
||||
|
||||
it("可独立替换 DeepSeek API Key,并在成功后清空输入", async () => {
|
||||
mockSession();
|
||||
mockProviders();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const update = vi.spyOn(adminApi, "updateProviderApiKey").mockResolvedValue({
|
||||
providerId: "deepseek",
|
||||
configured: true,
|
||||
source: "RUNTIME_OVERRIDE",
|
||||
updatedAt: "2026-08-22T08:00:00Z",
|
||||
});
|
||||
window.location.hash = "#/providers";
|
||||
render(<App />);
|
||||
|
||||
const input = await screen.findByLabelText("DeepSeek 新 API Key");
|
||||
await userEvent.type(input, "new-deepseek-key");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "替换 DeepSeek API Key" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith("deepseek", { apiKey: "new-deepseek-key" }),
|
||||
);
|
||||
await waitFor(() => expect(input).toHaveProperty("value", ""));
|
||||
});
|
||||
});
|
||||
|
||||
function mockSession() {
|
||||
vi.spyOn(adminApi, "session").mockResolvedValue({
|
||||
authenticated: true,
|
||||
operatorName: "owner",
|
||||
role: "SUPER_ADMIN",
|
||||
});
|
||||
}
|
||||
|
||||
function mockProviders() {
|
||||
vi.spyOn(adminApi, "providers").mockResolvedValue(providerOverview());
|
||||
}
|
||||
|
||||
function providerOverview(): ManagedProviderOverview {
|
||||
return [
|
||||
{
|
||||
providerId: "deepseek",
|
||||
configured: true,
|
||||
source: "RUNTIME_OVERRIDE",
|
||||
updatedAt: "2026-08-22T07:30:00Z",
|
||||
},
|
||||
{
|
||||
providerId: "volcengine",
|
||||
configured: true,
|
||||
source: "ENVIRONMENT",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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 { CohortHeatmap } from "../components/charts/cohort-heatmap";
|
||||
import { ComparisonBarChart } from "../components/charts/comparison-bar-chart";
|
||||
import { FunnelChart } from "../components/charts/funnel-chart";
|
||||
import { RadialMetric } from "../components/charts/radial-metric";
|
||||
import { TrendChart } from "../components/charts/trend-chart";
|
||||
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("比较、漏斗、留存与环形图保持可访问且不生成内联样式", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<ComparisonBarChart
|
||||
items={[
|
||||
{ label: "自然量", value: 100, secondaryValue: 60 },
|
||||
{ label: "样本不足", value: null },
|
||||
]}
|
||||
primaryLabel="新增安装"
|
||||
secondaryLabel="24 小时激活"
|
||||
/>
|
||||
<FunnelChart steps={[{ label: "首次启动", count: 100 }]} />
|
||||
<CohortHeatmap
|
||||
cohorts={[
|
||||
{
|
||||
cohortDate: "2026-08-01",
|
||||
size: 20,
|
||||
d1: { numerator: 10, denominator: 20, percent: 50 },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RadialMetric label="用户活跃率" percent={50} />
|
||||
<RadialMetric label="无样本活跃率" percent={null} />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("progressbar", { name: "自然量 新增安装:100" })).toBeTruthy();
|
||||
expect(screen.getByRole("progressbar", { name: /首次启动:100/ })).toBeTruthy();
|
||||
expect(screen.getByText("D1、D7、D30 价值留存 cohort 热力图")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "用户活跃率:50.0%" })).toBeTruthy();
|
||||
expect(screen.getByRole("progressbar", { name: "样本不足 新增安装:暂无数据" })).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "无样本活跃率:暂无数据" })).toBeTruthy();
|
||||
expect(container.querySelector("[style]")).toBeNull();
|
||||
});
|
||||
|
||||
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",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/admin/",
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/v1/admin": {
|
||||
@@ -12,6 +15,21 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
target: "es2022",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (
|
||||
id.includes("/node_modules/react/") ||
|
||||
id.includes("/node_modules/react-dom/") ||
|
||||
id.includes("/node_modules/react-router")
|
||||
) {
|
||||
return "react-vendor";
|
||||
}
|
||||
if (id.includes("/node_modules/@base-ui/")) return "base-ui";
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
|
||||
@@ -134,6 +134,7 @@ dependencies {
|
||||
|
||||
implementation("com.auth0:java-jwt:4.6.0")
|
||||
implementation("com.nimbusds:nimbus-jose-jwt:10.9.1")
|
||||
implementation("com.apple.itunes.storekit:app-store-server-library:5.2.0")
|
||||
implementation("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
|
||||
implementation("com.upokecenter:cbor:4.5.6")
|
||||
|
||||
+15
-2
@@ -26,12 +26,14 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET:?set a random JWT secret}
|
||||
ACCESS_TOKEN_MINUTES: ${ACCESS_TOKEN_MINUTES:-15}
|
||||
REFRESH_TOKEN_DAYS: ${REFRESH_TOKEN_DAYS:-30}
|
||||
LEGACY_REFRESH_REPLAY_SECONDS: ${LEGACY_REFRESH_REPLAY_SECONDS:-30}
|
||||
GATEWAY_GRANT_DAYS: ${GATEWAY_GRANT_DAYS:-30}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY:?set a 32-byte Base64 key}
|
||||
IDENTITY_HMAC_KEY: ${IDENTITY_HMAC_KEY:?set a distinct Base64 key}
|
||||
IDENTITY_TOMBSTONE_RETENTION_DAYS: ${IDENTITY_TOMBSTONE_RETENTION_DAYS:-365}
|
||||
|
||||
ADMIN_ENABLED: ${ADMIN_ENABLED:-false}
|
||||
ADMIN_MTLS_REQUIRED: ${ADMIN_MTLS_REQUIRED:-true}
|
||||
ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-false}
|
||||
ADMIN_BOOTSTRAP_OPERATOR_ID: ${ADMIN_BOOTSTRAP_OPERATOR_ID:-}
|
||||
ADMIN_BOOTSTRAP_USERNAME: ${ADMIN_BOOTSTRAP_USERNAME:-}
|
||||
@@ -40,12 +42,18 @@ services:
|
||||
ADMIN_SESSION_HOURS: ${ADMIN_SESSION_HOURS:-8}
|
||||
ADMIN_MAXIMUM_MANUAL_GRANT: ${ADMIN_MAXIMUM_MANUAL_GRANT:-100000}
|
||||
|
||||
# The legacy key.osglab.com service remains independent and unchanged.
|
||||
HINT_FEED_ENABLED: ${HINT_FEED_ENABLED:-false}
|
||||
HINT_FEED_ZONE_ID: ${HINT_FEED_ZONE_ID:-UTC}
|
||||
TOPHUB_API_KEY: ${TOPHUB_API_KEY:-}
|
||||
|
||||
APPLE_TEAM_ID: ${APPLE_TEAM_ID:?set Apple team ID}
|
||||
APPLE_KEY_ID: ${APPLE_KEY_ID:?set Apple key ID}
|
||||
APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-com.osgkeyboard.ios}
|
||||
APPLE_PRIVATE_KEY_PEM: ${APPLE_PRIVATE_KEY_PEM:?set Apple private key PEM}
|
||||
APPLE_INTEGRITY_ENVIRONMENT: production
|
||||
APP_ATTEST_CHALLENGE_TTL_SECONDS: ${APP_ATTEST_CHALLENGE_TTL_SECONDS:-300}
|
||||
ALLOW_DEVELOPMENT_APP_ATTEST: ${ALLOW_DEVELOPMENT_APP_ATTEST:-false}
|
||||
ENFORCE_DEVICE_CHECK: "true"
|
||||
ENFORCE_APP_ATTEST: "true"
|
||||
|
||||
@@ -54,12 +62,17 @@ services:
|
||||
VOLCENGINE_ASR_ENDPOINT: ${VOLCENGINE_ASR_ENDPOINT:-wss://openspeech.bytedance.com/api/v3/sauc/bigmodel}
|
||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?set DeepSeek API key}
|
||||
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash}
|
||||
DEEPSEEK_REASONING_MODEL: ${DEEPSEEK_REASONING_MODEL:-}
|
||||
DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1}
|
||||
|
||||
SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000}
|
||||
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-3000}
|
||||
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-3000}
|
||||
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-1000}
|
||||
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-1000}
|
||||
REFERRAL_BINDING_DAYS: ${REFERRAL_BINDING_DAYS:-7}
|
||||
STOREKIT_ENABLED: ${STOREKIT_ENABLED:-true}
|
||||
STOREKIT_BUNDLE_ID: ${STOREKIT_BUNDLE_ID:-com.osgkeyboard.ios}
|
||||
STOREKIT_APP_APPLE_ID: ${STOREKIT_APP_APPLE_ID:-6781553267}
|
||||
STOREKIT_PRODUCTS: ${STOREKIT_PRODUCTS:-500tks:500,1500tks:1500,3000tks:3000}
|
||||
ports:
|
||||
- "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080"
|
||||
read_only: true
|
||||
|
||||
+24
-6
@@ -2,8 +2,21 @@
|
||||
|
||||
`account.osglab.com` 在同一个 TLS `server` 中同时承载移动端 API 和管理端。
|
||||
由于 TLS 握手发生在 HTTP 路径匹配之前,配置必须使用 server 级
|
||||
`ssl_verify_client optional`:普通客户端不提供证书时仍可正常访问,只有
|
||||
`/admin`、`/admin/`、`/v1/admin` 和其子路径要求验证成功。
|
||||
`ssl_verify_client optional`:普通客户端不提供证书时仍可正常访问。OpenResty 会把实际
|
||||
证书验证结果传给 Ktor;`ADMIN_MTLS_REQUIRED=true`(默认值)时,`/admin`、`/admin/`、
|
||||
`/v1/admin` 和其子路径要求验证成功。
|
||||
|
||||
## 临时关闭
|
||||
|
||||
在 1Panel/Compose 环境中显式设置并重启应用:
|
||||
|
||||
```text
|
||||
ADMIN_MTLS_REQUIRED=false
|
||||
```
|
||||
|
||||
关闭后,管理端无需客户端证书,但登录仍要求用户名、密码和 TOTP,其他会话、CSRF、RBAC、
|
||||
失败锁定、限流与审计规则保持不变。恢复时将该值改回 `true` 并重启应用。不要为了临时关闭
|
||||
而删除客户端 CA、证书或轮换记录。
|
||||
|
||||
## CA 与证书
|
||||
|
||||
@@ -22,14 +35,15 @@
|
||||
|
||||
## 上游信任边界
|
||||
|
||||
OpenResty 仅在管理路径且 `$ssl_client_verify = SUCCESS` 时向 Ktor 设置固定头:
|
||||
OpenResty 在管理路径用 `$ssl_client_verify` 覆盖并转发证书验证结果。有效证书对应:
|
||||
|
||||
```text
|
||||
X-OSG-mTLS-Verified: SUCCESS
|
||||
```
|
||||
|
||||
客户端传入的同名头会被覆盖;其他路径会删除该头。Ktor 只能把这个头作为“边缘已验证”
|
||||
信号,不能信任客户端提供的证书相关头,也不能用 DN、CN 或证书正文做隐式授权。
|
||||
无证书时该值为 `NONE`。客户端传入的同名头会被覆盖;其他路径会删除该头。Ktor 只能把
|
||||
OpenResty 写入的这个头作为“边缘已验证”信号,不能信任客户端提供的证书相关头,也不能用
|
||||
DN、CN 或证书正文做隐式授权。
|
||||
后端端口必须继续只监听 `127.0.0.1:18080`,否则攻击者可绕过边缘伪造该头。
|
||||
mTLS 只证明客户端持有受信证书,管理接口仍应执行应用层身份认证、授权和审计。
|
||||
|
||||
@@ -52,7 +66,7 @@ ADMIN_BOOTSTRAP_ENABLED=true
|
||||
|
||||
## 验证
|
||||
|
||||
将测试域名解析到目标边缘后执行:
|
||||
`ADMIN_MTLS_REQUIRED=true` 时,将测试域名解析到目标边缘后执行:
|
||||
|
||||
```sh
|
||||
# 无证书:管理路径必须是 404。
|
||||
@@ -74,6 +88,10 @@ curl -i -H 'X-OSG-mTLS-Verified: SUCCESS' \
|
||||
还应使用由非管理 CA 签发或已过期的客户端证书确认返回 404,并在 Ktor 测试端点确认:
|
||||
管理请求只收到固定值 `SUCCESS`,普通 API 不收到 `X-OSG-mTLS-Verified`。
|
||||
|
||||
`ADMIN_MTLS_REQUIRED=false` 时,无证书访问 `/admin/` 应返回管理页面,
|
||||
`/v1/admin/auth/session` 应返回匿名会话状态。伪造 `X-OSG-mTLS-Verified: SUCCESS` 不会
|
||||
改变结果,因为 OpenResty 会将其覆盖为实际验证状态。
|
||||
|
||||
部署后可在受信设备运行不含登录凭据的自动验收:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -52,13 +52,10 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Administrative endpoints are indistinguishable from missing routes unless
|
||||
# OpenResty verified a certificate issued by the dedicated admin client CA.
|
||||
# Always forward administrative paths to Ktor. Ktor decides whether mTLS is
|
||||
# required from ADMIN_MTLS_REQUIRED, while this edge overwrites the trust
|
||||
# signal so clients cannot spoof successful certificate verification.
|
||||
location ~ ^/(?:admin|v1/admin)(?:/|$) {
|
||||
if ($ssl_client_verify != SUCCESS) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
client_max_body_size 32k;
|
||||
limit_req zone=account_api burst=40 nodelay;
|
||||
# Defining a location-level header disables inheritance from the server
|
||||
@@ -76,8 +73,7 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Request-ID $request_id;
|
||||
# Overwrite any client-supplied value; Ktor must trust only this header.
|
||||
proxy_set_header X-OSG-mTLS-Verified "SUCCESS";
|
||||
proxy_set_header X-OSG-mTLS-Verified $ssl_client_verify;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_hide_header Server;
|
||||
|
||||
+48
-4
@@ -258,8 +258,15 @@ grant_pattern = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
expected = set()
|
||||
for raw_line in open(sys.argv[1], encoding="utf-8"):
|
||||
match = grant_pattern.match(raw_line.strip())
|
||||
with open(sys.argv[1], encoding="utf-8") as grants_file:
|
||||
statements = grants_file.read().split(";")
|
||||
for statement in statements:
|
||||
normalized = " ".join(
|
||||
line.strip()
|
||||
for line in statement.splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("--")
|
||||
)
|
||||
match = grant_pattern.match(f"{normalized};")
|
||||
if match:
|
||||
for privilege in match.group(1).split(","):
|
||||
expected.add((match.group(2).lower(), privilege.strip().upper()))
|
||||
@@ -319,6 +326,12 @@ verify_immutable_history_denials() {
|
||||
expect_runtime_denied \
|
||||
"admin grant DELETE" \
|
||||
"DELETE FROM admin_credit_grants WHERE 1 = 0"
|
||||
expect_runtime_denied \
|
||||
"StoreKit purchase UPDATE" \
|
||||
"UPDATE storekit_credit_purchases SET credits_granted = credits_granted WHERE 1 = 0"
|
||||
expect_runtime_denied \
|
||||
"StoreKit purchase DELETE" \
|
||||
"DELETE FROM storekit_credit_purchases WHERE 1 = 0"
|
||||
expect_runtime_denied \
|
||||
"Flyway metadata read" \
|
||||
"SELECT version FROM flyway_schema_history LIMIT 1"
|
||||
@@ -464,9 +477,40 @@ WHERE version IS NOT NULL
|
||||
ORDER BY installed_rank;
|
||||
SQL
|
||||
)"
|
||||
EXPECTED_MIGRATIONS=$'1:1\n2:1\n3:1\n4:1\n5:1\n6:1\n7:1\n8:1'
|
||||
EXPECTED_MIGRATIONS="$(seq 1 28 | awk '{ print $1 ":1" }')"
|
||||
[[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] ||
|
||||
fail "Flyway history was not exactly successful V1-V8"
|
||||
fail "Flyway history was not exactly successful V1-V28"
|
||||
REFERRAL_REWARDS="$(
|
||||
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
|
||||
SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits)
|
||||
FROM referral_campaigns
|
||||
WHERE id = '00000000-0000-0000-0000-000000000001';
|
||||
SQL
|
||||
)"
|
||||
[[ "$REFERRAL_REWARDS" == "1000:1000" ]] ||
|
||||
fail "default referral rewards were not 1000 credits for both accounts"
|
||||
ACTIVE_RATES="$(
|
||||
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
|
||||
SELECT CONCAT_WS(
|
||||
':',
|
||||
kind,
|
||||
provider,
|
||||
model,
|
||||
COALESCE(asr_credits_numerator, '-'),
|
||||
COALESCE(asr_millis_denominator, '-'),
|
||||
COALESCE(input_credits_numerator, '-'),
|
||||
COALESCE(input_tokens_denominator, '-'),
|
||||
COALESCE(output_credits_numerator, '-'),
|
||||
COALESCE(output_tokens_denominator, '-')
|
||||
)
|
||||
FROM credit_rate_versions
|
||||
WHERE effective_until IS NULL
|
||||
ORDER BY kind, provider, model;
|
||||
SQL
|
||||
)"
|
||||
EXPECTED_ACTIVE_RATES=$'ASR:volcengine-sauc-v3:volc.seedasr.sauc.duration:1:3000:-:-:-:-\nLLM:deepseek:deepseek-v4-flash:-:-:1:1000:1:400'
|
||||
[[ "$ACTIVE_RATES" == "$EXPECTED_ACTIVE_RATES" ]] ||
|
||||
fail "active smaller credit rates did not match the V10 contract"
|
||||
compose --profile setup stop schema-migrator >/dev/null
|
||||
|
||||
log "installing exact runtime grants and disposable fixture"
|
||||
|
||||
@@ -9,6 +9,7 @@ GRANT SELECT ON osg_account_smoke.credit_reservations TO 'osg_smoke_runtime'@'%'
|
||||
GRANT SELECT ON osg_account_smoke.referral_campaigns TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.referral_campaign_budgets TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.referral_codes TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.referral_owner_codes TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.referral_bindings TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.credit_usage_records TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.credit_ledger TO 'osg_smoke_runtime'@'%';
|
||||
@@ -17,15 +18,34 @@ GRANT SELECT ON osg_account_smoke.usage_records TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.gateway_grants TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.gateway_grant_scopes TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.gateway_complimentary_requests TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.oobe_subjects TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.oobe_gateway_grants TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.oobe_gateway_refresh_tokens TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.oobe_gateway_claims TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.oobe_provider_requests TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.account_profiles TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.gateway_provider_credentials TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.product_analytics_installations TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.product_analytics_events TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.product_analytics_daily_counters TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.keyboard_usage_daily_summaries TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.official_content_catalog TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.official_skills TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.official_skill_localizations TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.official_hint_packs TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.hint_feed_settings TO 'osg_smoke_runtime'@'%';
|
||||
GRANT SELECT ON osg_account_smoke.hint_feed_generation_state 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'@'%';
|
||||
@@ -35,6 +55,7 @@ GRANT INSERT, UPDATE ON osg_account_smoke.credit_accounts TO 'osg_smoke_runtime'
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.credit_reservations TO 'osg_smoke_runtime'@'%';
|
||||
GRANT UPDATE ON osg_account_smoke.referral_campaign_budgets TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.referral_codes TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.referral_owner_codes TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.referral_bindings TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.credit_usage_records TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.credit_ledger TO 'osg_smoke_runtime'@'%';
|
||||
@@ -43,12 +64,35 @@ GRANT INSERT ON osg_account_smoke.usage_records TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.gateway_grants TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.gateway_grant_scopes TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.gateway_refresh_tokens TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.gateway_complimentary_requests
|
||||
TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.oobe_subjects TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.oobe_gateway_grants TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.oobe_gateway_refresh_tokens TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.oobe_gateway_claims TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.oobe_provider_requests TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.devicecheck_trial_claims TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_challenges 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.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.account_profiles TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.gateway_provider_credentials TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.product_analytics_installations
|
||||
TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT ON osg_account_smoke.product_analytics_events TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.product_analytics_daily_counters
|
||||
TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, DELETE ON osg_account_smoke.keyboard_usage_daily_summaries
|
||||
TO 'osg_smoke_runtime'@'%';
|
||||
GRANT UPDATE ON osg_account_smoke.official_content_catalog TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.official_skills TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.official_skill_localizations TO 'osg_smoke_runtime'@'%';
|
||||
GRANT INSERT, UPDATE ON osg_account_smoke.official_hint_packs TO 'osg_smoke_runtime'@'%';
|
||||
GRANT UPDATE ON osg_account_smoke.hint_feed_settings TO 'osg_smoke_runtime'@'%';
|
||||
GRANT UPDATE ON osg_account_smoke.hint_feed_generation_state TO 'osg_smoke_runtime'@'%';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
- Product analytics installations linked to the account and all of their events
|
||||
are deleted by database cascade. The service stores only the digest of a
|
||||
random installation UUID and never stores user content in analytics events.
|
||||
- Keyboard usage contains daily Chinese, English, other-character and
|
||||
input-session counters only. It never contains text or keystrokes and is
|
||||
purged after 90 days regardless of account linkage.
|
||||
- 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,251 @@
|
||||
# OSGKeyboard product analytics metrics dictionary
|
||||
|
||||
This document is the canonical definition of product metrics. All dates and
|
||||
cohorts use UTC calendar boundaries. Counts are based on distinct accounts when
|
||||
an installation is linked, otherwise on the pseudonymous installation.
|
||||
|
||||
Admin presets cover exactly 7, 30, or 90 UTC calendar dates, starting at 00:00
|
||||
on the first date and ending at the current instant. The current UTC date is
|
||||
therefore explicitly partial.
|
||||
|
||||
## North-star metric
|
||||
|
||||
### Weekly AI active users (WAIU)
|
||||
|
||||
The number of distinct users that successfully complete at least one AI feature
|
||||
during a UTC calendar week.
|
||||
|
||||
- Managed AI and ASR use server-settled `credit_usage_records`.
|
||||
- Local and BYOK use accepted `AI_FEATURE_SUCCEEDED` client events.
|
||||
- Managed client success events provide feature breakdowns but are not added to
|
||||
the server-settled total, preventing double counting.
|
||||
- Week-over-week growth is `(current WAIU - previous WAIU) / previous WAIU`.
|
||||
A missing previous population is reported without a percentage.
|
||||
|
||||
## Growth and activation
|
||||
|
||||
### New installations
|
||||
|
||||
Distinct installations whose first accepted `FIRST_OPEN` event occurred in the
|
||||
selected period. Acquisition channel is fixed by the first non-`UNKNOWN`
|
||||
channel observed for the installation.
|
||||
|
||||
Allowed channels:
|
||||
|
||||
- `APP_STORE_ORGANIC`
|
||||
- `REFERRAL`
|
||||
- `SOCIAL_CONTENT`
|
||||
- `UNKNOWN`
|
||||
|
||||
### New accounts
|
||||
|
||||
Accounts whose `accounts.created_at` falls in the selected period.
|
||||
|
||||
### 24-hour AI activation rate
|
||||
|
||||
The percentage of new installations that have completed their full 24-hour
|
||||
observation window and successfully complete any AI feature within 24 hours of
|
||||
their first open. Unmatured installations are excluded from both numerator and
|
||||
denominator. Managed usage before that installation's first open is ignored.
|
||||
|
||||
### Time to first value
|
||||
|
||||
Elapsed time from `FIRST_OPEN` to the first successful AI feature. The dashboard
|
||||
reports the median in minutes. Users without a successful AI feature are not
|
||||
included in the median and remain visible in the activation denominator.
|
||||
|
||||
### 24-hour growth funnel
|
||||
|
||||
A strict cohort of installations with a completed 24-hour observation window:
|
||||
first open, account registration after first open, first AI value event after
|
||||
registration, and first server-verified purchase after that value event. Every
|
||||
downstream step must occur within 24 hours of first open. D7 belongs only to the
|
||||
retention report and is not mixed into this funnel.
|
||||
|
||||
## Activity
|
||||
|
||||
### AI DAU, WAU and MAU
|
||||
|
||||
Distinct value-active users in the last 1, 7 and 30 UTC days ending at the
|
||||
report's `until` timestamp.
|
||||
|
||||
### DAU/MAU stickiness
|
||||
|
||||
`AI DAU / AI MAU`. The value is null when MAU is zero.
|
||||
|
||||
### Successful AI requests
|
||||
|
||||
The sum of settled managed requests and successful local/BYOK client events.
|
||||
Managed client success events are excluded from this total.
|
||||
|
||||
### Successful AI requests per active user
|
||||
|
||||
`successful AI requests / distinct value-active users` for the selected period.
|
||||
|
||||
### Registered product-active users
|
||||
|
||||
The operations overview counts distinct registered accounts with either a
|
||||
successful AI value event (managed, local, or BYOK) or a finalized manual
|
||||
keyboard-input summary in the selected period. The displayed rate divides this
|
||||
population by all registered accounts.
|
||||
|
||||
## Keyboard input usage
|
||||
|
||||
Keyboard input metrics use finalized UTC-day summaries produced on-device.
|
||||
They describe manually committed OSGKeyboard text only and are independent
|
||||
from AI value events, billing and referral qualification.
|
||||
|
||||
### Keyboard input active users
|
||||
|
||||
Distinct account identities, falling back to pseudonymous installations, with
|
||||
at least one accepted keyboard usage summary in the selected UTC-date window.
|
||||
|
||||
### Activation-to-input conversion
|
||||
|
||||
Keyboard input active users divided by distinct identities with either a
|
||||
`KEYBOARD_ACTIVATED` event or an accepted keyboard usage summary in the same
|
||||
UTC-date window. Including summary-only identities prevents missing activation
|
||||
telemetry from producing rates above 100%.
|
||||
|
||||
### Chinese, English and bilingual active users
|
||||
|
||||
- Chinese active: at least one committed Han-script character.
|
||||
- English active: at least one committed Latin letter.
|
||||
- Bilingual active: both Chinese and English counts are non-zero.
|
||||
|
||||
These populations overlap and must not be summed.
|
||||
|
||||
### Character volume and language share
|
||||
|
||||
Character volume is the sum of client-classified Chinese, English and other
|
||||
committed characters. Chinese and English share use only classified language
|
||||
characters as the denominator:
|
||||
|
||||
- Chinese share: `Chinese / (Chinese + English)`.
|
||||
- English share: `English / (Chinese + English)`.
|
||||
|
||||
Both shares are unavailable when the denominator is zero. Other characters
|
||||
remain visible in total volume but do not dilute the language split.
|
||||
|
||||
### Input sessions
|
||||
|
||||
An input session is a keyboard activation containing at least one manually
|
||||
committed character. Chinese-only, English-only, mixed-language and other-only
|
||||
session counts form a complete partition. Average characters per input session
|
||||
is `total committed characters / input sessions`.
|
||||
|
||||
## Retention
|
||||
|
||||
The cohort date is the UTC date of a user's first successful AI feature.
|
||||
Retention is value retention, not application-open retention.
|
||||
|
||||
- `D1`: active on cohort date + 1 day.
|
||||
- `D7`: active on cohort date + 7 days.
|
||||
- `D30`: active on cohort date + 30 days.
|
||||
|
||||
Each retention rate uses the original cohort size as denominator. A day that has
|
||||
not fully elapsed at the report's `until` timestamp is returned as unavailable,
|
||||
not zero. Channel and first-feature breakdowns are optional dimensions and must
|
||||
not alter the base cohort definition.
|
||||
|
||||
## AI feature usage
|
||||
|
||||
Allowed feature types:
|
||||
|
||||
- `TRANSCRIPTION`
|
||||
- `POLISH`
|
||||
- `AI_ASSISTANT`
|
||||
- `AGENT`
|
||||
- `HOTWORD`
|
||||
- `OTHER`
|
||||
|
||||
Allowed execution modes:
|
||||
|
||||
- `MANAGED`
|
||||
- `LOCAL`
|
||||
- `BYOK`
|
||||
|
||||
Feature distributions use accepted client events because server billing only
|
||||
distinguishes `ASR` and `LLM`. Server-settled aggregates remain authoritative
|
||||
for managed totals, credits, token counts and ASR duration.
|
||||
|
||||
## Credit consumption
|
||||
|
||||
### Daily total credit consumption
|
||||
|
||||
The sum of non-negative `credit_usage_records.charged_credits` by UTC date.
|
||||
|
||||
### Average daily credits per AI active user
|
||||
|
||||
For each UTC date, divide total settled credits by distinct managed AI users,
|
||||
then average those daily values across days containing at least one active user.
|
||||
|
||||
### Median user-day credits
|
||||
|
||||
The median of per-account daily settled credits. This is shown beside the mean
|
||||
to prevent a small number of heavy users from distorting typical consumption.
|
||||
|
||||
### Average credits per managed request
|
||||
|
||||
`settled credits / settled managed requests`. Local and BYOK events consume no
|
||||
server credits and are excluded.
|
||||
|
||||
## Monetization
|
||||
|
||||
### 7-day and 30-day free-to-paid conversion
|
||||
|
||||
The percentage of newly registered accounts with a first credited StoreKit
|
||||
purchase no later than 7 or 30 days after registration. The selected report
|
||||
period filters when each observation window matures: a 7-day report cohort uses
|
||||
registrations shifted exactly 7 days earlier, and the 30-day cohort is shifted
|
||||
30 days earlier. This keeps every denominator fully observed and makes the rate
|
||||
available even when the selected preset is no longer than the conversion window.
|
||||
|
||||
### Paying users
|
||||
|
||||
Distinct accounts with at least one credited StoreKit purchase in the period.
|
||||
|
||||
### Repeat purchase rate
|
||||
|
||||
The percentage of paying accounts with at least two credited StoreKit purchases
|
||||
across their lifetime.
|
||||
|
||||
### Purchase intent funnel
|
||||
|
||||
A strict installation cohort: `PURCHASE_VIEWED`, followed by
|
||||
`PURCHASE_STARTED`, followed by a StoreKit purchase verified by the server for
|
||||
the linked account. Each event must occur after the previous step and before the
|
||||
report's `until`. `PURCHASE_CANCELLED` is a separate signal, not a funnel step.
|
||||
|
||||
StoreKit transaction count and granted credits are operational proxies. Net
|
||||
revenue, App Store commission and refunds require App Store financial data and
|
||||
are outside this service's first version.
|
||||
|
||||
## Referral funnel
|
||||
|
||||
The ordered cohort contains bindings created in the selected period:
|
||||
|
||||
1. Referral binding created.
|
||||
2. The same invitee reaches an AI value event after binding.
|
||||
3. The same binding is rewarded before the report's `until`.
|
||||
|
||||
`REFERRAL_SHARED` distinct installations and invitation opens are independent
|
||||
directional signals. Invitation opens combine accepted `INVITE_OPENED` events
|
||||
with anonymous first-party page-view counters, so they are not people and must
|
||||
never be placed in the ordered conversion funnel.
|
||||
|
||||
Pending and ineligible bindings are parallel status counts, not sequential
|
||||
funnel steps.
|
||||
|
||||
## Experience guardrails
|
||||
|
||||
- AI success rate: successful client AI completions divided by starts with a
|
||||
terminal success or failure event.
|
||||
- Managed request failure rate: terminal non-settled `provider_requests` divided
|
||||
by terminal managed requests.
|
||||
- Client latency: successful and failed terminal events grouped by declared
|
||||
duration bucket. Exact P50/P95 values are not inferred from buckets.
|
||||
- Credit-blocked users: distinct installations reporting
|
||||
`INSUFFICIENT_CREDITS` during the period.
|
||||
|
||||
Guardrails are diagnostic and never count as value-active events.
|
||||
@@ -26,6 +26,10 @@ It does not replace the app's local ASR, BYOK provider access, or iCloud sync.
|
||||
- `integrity` evaluates DeviceCheck and App Attest evidence.
|
||||
- `gateway` proxies Volcengine ASR and DeepSeek text requests.
|
||||
- `inviteweb` serves the first-party invitation landing page.
|
||||
- `analytics` accepts a strict allowlist of product event metadata and links a
|
||||
pseudonymous installation to an account only when a valid session is present.
|
||||
- `admin/stats` combines accepted product events with authoritative billing,
|
||||
StoreKit, account, and referral aggregates for the internal dashboard.
|
||||
|
||||
Modules communicate through narrow ports. Provider clients, Apple clients,
|
||||
integrity clients, clocks, token generators, and repositories are replaceable in
|
||||
@@ -60,7 +64,10 @@ tests.
|
||||
claims required for replay protection.
|
||||
- Usage metadata contains feature, model, metering units, latency, status, and
|
||||
rate-card version.
|
||||
- Product events contain enums, release versions, timestamps, and duration
|
||||
buckets only. Installation UUIDs are stored as SHA-256 digests.
|
||||
- Operational logs use request IDs and internal opaque IDs, never Apple subjects
|
||||
or bearer credentials.
|
||||
- Account deletion revokes credentials and removes user-linked records. Only
|
||||
non-identifying aggregate service metrics may remain.
|
||||
- Account deletion revokes credentials and cascades through linked product
|
||||
installations and events. Only non-identifying aggregate service metrics may
|
||||
remain.
|
||||
|
||||
+11
-1
@@ -97,6 +97,10 @@ Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 `.p8
|
||||
v3 WSS endpoint、资源 ID 和 API Key;DeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。
|
||||
三方凭据分别创建、分别轮换,不得复用。
|
||||
|
||||
生产默认仅接受 TestFlight 与 App Store 构建的 production App Attest。确需让已登记真机上的
|
||||
Xcode Development 构建连接生产服务时,可临时设置
|
||||
`ALLOW_DEVELOPMENT_APP_ATTEST=true`;完成测试后应立即恢复为 `false` 并重启服务。
|
||||
|
||||
## 5. 构建与启动
|
||||
|
||||
GitHub CI 在测试通过后发布私有镜像
|
||||
@@ -132,10 +136,16 @@ docker compose logs --since=10m account-server
|
||||
2. 将三个 `server` 块作为站点配置;按 1Panel 实际证书路径调整 `ssl_certificate`。
|
||||
3. 示例 upstream 指向宿主机 `127.0.0.1:18080`。若 OpenResty 自身在容器中,则将其加入
|
||||
`account-backend`,并改为 `account-server:8080`。
|
||||
4. 配置明确对 `/admin`、`/internal`、`/v1/admin` 返回 404;不要新增绕过该规则的泛域名代理。
|
||||
4. `/internal` 始终返回 404;`/admin` 和 `/v1/admin` 是否要求客户端证书由
|
||||
`ADMIN_MTLS_REQUIRED` 控制,默认值为 `true`。
|
||||
5. API 示例按 IP 限制 20 请求/秒,邀请页限制 5 请求/秒,可基于真实流量谨慎调整。
|
||||
6. 代理统一支持 HTTP/1.1 Upgrade/Connection,因此当前 HTTP API 与后续 WebSocket 入口都可用。
|
||||
|
||||
临时关闭管理端 mTLS 时,在 1Panel/Compose 环境中显式设置
|
||||
`ADMIN_MTLS_REQUIRED=false` 并重启 `account-server`。此时管理端仍要求用户名、密码和 TOTP,
|
||||
且保留登录限流、失败锁定、同源校验、CSRF、会话 Cookie 与审计。恢复时将该值改回 `true`;
|
||||
不要删除客户端 CA、证书或轮换记录。
|
||||
|
||||
两个 AASA 地址由 Ktor 根据 `appleAppId` 模板输出,不需要复制静态文件。配置检查成功后再通过
|
||||
1Panel 重载 OpenResty:
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# AI Hint Feed migration and coexistence
|
||||
|
||||
## Boundary
|
||||
|
||||
`key.osglab.com` remains the legacy AI Hint Feed for installed clients that still
|
||||
use that origin. This repository must not:
|
||||
|
||||
- change the `key.osglab.com` DNS record;
|
||||
- redirect `key.osglab.com` to `account.osglab.com`;
|
||||
- reuse or delete the legacy container, image, settings file, or data volume;
|
||||
- require the legacy service to call this account service.
|
||||
|
||||
The migrated generator runs independently inside OSGAccountServer and publishes:
|
||||
|
||||
- `https://account.osglab.com/v1/content/hints/manifest`
|
||||
- `https://account.osglab.com/v1/content/hints/{locale}`
|
||||
- `https://account.osglab.com/hints/manifest.json`
|
||||
- `https://account.osglab.com/hints/hints-{locale}.json`
|
||||
|
||||
## Safe rollout
|
||||
|
||||
1. Back up the legacy `settings.json`, `manifest.json`, `hints-zh.json`, and
|
||||
`hints-en.json` from its persistent volume.
|
||||
2. Deploy OSGAccountServer with `HINT_FEED_ENABLED=false`.
|
||||
3. Apply Flyway migration `V24__hint_feed_generation.sql` and the matching
|
||||
runtime grants.
|
||||
4. Use the protected admin console to review generation settings and run one
|
||||
manual generation.
|
||||
5. Verify both v1 and legacy paths on `account.osglab.com`, including ETag/304.
|
||||
6. Set `HINT_FEED_ENABLED=true` only after the generated packs are accepted.
|
||||
7. Point only new client releases at `account.osglab.com`. Existing clients may
|
||||
continue to use `key.osglab.com`.
|
||||
|
||||
## Rollback
|
||||
|
||||
Disable `HINT_FEED_ENABLED` to stop scheduled generation. Published packs remain
|
||||
available from MySQL and manual editing/saving remains available. No rollback step
|
||||
depends on or modifies `key.osglab.com`.
|
||||
|
||||
Provider credentials such as `TOPHUB_API_KEY` stay in environment-backed secret
|
||||
storage. They are never written to the generation settings table or returned to
|
||||
the admin browser.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Product analytics
|
||||
|
||||
OSGKeyboard analytics records product behavior metadata only. It is separate
|
||||
from billing, provider execution and immutable credit ledgers.
|
||||
|
||||
## Privacy boundary
|
||||
|
||||
Never send or persist:
|
||||
|
||||
- audio or audio-derived content;
|
||||
- raw keyboard input, keystrokes, prompts, context, transcripts or model output;
|
||||
- Apple subjects, email addresses, names, tokens, API keys or provider
|
||||
credentials;
|
||||
- arbitrary property names or free-form text.
|
||||
|
||||
The service stores a SHA-256 digest of the random installation identifier, not
|
||||
the identifier supplied by the client. Event DTO string representations are
|
||||
redacted. Product analytics rows linked to an account are deleted with that
|
||||
account. Anonymous installations that never link to an account are retained for
|
||||
90 days and are eligible for scheduled deletion in a later operational job.
|
||||
Per-installation keyboard usage summaries are retained for at most 90 days
|
||||
regardless of account linkage.
|
||||
|
||||
## Ingestion API
|
||||
|
||||
`POST /v1/analytics/events` accepts one batch of 1 to 50 events.
|
||||
|
||||
- Authentication is optional so first-open and pre-login events can be
|
||||
measured. Invalid bearer credentials are rejected.
|
||||
- `installationId` must be a client-generated UUID stored in the containing app
|
||||
and shared with the keyboard extension through the App Group. New clients send
|
||||
it once at the batch root. During the migration window, the server also accepts
|
||||
released clients that repeat one identical `installationId` on every event;
|
||||
missing, incomplete, or conflicting identities reject the entire batch.
|
||||
- When a valid account session is present, the installation is linked to that
|
||||
account. An installation cannot later be linked to a different account.
|
||||
- Every `clientEventId` is a client-generated UUID. The pair
|
||||
`(installation, clientEventId)` is unique.
|
||||
- Replaying an identical event is accepted and reported as replayed.
|
||||
- Reusing an event ID with different values returns `409 conflict`.
|
||||
- The whole batch is validated before persistence and committed atomically.
|
||||
- `occurredAt` must be no more than 35 days old and no more than five minutes in
|
||||
the future. UTC ISO-8601 timestamps are required.
|
||||
|
||||
The successful response reports `accepted` and `replayed` event counts. It does
|
||||
not return account or installation identifiers.
|
||||
|
||||
## Keyboard usage summaries
|
||||
|
||||
`POST /v1/analytics/keyboard-usage` accepts 1 to 50 finalized UTC-day
|
||||
summaries. Authentication, installation hashing, account linking, atomic
|
||||
batching and replay semantics match the event ingestion API.
|
||||
|
||||
- Language classification and counting happen on-device.
|
||||
- Only text manually committed by OSGKeyboard is counted. Voice transcripts,
|
||||
pasted text, AI-generated output and text entered with another keyboard are
|
||||
excluded.
|
||||
- Chinese counts Unicode characters classified with the Han script. English
|
||||
counts Latin letters. Digits, punctuation and Emoji are counted as `other`.
|
||||
- Pinyin composition keystrokes are not counted as English; only the final
|
||||
committed text is classified.
|
||||
- An input session is one keyboard activation containing at least one
|
||||
committed character.
|
||||
- Chinese-only sessions contain Chinese but no English; English-only sessions
|
||||
contain English but no Chinese; mixed sessions contain both. Other
|
||||
characters may occur in any of those sessions. Other-only sessions contain
|
||||
neither Chinese nor English.
|
||||
- The four language-session counters must sum to `inputSessionCount`, and the
|
||||
total committed character count must be at least the session count.
|
||||
- Each installation may submit only one immutable summary per UTC date.
|
||||
- Accepted dates are from 35 days ago through yesterday. Current-day partial
|
||||
summaries are rejected.
|
||||
- Raw text, per-keystroke events, surrounding context, host application
|
||||
identifiers and free-form properties are never accepted.
|
||||
|
||||
The client should keep counters and the finalized outbox in the shared App
|
||||
Group so either the containing app or keyboard extension can deliver them.
|
||||
Retries must reuse the stored `clientSummaryId`.
|
||||
|
||||
## Event catalog
|
||||
|
||||
### Lifecycle events
|
||||
|
||||
`FIRST_OPEN`
|
||||
|
||||
- Required: `surface=APP`, `acquisitionChannel`.
|
||||
- Optional: `appVersion`, `osVersion`.
|
||||
- Must be emitted once per installation. Server idempotency protects retries.
|
||||
|
||||
`SESSION_STARTED`
|
||||
|
||||
- Required: `surface` (`APP` or `KEYBOARD`).
|
||||
- Optional: `appVersion`, `osVersion`.
|
||||
|
||||
`KEYBOARD_ACTIVATED`
|
||||
|
||||
- Required: `surface=KEYBOARD`.
|
||||
- Emit when the custom keyboard becomes active, not for every keystroke.
|
||||
|
||||
### AI events
|
||||
|
||||
`AI_FEATURE_STARTED`
|
||||
|
||||
- Required: `feature`, `executionMode`, `surface`.
|
||||
- Optional: `durationBucket` is not allowed.
|
||||
|
||||
`AI_FEATURE_SUCCEEDED`
|
||||
|
||||
- Required: `feature`, `executionMode`, `surface`, `durationBucket`.
|
||||
- This is the client-side core value event.
|
||||
- Managed totals remain server-authoritative; the event provides feature and
|
||||
surface detail.
|
||||
|
||||
`AI_FEATURE_FAILED`
|
||||
|
||||
- Required: `feature`, `executionMode`, `surface`, `failureCategory`.
|
||||
- Optional: `durationBucket`.
|
||||
- Never include provider response bodies or user content.
|
||||
|
||||
### Purchase events
|
||||
|
||||
`PURCHASE_VIEWED`
|
||||
|
||||
- Required: `surface=APP`.
|
||||
|
||||
`PURCHASE_STARTED`
|
||||
|
||||
- Required: `surface=APP`.
|
||||
|
||||
`PURCHASE_CANCELLED`
|
||||
|
||||
- Required: `surface=APP`, `failureCategory=CANCELLED`.
|
||||
|
||||
Successful purchases are derived from verified StoreKit transactions and must
|
||||
not be duplicated as client success events.
|
||||
|
||||
### Referral events
|
||||
|
||||
`REFERRAL_SHARED`
|
||||
|
||||
- Required: `surface=APP`.
|
||||
|
||||
`INVITE_OPENED`
|
||||
|
||||
- Required: `surface=INVITE_WEB`, `acquisitionChannel=REFERRAL`.
|
||||
- The app may emit this event after handling an invitation Universal Link.
|
||||
- The first-party web route separately increments a UTC daily aggregate after
|
||||
validating the referral code. It stores no IP address, user agent, cookie,
|
||||
installation ID, referral code, or other request metadata.
|
||||
- No third-party analytics script is permitted.
|
||||
|
||||
Referral binding, value qualification and reward are derived from server data.
|
||||
|
||||
## Allowed dimensions
|
||||
|
||||
`surface`
|
||||
|
||||
- `APP`
|
||||
- `KEYBOARD`
|
||||
- `INVITE_WEB`
|
||||
|
||||
`acquisitionChannel`
|
||||
|
||||
- `APP_STORE_ORGANIC`
|
||||
- `REFERRAL`
|
||||
- `SOCIAL_CONTENT`
|
||||
- `UNKNOWN`
|
||||
|
||||
`feature`
|
||||
|
||||
- `TRANSCRIPTION`
|
||||
- `POLISH`
|
||||
- `AI_ASSISTANT`
|
||||
- `AGENT`
|
||||
- `HOTWORD`
|
||||
- `OTHER`
|
||||
|
||||
`executionMode`
|
||||
|
||||
- `MANAGED`
|
||||
- `LOCAL`
|
||||
- `BYOK`
|
||||
|
||||
`failureCategory`
|
||||
|
||||
- `NETWORK`
|
||||
- `PROVIDER`
|
||||
- `TIMEOUT`
|
||||
- `CANCELLED`
|
||||
- `INSUFFICIENT_CREDITS`
|
||||
- `VALIDATION`
|
||||
- `UNKNOWN`
|
||||
|
||||
`durationBucket`
|
||||
|
||||
- `LT_1S`
|
||||
- `S1_TO_3`
|
||||
- `S3_TO_10`
|
||||
- `S10_TO_30`
|
||||
- `GTE_30S`
|
||||
|
||||
`appVersion` and `osVersion` are optional ASCII release identifiers of at most
|
||||
32 characters. They may not contain spaces, user-generated values or device
|
||||
names.
|
||||
|
||||
## Client delivery guidance
|
||||
|
||||
- Persist pending events in an append-only local queue.
|
||||
- Retry with exponential backoff after network failures and HTTP 5xx.
|
||||
- Drop events rejected with HTTP 400/422 after recording a local diagnostic
|
||||
counter; do not retry malformed events indefinitely.
|
||||
- Reuse the same `clientEventId` for every retry.
|
||||
- Send at most 50 events per batch and remove events only after a successful
|
||||
response.
|
||||
- Do not block the AI interaction or purchase flow on analytics delivery.
|
||||
|
||||
## Reporting
|
||||
|
||||
The admin analytics endpoints combine:
|
||||
|
||||
- accepted product events for acquisition, local/BYOK usage and client funnel
|
||||
detail;
|
||||
- settled credit usage for managed AI counts and credit consumption;
|
||||
- accounts, StoreKit and referrals for registration, monetization and referral
|
||||
outcomes.
|
||||
- daily keyboard summaries for privacy-minimized Chinese and English input
|
||||
activity, character volume and input sessions.
|
||||
|
||||
Metric formulas are defined in
|
||||
[`ANALYTICS_METRICS_DICTIONARY.md`](ANALYTICS_METRICS_DICTIONARY.md).
|
||||
@@ -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.
|
||||
@@ -21,6 +21,7 @@ GRANT SELECT ON osg_account.credit_reservations TO 'osg_account_runtime'@'10.20.
|
||||
GRANT SELECT ON osg_account.referral_campaigns TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.referral_campaign_budgets TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.referral_codes TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.referral_owner_codes TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.referral_bindings TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.credit_usage_records TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%';
|
||||
@@ -29,15 +30,34 @@ GRANT SELECT ON osg_account.usage_records TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.gateway_grant_scopes TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.gateway_complimentary_requests TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.oobe_subjects TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.oobe_gateway_grants TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.oobe_gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.oobe_gateway_claims TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.oobe_provider_requests TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.app_attest_challenges 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.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.account_profiles TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.gateway_provider_credentials TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.product_analytics_installations TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.product_analytics_events TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.product_analytics_daily_counters TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.keyboard_usage_daily_summaries TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.official_content_catalog TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.official_skills TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.official_skill_localizations TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.official_hint_packs TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.hint_feed_settings TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT SELECT ON osg_account.hint_feed_generation_state 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.%';
|
||||
@@ -47,6 +67,7 @@ GRANT INSERT, UPDATE ON osg_account.credit_accounts TO 'osg_account_runtime'@'10
|
||||
GRANT INSERT, UPDATE ON osg_account.credit_reservations TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT UPDATE ON osg_account.referral_campaign_budgets TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.referral_codes TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.referral_owner_codes TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.referral_bindings TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.credit_usage_records TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.credit_ledger TO 'osg_account_runtime'@'10.20.%';
|
||||
@@ -55,17 +76,41 @@ GRANT INSERT ON osg_account.usage_records TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.gateway_grants TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.gateway_grant_scopes TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE, DELETE ON osg_account.gateway_complimentary_requests
|
||||
TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.oobe_subjects TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.oobe_gateway_grants TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.oobe_gateway_refresh_tokens TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE, DELETE ON osg_account.oobe_gateway_claims TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.oobe_provider_requests TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.devicecheck_trial_claims TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.app_attest_challenges 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.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.account_profiles TO 'osg_account_runtime'@'10.20.%';
|
||||
-- Operators and sessions are mutable authentication state. Audit and grant
|
||||
-- records remain append-only and deliberately receive no UPDATE or DELETE.
|
||||
GRANT INSERT, UPDATE ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.gateway_provider_credentials
|
||||
TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE, DELETE ON osg_account.product_analytics_installations
|
||||
TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT ON osg_account.product_analytics_events TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.product_analytics_daily_counters
|
||||
TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, DELETE ON osg_account.keyboard_usage_daily_summaries
|
||||
TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT UPDATE ON osg_account.official_content_catalog TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.official_skills TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.official_skill_localizations TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT INSERT, UPDATE ON osg_account.official_hint_packs TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT UPDATE ON osg_account.hint_feed_settings TO 'osg_account_runtime'@'10.20.%';
|
||||
GRANT UPDATE ON osg_account.hint_feed_generation_state TO 'osg_account_runtime'@'10.20.%';
|
||||
|
||||
-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
|
||||
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
|
||||
|
||||
+1643
-26
File diff suppressed because it is too large
Load Diff
@@ -23,18 +23,28 @@ import com.osglab.account.features.admin.services.AdminBootstrapService
|
||||
import com.osglab.account.features.admin.services.AdminOperatorService
|
||||
import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
|
||||
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsRepository
|
||||
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
|
||||
import com.osglab.account.features.admin.stats.repositories.ExposedAdminProductAnalyticsRepository
|
||||
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
||||
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
import com.osglab.account.features.account.AccountRepository
|
||||
import com.osglab.account.features.account.AccountOperations
|
||||
import com.osglab.account.features.account.AccountReauthenticator
|
||||
import com.osglab.account.features.account.AccountRepository
|
||||
import com.osglab.account.features.account.AccountService
|
||||
import com.osglab.account.features.account.AppleAccountReauthenticator
|
||||
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
|
||||
import com.osglab.account.features.account.ExposedAccountRepository
|
||||
import com.osglab.account.features.account.accountRoutes
|
||||
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
|
||||
import com.osglab.account.features.analytics.repositories.ExposedAnalyticsRepository
|
||||
import com.osglab.account.features.analytics.routes.analyticsRoutes
|
||||
import com.osglab.account.features.analytics.services.AnalyticsMaintenanceService
|
||||
import com.osglab.account.features.analytics.services.AnalyticsService
|
||||
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
|
||||
import com.osglab.account.features.appleevents.AppleEventService
|
||||
import com.osglab.account.features.appleevents.AppleEventRepository
|
||||
import com.osglab.account.features.appleevents.AppleEventVerifier
|
||||
@@ -58,11 +68,32 @@ import com.osglab.account.features.credits.routes.creditRoutes
|
||||
import com.osglab.account.features.credits.services.CreditOperations
|
||||
import com.osglab.account.features.credits.services.CreditService
|
||||
import com.osglab.account.features.credits.services.ReferralRewardConfig
|
||||
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
|
||||
import com.osglab.account.features.content.repositories.ContentRepository
|
||||
import com.osglab.account.features.content.repositories.ExposedContentRepository
|
||||
import com.osglab.account.features.content.routes.contentRoutes
|
||||
import com.osglab.account.features.content.services.ContentService
|
||||
import com.osglab.account.features.content.feed.ExposedHintFeedRepository
|
||||
import com.osglab.account.features.content.feed.HintFeedRepository
|
||||
import com.osglab.account.features.content.feed.HintFeedGenerationLock
|
||||
import com.osglab.account.features.content.feed.HintFeedScheduler
|
||||
import com.osglab.account.features.content.feed.HintFeedService
|
||||
import com.osglab.account.features.content.feed.MysqlHintFeedGenerationLock
|
||||
import com.osglab.account.features.content.feed.sources.GoogleFeedHintSource
|
||||
import com.osglab.account.features.content.feed.sources.HolidayHintSource
|
||||
import com.osglab.account.features.content.feed.sources.TopHubHintSource
|
||||
import com.osglab.account.features.gateway.adapters.CreditReservationAdapter
|
||||
import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
|
||||
import com.osglab.account.features.gateway.GatewaySettings
|
||||
import com.osglab.account.features.gateway.asr.AsrStreamingService
|
||||
import com.osglab.account.features.gateway.credentials.DatabaseProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.credentials.EnvironmentProviderCredentials
|
||||
import com.osglab.account.features.gateway.credentials.ExposedGatewayCredentialRepository
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialRepository
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialService
|
||||
import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.ports.CreditReservationPort
|
||||
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
|
||||
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
|
||||
import com.osglab.account.features.gateway.ports.GatewayGrantPort
|
||||
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
|
||||
@@ -81,6 +112,7 @@ import com.osglab.account.features.gateway.services.GatewayBearerIdentity
|
||||
import com.osglab.account.features.gateway.services.GatewayGrantService
|
||||
import com.osglab.account.features.gateway.services.GatewayReconciliationService
|
||||
import com.osglab.account.features.gateway.services.GatewayService
|
||||
import com.osglab.account.features.health.healthRoutes
|
||||
import com.osglab.account.features.integrity.AppAttestCrypto
|
||||
import com.osglab.account.features.integrity.AppAttestRepository
|
||||
import com.osglab.account.features.integrity.AppAttestService
|
||||
@@ -101,21 +133,30 @@ import com.osglab.account.features.integrity.UnavailableAppleDeviceCheckClient
|
||||
import com.osglab.account.features.integrity.createDeviceCheckClient
|
||||
import com.osglab.account.features.integrity.integrityRoutes
|
||||
import com.osglab.account.features.inviteweb.InviteWebConfig
|
||||
import com.osglab.account.features.inviteweb.InviteOpenRecorder
|
||||
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
||||
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
||||
import com.osglab.account.features.oobe.ExposedOobeRepository
|
||||
import com.osglab.account.features.oobe.OobeGrantService
|
||||
import com.osglab.account.features.oobe.OobeRepository
|
||||
import com.osglab.account.features.oobe.OobeTokenSettings
|
||||
import com.osglab.account.features.oobe.oobeRoutes
|
||||
import com.osglab.account.features.referrals.routes.referralRoutes
|
||||
import com.osglab.account.features.referrals.services.ReferralOperations
|
||||
import com.osglab.account.features.referrals.services.ReferralService
|
||||
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
|
||||
import com.osglab.account.features.referrals.services.ReferralRiskProvider
|
||||
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
|
||||
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||
import com.osglab.account.features.storekit.routes.storeKitRoutes
|
||||
import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSockets
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationStopped
|
||||
@@ -131,9 +172,6 @@ import io.ktor.server.plugins.ratelimit.RateLimitName
|
||||
import io.ktor.server.plugins.ratelimit.rateLimit
|
||||
import io.ktor.server.request.httpMethod
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -238,13 +276,23 @@ fun Application.module() {
|
||||
val providerConfig = appConfig.providers.volcengine.toProviderConfig()
|
||||
AsrStreamingService(
|
||||
gateway = koin.get(),
|
||||
upstream = KtorVolcengineAsrTransport(koin.get(), providerConfig),
|
||||
upstream = KtorVolcengineAsrTransport(
|
||||
client = koin.get(),
|
||||
config = providerConfig,
|
||||
credentialResolver = koin.get(),
|
||||
),
|
||||
scope = this,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (appConfig.hintFeed.enabled) {
|
||||
launch {
|
||||
koin.get<HintFeedScheduler>().run()
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
@@ -254,6 +302,13 @@ fun Application.module() {
|
||||
} catch (_: Exception) {
|
||||
// Durable outbox state is retried; never log sensitive token material.
|
||||
}
|
||||
try {
|
||||
koin.get<AnalyticsMaintenanceService>().purgeStaleAnonymousInstallations()
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (_: Exception) {
|
||||
// Anonymous analytics cleanup is bounded and retried on the next cycle.
|
||||
}
|
||||
try {
|
||||
koin.get<GatewayReconciliationService>().reconcile()
|
||||
} catch (exception: CancellationException) {
|
||||
@@ -283,11 +338,13 @@ fun Application.module() {
|
||||
healthRoutes(koin.get())
|
||||
rateLimit(AUTH_RATE_LIMIT) {
|
||||
authRoutes(koin.get())
|
||||
oobeRoutes(koin.get())
|
||||
}
|
||||
rateLimit(ACCOUNT_RATE_LIMIT) {
|
||||
accountRoutes(koin.get())
|
||||
creditRoutes(koin.get(), koin.get())
|
||||
referralRoutes(koin.get(), koin.get())
|
||||
referralRoutes(koin.get(), appConfig.inviteBaseUrl, koin.get())
|
||||
storeKitRoutes(koin.get())
|
||||
}
|
||||
rateLimit(GATEWAY_RATE_LIMIT) {
|
||||
configureGatewayRoutes(
|
||||
@@ -300,43 +357,33 @@ fun Application.module() {
|
||||
}
|
||||
rateLimit(PUBLIC_RATE_LIMIT) {
|
||||
appleEventRoutes(koin.get())
|
||||
configureInviteWebRoutes(koin.get(), koin.get())
|
||||
analyticsRoutes(koin.get())
|
||||
configureInviteWebRoutes(koin.get(), koin.get(), koin.get())
|
||||
integrityRoutes(koin.get())
|
||||
contentRoutes(koin.get())
|
||||
}
|
||||
if (appConfig.admin.enabled) {
|
||||
adminWebRoutes()
|
||||
adminWebRoutes(appConfig)
|
||||
rateLimit(ADMIN_API_RATE_LIMIT) {
|
||||
adminApiRoutes(
|
||||
config = appConfig,
|
||||
authService = koin.get(),
|
||||
sessionService = koin.get(),
|
||||
statsService = koin.get(),
|
||||
productAnalyticsService = koin.get(),
|
||||
usersService = koin.get(),
|
||||
grantService = koin.get(),
|
||||
operatorService = koin.get(),
|
||||
auditService = koin.get(),
|
||||
credentialService = koin.get(),
|
||||
contentService = koin.get(),
|
||||
hintFeedService = koin.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.healthRoutes(databaseFactory: DatabaseFactory? = null) {
|
||||
get("/health") { call.respondText("""{"status":"UP"}""", ContentType.Application.Json) }
|
||||
get("/health/live") { call.respondText("""{"status":"UP"}""", ContentType.Application.Json) }
|
||||
get("/health/ready") {
|
||||
if (databaseFactory?.isReady() == true) {
|
||||
call.respondText("""{"status":"UP"}""", ContentType.Application.Json)
|
||||
} else {
|
||||
call.respondText(
|
||||
"""{"status":"DOWN"}""",
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun accountServerModule(config: AppConfig): Module = module {
|
||||
single { config }
|
||||
single { DatabaseFactory(config.database) }
|
||||
@@ -361,6 +408,19 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single { SessionJwt(config.session) }
|
||||
single { FieldEncryptor(config.encryption.key) }
|
||||
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
|
||||
single<GatewayCredentialRepository> { ExposedGatewayCredentialRepository(get()) }
|
||||
single {
|
||||
EnvironmentProviderCredentials(
|
||||
deepSeekApiKey = config.providers.deepSeek.apiKey,
|
||||
volcengineApiKey = config.providers.volcengine.apiKey,
|
||||
volcengineLegacyConfigured =
|
||||
!config.providers.volcengine.appId.isNullOrBlank() &&
|
||||
!config.providers.volcengine.accessToken.isNullOrBlank(),
|
||||
)
|
||||
}
|
||||
single { DatabaseProviderApiKeyResolver(get(), get(), get()) }
|
||||
single<ProviderApiKeyResolver> { get<DatabaseProviderApiKeyResolver>() }
|
||||
single { GatewayCredentialService(get(), get(), get()) }
|
||||
single<AdminRepository> { ExposedAdminRepository(get()) }
|
||||
single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() }
|
||||
single<AdminTotpVerifier> { HmacTotpVerifier() }
|
||||
@@ -385,9 +445,30 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single { AdminAuditService(get()) }
|
||||
single<AdminStatsRepository> { ExposedAdminStatsRepository(get()) }
|
||||
single { AdminStatsService(get()) }
|
||||
single<AdminProductAnalyticsRepository> { ExposedAdminProductAnalyticsRepository(get()) }
|
||||
single { AdminProductAnalyticsService(get()) }
|
||||
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
|
||||
single { AdminUsersService(get()) }
|
||||
single { AdminGrantService(get()) }
|
||||
single<ContentRepository> { ExposedContentRepository(get()) }
|
||||
single { ContentService(get()) }
|
||||
single<HintFeedRepository> { ExposedHintFeedRepository(get()) }
|
||||
single<HintFeedGenerationLock> { MysqlHintFeedGenerationLock(get()) }
|
||||
single {
|
||||
val client = get<HttpClient>()
|
||||
HintFeedService(
|
||||
repository = get(),
|
||||
contentService = get(),
|
||||
generationLock = get(),
|
||||
sources = listOf(
|
||||
HolidayHintSource(client),
|
||||
TopHubHintSource(client, config.hintFeed.topHubApiKey),
|
||||
GoogleFeedHintSource(client),
|
||||
),
|
||||
config = config.hintFeed,
|
||||
)
|
||||
}
|
||||
single { HintFeedScheduler(get()) }
|
||||
single<AppleJwksProvider> {
|
||||
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
|
||||
}
|
||||
@@ -414,6 +495,14 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single<AuthRepository> { ExposedAuthRepository(get()) }
|
||||
single { SessionAccessAuthenticator(get(), get()) }
|
||||
single<AccountRepository> { ExposedAccountRepository(get(), get()) }
|
||||
single<AnalyticsRepository> { ExposedAnalyticsRepository(get()) }
|
||||
single<AnalyticsService> { DefaultAnalyticsService(get()) }
|
||||
single { AnalyticsMaintenanceService(get()) }
|
||||
single<InviteOpenRecorder> {
|
||||
InviteOpenRecorder {
|
||||
get<AnalyticsRepository>().recordInvitePageOpen(Instant.now())
|
||||
}
|
||||
}
|
||||
single<AppleEventRepository> { ExposedAppleEventRepository(get(), get(), config.antiAbuse) }
|
||||
single { AppleEventVerifier(config.apple, get()) }
|
||||
single { AppleEventService(get(), get()) }
|
||||
@@ -431,14 +520,37 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
)
|
||||
}
|
||||
single<CreditOperations> { get<CreditService>() }
|
||||
single<StoreKitTransactionVerifier> {
|
||||
if (config.storeKit.enabled) {
|
||||
AppleStoreKitTransactionVerifier(
|
||||
bundleId = config.storeKit.bundleId,
|
||||
appAppleId = requireNotNull(config.storeKit.appAppleId),
|
||||
)
|
||||
} else {
|
||||
StoreKitTransactionVerifier { throw StoreKitUnavailable() }
|
||||
}
|
||||
}
|
||||
single {
|
||||
StoreKitService(
|
||||
products = if (config.storeKit.enabled) config.storeKit.products else emptyList(),
|
||||
verifier = get(),
|
||||
transactions = get(),
|
||||
)
|
||||
}
|
||||
single<TrialCreditGranter> {
|
||||
TrialCreditGranter { accountId ->
|
||||
get<CreditService>().grantSignupTrial(
|
||||
val creditService = get<CreditService>()
|
||||
object : TrialCreditGranter {
|
||||
override suspend fun grant(accountId: UUID) {
|
||||
creditService.grantSignupTrial(
|
||||
userId = accountId,
|
||||
credits = config.credits.signupTrial,
|
||||
idempotencyKey = "internal:signup-trial:$accountId",
|
||||
idempotencyKey = signupTrialIdempotencyKey(accountId),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun wasGranted(accountId: UUID): Boolean =
|
||||
creditService.hasSignupTrial(accountId)
|
||||
}
|
||||
}
|
||||
single {
|
||||
DeviceCheckTrialService(
|
||||
@@ -453,15 +565,19 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single<GatewayGrantRepository> { get<ExposedGatewayRepository>() }
|
||||
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
|
||||
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
|
||||
single<ComplimentaryRequestPort> { get<ExposedGatewayRepository>() }
|
||||
single<AccountProvisioner> {
|
||||
AccountProvisioner { accountId, deviceCheckToken ->
|
||||
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
||||
if (deviceCheckToken != null && !granted) {
|
||||
AccountProvisioner { accountId, deviceCheckToken, displayName ->
|
||||
val trial = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
||||
if (trial.shouldRestrictAccount) {
|
||||
get<AuthRepository>().restrictAccountForAntiAbuse(
|
||||
accountId,
|
||||
java.time.Instant.now(),
|
||||
)
|
||||
}
|
||||
get<AccountService>().seedDisplayName(accountId, displayName)
|
||||
// Referral provisioning is intentionally handled by /v1/referrals/me
|
||||
// after authentication so referral storage can never block sign-in.
|
||||
}
|
||||
}
|
||||
single {
|
||||
@@ -494,6 +610,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
reauthenticator = get(),
|
||||
)
|
||||
}
|
||||
single<AccountOperations> { get<AccountService>() }
|
||||
|
||||
single<UserRegistrationTimeProvider> {
|
||||
UserRegistrationTimeProvider { accountId ->
|
||||
@@ -520,10 +637,9 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
val transactions = get<BillingTransactionRunner>()
|
||||
ReferralLookupPort { code ->
|
||||
transactions.inTransaction { unit ->
|
||||
val referralCode = unit.referrals.findCode(code)
|
||||
referralCode?.campaignId
|
||||
?.let(unit.referrals::findCampaign)
|
||||
?.isActive(Instant.now()) == true
|
||||
// Invitation codes are permanent account identifiers. Campaign
|
||||
// availability is evaluated only when an invitee redeems one.
|
||||
unit.referrals.findCode(code) != null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,8 +658,27 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
maximumGrantLifetime = Duration.ofDays(config.session.gatewayGrantDays),
|
||||
)
|
||||
}
|
||||
single<OobeRepository> { ExposedOobeRepository(get()) }
|
||||
single {
|
||||
OobeTokenSettings(
|
||||
issuer = config.session.issuer,
|
||||
audience = "${config.session.audience}-gateway",
|
||||
accessTokenHmacSecret = deriveGatewaySecret(
|
||||
config.session.hmacSecret,
|
||||
"oobe-gateway-access",
|
||||
),
|
||||
refreshTokenHmacSecret = deriveGatewaySecret(
|
||||
config.session.hmacSecret,
|
||||
"oobe-gateway-refresh",
|
||||
),
|
||||
)
|
||||
}
|
||||
single { OobeGrantService(get(), get(), get()) }
|
||||
single { GatewayGrantService(get(), get()) }
|
||||
single<GatewayAccessTokenPort> { GatewayBearerIdentity(get()) }
|
||||
single<GatewayAccessTokenPort> {
|
||||
val oobeGrants = get<OobeGrantService>()
|
||||
GatewayBearerIdentity(get(), oobeGrants::authenticate)
|
||||
}
|
||||
single<CreditReservationPort> {
|
||||
CreditReservationAdapter(
|
||||
creditService = get(),
|
||||
@@ -552,9 +687,9 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
)
|
||||
}
|
||||
single {
|
||||
ProviderCatalog(configuredProviders(config, get()))
|
||||
ProviderCatalog(configuredProviders(config, get(), get()))
|
||||
}
|
||||
single { GatewayService(get(), get(), get(), get()) }
|
||||
single { GatewayService(get(), get(), get(), get(), get(), get()) }
|
||||
single { GatewayReconciliationService(get(), get()) }
|
||||
single {
|
||||
InviteWebConfig(
|
||||
@@ -565,7 +700,11 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
}
|
||||
}
|
||||
|
||||
private fun configuredProviders(config: AppConfig, client: HttpClient): List<GatewayProvider> =
|
||||
private fun configuredProviders(
|
||||
config: AppConfig,
|
||||
client: HttpClient,
|
||||
credentialResolver: ProviderApiKeyResolver,
|
||||
): List<GatewayProvider> =
|
||||
buildList {
|
||||
config.providers.deepSeek.apiKey?.let { apiKey ->
|
||||
add(
|
||||
@@ -575,13 +714,23 @@ private fun configuredProviders(config: AppConfig, client: HttpClient): List<Gat
|
||||
endpoint = config.providers.deepSeek.endpoint,
|
||||
apiKey = apiKey,
|
||||
model = config.providers.deepSeek.model,
|
||||
reasoningModel = config.providers.deepSeek.reasoningModel,
|
||||
),
|
||||
credentialResolver = credentialResolver,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (config.providers.volcengine.credentialsAvailable) {
|
||||
val providerConfig = config.providers.volcengine.toProviderConfig()
|
||||
add(VolcengineAsrProvider(KtorVolcengineAsrTransport(client, providerConfig)))
|
||||
add(
|
||||
VolcengineAsrProvider(
|
||||
KtorVolcengineAsrTransport(
|
||||
client = client,
|
||||
config = providerConfig,
|
||||
credentialResolver = credentialResolver,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.osglab.account.config
|
||||
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import io.ktor.server.config.ApplicationConfig
|
||||
import java.net.URI
|
||||
import java.time.ZoneId
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
@@ -16,9 +18,11 @@ data class AppConfig(
|
||||
val antiAbuse: AntiAbuseConfig,
|
||||
val apple: AppleConfig,
|
||||
val credits: CreditsConfig,
|
||||
val storeKit: StoreKitConfig = StoreKitConfig(),
|
||||
val providers: ProvidersConfig,
|
||||
val integrity: IntegrityConfig,
|
||||
val admin: AdminConfig = AdminConfig(),
|
||||
val hintFeed: HintFeedConfig = HintFeedConfig(),
|
||||
) {
|
||||
val isProduction: Boolean = environment == Environment.PRODUCTION
|
||||
|
||||
@@ -52,6 +56,10 @@ data class AppConfig(
|
||||
hmacSecret = config.secret("app.session.secret", production).toByteArray(),
|
||||
accessMinutes = config.positiveLong("app.session.accessMinutes"),
|
||||
refreshDays = config.positiveLong("app.session.refreshDays"),
|
||||
legacyRefreshReplaySeconds = config.positiveLong(
|
||||
"app.session.legacyRefreshReplaySeconds",
|
||||
30,
|
||||
),
|
||||
gatewayGrantDays = config.positiveLong("app.session.gatewayGrantDays", 30),
|
||||
)
|
||||
val encryption = EncryptionConfig(
|
||||
@@ -76,10 +84,36 @@ data class AppConfig(
|
||||
)
|
||||
val credits = CreditsConfig(
|
||||
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
|
||||
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000),
|
||||
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_000),
|
||||
referralInviter = config.positiveLong("app.credits.referralInviter", 1_000),
|
||||
referralInvitee = config.positiveLong("app.credits.referralInvitee", 1_000),
|
||||
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
|
||||
)
|
||||
val storeKitEnabled = config.booleanOrDefault("app.storeKit.enabled", false)
|
||||
val storeKitAppAppleId = config.optionalValue("app.storeKit.appAppleId")?.let { raw ->
|
||||
raw.toLongOrNull()?.takeIf { it > 0 }
|
||||
?: throw ConfigValidationException("app.storeKit.appAppleId must be positive")
|
||||
}
|
||||
val storeKit = StoreKitConfig(
|
||||
enabled = storeKitEnabled,
|
||||
bundleId = config.valueOrDefault("app.storeKit.bundleId", apple.clientId),
|
||||
appAppleId = storeKitAppAppleId,
|
||||
products = config.storeKitProducts("app.storeKit.products"),
|
||||
)
|
||||
if (storeKitEnabled) {
|
||||
require(storeKit.bundleId == apple.clientId) {
|
||||
"app.storeKit.bundleId must match app.apple.clientId"
|
||||
}
|
||||
require(storeKit.appAppleId != null) {
|
||||
"app.storeKit.appAppleId is required when StoreKit is enabled"
|
||||
}
|
||||
require(storeKit.products.isNotEmpty()) {
|
||||
"app.storeKit.products is required when StoreKit is enabled"
|
||||
}
|
||||
}
|
||||
val deepSeekModel = config.valueOrDefault(
|
||||
"app.providers.deepseek.model",
|
||||
"deepseek-v4-flash",
|
||||
)
|
||||
val providers = ProvidersConfig(
|
||||
volcengine = VolcengineConfig(
|
||||
endpoint = config.valueOrDefault(
|
||||
@@ -100,7 +134,9 @@ data class AppConfig(
|
||||
"https://api.deepseek.com/v1",
|
||||
),
|
||||
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
|
||||
model = config.valueOrDefault("app.providers.deepseek.model", "deepseek-v4-flash"),
|
||||
model = deepSeekModel,
|
||||
reasoningModel = config.optionalValue("app.providers.deepseek.reasoningModel")
|
||||
?: deepSeekModel,
|
||||
),
|
||||
)
|
||||
val integrity = IntegrityConfig(
|
||||
@@ -116,6 +152,10 @@ data class AppConfig(
|
||||
if (production) "production" else "development",
|
||||
),
|
||||
),
|
||||
allowDevelopmentAppAttest = config.booleanOrDefault(
|
||||
"app.integrity.allowDevelopmentAppAttest",
|
||||
false,
|
||||
),
|
||||
challengeLifetimeSeconds = config.positiveLong(
|
||||
"app.integrity.challengeLifetimeSeconds",
|
||||
300,
|
||||
@@ -131,6 +171,7 @@ data class AppConfig(
|
||||
}
|
||||
val admin = AdminConfig(
|
||||
enabled = adminEnabled,
|
||||
mtlsRequired = config.booleanOrDefault("app.admin.mtlsRequired", true),
|
||||
bootstrapEnabled = adminBootstrapEnabled,
|
||||
bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId")
|
||||
?.let {
|
||||
@@ -156,6 +197,18 @@ data class AppConfig(
|
||||
100_000,
|
||||
),
|
||||
)
|
||||
val hintFeed = HintFeedConfig(
|
||||
enabled = config.booleanOrDefault("app.hintFeed.enabled", false),
|
||||
topHubApiKey = config.optionalSecret(
|
||||
"app.hintFeed.topHubApiKey",
|
||||
production = false,
|
||||
),
|
||||
zoneId = config.valueOrDefault("app.hintFeed.zoneId", "UTC").let { raw ->
|
||||
runCatching { ZoneId.of(raw) }.getOrElse { cause ->
|
||||
throw ConfigValidationException("app.hintFeed.zoneId must be a valid time zone", cause)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) {
|
||||
"app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes"
|
||||
@@ -195,6 +248,9 @@ data class AppConfig(
|
||||
require(session.refreshDays in 1..365) {
|
||||
"app.session.refreshDays must be between 1 and 365"
|
||||
}
|
||||
require(session.legacyRefreshReplaySeconds in 5..120) {
|
||||
"app.session.legacyRefreshReplaySeconds must be between 5 and 120"
|
||||
}
|
||||
require(!production || providers.volcengine.credentialsAvailable) {
|
||||
"Production Volcengine credentials are missing"
|
||||
}
|
||||
@@ -294,9 +350,11 @@ data class AppConfig(
|
||||
antiAbuse = antiAbuse,
|
||||
apple = apple,
|
||||
credits = credits,
|
||||
storeKit = storeKit,
|
||||
providers = providers,
|
||||
integrity = integrity,
|
||||
admin = admin,
|
||||
hintFeed = hintFeed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -329,6 +387,7 @@ data class SessionConfig(
|
||||
val hmacSecret: ByteArray,
|
||||
val accessMinutes: Long,
|
||||
val refreshDays: Long,
|
||||
val legacyRefreshReplaySeconds: Long = 30,
|
||||
val gatewayGrantDays: Long = 30,
|
||||
)
|
||||
|
||||
@@ -339,6 +398,12 @@ data class AntiAbuseConfig(
|
||||
val tombstoneRetentionDays: Long,
|
||||
)
|
||||
|
||||
data class HintFeedConfig(
|
||||
val enabled: Boolean = false,
|
||||
val topHubApiKey: String? = null,
|
||||
val zoneId: ZoneId = ZoneId.of("UTC"),
|
||||
)
|
||||
|
||||
data class AppleConfig(
|
||||
val teamId: String?,
|
||||
val keyId: String?,
|
||||
@@ -359,6 +424,13 @@ data class CreditsConfig(
|
||||
val referralBindingDays: Long,
|
||||
)
|
||||
|
||||
data class StoreKitConfig(
|
||||
val enabled: Boolean = false,
|
||||
val bundleId: String = "com.osgkeyboard.ios",
|
||||
val appAppleId: Long? = null,
|
||||
val products: List<StoreKitProduct> = emptyList(),
|
||||
)
|
||||
|
||||
data class ProvidersConfig(
|
||||
val volcengine: VolcengineConfig,
|
||||
val deepSeek: DeepSeekConfig,
|
||||
@@ -379,6 +451,7 @@ data class DeepSeekConfig(
|
||||
val endpoint: String,
|
||||
val apiKey: String?,
|
||||
val model: String,
|
||||
val reasoningModel: String = model,
|
||||
) {
|
||||
val credentialsAvailable: Boolean
|
||||
get() = !apiKey.isNullOrBlank()
|
||||
@@ -388,6 +461,7 @@ data class IntegrityConfig(
|
||||
val deviceCheckPolicy: IntegrityPolicy,
|
||||
val appAttestPolicy: IntegrityPolicy,
|
||||
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
|
||||
val allowDevelopmentAppAttest: Boolean = false,
|
||||
val challengeLifetimeSeconds: Long = 300,
|
||||
val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
|
||||
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
|
||||
@@ -395,6 +469,7 @@ data class IntegrityConfig(
|
||||
|
||||
data class AdminConfig(
|
||||
val enabled: Boolean = false,
|
||||
val mtlsRequired: Boolean = true,
|
||||
val bootstrapEnabled: Boolean = false,
|
||||
val bootstrapOperatorId: UUID? = null,
|
||||
val bootstrapUsername: String? = null,
|
||||
@@ -492,6 +567,24 @@ private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
|
||||
?: throw ConfigValidationException("$path must be a positive integer")
|
||||
} ?: default
|
||||
|
||||
private fun ApplicationConfig.storeKitProducts(path: String): List<StoreKitProduct> {
|
||||
val raw = optionalValue(path) ?: return emptyList()
|
||||
val products = raw.split(',').map { entry ->
|
||||
val parts = entry.split(':', limit = 2).map(String::trim)
|
||||
if (parts.size != 2) {
|
||||
throw ConfigValidationException("$path must use productId:credits entries")
|
||||
}
|
||||
val credits = parts[1].toLongOrNull()?.takeIf { it > 0 }
|
||||
?: throw ConfigValidationException("$path credits must be positive integers")
|
||||
runCatching { StoreKitProduct(productId = parts[0], credits = credits) }
|
||||
.getOrElse { throw ConfigValidationException("$path contains an invalid product", it) }
|
||||
}
|
||||
if (products.map(StoreKitProduct::productId).distinct().size != products.size) {
|
||||
throw ConfigValidationException("$path contains duplicate product IDs")
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
private fun ApplicationConfig.boolean(path: String): Boolean =
|
||||
required(path).let {
|
||||
when (it.lowercase()) {
|
||||
|
||||
@@ -26,11 +26,13 @@ data class AccountRecord(
|
||||
val identityFingerprint: String,
|
||||
val antiAbuseRestricted: Boolean,
|
||||
val encryptedAppleRefreshToken: String?,
|
||||
val encryptedDisplayName: String? = null,
|
||||
val createdAt: Instant,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
|
||||
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
|
||||
"encryptedDisplayName=[REDACTED], " +
|
||||
"createdAt=$createdAt)"
|
||||
}
|
||||
|
||||
@@ -61,8 +63,18 @@ internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private object AccountProfilesTable : Table("account_profiles") {
|
||||
val accountId = varchar("account_id", 36)
|
||||
val encryptedDisplayName = text("encrypted_display_name")
|
||||
val createdAt = timestamp("created_at")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
override val primaryKey = PrimaryKey(accountId)
|
||||
}
|
||||
|
||||
interface AccountRepository {
|
||||
suspend fun findById(accountId: UUID): AccountRecord?
|
||||
suspend fun seedDisplayNameIfAbsent(accountId: UUID, encryptedDisplayName: String, now: Instant)
|
||||
suspend fun updateDisplayName(accountId: UUID, encryptedDisplayName: String, now: Instant)
|
||||
suspend fun deleteById(
|
||||
accountId: UUID,
|
||||
deletedAt: Instant,
|
||||
@@ -89,17 +101,58 @@ class ExposedAccountRepository(
|
||||
.where { AppleCredentialsTable.accountId eq accountId.toString() }
|
||||
.singleOrNull()
|
||||
?.get(AppleCredentialsTable.encryptedRefreshToken)
|
||||
val encryptedDisplayName = AccountProfilesTable.selectAll()
|
||||
.where { AccountProfilesTable.accountId eq accountId.toString() }
|
||||
.singleOrNull()
|
||||
?.get(AccountProfilesTable.encryptedDisplayName)
|
||||
row.let {
|
||||
AccountRecord(
|
||||
id = UUID.fromString(it[AccountsTable.id]),
|
||||
identityFingerprint = fingerprint,
|
||||
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
|
||||
encryptedAppleRefreshToken = encryptedRefreshToken,
|
||||
encryptedDisplayName = encryptedDisplayName,
|
||||
createdAt = it[AccountsTable.createdAt],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun seedDisplayNameIfAbsent(
|
||||
accountId: UUID,
|
||||
encryptedDisplayName: String,
|
||||
now: Instant,
|
||||
) {
|
||||
databaseFactory.query {
|
||||
AccountProfilesTable.insertIgnore {
|
||||
it[AccountProfilesTable.accountId] = accountId.toString()
|
||||
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateDisplayName(
|
||||
accountId: UUID,
|
||||
encryptedDisplayName: String,
|
||||
now: Instant,
|
||||
) {
|
||||
databaseFactory.query {
|
||||
AccountProfilesTable.insertIgnore {
|
||||
it[AccountProfilesTable.accountId] = accountId.toString()
|
||||
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
AccountProfilesTable.update({
|
||||
AccountProfilesTable.accountId eq accountId.toString()
|
||||
}) {
|
||||
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||
it[updatedAt] = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteById(
|
||||
accountId: UUID,
|
||||
deletedAt: Instant,
|
||||
|
||||
@@ -12,6 +12,7 @@ import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.patch
|
||||
import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -19,8 +20,12 @@ import kotlinx.serialization.Serializable
|
||||
data class AccountResponse(
|
||||
val id: String,
|
||||
val createdAtEpochSeconds: Long,
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateAccountProfileRequest(val displayName: String)
|
||||
|
||||
@Serializable
|
||||
data class DeleteAccountRequest(
|
||||
val identityToken: String,
|
||||
@@ -39,7 +44,7 @@ data class DeleteAccountRequest(
|
||||
}
|
||||
|
||||
class AccountRoutes(
|
||||
private val accountService: AccountService,
|
||||
private val accountService: AccountOperations,
|
||||
) {
|
||||
fun register(parent: Route) {
|
||||
with(parent) {
|
||||
@@ -49,14 +54,17 @@ class AccountRoutes(
|
||||
val principal = call.principal<AccountPrincipal>()
|
||||
?: throw UnauthorizedException()
|
||||
val account = accountService.get(principal.userId)
|
||||
call.respond(
|
||||
ApiResponse(
|
||||
data = AccountResponse(
|
||||
id = account.id.toString(),
|
||||
createdAtEpochSeconds = account.createdAt.epochSecond,
|
||||
),
|
||||
),
|
||||
call.respond(ApiResponse(data = account.toResponse()))
|
||||
}
|
||||
patch {
|
||||
val principal = call.principal<AccountPrincipal>()
|
||||
?: throw UnauthorizedException()
|
||||
val request = call.receive<UpdateAccountProfileRequest>()
|
||||
val account = accountService.updateDisplayName(
|
||||
principal.userId,
|
||||
request.displayName,
|
||||
)
|
||||
call.respond(ApiResponse(data = account.toResponse()))
|
||||
}
|
||||
delete {
|
||||
val principal = call.principal<AccountPrincipal>()
|
||||
@@ -73,5 +81,11 @@ class AccountRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.accountRoutes(accountService: AccountService) =
|
||||
fun Route.accountRoutes(accountService: AccountOperations) =
|
||||
AccountRoutes(accountService).register(this)
|
||||
|
||||
private fun AccountView.toResponse() = AccountResponse(
|
||||
id = id.toString(),
|
||||
createdAtEpochSeconds = createdAt.epochSecond,
|
||||
displayName = displayName,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.osglab.account.features.account
|
||||
|
||||
import com.osglab.account.common.errors.ExternalServiceUnavailableException
|
||||
import com.osglab.account.common.errors.InvalidRequestException
|
||||
import com.osglab.account.common.errors.UnauthorizedException
|
||||
import com.osglab.account.common.security.FieldDecryptionException
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
@@ -16,11 +17,13 @@ import kotlinx.coroutines.CancellationException
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.text.Normalizer
|
||||
import java.util.UUID
|
||||
|
||||
data class AccountView(
|
||||
val id: UUID,
|
||||
val createdAt: Instant,
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
data class AppleReauthenticationProof(
|
||||
@@ -41,6 +44,13 @@ fun interface AccountReauthenticator {
|
||||
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
|
||||
}
|
||||
|
||||
interface AccountOperations {
|
||||
suspend fun get(accountId: UUID): AccountView
|
||||
suspend fun seedDisplayName(accountId: UUID, candidate: String?)
|
||||
suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView
|
||||
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof)
|
||||
}
|
||||
|
||||
class AppleAccountReauthenticator(
|
||||
private val identityVerifier: AppleIdentityTokenVerifier,
|
||||
private val appleTokenClient: AppleTokenClient,
|
||||
@@ -94,13 +104,34 @@ class AccountService(
|
||||
private val revocationProcessor: AppleRevocationOutboxProcessor,
|
||||
private val reauthenticator: AccountReauthenticator,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
suspend fun get(accountId: UUID): AccountView {
|
||||
) : AccountOperations {
|
||||
override suspend fun get(accountId: UUID): AccountView {
|
||||
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
||||
return AccountView(account.id, account.createdAt)
|
||||
return account.toView()
|
||||
}
|
||||
|
||||
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
|
||||
override suspend fun seedDisplayName(accountId: UUID, candidate: String?) {
|
||||
val displayName = candidate?.let(::normalizedDisplayNameOrNull) ?: return
|
||||
val account = repository.findById(accountId) ?: return
|
||||
repository.seedDisplayNameIfAbsent(
|
||||
accountId,
|
||||
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
|
||||
clock.instant(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView {
|
||||
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
||||
val displayName = normalizedDisplayName(candidate)
|
||||
repository.updateDisplayName(
|
||||
accountId,
|
||||
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
|
||||
clock.instant(),
|
||||
)
|
||||
return requireNotNull(repository.findById(accountId)).toView()
|
||||
}
|
||||
|
||||
override suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
|
||||
val account = repository.findById(accountId) ?: return
|
||||
val now = clock.instant()
|
||||
val currentRefreshToken = reauthenticator.verify(account, proof)
|
||||
@@ -126,6 +157,14 @@ class AccountService(
|
||||
// Local deletion is final. The durable outbox retry loop handles Apple outages.
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccountRecord.toView(): AccountView = AccountView(
|
||||
id = id,
|
||||
createdAt = createdAt,
|
||||
displayName = encryptedDisplayName?.let {
|
||||
fieldEncryptor.decrypt(it, accountProfileContext(id))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
class AppleRevocationOutboxProcessor(
|
||||
@@ -175,3 +214,24 @@ class AppleRevocationOutboxProcessor(
|
||||
}
|
||||
|
||||
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
|
||||
|
||||
private fun accountProfileContext(id: UUID): String = "account-profile:$id"
|
||||
|
||||
private fun normalizedDisplayNameOrNull(candidate: String): String? =
|
||||
runCatching { normalizedDisplayName(candidate) }.getOrNull()
|
||||
|
||||
private fun normalizedDisplayName(candidate: String): String {
|
||||
val normalized = Normalizer.normalize(candidate.trim(), Normalizer.Form.NFC)
|
||||
.replace(WHITESPACE_REGEX, " ")
|
||||
if (
|
||||
normalized.isBlank() ||
|
||||
normalized.codePointCount(0, normalized.length) > MAX_DISPLAY_NAME_CODE_POINTS ||
|
||||
normalized.any { it.isISOControl() }
|
||||
) {
|
||||
throw InvalidRequestException("Display name is invalid")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private val WHITESPACE_REGEX = Regex("\\s+")
|
||||
private const val MAX_DISPLAY_NAME_CODE_POINTS = 64
|
||||
|
||||
@@ -20,11 +20,6 @@ data class AdminOperatorRecord(
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
data class AdminOperatorCursor(
|
||||
val createdAt: Instant,
|
||||
val id: UUID,
|
||||
)
|
||||
|
||||
data class NewAdminOperator(
|
||||
val id: UUID,
|
||||
val normalizedUsername: String,
|
||||
@@ -116,6 +111,16 @@ enum class AdminAuditAction {
|
||||
OPERATOR_CREDENTIALS_RESET,
|
||||
OPERATOR_SESSIONS_REVOKED,
|
||||
MANUAL_CREDIT_GRANTED,
|
||||
CONTENT_SKILL_CREATED,
|
||||
CONTENT_SKILL_UPDATED,
|
||||
CONTENT_SKILL_ENABLED,
|
||||
CONTENT_SKILL_DISABLED,
|
||||
CONTENT_HINT_PACK_PUBLISHED,
|
||||
CONTENT_HINT_PACK_SAVED,
|
||||
CONTENT_HINT_FEED_SETTINGS_UPDATED,
|
||||
CONTENT_HINT_FEED_GENERATED,
|
||||
PROVIDER_API_KEY_UPDATED,
|
||||
PROVIDER_API_KEY_REVEALED,
|
||||
}
|
||||
|
||||
enum class AdminAuditOutcome {
|
||||
@@ -123,6 +128,12 @@ enum class AdminAuditOutcome {
|
||||
DENIED,
|
||||
}
|
||||
|
||||
enum class AdminStepUpResult {
|
||||
VERIFIED,
|
||||
INVALID_TOTP,
|
||||
LOCKED,
|
||||
}
|
||||
|
||||
data class NewAdminAuditEvent(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val actorOperatorId: UUID?,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.osglab.account.features.admin.models
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
enum class AdminSortOrder {
|
||||
ASC,
|
||||
DESC,
|
||||
}
|
||||
|
||||
data class AdminTimeFilter(
|
||||
val from: Instant? = null,
|
||||
val until: Instant? = null,
|
||||
) {
|
||||
init {
|
||||
require(from == null || until == null || from < until) {
|
||||
"Admin query time range must be non-empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminAuditQuery(
|
||||
val time: AdminTimeFilter = AdminTimeFilter(),
|
||||
val action: AdminAuditAction? = null,
|
||||
val outcome: AdminAuditOutcome? = null,
|
||||
val order: AdminSortOrder = AdminSortOrder.DESC,
|
||||
)
|
||||
|
||||
enum class AdminOperatorSort {
|
||||
CREATED_AT,
|
||||
USERNAME,
|
||||
LAST_LOGIN_AT,
|
||||
}
|
||||
|
||||
data class AdminOperatorQuery(
|
||||
val time: AdminTimeFilter = AdminTimeFilter(),
|
||||
val role: AdminRole? = null,
|
||||
val enabled: Boolean? = null,
|
||||
val locked: Boolean? = null,
|
||||
val sort: AdminOperatorSort = AdminOperatorSort.CREATED_AT,
|
||||
val order: AdminSortOrder = AdminSortOrder.ASC,
|
||||
val now: Instant,
|
||||
)
|
||||
|
||||
data class AdminOperatorCursor(
|
||||
val value: String?,
|
||||
val id: UUID,
|
||||
)
|
||||
+142
-15
@@ -1,6 +1,7 @@
|
||||
package com.osglab.account.features.admin.repositories
|
||||
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.AdminAuditQuery
|
||||
import com.osglab.account.features.admin.models.AdminAuditCursor
|
||||
import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
@@ -8,10 +9,13 @@ import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
@@ -21,6 +25,7 @@ import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.greater
|
||||
import org.jetbrains.exposed.v1.core.greaterEq
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.isNotNull
|
||||
import org.jetbrains.exposed.v1.core.isNull
|
||||
@@ -86,6 +91,7 @@ interface AdminRepository {
|
||||
suspend fun listOperatorsPage(
|
||||
limit: Int,
|
||||
before: AdminOperatorCursor? = null,
|
||||
query: AdminOperatorQuery,
|
||||
): List<AdminOperatorRecord>
|
||||
suspend fun countActiveSessions(now: Instant): Long
|
||||
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
|
||||
@@ -148,6 +154,7 @@ interface AdminRepository {
|
||||
suspend fun listAudit(
|
||||
limit: Int,
|
||||
before: AdminAuditCursor? = null,
|
||||
query: AdminAuditQuery = AdminAuditQuery(),
|
||||
): List<AdminAuditRecord>
|
||||
}
|
||||
|
||||
@@ -185,24 +192,119 @@ class ExposedAdminRepository(
|
||||
override suspend fun listOperatorsPage(
|
||||
limit: Int,
|
||||
before: AdminOperatorCursor?,
|
||||
query: AdminOperatorQuery,
|
||||
): List<AdminOperatorRecord> =
|
||||
databaseFactory.query {
|
||||
require(limit in 1..101)
|
||||
val query = AdminOperatorsTable.selectAll()
|
||||
val statement = AdminOperatorsTable.selectAll()
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminOperatorsTable.createdAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminOperatorsTable.createdAt less until }
|
||||
}
|
||||
query.role?.let { role ->
|
||||
statement.andWhere { AdminOperatorsTable.role eq role.name }
|
||||
}
|
||||
query.enabled?.let { enabled ->
|
||||
statement.andWhere {
|
||||
if (enabled) AdminOperatorsTable.disabledAt.isNull()
|
||||
else AdminOperatorsTable.disabledAt.isNotNull()
|
||||
}
|
||||
}
|
||||
query.locked?.let { locked ->
|
||||
statement.andWhere {
|
||||
if (locked) {
|
||||
AdminOperatorsTable.lockedUntil.isNotNull() and
|
||||
(AdminOperatorsTable.lockedUntil greater query.now)
|
||||
} else {
|
||||
AdminOperatorsTable.lockedUntil.isNull() or
|
||||
(AdminOperatorsTable.lockedUntil lessEq query.now)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (before != null) {
|
||||
query.andWhere {
|
||||
(AdminOperatorsTable.createdAt greater before.createdAt) or
|
||||
statement.andWhere {
|
||||
val ascending = query.order == AdminSortOrder.ASC
|
||||
when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT -> {
|
||||
val value = Instant.parse(requireNotNull(before.value))
|
||||
if (ascending) {
|
||||
(AdminOperatorsTable.createdAt greater value) or
|
||||
(
|
||||
(AdminOperatorsTable.createdAt eq before.createdAt) and
|
||||
(AdminOperatorsTable.createdAt eq value) and
|
||||
(AdminOperatorsTable.id greater before.id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
query
|
||||
.orderBy(
|
||||
AdminOperatorsTable.createdAt to SortOrder.ASC,
|
||||
AdminOperatorsTable.id to SortOrder.ASC,
|
||||
} else {
|
||||
(AdminOperatorsTable.createdAt less value) or
|
||||
(
|
||||
(AdminOperatorsTable.createdAt eq value) and
|
||||
(AdminOperatorsTable.id less before.id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
AdminOperatorSort.USERNAME -> {
|
||||
val value = requireNotNull(before.value)
|
||||
if (ascending) {
|
||||
(AdminOperatorsTable.username greater value) or
|
||||
(
|
||||
(AdminOperatorsTable.username eq value) and
|
||||
(AdminOperatorsTable.id greater before.id.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminOperatorsTable.username less value) or
|
||||
(
|
||||
(AdminOperatorsTable.username eq value) and
|
||||
(AdminOperatorsTable.id less before.id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> {
|
||||
val value = before.value?.let(Instant::parse)
|
||||
if (value == null) {
|
||||
AdminOperatorsTable.lastLoginAt.isNull() and
|
||||
if (ascending) {
|
||||
AdminOperatorsTable.id greater before.id.toString()
|
||||
} else {
|
||||
AdminOperatorsTable.id less before.id.toString()
|
||||
}
|
||||
} else {
|
||||
val nonNullAfter = if (ascending) {
|
||||
(AdminOperatorsTable.lastLoginAt greater value) or
|
||||
(
|
||||
(AdminOperatorsTable.lastLoginAt eq value) and
|
||||
(AdminOperatorsTable.id greater before.id.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminOperatorsTable.lastLoginAt less value) or
|
||||
(
|
||||
(AdminOperatorsTable.lastLoginAt eq value) and
|
||||
(AdminOperatorsTable.id less before.id.toString())
|
||||
)
|
||||
}
|
||||
nonNullAfter or AdminOperatorsTable.lastLoginAt.isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT -> statement.orderBy(
|
||||
AdminOperatorsTable.createdAt to sortOrder,
|
||||
AdminOperatorsTable.id to sortOrder,
|
||||
)
|
||||
AdminOperatorSort.USERNAME -> statement.orderBy(
|
||||
AdminOperatorsTable.username to sortOrder,
|
||||
AdminOperatorsTable.id to sortOrder,
|
||||
)
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> statement.orderBy(
|
||||
AdminOperatorsTable.lastLoginAt.isNull() to SortOrder.ASC,
|
||||
AdminOperatorsTable.lastLoginAt to sortOrder,
|
||||
AdminOperatorsTable.id to sortOrder,
|
||||
)
|
||||
}
|
||||
statement
|
||||
.limit(limit)
|
||||
.map(ResultRow::toOperatorRecord)
|
||||
}
|
||||
@@ -495,12 +597,32 @@ class ExposedAdminRepository(
|
||||
override suspend fun listAudit(
|
||||
limit: Int,
|
||||
before: AdminAuditCursor?,
|
||||
query: AdminAuditQuery,
|
||||
): List<AdminAuditRecord> =
|
||||
databaseFactory.query {
|
||||
require(limit in 1..101)
|
||||
val query = AdminAuditLogTable.selectAll()
|
||||
val statement = AdminAuditLogTable.selectAll()
|
||||
query.time.from?.let { from ->
|
||||
statement.andWhere { AdminAuditLogTable.occurredAt greaterEq from }
|
||||
}
|
||||
query.time.until?.let { until ->
|
||||
statement.andWhere { AdminAuditLogTable.occurredAt less until }
|
||||
}
|
||||
query.action?.let { action ->
|
||||
statement.andWhere { AdminAuditLogTable.action eq action.name }
|
||||
}
|
||||
query.outcome?.let { outcome ->
|
||||
statement.andWhere { AdminAuditLogTable.outcome eq outcome.name }
|
||||
}
|
||||
if (before != null) {
|
||||
query.andWhere {
|
||||
statement.andWhere {
|
||||
if (query.order == AdminSortOrder.ASC) {
|
||||
(AdminAuditLogTable.occurredAt greater before.occurredAt) or
|
||||
(
|
||||
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
|
||||
(AdminAuditLogTable.id greater before.id.toString())
|
||||
)
|
||||
} else {
|
||||
(AdminAuditLogTable.occurredAt less before.occurredAt) or
|
||||
(
|
||||
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
|
||||
@@ -508,10 +630,12 @@ class ExposedAdminRepository(
|
||||
)
|
||||
}
|
||||
}
|
||||
query
|
||||
}
|
||||
val sortOrder = query.order.toExposedSortOrder()
|
||||
statement
|
||||
.orderBy(
|
||||
AdminAuditLogTable.occurredAt to SortOrder.DESC,
|
||||
AdminAuditLogTable.id to SortOrder.DESC,
|
||||
AdminAuditLogTable.occurredAt to sortOrder,
|
||||
AdminAuditLogTable.id to sortOrder,
|
||||
)
|
||||
.limit(limit)
|
||||
.map {
|
||||
@@ -528,6 +652,9 @@ class ExposedAdminRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun AdminSortOrder.toExposedSortOrder(): SortOrder =
|
||||
if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC
|
||||
|
||||
private fun insertAudit(event: NewAdminAuditEvent) {
|
||||
AdminAuditLogTable.insert {
|
||||
it[id] = event.id.toString()
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.osglab.account.features.admin.routes
|
||||
|
||||
import com.osglab.account.config.AppConfig
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.content.feed.HintFeedErrorCode
|
||||
import com.osglab.account.features.content.feed.HintFeedException
|
||||
import com.osglab.account.features.content.feed.HintFeedService
|
||||
import com.osglab.account.features.content.feed.UpdateHintFeedSettingsRequest
|
||||
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
|
||||
import com.osglab.account.features.content.models.UpdateHintPackRequest
|
||||
import com.osglab.account.features.content.models.UpdateOfficialSkillRequest
|
||||
import com.osglab.account.features.content.services.ContentErrorCode
|
||||
import com.osglab.account.features.content.services.ContentException
|
||||
import com.osglab.account.features.content.services.ContentService
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.plugins.BadRequestException
|
||||
import io.ktor.server.request.header
|
||||
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.put
|
||||
import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
internal fun Route.adminContentRoutes(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
service: ContentService,
|
||||
hintFeedService: HintFeedService? = null,
|
||||
) {
|
||||
route("/content") {
|
||||
get("/skills") {
|
||||
if (call.requireContentReader(config, sessions) == null) return@get
|
||||
call.respond(service.adminSkills())
|
||||
}
|
||||
|
||||
post("/skills") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@post
|
||||
val request = call.receiveContentRequest<CreateOfficialSkillRequest>() ?: return@post
|
||||
call.respondContentError {
|
||||
call.respond(
|
||||
HttpStatusCode.Created,
|
||||
service.createSkill(principal, request, call.request.header("X-Request-ID")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
put("/skills/{id}") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@put
|
||||
val id = call.parameters["id"] ?: return@put call.respondContentValidationError()
|
||||
val request = call.receiveContentRequest<UpdateOfficialSkillRequest>() ?: return@put
|
||||
call.respondContentError {
|
||||
call.respond(
|
||||
service.updateSkill(principal, id, request, call.request.header("X-Request-ID")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
post("/skills/{id}/enable") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@post
|
||||
val id = call.parameters["id"] ?: return@post call.respondContentValidationError()
|
||||
call.respondContentError {
|
||||
service.setSkillEnabled(principal, id, enabled = true, call.request.header("X-Request-ID"))
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
|
||||
post("/skills/{id}/disable") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@post
|
||||
val id = call.parameters["id"] ?: return@post call.respondContentValidationError()
|
||||
call.respondContentError {
|
||||
service.setSkillEnabled(principal, id, enabled = false, call.request.header("X-Request-ID"))
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
|
||||
hintFeedService?.let { feed ->
|
||||
get("/hints/generation/settings") {
|
||||
if (call.requireContentReader(config, sessions) == null) return@get
|
||||
call.respond(feed.settings())
|
||||
}
|
||||
put("/hints/generation/settings") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@put
|
||||
val request = call.receiveContentRequest<UpdateHintFeedSettingsRequest>() ?: return@put
|
||||
call.respondHintFeedError {
|
||||
call.respond(
|
||||
feed.updateSettings(
|
||||
principal,
|
||||
request,
|
||||
call.request.header("X-Request-ID"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
get("/hints/generation/status") {
|
||||
if (call.requireContentReader(config, sessions) == null) return@get
|
||||
call.respond(feed.status())
|
||||
}
|
||||
post("/hints/generation/regenerate") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@post
|
||||
call.respondHintFeedError {
|
||||
call.respond(
|
||||
feed.regenerate(principal, call.request.header("X-Request-ID")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get("/hints/{locale}") {
|
||||
if (call.requireContentReader(config, sessions) == null) return@get
|
||||
val locale = call.parameters["locale"] ?: return@get call.respondContentValidationError()
|
||||
call.respondContentError {
|
||||
call.respond(service.adminHintPack(locale))
|
||||
}
|
||||
}
|
||||
|
||||
put("/hints/{locale}") {
|
||||
val principal = call.requireContentEditor(config, sessions) ?: return@put
|
||||
val locale = call.parameters["locale"] ?: return@put call.respondContentValidationError()
|
||||
val request = call.receiveContentRequest<UpdateHintPackRequest>() ?: return@put
|
||||
call.respondContentError {
|
||||
call.respond(
|
||||
service.putHintPack(principal, locale, request, call.request.header("X-Request-ID")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireContentReader(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
) = requireRole(config, sessions, setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT))
|
||||
|
||||
private suspend fun ApplicationCall.requireContentEditor(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
): AdminPrincipal? {
|
||||
val principal = requireMutationPrincipal(config, sessions) ?: return null
|
||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||
respond(HttpStatusCode.Forbidden, ContentAdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
||||
return null
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : Any> ApplicationCall.receiveContentRequest(): T? =
|
||||
try {
|
||||
receive<T>()
|
||||
} catch (_: BadRequestException) {
|
||||
respondContentValidationError()
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.respondContentError(block: suspend () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
} catch (exception: ContentException) {
|
||||
val status = when (exception.code) {
|
||||
ContentErrorCode.VALIDATION_ERROR -> HttpStatusCode.BadRequest
|
||||
ContentErrorCode.CONTENT_SKILL_NOT_FOUND,
|
||||
ContentErrorCode.CONTENT_HINT_PACK_NOT_FOUND,
|
||||
-> HttpStatusCode.NotFound
|
||||
ContentErrorCode.CONTENT_SKILL_CONFLICT -> HttpStatusCode.Conflict
|
||||
}
|
||||
respond(status, ContentAdminErrorResponse(exception.code.name))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.respondContentValidationError() {
|
||||
respond(HttpStatusCode.BadRequest, ContentAdminErrorResponse(ContentErrorCode.VALIDATION_ERROR.name))
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.respondHintFeedError(block: suspend () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
} catch (exception: HintFeedException) {
|
||||
val status = when (exception.code) {
|
||||
HintFeedErrorCode.HINT_FEED_GENERATION_IN_PROGRESS -> HttpStatusCode.Conflict
|
||||
HintFeedErrorCode.HINT_FEED_SETTINGS_INVALID -> HttpStatusCode.BadRequest
|
||||
HintFeedErrorCode.HINT_FEED_GENERATION_FAILED -> HttpStatusCode.BadGateway
|
||||
}
|
||||
respond(status, ContentAdminErrorResponse(exception.code.name))
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ContentAdminErrorResponse(val code: String)
|
||||
@@ -3,10 +3,18 @@ package com.osglab.account.features.admin.routes
|
||||
import com.osglab.account.config.AppConfig
|
||||
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
|
||||
import com.osglab.account.features.admin.grants.services.AdminGrantService
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminAuditQuery
|
||||
import com.osglab.account.features.admin.models.AdminLoginResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.AdminStepUpResult
|
||||
import com.osglab.account.features.admin.models.AdminTimeFilter
|
||||
import com.osglab.account.features.admin.services.AdminAuditCursorException
|
||||
import com.osglab.account.features.admin.services.AdminAuditService
|
||||
import com.osglab.account.features.admin.services.AdminAuthService
|
||||
@@ -18,40 +26,67 @@ import com.osglab.account.features.admin.services.AdminOperatorService
|
||||
import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.admin.stats.models.AdminStatsDto
|
||||
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
|
||||
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
|
||||
import com.osglab.account.features.admin.stats.services.AdminReferralSort
|
||||
import com.osglab.account.features.admin.stats.services.AdminStatsService
|
||||
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
|
||||
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
|
||||
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUsageType
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
|
||||
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
|
||||
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
|
||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.content.services.ContentService
|
||||
import com.osglab.account.features.content.feed.HintFeedService
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialService
|
||||
import com.osglab.account.features.gateway.credentials.InvalidProviderApiKeyException
|
||||
import io.ktor.http.Cookie
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.application.createRouteScopedPlugin
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.http.content.staticResources
|
||||
import io.ktor.server.plugins.BadRequestException
|
||||
import io.ktor.server.plugins.ratelimit.RateLimitName
|
||||
import io.ktor.server.plugins.ratelimit.rateLimit
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.header
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.put
|
||||
import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
fun Route.adminWebRoutes() {
|
||||
staticResources("/admin", "admin", index = "index.html")
|
||||
fun Route.adminWebRoutes(config: AppConfig) {
|
||||
route("/admin") {
|
||||
install(RequireVerifiedAdminEdge) {
|
||||
appConfig = config
|
||||
}
|
||||
staticResources("/", "admin", index = "index.html")
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.adminApiRoutes(
|
||||
@@ -59,17 +94,21 @@ fun Route.adminApiRoutes(
|
||||
authService: AdminAuthService,
|
||||
sessionService: AdminSessionService,
|
||||
statsService: AdminStatsService,
|
||||
productAnalyticsService: AdminProductAnalyticsService,
|
||||
usersService: AdminUsersService,
|
||||
grantService: AdminGrantService,
|
||||
operatorService: AdminOperatorService,
|
||||
auditService: AdminAuditService,
|
||||
credentialService: GatewayCredentialService? = null,
|
||||
contentService: ContentService? = null,
|
||||
hintFeedService: HintFeedService? = null,
|
||||
clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
route("/v1/admin") {
|
||||
rateLimit(ADMIN_AUTH_RATE_LIMIT) {
|
||||
route("/auth") {
|
||||
get("/session") {
|
||||
if (!call.requireVerifiedAdminEdge()) return@get
|
||||
if (!call.requireVerifiedAdminEdge(config)) return@get
|
||||
val principal = call.currentPrincipal(sessionService)
|
||||
call.respond(
|
||||
AdminSessionResponse(
|
||||
@@ -81,7 +120,7 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
|
||||
post("/login") {
|
||||
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
|
||||
if (!call.requireVerifiedAdminEdge(config) || !call.requireSameOrigin(config)) return@post
|
||||
val request = call.receive<AdminLoginRequest>()
|
||||
val password = request.password.toCharArray()
|
||||
val result = try {
|
||||
@@ -117,7 +156,7 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
|
||||
post("/logout") {
|
||||
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
|
||||
if (!call.requireVerifiedAdminEdge(config) || !call.requireSameOrigin(config)) return@post
|
||||
val sessionToken = call.request.cookies[SESSION_COOKIE]
|
||||
val csrfToken = call.request.header(CSRF_HEADER)
|
||||
if (
|
||||
@@ -138,8 +177,101 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
contentService?.let {
|
||||
adminContentRoutes(config, sessionService, it, hintFeedService)
|
||||
}
|
||||
|
||||
credentialService?.let { service ->
|
||||
get("/providers") {
|
||||
if (
|
||||
call.requireRole(
|
||||
config,
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN),
|
||||
) == null
|
||||
) return@get
|
||||
call.respond(service.listStatuses())
|
||||
}
|
||||
|
||||
rateLimit(ADMIN_AUTH_RATE_LIMIT) {
|
||||
post("/providers/{providerId}/api-key/reveal") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||
call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
||||
return@post
|
||||
}
|
||||
val provider = GatewayCredentialProvider.fromProviderId(
|
||||
call.parameters["providerId"],
|
||||
) ?: run {
|
||||
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("PROVIDER_NOT_FOUND"))
|
||||
return@post
|
||||
}
|
||||
val request = call.receiveAdminRequest<ProviderApiKeyRevealRequest>() ?: return@post
|
||||
when (
|
||||
authService.verifyStepUpTotp(
|
||||
principal = principal,
|
||||
totpCode = request.totpCode,
|
||||
action = AdminAuditAction.PROVIDER_API_KEY_REVEALED,
|
||||
targetType = "PROVIDER",
|
||||
targetId = provider.providerId,
|
||||
requestId = call.request.header("X-Request-ID"),
|
||||
)
|
||||
) {
|
||||
AdminStepUpResult.LOCKED -> {
|
||||
call.respond(HttpStatusCode.TooManyRequests, AdminErrorResponse("RATE_LIMITED"))
|
||||
return@post
|
||||
}
|
||||
|
||||
AdminStepUpResult.INVALID_TOTP -> {
|
||||
call.respond(HttpStatusCode.Unauthorized, AdminErrorResponse("INVALID_TOTP"))
|
||||
return@post
|
||||
}
|
||||
|
||||
AdminStepUpResult.VERIFIED -> Unit
|
||||
}
|
||||
val revealed = service.revealApiKey(provider) ?: run {
|
||||
call.respond(
|
||||
HttpStatusCode.NotFound,
|
||||
AdminErrorResponse("PROVIDER_API_KEY_NOT_CONFIGURED"),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
call.response.header(HttpHeaders.CacheControl, "no-store")
|
||||
call.response.header(HttpHeaders.Pragma, "no-cache")
|
||||
call.respond(ProviderApiKeyRevealResponse(revealed.value))
|
||||
}
|
||||
}
|
||||
|
||||
put("/providers/{providerId}/api-key") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@put
|
||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||
call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
||||
return@put
|
||||
}
|
||||
val provider = GatewayCredentialProvider.fromProviderId(
|
||||
call.parameters["providerId"],
|
||||
) ?: run {
|
||||
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("PROVIDER_NOT_FOUND"))
|
||||
return@put
|
||||
}
|
||||
val request = call.receiveAdminRequest<ProviderApiKeyUpdateRequest>() ?: return@put
|
||||
val status = try {
|
||||
service.updateApiKey(
|
||||
provider = provider,
|
||||
apiKey = request.apiKey,
|
||||
operatorId = principal.operatorId,
|
||||
requestId = call.request.header("X-Request-ID"),
|
||||
)
|
||||
} catch (_: InvalidProviderApiKeyException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@put
|
||||
}
|
||||
call.respond(status)
|
||||
}
|
||||
}
|
||||
|
||||
get("/overview") {
|
||||
if (call.requirePrincipal(sessionService) == null) return@get
|
||||
if (call.requirePrincipal(config, sessionService) == null) return@get
|
||||
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
@@ -149,8 +281,19 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
|
||||
get("/referrals") {
|
||||
if (call.requirePrincipal(sessionService) == null) return@get
|
||||
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
|
||||
if (call.requirePrincipal(config, sessionService) == null) return@get
|
||||
val options = runCatching { call.adminReferralQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val stats = statsService.getRange(
|
||||
range = call.request.queryParameters["range"],
|
||||
clock = clock,
|
||||
referralRankLimit = options.limit,
|
||||
referralSort = options.sort,
|
||||
referralOrder = options.order,
|
||||
)
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -158,13 +301,29 @@ fun Route.adminApiRoutes(
|
||||
call.respond(stats.toReferralResponse())
|
||||
}
|
||||
|
||||
get("/analytics") {
|
||||
if (call.requirePrincipal(config, sessionService) == null) return@get
|
||||
val window = parseAdminStatsRange(call.request.queryParameters["range"], clock)
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
call.respond(productAnalyticsService.get(window.first, window.second))
|
||||
}
|
||||
|
||||
get("/users") {
|
||||
if (
|
||||
call.requireRole(
|
||||
config,
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
) return@get
|
||||
val listQuery = runCatching { call.adminUserListQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -175,9 +334,10 @@ fun Route.adminApiRoutes(
|
||||
usersService.list(
|
||||
limit = limit,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
query = listQuery,
|
||||
)
|
||||
} else {
|
||||
usersService.searchByInternalId(query)
|
||||
usersService.searchByInternalId(query, listQuery)
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
@@ -194,6 +354,7 @@ fun Route.adminApiRoutes(
|
||||
get("/users/{userId}") {
|
||||
if (
|
||||
call.requireRole(
|
||||
config,
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
@@ -209,11 +370,17 @@ fun Route.adminApiRoutes(
|
||||
get("/users/{userId}/ledger") {
|
||||
if (
|
||||
call.requireRole(
|
||||
config,
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
) return@get
|
||||
val userId = call.uuidPathParameter("userId") ?: return@get
|
||||
val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -223,6 +390,7 @@ fun Route.adminApiRoutes(
|
||||
userId = userId,
|
||||
limit = limit,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
query = ledgerQuery,
|
||||
)
|
||||
call.respond(
|
||||
PageResponse(
|
||||
@@ -237,6 +405,40 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
get("/credits/ledger") {
|
||||
if (
|
||||
call.requireRole(
|
||||
config,
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||
) == null
|
||||
) return@get
|
||||
val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
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"],
|
||||
query = ledgerQuery,
|
||||
)
|
||||
call.respond(
|
||||
PageResponse(
|
||||
page.items.map(AdminUserLedgerEntryDto::toLedgerResponse),
|
||||
page.nextCursor,
|
||||
),
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
}
|
||||
}
|
||||
|
||||
post("/credits/grants") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||
@@ -286,7 +488,7 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
|
||||
get("/operators/summary") {
|
||||
val principal = call.requirePrincipal(sessionService) ?: return@get
|
||||
val principal = call.requirePrincipal(config, sessionService) ?: return@get
|
||||
try {
|
||||
val summary = operatorService.summary(principal)
|
||||
call.respond(
|
||||
@@ -302,7 +504,12 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
|
||||
get("/operators") {
|
||||
val principal = call.requirePrincipal(sessionService) ?: return@get
|
||||
val principal = call.requirePrincipal(config, sessionService) ?: return@get
|
||||
val operatorQuery = runCatching { call.adminOperatorQuery(clock.instant()) }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -312,6 +519,7 @@ fun Route.adminApiRoutes(
|
||||
actor = principal,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
limit = limit,
|
||||
query = operatorQuery,
|
||||
)
|
||||
call.respond(
|
||||
PageResponse(
|
||||
@@ -429,7 +637,12 @@ fun Route.adminApiRoutes(
|
||||
}
|
||||
|
||||
get("/audit") {
|
||||
val principal = call.requirePrincipal(sessionService) ?: return@get
|
||||
val principal = call.requirePrincipal(config, sessionService) ?: return@get
|
||||
val auditQuery = runCatching { call.adminAuditQuery() }.getOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
}
|
||||
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@get
|
||||
@@ -439,6 +652,7 @@ fun Route.adminApiRoutes(
|
||||
actor = principal,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
limit = limit,
|
||||
query = auditQuery,
|
||||
)
|
||||
} catch (exception: AdminOperatorException) {
|
||||
call.respondOperatorError(exception)
|
||||
@@ -472,7 +686,21 @@ fun Route.adminApiRoutes(
|
||||
private suspend fun AdminStatsService.getRange(
|
||||
range: String?,
|
||||
clock: Clock,
|
||||
referralRankLimit: Int = 20,
|
||||
referralSort: AdminReferralSort? = null,
|
||||
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
|
||||
): AdminStatsDto? {
|
||||
val window = parseAdminStatsRange(range, clock) ?: return null
|
||||
return get(
|
||||
from = window.first,
|
||||
until = window.second,
|
||||
referralRankLimit = referralRankLimit,
|
||||
referralSort = referralSort,
|
||||
referralOrder = referralOrder,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.Instant, java.time.Instant>? {
|
||||
val days = when (range) {
|
||||
null, "30d" -> 30L
|
||||
"7d" -> 7L
|
||||
@@ -480,13 +708,211 @@ private suspend fun AdminStatsService.getRange(
|
||||
else -> return null
|
||||
}
|
||||
val until = clock.instant()
|
||||
return get(until.minus(Duration.ofDays(days)), until)
|
||||
val firstIncludedDate = until.atZone(ZoneOffset.UTC).toLocalDate().minusDays(days - 1)
|
||||
val from = firstIncludedDate.atStartOfDay(ZoneOffset.UTC).toInstant()
|
||||
return from to until
|
||||
}
|
||||
|
||||
private data class AdminReferralQueryOptions(
|
||||
val sort: AdminReferralSort?,
|
||||
val order: AdminSortOrder,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
private fun ApplicationCall.adminReferralQuery(): AdminReferralQueryOptions {
|
||||
requireQueryParameters(setOf("range", "sort", "order", "limit"))
|
||||
val explicitSort = request.queryParameters["sort"]?.let {
|
||||
when (it) {
|
||||
"invited" -> AdminReferralSort.INVITED
|
||||
"qualified" -> AdminReferralSort.QUALIFIED
|
||||
"creditsEarned" -> AdminReferralSort.CREDITS_EARNED
|
||||
else -> throw IllegalArgumentException("Invalid referral sort")
|
||||
}
|
||||
}
|
||||
val orderValue = request.queryParameters["order"]
|
||||
val order = parseSortOrder(orderValue, AdminSortOrder.DESC)
|
||||
val limit = request.queryParameters["limit"]?.toIntOrNull()?.takeIf { it in 1..100 } ?: run {
|
||||
require(request.queryParameters["limit"] == null)
|
||||
20
|
||||
}
|
||||
return AdminReferralQueryOptions(
|
||||
sort = explicitSort ?: if (orderValue != null) AdminReferralSort.INVITED else null,
|
||||
order = order,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminUserListQuery(): AdminUserListQuery {
|
||||
requireQueryParameters(setOf("q", "cursor", "limit", "from", "until", "status", "sort", "order"))
|
||||
request.queryParameters["q"]?.let { require(it.trim().length <= 36) }
|
||||
requireCreatedAtSort()
|
||||
val status = request.queryParameters["status"]?.let {
|
||||
when (it) {
|
||||
"active" -> AdminUserStatus.ACTIVE
|
||||
"suspended" -> AdminUserStatus.SUSPENDED
|
||||
else -> throw IllegalArgumentException("Invalid user status")
|
||||
}
|
||||
}
|
||||
return AdminUserListQuery(
|
||||
time = adminTimeFilter(),
|
||||
status = status,
|
||||
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery {
|
||||
requireQueryParameters(
|
||||
setOf(
|
||||
"cursor",
|
||||
"limit",
|
||||
"from",
|
||||
"until",
|
||||
"type",
|
||||
"entryType",
|
||||
"usageType",
|
||||
"referenceId",
|
||||
"sort",
|
||||
"order",
|
||||
),
|
||||
)
|
||||
val type = request.queryParameters["type"]?.let {
|
||||
when (it) {
|
||||
"reserve" -> AdminLedgerType.RESERVE
|
||||
"settle" -> AdminLedgerType.SETTLE
|
||||
"refund" -> AdminLedgerType.REFUND
|
||||
"grant" -> AdminLedgerType.GRANT
|
||||
else -> throw IllegalArgumentException("Invalid ledger type")
|
||||
}
|
||||
}
|
||||
val entryType = request.queryParameters["entryType"]?.let {
|
||||
runCatching { LedgerEntryType.valueOf(it) }.getOrNull()
|
||||
?: throw IllegalArgumentException("Invalid ledger entry type")
|
||||
}
|
||||
val usageType = request.queryParameters["usageType"]?.let {
|
||||
when (it) {
|
||||
"polish" -> AdminUsageType.POLISH
|
||||
"asr" -> AdminUsageType.ASR
|
||||
"ai" -> AdminUsageType.AI
|
||||
"agent" -> AdminUsageType.AGENT
|
||||
"hotword" -> AdminUsageType.HOTWORD
|
||||
else -> throw IllegalArgumentException("Invalid ledger usage type")
|
||||
}
|
||||
}
|
||||
val referenceId = request.queryParameters["referenceId"]?.let {
|
||||
runCatching { UUID.fromString(it) }.getOrNull()
|
||||
?: throw IllegalArgumentException("Invalid ledger reference ID")
|
||||
}
|
||||
val sort = request.queryParameters["sort"]?.let {
|
||||
when (it) {
|
||||
"createdAt" -> AdminLedgerSort.CREATED_AT
|
||||
"amount" -> AdminLedgerSort.AMOUNT
|
||||
else -> throw IllegalArgumentException("Invalid ledger sort")
|
||||
}
|
||||
} ?: AdminLedgerSort.CREATED_AT
|
||||
return AdminLedgerQuery(
|
||||
time = adminTimeFilter(),
|
||||
type = type,
|
||||
entryType = entryType,
|
||||
usageType = usageType,
|
||||
referenceId = referenceId,
|
||||
sort = sort,
|
||||
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminOperatorQuery(now: Instant): AdminOperatorQuery {
|
||||
requireQueryParameters(
|
||||
setOf("cursor", "limit", "from", "until", "role", "enabled", "locked", "sort", "order"),
|
||||
)
|
||||
val role = request.queryParameters["role"]?.let {
|
||||
runCatching { AdminRole.valueOf(it) }.getOrNull()
|
||||
?: throw IllegalArgumentException("Invalid operator role")
|
||||
}
|
||||
val sort = request.queryParameters["sort"]?.let {
|
||||
when (it) {
|
||||
"createdAt" -> AdminOperatorSort.CREATED_AT
|
||||
"username" -> AdminOperatorSort.USERNAME
|
||||
"lastLoginAt" -> AdminOperatorSort.LAST_LOGIN_AT
|
||||
else -> throw IllegalArgumentException("Invalid operator sort")
|
||||
}
|
||||
} ?: AdminOperatorSort.CREATED_AT
|
||||
return AdminOperatorQuery(
|
||||
time = adminTimeFilter(),
|
||||
role = role,
|
||||
enabled = request.queryParameters["enabled"]?.let(::parseStrictBoolean),
|
||||
locked = request.queryParameters["locked"]?.let(::parseStrictBoolean),
|
||||
sort = sort,
|
||||
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.ASC),
|
||||
now = now,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminAuditQuery(): AdminAuditQuery {
|
||||
requireQueryParameters(
|
||||
setOf("cursor", "limit", "from", "until", "action", "result", "sort", "order"),
|
||||
)
|
||||
requireCreatedAtSort()
|
||||
val action = request.queryParameters["action"]?.let {
|
||||
runCatching { AdminAuditAction.valueOf(it) }.getOrNull()
|
||||
?: throw IllegalArgumentException("Invalid audit action")
|
||||
}
|
||||
val outcome = request.queryParameters["result"]?.let {
|
||||
when (it) {
|
||||
"success" -> AdminAuditOutcome.SUCCESS
|
||||
"rejected" -> AdminAuditOutcome.DENIED
|
||||
else -> throw IllegalArgumentException("Invalid audit result")
|
||||
}
|
||||
}
|
||||
return AdminAuditQuery(
|
||||
time = adminTimeFilter(),
|
||||
action = action,
|
||||
outcome = outcome,
|
||||
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ApplicationCall.adminTimeFilter(): AdminTimeFilter =
|
||||
AdminTimeFilter(
|
||||
from = request.queryParameters["from"]?.let(::parseUtcInstant),
|
||||
until = request.queryParameters["until"]?.let(::parseUtcInstant),
|
||||
)
|
||||
|
||||
private fun parseUtcInstant(value: String): Instant {
|
||||
val parsed = OffsetDateTime.parse(value)
|
||||
require(parsed.offset == ZoneOffset.UTC)
|
||||
return parsed.toInstant()
|
||||
}
|
||||
|
||||
private fun ApplicationCall.requireCreatedAtSort() {
|
||||
request.queryParameters["sort"]?.let { require(it == "createdAt") }
|
||||
}
|
||||
|
||||
private fun ApplicationCall.requireQueryParameters(allowed: Set<String>) {
|
||||
val parameters = request.queryParameters
|
||||
require(parameters.names().all { it in allowed })
|
||||
require(parameters.names().all { parameters.getAll(it).orEmpty().size == 1 })
|
||||
}
|
||||
|
||||
private fun parseSortOrder(value: String?, default: AdminSortOrder): AdminSortOrder =
|
||||
when (value) {
|
||||
null -> default
|
||||
"asc" -> AdminSortOrder.ASC
|
||||
"desc" -> AdminSortOrder.DESC
|
||||
else -> throw IllegalArgumentException("Invalid sort order")
|
||||
}
|
||||
|
||||
private fun parseStrictBoolean(value: String): Boolean =
|
||||
when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> throw IllegalArgumentException("Invalid boolean filter")
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requirePrincipal(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
): AdminPrincipal? {
|
||||
if (!requireVerifiedAdminEdge()) return null
|
||||
if (!requireVerifiedAdminEdge(config)) return null
|
||||
val principal = currentPrincipal(sessions)
|
||||
if (principal == null) {
|
||||
respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
|
||||
@@ -494,11 +920,12 @@ private suspend fun ApplicationCall.requirePrincipal(
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireRole(
|
||||
internal suspend fun ApplicationCall.requireRole(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
allowedRoles: Set<AdminRole>,
|
||||
): AdminPrincipal? {
|
||||
val principal = requirePrincipal(sessions) ?: return null
|
||||
val principal = requirePrincipal(config, sessions) ?: return null
|
||||
if (principal.role !in allowedRoles) {
|
||||
respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
||||
return null
|
||||
@@ -506,11 +933,11 @@ private suspend fun ApplicationCall.requireRole(
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireMutationPrincipal(
|
||||
internal suspend fun ApplicationCall.requireMutationPrincipal(
|
||||
config: AppConfig,
|
||||
sessions: AdminSessionService,
|
||||
): AdminPrincipal? {
|
||||
if (!requireVerifiedAdminEdge() || !requireSameOrigin(config)) return null
|
||||
if (!requireVerifiedAdminEdge(config) || !requireSameOrigin(config)) return null
|
||||
val sessionToken = request.cookies[SESSION_COOKIE]
|
||||
val csrfToken = request.header(CSRF_HEADER)
|
||||
val principal = if (sessionToken != null && csrfToken != null) {
|
||||
@@ -524,12 +951,31 @@ private suspend fun ApplicationCall.requireMutationPrincipal(
|
||||
return principal
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireVerifiedAdminEdge(): Boolean {
|
||||
if (request.header(MTLS_HEADER) == MTLS_VERIFIED) return true
|
||||
private suspend fun ApplicationCall.requireVerifiedAdminEdge(config: AppConfig): Boolean {
|
||||
if (isVerifiedAdminEdge(config)) return true
|
||||
respond(HttpStatusCode.NotFound)
|
||||
return false
|
||||
}
|
||||
|
||||
private fun ApplicationCall.isVerifiedAdminEdge(config: AppConfig): Boolean =
|
||||
!config.admin.mtlsRequired || request.header(MTLS_HEADER) == MTLS_VERIFIED
|
||||
|
||||
private class AdminEdgePluginConfig {
|
||||
lateinit var appConfig: AppConfig
|
||||
}
|
||||
|
||||
private val RequireVerifiedAdminEdge = createRouteScopedPlugin(
|
||||
name = "RequireVerifiedAdminEdge",
|
||||
createConfiguration = ::AdminEdgePluginConfig,
|
||||
) {
|
||||
val appConfig = pluginConfig.appConfig
|
||||
onCall { call ->
|
||||
if (!call.isVerifiedAdminEdge(appConfig)) {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ApplicationCall.requireSameOrigin(config: AppConfig): Boolean {
|
||||
if (request.header(HttpHeaders.Origin) == config.publicBaseUrl) return true
|
||||
respond(HttpStatusCode.Forbidden, AdminErrorResponse("ORIGIN_INVALID"))
|
||||
@@ -605,11 +1051,12 @@ private fun adminCookie(
|
||||
private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
|
||||
val consumedByDate = creditFlow.associateBy { it.date }
|
||||
return AdminOverviewResponse(
|
||||
period = period,
|
||||
totalUsers = overview.totalUsers,
|
||||
activeUsers = overview.activeUsers,
|
||||
newUsers = overview.registrations,
|
||||
totalCreditBalance = overview.totalCreditBalance,
|
||||
creditsGranted = overview.issuedCredits,
|
||||
creditsGranted = overview.grantedCredits,
|
||||
creditsUsed = overview.consumedCredits,
|
||||
trend = registrationTrend.map {
|
||||
AdminTrendResponse(
|
||||
@@ -624,14 +1071,13 @@ private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
|
||||
|
||||
private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
|
||||
AdminReferralResponse(
|
||||
period = period,
|
||||
pendingBindings = referralFunnel.pendingBindings,
|
||||
ineligibleBindings = referralFunnel.ineligibleBindings,
|
||||
funnel = listOf(
|
||||
AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated),
|
||||
AdminFunnelResponse("成功绑定", referralFunnel.bindings),
|
||||
AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings),
|
||||
AdminFunnelResponse("待资格确认", referralFunnel.pendingBindings),
|
||||
AdminFunnelResponse("不符合奖励条件", referralFunnel.ineligibleBindings),
|
||||
AdminFunnelResponse("绑定后首次 AI 成功", referralFunnel.activatedBindings),
|
||||
AdminFunnelResponse("完成奖励", referralFunnel.rewardedBindings),
|
||||
),
|
||||
ranking = referralRanking.map {
|
||||
AdminReferralRankResponse(
|
||||
@@ -646,9 +1092,10 @@ private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
|
||||
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
|
||||
AdminUserSummaryResponse(
|
||||
userId = id,
|
||||
displayName = "用户 ${id.take(8)}",
|
||||
displayName = "用户 ${id.takeLast(8).uppercase()}",
|
||||
status = if (antiAbuseRestricted) "suspended" else "active",
|
||||
creditBalance = creditBalance,
|
||||
consumedCredits = consumedCredits,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
@@ -666,20 +1113,35 @@ private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
|
||||
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
||||
AdminLedgerResponse(
|
||||
entryId = id,
|
||||
type = when (type) {
|
||||
"USAGE_RESERVE" -> "reserve"
|
||||
"USAGE_SETTLE" -> "settle"
|
||||
"USAGE_RELEASE", "USAGE_REFUND" -> "refund"
|
||||
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
|
||||
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant"
|
||||
else -> "adjustment"
|
||||
},
|
||||
userId = userId,
|
||||
type = entryType.toAdminLedgerType(),
|
||||
entryType = entryType.name,
|
||||
amount = amountDelta,
|
||||
balanceAfter = balanceAfter,
|
||||
reasonCode = type,
|
||||
reasonCode = entryType.name,
|
||||
referenceId = referenceId,
|
||||
usageType = usageType,
|
||||
details = details,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun LedgerEntryType.toAdminLedgerType(): String =
|
||||
when (this) {
|
||||
LedgerEntryType.USAGE_RESERVE -> "reserve"
|
||||
LedgerEntryType.USAGE_SETTLE -> "settle"
|
||||
LedgerEntryType.USAGE_RELEASE,
|
||||
LedgerEntryType.USAGE_REFUND,
|
||||
-> "refund"
|
||||
|
||||
LedgerEntryType.SIGNUP_TRIAL,
|
||||
LedgerEntryType.MANUAL_GRANT,
|
||||
LedgerEntryType.REFERRAL_INVITER,
|
||||
LedgerEntryType.REFERRAL_INVITEE,
|
||||
LedgerEntryType.STOREKIT_PURCHASE,
|
||||
LedgerEntryType.SUBSCRIPTION_GRANT,
|
||||
-> "grant"
|
||||
}
|
||||
|
||||
private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse =
|
||||
AdminOperatorResponse(
|
||||
operatorId = id.toString(),
|
||||
@@ -732,6 +1194,19 @@ private data class AdminGrantRequest(val userId: String, val amount: Long, val r
|
||||
@Serializable
|
||||
private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long)
|
||||
|
||||
@Serializable
|
||||
private data class ProviderApiKeyUpdateRequest(val apiKey: String)
|
||||
|
||||
@Serializable
|
||||
private class ProviderApiKeyRevealRequest(val totpCode: String) {
|
||||
override fun toString(): String = "ProviderApiKeyRevealRequest(totpCode=[REDACTED])"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private class ProviderApiKeyRevealResponse(val apiKey: String) {
|
||||
override fun toString(): String = "ProviderApiKeyRevealResponse(apiKey=[REDACTED])"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class AdminOperatorCreateRequest(
|
||||
val username: String,
|
||||
@@ -775,6 +1250,7 @@ private data class PageResponse<T>(val items: List<T>, val nextCursor: String? =
|
||||
|
||||
@Serializable
|
||||
private data class AdminOverviewResponse(
|
||||
val period: com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto,
|
||||
val totalUsers: Long,
|
||||
val activeUsers: Long,
|
||||
val newUsers: Long,
|
||||
@@ -794,6 +1270,7 @@ private data class AdminTrendResponse(
|
||||
|
||||
@Serializable
|
||||
private data class AdminReferralResponse(
|
||||
val period: com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto,
|
||||
val pendingBindings: Long,
|
||||
val ineligibleBindings: Long,
|
||||
val funnel: List<AdminFunnelResponse>,
|
||||
@@ -817,6 +1294,7 @@ private data class AdminUserSummaryResponse(
|
||||
val displayName: String,
|
||||
val status: String,
|
||||
val creditBalance: Long,
|
||||
val consumedCredits: Long,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
@@ -826,6 +1304,7 @@ private data class AdminUserDetailResponse(
|
||||
val displayName: String,
|
||||
val status: String,
|
||||
val creditBalance: Long,
|
||||
val consumedCredits: Long,
|
||||
val createdAt: String,
|
||||
val lastActiveAt: String?,
|
||||
val qualifiedUsage: Boolean,
|
||||
@@ -847,6 +1326,7 @@ private data class AdminUserDetailResponse(
|
||||
displayName = summary.displayName,
|
||||
status = summary.status,
|
||||
creditBalance = summary.creditBalance,
|
||||
consumedCredits = summary.consumedCredits,
|
||||
createdAt = summary.createdAt,
|
||||
lastActiveAt = lastActiveAt,
|
||||
qualifiedUsage = qualifiedUsage,
|
||||
@@ -860,10 +1340,15 @@ private data class AdminUserDetailResponse(
|
||||
@Serializable
|
||||
private data class AdminLedgerResponse(
|
||||
val entryId: String,
|
||||
val userId: String,
|
||||
val type: String,
|
||||
val entryType: String,
|
||||
val amount: Long,
|
||||
val balanceAfter: Long,
|
||||
val reasonCode: String,
|
||||
val referenceId: String?,
|
||||
val usageType: String?,
|
||||
val details: AdminLedgerDetailsDto?,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.osglab.account.features.admin.services
|
||||
|
||||
import com.osglab.account.features.admin.models.AdminAuditCursor
|
||||
import com.osglab.account.features.admin.models.AdminAuditQuery
|
||||
import com.osglab.account.features.admin.models.AdminAuditRecord
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Instant
|
||||
@@ -29,13 +31,14 @@ class AdminAuditService(
|
||||
actor: AdminPrincipal,
|
||||
cursor: String?,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
query: AdminAuditQuery = AdminAuditQuery(),
|
||||
): AdminAuditPage {
|
||||
if (actor.role != AdminRole.SUPER_ADMIN) {
|
||||
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
||||
}
|
||||
require(limit in 1..MAX_PAGE_SIZE)
|
||||
val decodedCursor = cursor?.let(::decodeCursor)
|
||||
val records = repository.listAudit(limit + 1, decodedCursor)
|
||||
val decodedCursor = cursor?.let { decodeCursor(it, query.order) }
|
||||
val records = repository.listAudit(limit + 1, decodedCursor, query)
|
||||
val pageRecords = records.take(limit)
|
||||
val operatorNames = repository.listOperators().associate {
|
||||
it.id to it.normalizedUsername
|
||||
@@ -50,36 +53,41 @@ class AdminAuditService(
|
||||
)
|
||||
},
|
||||
nextCursor = if (records.size > limit) {
|
||||
pageRecords.lastOrNull()?.let(::encodeCursor)
|
||||
pageRecords.lastOrNull()?.let { encodeCursor(it, query.order) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun decodeCursor(value: String): AdminAuditCursor {
|
||||
private fun decodeCursor(value: String, expectedOrder: AdminSortOrder): AdminAuditCursor {
|
||||
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException()
|
||||
return runCatching {
|
||||
val decoded = String(
|
||||
Base64.getUrlDecoder().decode(value),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split(':', limit = 3)
|
||||
require(parts.size == 3)
|
||||
val parts = decoded.split(':', limit = 5)
|
||||
require(parts.size == 5)
|
||||
require(parts[0] == "v1")
|
||||
require(parts[1] == expectedOrder.name)
|
||||
AdminAuditCursor(
|
||||
occurredAt = Instant.ofEpochSecond(
|
||||
parts[0].toLong(),
|
||||
parts[1].toLong(),
|
||||
parts[2].toLong(),
|
||||
parts[3].toLong(),
|
||||
),
|
||||
id = UUID.fromString(parts[2]),
|
||||
id = UUID.fromString(parts[4]),
|
||||
)
|
||||
}.getOrElse {
|
||||
throw AdminAuditCursorException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeCursor(record: AdminAuditRecord): String {
|
||||
private fun encodeCursor(record: AdminAuditRecord, order: AdminSortOrder): String {
|
||||
val payload = buildString {
|
||||
append("v1:")
|
||||
append(order.name)
|
||||
append(':')
|
||||
append(record.occurredAt.epochSecond)
|
||||
append(':')
|
||||
append(record.occurredAt.nano)
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.osglab.account.features.admin.models.AdminLockState
|
||||
import com.osglab.account.features.admin.models.AdminLoginResult
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminSessionCredentials
|
||||
import com.osglab.account.features.admin.models.AdminStepUpResult
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminSession
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
@@ -155,6 +156,50 @@ class AdminAuthService(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun verifyStepUpTotp(
|
||||
principal: AdminPrincipal,
|
||||
totpCode: String,
|
||||
action: AdminAuditAction,
|
||||
targetType: String,
|
||||
targetId: String,
|
||||
requestId: String? = null,
|
||||
): AdminStepUpResult {
|
||||
val now = clock.instant()
|
||||
val operator = repository.findOperatorForAuthentication(principal.normalizedUsername)
|
||||
val result = when {
|
||||
operator == null ||
|
||||
operator.id != principal.operatorId ||
|
||||
operator.disabledAt != null -> AdminStepUpResult.INVALID_TOTP
|
||||
|
||||
operator.lockState.isLockedAt(now) -> AdminStepUpResult.LOCKED
|
||||
|
||||
verifyTotp(
|
||||
operator.id,
|
||||
operator.encryptedTotpSecret,
|
||||
totpCode,
|
||||
now,
|
||||
) != null -> AdminStepUpResult.VERIFIED
|
||||
|
||||
else -> AdminStepUpResult.INVALID_TOTP
|
||||
}
|
||||
repository.appendAudit(
|
||||
NewAdminAuditEvent(
|
||||
actorOperatorId = principal.operatorId,
|
||||
action = action,
|
||||
outcome = if (result == AdminStepUpResult.VERIFIED) {
|
||||
AdminAuditOutcome.SUCCESS
|
||||
} else {
|
||||
AdminAuditOutcome.DENIED
|
||||
},
|
||||
targetType = targetType,
|
||||
targetId = targetId,
|
||||
requestId = validateRequestId(requestId),
|
||||
occurredAt = now,
|
||||
),
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun failAuthentication(
|
||||
operatorId: UUID,
|
||||
now: Instant,
|
||||
|
||||
+39
-12
@@ -5,9 +5,12 @@ import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
||||
import com.osglab.account.features.admin.models.AdminOperatorQuery
|
||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.AdminSortOrder
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
||||
@@ -15,9 +18,8 @@ import com.osglab.account.features.admin.security.AdminPasswordHasher
|
||||
import com.osglab.account.features.admin.security.AdminTotpProvisioning
|
||||
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
|
||||
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Clock
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
@@ -73,17 +75,33 @@ class AdminOperatorService(
|
||||
actor: AdminPrincipal,
|
||||
cursor: String?,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
): AdminOperatorPage = listPage(
|
||||
actor = actor,
|
||||
cursor = cursor,
|
||||
limit = limit,
|
||||
query = AdminOperatorQuery(now = clock.instant()),
|
||||
)
|
||||
|
||||
suspend fun listPage(
|
||||
actor: AdminPrincipal,
|
||||
cursor: String?,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
query: AdminOperatorQuery,
|
||||
): AdminOperatorPage {
|
||||
requireSuperAdministrator(actor)
|
||||
if (limit !in 1..MAX_PAGE_SIZE) {
|
||||
throw AdminOperatorCursorException()
|
||||
}
|
||||
val decodedCursor = cursor?.let(::decodeCursor)
|
||||
val records = repository.listOperatorsPage(limit + 1, decodedCursor)
|
||||
val decodedCursor = cursor?.let { decodeCursor(it, query) }
|
||||
val records = repository.listOperatorsPage(limit + 1, decodedCursor, query)
|
||||
val items = records.take(limit)
|
||||
return AdminOperatorPage(
|
||||
items = items,
|
||||
nextCursor = if (records.size > limit) items.lastOrNull()?.let(::encodeCursor) else null,
|
||||
nextCursor = if (records.size > limit) {
|
||||
items.lastOrNull()?.let { encodeCursor(it, query) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -370,28 +388,37 @@ class AdminOperatorService(
|
||||
const val MAX_PASSWORD_CHARS = 1_024
|
||||
const val MAX_AUDIT_TARGET_CHARS = 128
|
||||
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
|
||||
const val NULL_CURSOR_VALUE = "~"
|
||||
}
|
||||
|
||||
private fun decodeCursor(value: String): AdminOperatorCursor {
|
||||
private fun decodeCursor(value: String, query: AdminOperatorQuery): AdminOperatorCursor {
|
||||
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException()
|
||||
return runCatching {
|
||||
val decoded = String(
|
||||
Base64.getUrlDecoder().decode(value),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split(':', limit = 3)
|
||||
require(parts.size == 3)
|
||||
val parts = decoded.split('|')
|
||||
require(parts.size == 5)
|
||||
require(parts[0] == "v1")
|
||||
require(parts[1] == query.sort.name)
|
||||
require(parts[2] == query.order.name)
|
||||
AdminOperatorCursor(
|
||||
createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()),
|
||||
id = UUID.fromString(parts[2]),
|
||||
value = parts[3].takeUnless { it == NULL_CURSOR_VALUE },
|
||||
id = UUID.fromString(parts[4]),
|
||||
)
|
||||
}.getOrElse {
|
||||
throw AdminOperatorCursorException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeCursor(record: AdminOperatorRecord): String {
|
||||
val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}"
|
||||
private fun encodeCursor(record: AdminOperatorRecord, query: AdminOperatorQuery): String {
|
||||
val value = when (query.sort) {
|
||||
AdminOperatorSort.CREATED_AT -> record.createdAt.toString()
|
||||
AdminOperatorSort.USERNAME -> record.normalizedUsername
|
||||
AdminOperatorSort.LAST_LOGIN_AT -> record.lastLoginAt?.toString() ?: NULL_CURSOR_VALUE
|
||||
}
|
||||
val payload = "v1|${query.sort.name}|${query.order.name}|$value|${record.id}"
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||
payload.toByteArray(StandardCharsets.UTF_8),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user