37 Commits

Author SHA1 Message Date
Rocky 505515f746 test(auth): cover refresh route operation id forwarding
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Add AuthRoutesTest to lock in the contract for /v1/auth/refresh:
- forwards a valid refreshOperationId UUID into SessionService.refresh
- returns 400 with invalid_request when the operation id is malformed

These tests back the idempotent session refresh behaviour introduced in
4c9e5fe ('Make session refresh retries idempotent').
2026-08-30 12:10:56 +08:00
Rocky 4c9e5feec0 Make session refresh retries idempotent
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Preserve the successor session for legitimate refresh retries so transient failures no longer revoke the user's session family.
2026-08-25 13:13:41 +08:00
Rocky 36a926f12f Improve dynamic hint availability and streaming
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Expand curated hot topics while keeping slow current-information responses alive through reverse proxies and client idle timeouts.
2026-08-24 11:14:09 +08:00
Rocky e522788867 Refine dynamic hint feed curation
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Remove redundant cloud fallbacks and focus each locale on a compact, safer set of timely topics with diversified English RSS coverage.
2026-08-23 20:20:53 +08:00
Rocky 51c37e6206 fix(gateway): restore safe search fallback
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Fall back to guarded Chat Completions answers when DeepSeek web search fails so grounded current-information hints do not surface generic provider errors.
2026-08-23 14:32:43 +08:00
Rocky 03eac71905 Harden current-information search failures
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Preserve required-search semantics for live hints and expose stable gateway error codes without leaking provider details.
2026-08-22 22:43:16 +08:00
Rocky 4f5b6eafbd Merge remote-tracking branch 'origin/dependabot/github_actions/gradle/actions-6'
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
2026-08-22 17:56:54 +08:00
Rocky f95d09f303 Merge remote-tracking branch 'origin/dependabot/github_actions/actions/setup-java-5' 2026-08-22 17:56:54 +08:00
Rocky b4064126fe Merge remote-tracking branch 'origin/dependabot/github_actions/actions/checkout-7' 2026-08-22 17:56:54 +08:00
Rocky 544e0d7356 Add TOTP-gated provider API key reveal
Allow super administrators to inspect effective provider credentials only after audited, rate-limited step-up verification.
2026-08-22 17:56:35 +08:00
Rocky 636a8541bc Fix provider configuration response rendering
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Align the admin page with the array response defined by the production API and OpenAPI contract so successful status loads cannot trip the route error boundary.
2026-08-22 17:21:20 +08:00
Rocky 10ba4f0a0c Fix production migration privilege compatibility
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Rescope OOBE claims with permitted ALTER operations and make deployment smoke validation require every migration through V28.
2026-08-22 16:44:24 +08:00
Rocky 9fb947aa7d Add runtime provider controls and searchable AI routing
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Manage provider keys at runtime, route current-information questions through server-side search with safe fallback, and scope OOBE usage claims to grants.
2026-08-22 16:33:17 +08:00
Rocky f8fa93dc48 Fix production health build identification
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Mount the versioned health route instead of the legacy shadowing handler so deployments can verify the exact running image.
2026-08-21 23:15:59 +08:00
Rocky 0d236f57fb Add anonymous OOBE gateway grants
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Provide App Attest-bound, one-time onboarding AI access without creating accounts, with durable replay protection and production deployment safeguards.
2026-08-21 22:55:46 +08:00
Rocky edd0d9feca Correct product analytics cohorts and reporting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
2026-08-21 17:50:35 +08:00
Rocky b25f5ae6e9 Clarify Hint pack save behavior
Keep manual editing while making save the only user action and applying changes immediately without a separate publish workflow.
2026-08-21 15:37:13 +08:00
Rocky 454ba8ddc5 Migrate AI Hint feed generation
Bring dynamic hint generation into the account service while preserving the legacy key.osglab.com deployment for existing clients.
2026-08-21 15:17:15 +08:00
Rocky d0abe27623 Add managed content and keyboard usage insights
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Introduce versioned official content workflows and privacy-safe keyboard analytics, while preventing repeat DeviceCheck sign-ins from incorrectly restricting eligible accounts.
2026-08-21 13:34:03 +08:00
Rocky b5212dcdc2 Enhance ledger operations and referral lifecycle
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
2026-08-20 22:14:22 +08:00
Rocky 74c3fcd45f Add admin dashboard filtering and sorting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Provide stable server-side list queries and focused chart controls so operators can inspect large datasets without misleading partial-page ordering.
2026-08-20 18:05:49 +08:00
Rocky 034a3e8745 Add complimentary OOBE polish and configurable admin mTLS
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Allow one server-audited onboarding polish request without credits and make the certificate gate temporarily reversible while preserving application authentication.
2026-08-20 17:05:35 +08:00
Rocky 0b4acb5978 Visualize admin analytics dashboards
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Turn operational, product, and referral metrics into accessible charts so trends, funnels, retention, and channel quality are faster to interpret.
2026-08-20 16:02:21 +08:00
Rocky 4b465e0e5e Add privacy-safe product analytics
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Establish an idempotent analytics pipeline and internal decision dashboard while keeping event metadata allowlisted and account deletion enforceable.
2026-08-20 15:20:01 +08:00
Rocky 231c5040a5 Add StoreKit history and modernize admin console
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
2026-08-19 22:13:13 +08:00
Rocky 11ec34dacb Enforce deterministic gateway task policies
Make the server authoritative for thinking, model, search, tools, retry, and output budgets while preserving legacy client behavior.
2026-08-19 20:55:05 +08:00
Rocky 3edc86a9a0 Show product usage types in credit ledger
Persist hotword request origin so every reservation lifecycle entry can be classified without mutating the immutable ledger.
2026-08-19 19:47:12 +08:00
Rocky e1fd35b1ff Fix managed ASR settlement and empty polish recovery
Accept the final sequence format returned by Volcengine without weakening ordering checks, and retry one safe buffered DeepSeek empty response under the same credit reservation.
2026-08-19 19:16:05 +08:00
Rocky 2194e69bb8 Accept bounded StoreKit signing clock skew
Allow legitimate App Store transaction timestamps to differ within the existing verification tolerance while preserving rejection beyond that boundary.
2026-08-19 18:26:54 +08:00
Rocky 75c046d91d Show latest credit ledger in admin
Expose a paginated global ledger timeline and load it automatically so operators can see recent credit activity without first locating a user.
2026-08-19 18:05:54 +08:00
Rocky 58445dd880 Show complete user credit data in admin
Load users by registration order, expose consumed and current credits, and accept short internal IDs so support can reliably locate accounts.
2026-08-19 17:54:27 +08:00
Rocky c592f426be Enable StoreKit credit purchases in production
Keep runtime grant verification aligned with all database migrations so release builds fail before missing table privileges reach deployment.
2026-08-19 15:57:45 +08:00
Rocky 1737106560 checkpoint before checking out feature/account-managed-gateway 2026-08-19 15:53:08 +08:00
Rocky 25cfbfa4e6 Polish admin layout and navigation rhythm
Prevent Web Awesome controls from overflowing while giving dashboard sections and navigation states consistent visual spacing.
2026-08-18 09:22:17 +08:00
dependabot[bot] babc80044d Bump actions/setup-java from 4 to 5
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 07:23:55 +00:00
dependabot[bot] 0dff35a0f7 Bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 07:23:55 +00:00
dependabot[bot] e6ce70117b Bump gradle/actions from 4 to 6
Bumps [gradle/actions](https://github.com/gradle/actions) from 4 to 6.
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v4...v6)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 06:48:12 +00:00
260 changed files with 34369 additions and 4618 deletions
+22 -2
View File
@@ -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
+6 -4
View File
@@ -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
+2
View File
@@ -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
+32 -8
View File
@@ -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
+2 -1
View File
@@ -4,11 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f5f7fb" />
<meta name="referrer" content="same-origin" />
<title>OSG 运营后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1629 -330
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -9,13 +9,29 @@
"test": "vitest run"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^27.0.1",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.2",
"vite": "^7.1.2",
"vitest": "^3.2.4"
},
"dependencies": {
"@awesome.me/webawesome": "^3.11.0"
"@base-ui/react": "^1.7.0",
"@tanstack/react-table": "^9.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0"
}
}
+149 -14
View File
@@ -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
View File
@@ -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;
}
-289
View File
@@ -1,289 +0,0 @@
import { adminApi, ApiError, setCsrfToken } from "./api/client";
import type { AdminRole, AuthState } from "./api/types";
import { renderLoading, showToast } from "./components/ui";
import { escapeHtml } from "./lib/format";
import { renderAudit } from "./pages/audit";
import { renderPasswordLogin } from "./pages/auth";
import { renderCredits } from "./pages/credits";
import { renderOverview } from "./pages/overview";
import { renderReferrals } from "./pages/referrals";
import { renderSecurity } from "./pages/security";
import { renderUsers } from "./pages/users";
type Route =
| "overview"
| "referrals"
| "users"
| "credits"
| "audit"
| "security";
interface RouteDefinition {
id: Route;
label: string;
icon: string;
roles: AdminRole[];
}
const ALL_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"];
const SUPPORT_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"];
const SUPER_ADMIN_ONLY: AdminRole[] = ["SUPER_ADMIN"];
const routes: RouteDefinition[] = [
{
id: "overview",
label: "运营总览",
roles: ALL_ROLES,
icon: '<path d="M4 18V10M10 18V6M16 18V3M2 18h18" />',
},
{
id: "referrals",
label: "裂变分析",
roles: ALL_ROLES,
icon:
'<circle cx="5" cy="11" r="2.5" /><circle cx="17" cy="5" r="2.5" /><circle cx="17" cy="17" r="2.5" /><path d="m7.3 9.8 7.4-3.6M7.3 12.2l7.4 3.6" />',
},
{
id: "users",
label: "用户查询",
roles: SUPPORT_ROLES,
icon:
'<circle cx="8" cy="7" r="3" /><circle cx="16" cy="8" r="2.5" /><path d="M2.5 18c.5-3.5 2.3-5.5 5.5-5.5s5 2 5.5 5.5M13 13.5c3.8-.7 6 1 6.5 4.5" />',
},
{
id: "credits",
label: "积分流水",
roles: SUPPORT_ROLES,
icon:
'<rect x="3" y="4" width="16" height="15" rx="2" /><path d="M7 8h8M7 12h8M7 16h5" />',
},
{
id: "audit",
label: "审计日志",
roles: SUPER_ADMIN_ONLY,
icon:
'<path d="M11 2.5 18 5v5.5c0 4.5-2.7 7.3-7 9-4.3-1.7-7-4.5-7-9V5l7-2.5Z" /><path d="m8 11 2 2 4-4" />',
},
{
id: "security",
label: "安全中心",
roles: SUPER_ADMIN_ONLY,
icon:
'<rect x="4" y="9" width="14" height="10" rx="2" /><path d="M7.5 9V6.5a3.5 3.5 0 0 1 7 0V9M11 13v2.5" />',
},
];
export class AdminApp {
private auth: AuthState = { status: "anonymous" };
private renderVersion = 0;
constructor(private readonly root: HTMLElement) {
window.addEventListener("hashchange", () => void this.renderRoute());
window.addEventListener("admin:unauthorized", () => {
setCsrfToken();
this.auth = { status: "anonymous" };
this.render();
showToast("登录状态已失效,请重新登录", "error");
});
}
async start(): Promise<void> {
renderLoading(this.root, "检查登录状态");
try {
const session = await adminApi.session();
if (session.authenticated && session.operatorName && session.role) {
this.auth = {
status: "authenticated",
operatorName: session.operatorName,
role: session.role,
};
}
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) {
showToast("暂时无法确认登录状态", "error");
}
}
this.render();
}
private render(): void {
if (this.auth.status === "anonymous") {
renderPasswordLogin(this.root, { onChange: (state) => this.setAuth(state) });
return;
}
this.renderShell();
void this.renderRoute();
}
private setAuth(state: AuthState): void {
this.auth = state;
this.render();
}
private currentRoute(): Route {
const value = window.location.hash.replace(/^#\/?/, "") as Route;
return this.availableRoutes().some((route) => route.id === value)
? value
: "overview";
}
private availableRoutes(): RouteDefinition[] {
if (this.auth.status !== "authenticated") return [];
const role = this.auth.role;
return routes.filter((route) => route.roles.includes(role));
}
private renderShell(): void {
if (this.auth.status !== "authenticated") return;
this.root.innerHTML = `
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell">
<aside class="sidebar" id="primary-navigation" data-sidebar>
<div class="sidebar-brand">
<div class="brand-mark brand-mark--small">OSG</div>
<div><strong>管理中心</strong><span>OSG ACCOUNT</span></div>
</div>
<nav class="nav-list" aria-label="主导航">
${this.availableRoutes()
.map(
(route) => `
<a href="#/${route.id}" data-route="${route.id}">
<svg class="nav-icon" viewBox="0 0 22 22" aria-hidden="true">${route.icon}</svg>
${route.label}
</a>
`,
)
.join("")}
</nav>
<div class="sidebar-footer">
<div class="operator">
<span class="operator-avatar" aria-hidden="true">管</span>
<div><strong>${escapeHtml(this.auth.operatorName)}</strong><span>${roleLabel(this.auth.role)}</span></div>
</div>
<wa-button class="logout-button" appearance="plain" data-logout title="安全退出">
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M9 4H4v14h5M13 7l4 4-4 4M17 11H8" /></svg>
<span>退出</span>
</wa-button>
</div>
</aside>
<div class="main-column">
<header class="mobile-header">
<wa-button class="icon-button" appearance="plain" data-menu aria-label="打开导航" aria-expanded="false" aria-controls="primary-navigation">
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M3 6h16M3 11h16M3 16h16" /></svg>
</wa-button>
<strong data-mobile-title>运营总览</strong>
</header>
<main class="page-content" id="main-content" data-content tabindex="-1"></main>
</div>
<button class="sidebar-scrim" data-scrim aria-label="关闭导航"></button>
</div>
`;
const sidebar = this.root.querySelector<HTMLElement>("[data-sidebar]");
const menuButton =
this.root.querySelector<HTMLButtonElement>("[data-menu]");
const mobileLayout = window.matchMedia("(max-width: 1023px)");
const syncSidebarState = () => {
if (!sidebar) return;
sidebar.inert =
mobileLayout.matches && !sidebar.classList.contains("sidebar--open");
};
const closeMenu = (restoreFocus = false) => {
sidebar?.classList.remove("sidebar--open");
menuButton?.setAttribute("aria-expanded", "false");
syncSidebarState();
if (restoreFocus) menuButton?.focus();
};
const openMenu = () => {
sidebar?.classList.add("sidebar--open");
menuButton?.setAttribute("aria-expanded", "true");
syncSidebarState();
sidebar
?.querySelector<HTMLAnchorElement>('a[aria-current="page"], a')
?.focus();
};
menuButton?.addEventListener("click", openMenu);
this.root
.querySelector<HTMLButtonElement>("[data-scrim]")
?.addEventListener("click", () => closeMenu(true));
this.root.querySelectorAll("[data-route]").forEach((link) => {
link.addEventListener("click", () => closeMenu());
});
sidebar?.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenu(true);
});
mobileLayout.addEventListener("change", syncSidebarState);
syncSidebarState();
this.root
.querySelector<HTMLButtonElement>("[data-logout]")
?.addEventListener("click", () => void this.logout());
}
private async renderRoute(): Promise<void> {
if (this.auth.status !== "authenticated") return;
const route = this.currentRoute();
this.root.querySelectorAll<HTMLElement>("[data-route]").forEach((link) => {
const active = link.dataset.route === route;
link.classList.toggle("active", active);
if (active) {
link.setAttribute("aria-current", "page");
} else {
link.removeAttribute("aria-current");
}
});
const routeDefinition = routes.find((item) => item.id === route);
document.title = `${routeDefinition?.label ?? "管理中心"} · OSG`;
const mobileTitle =
this.root.querySelector<HTMLElement>("[data-mobile-title]");
if (mobileTitle) mobileTitle.textContent = routeDefinition?.label ?? "管理中心";
const content = this.root.querySelector<HTMLElement>("[data-content]");
if (!content) return;
const version = ++this.renderVersion;
const stage = document.createElement("div");
stage.className = "page-stage";
renderLoading(content);
if (route === "overview") await renderOverview(stage);
if (route === "referrals") await renderReferrals(stage);
if (route === "users") renderUsers(stage, this.auth.role);
if (route === "credits") renderCredits(stage);
if (route === "audit") await renderAudit(stage);
if (route === "security") {
await renderSecurity(stage, this.auth.operatorName);
}
if (version === this.renderVersion) {
content.replaceChildren(stage);
const heading = content.querySelector<HTMLElement>("h1");
if (heading) {
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
} else {
content.focus({ preventScroll: true });
}
}
}
private async logout(): Promise<void> {
try {
await adminApi.logout();
} catch {
// 即使服务端退出失败,也立即清理前端认证状态。
} finally {
setCsrfToken();
this.auth = { status: "anonymous" };
window.history.replaceState(null, "", window.location.pathname);
this.render();
}
}
}
function roleLabel(role: AdminRole): string {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return labels[role];
}
+512
View File
@@ -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>
);
}
-80
View File
@@ -1,80 +0,0 @@
import type { FunnelStep, TrendPoint } from "../api/types";
import { escapeHtml, formatNumber } from "../lib/format";
export function trendChart(points: TrendPoint[]): string {
if (points.length === 0) return '<div class="empty-state">暂无趋势数据</div>';
const width = 720;
const height = 240;
const padding = 28;
const values = points.map((point) => point.registrations);
const max = Math.max(...values, 1);
const step = points.length > 1 ? (width - padding * 2) / (points.length - 1) : 0;
const coordinates = points.map((point, index) => ({
x: points.length === 1 ? width / 2 : padding + index * step,
y: height - padding - (point.registrations / max) * (height - padding * 2),
point,
}));
const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(" ");
const midpoint = Math.ceil(max / 2);
return `
<div class="chart-scroll">
<svg class="trend-chart" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-chart-title trend-chart-description">
<title id="trend-chart-title">新增用户趋势</title>
<desc id="trend-chart-description">横轴为 UTC 日期,纵轴为每日新增用户数。图表后提供完整数据表。</desc>
<line x1="${padding}" y1="${padding}" x2="${width - padding}" y2="${padding}" class="chart-axis" />
<line x1="${padding}" y1="${height / 2}" x2="${width - padding}" y2="${height / 2}" class="chart-axis" />
<line x1="${padding}" y1="${height - padding}" x2="${width - padding}" y2="${height - padding}" class="chart-axis" />
<text x="${padding}" y="${padding - 8}" class="chart-label">${formatNumber(max)}</text>
<text x="${padding}" y="${height / 2 - 8}" class="chart-label">${formatNumber(midpoint)}</text>
<text x="${padding}" y="${height - 8}" class="chart-label">0</text>
<polyline points="${polyline}" class="chart-line" />
${coordinates
.map(
({ x, y, point }) => `
<circle cx="${x}" cy="${y}" r="4" class="chart-dot">
<title>${escapeHtml(point.date)}${formatNumber(point.registrations)} </title>
</circle>
`,
)
.join("")}
</svg>
</div>
<table class="sr-only">
<caption>新增用户与积分消耗趋势完整数据</caption>
<thead><tr><th scope="col">UTC 日期</th><th scope="col">新增用户</th><th scope="col">消耗积分</th></tr></thead>
<tbody>
${points
.map(
(point) =>
`<tr><td>${escapeHtml(point.date)}</td><td>${formatNumber(point.registrations)}</td><td>${formatNumber(point.creditsUsed)}</td></tr>`,
)
.join("")}
</tbody>
</table>
`;
}
export function funnelChart(steps: FunnelStep[]): string {
if (steps.length === 0) return '<div class="empty-state">暂无漏斗数据</div>';
const max = Math.max(...steps.map((step) => step.count), 1);
return `
<div class="funnel" aria-label="裂变漏斗">
${steps
.map((step, index) => {
return `
<div class="funnel-step">
<div class="funnel-label">
<span>${escapeHtml(step.label)}</span>
<strong>${formatNumber(step.count)}</strong>
</div>
<progress class="funnel-progress funnel-progress--${(index % 4) + 1}" max="${max}" value="${step.count}" aria-label="${escapeHtml(step.label)}${formatNumber(step.count)}"></progress>
</div>
`;
})
.join("")}
</div>
`;
}
@@ -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">D1D7D30 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>
</>
);
}
+182
View File
@@ -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);
}
+308
View File
@@ -0,0 +1,308 @@
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { cva, type VariantProps } from "class-variance-authority";
import {
AlertTriangle,
Inbox,
LoaderCircle,
RefreshCw,
X,
type LucideIcon,
} from "lucide-react";
import {
forwardRef,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
} from "react";
import { cn } from "../lib/utils";
const buttonVariants = cva(
"inline-flex min-h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
{
variants: {
variant: {
primary:
"bg-primary text-primary-foreground shadow-[0_8px_24px_-10px_var(--primary)] hover:-translate-y-0.5 hover:bg-primary/90",
secondary:
"border border-border bg-surface text-foreground shadow-sm hover:border-border-strong hover:bg-surface-muted",
ghost: "text-muted hover:bg-surface-muted hover:text-foreground",
danger:
"bg-danger text-white shadow-[0_8px_24px_-10px_var(--danger)] hover:-translate-y-0.5 hover:bg-danger/90",
},
size: {
sm: "min-h-8 rounded-lg px-3 text-xs",
md: "min-h-10 px-4",
lg: "min-h-12 px-5",
icon: "size-10 px-0",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
loading?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, children, disabled, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
{...props}
>
{loading ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
{children}
</button>
),
);
Button.displayName = "Button";
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
"h-11 w-full rounded-xl border border-border bg-input px-3.5 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:bg-surface-muted disabled:text-muted",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export const Textarea = forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"min-h-24 w-full resize-y rounded-xl border border-border bg-input px-3.5 py-3 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10",
className,
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-2xl border border-border bg-surface shadow-[0_1px_2px_rgb(15_23_42/0.02),0_10px_35px_rgb(15_23_42/0.035)]",
className,
)}
{...props}
/>
);
}
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold",
{
variants: {
tone: {
neutral: "bg-surface-muted text-muted",
success: "bg-success-soft text-success",
danger: "bg-danger-soft text-danger",
warning: "bg-warning-soft text-warning",
info: "bg-primary-soft text-primary",
violet: "bg-violet-soft text-violet",
},
},
defaultVariants: { tone: "neutral" },
},
);
export function Badge({
className,
tone,
...props
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
}
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-primary">
{eyebrow}
</p>
<h1 className="text-balance text-3xl font-bold tracking-[-0.045em] text-foreground sm:text-4xl">
{title}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{description}</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</header>
);
}
export function LoadingState({ label = "正在加载" }: { label?: string }) {
return (
<div className="grid min-h-72 place-items-center" role="status" aria-live="polite">
<div className="flex flex-col items-center gap-3 text-sm text-muted">
<span className="grid size-11 place-items-center rounded-2xl bg-primary-soft text-primary">
<LoaderCircle className="size-5 animate-spin" aria-hidden />
</span>
{label}
</div>
</div>
);
}
export function EmptyState({
title = "暂无数据",
description,
}: {
title?: string;
description?: string;
}) {
return (
<div className="grid min-h-56 place-items-center px-6 text-center">
<div>
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-surface-muted text-muted">
<Inbox className="size-5" aria-hidden />
</span>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{description ? <p className="mt-1 text-sm text-muted">{description}</p> : null}
</div>
</div>
);
}
export function ErrorState({
error,
retry,
}: {
error: unknown;
retry?: () => void;
}) {
const message = error instanceof Error ? error.message : "出现未知错误,请稍后重试";
return (
<Card className="mx-auto grid min-h-72 max-w-lg place-items-center border-danger/20 p-8 text-center">
<div>
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-danger-soft text-danger">
<AlertTriangle className="size-5" aria-hidden />
</span>
<h2 className="font-semibold text-foreground"></h2>
<p className="mt-2 text-sm text-muted">{message}</p>
{retry ? (
<Button className="mt-5" variant="secondary" onClick={retry}>
<RefreshCw className="size-4" aria-hidden />
</Button>
) : null}
</div>
</Card>
);
}
export function Dialog({
open,
onOpenChange,
title,
description,
children,
preventClose = false,
className,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
children: ReactNode;
preventClose?: boolean;
className?: string;
}) {
return (
<BaseDialog.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && preventClose) return;
onOpenChange(nextOpen);
}}
>
<BaseDialog.Portal>
<BaseDialog.Backdrop className="fixed inset-0 z-50 bg-slate-950/45 backdrop-blur-[3px] transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0" />
<BaseDialog.Viewport className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4">
<BaseDialog.Popup
className={cn(
"relative my-8 w-full max-w-lg rounded-3xl border border-white/10 bg-surface-elevated p-6 shadow-2xl outline-none transition-all data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 sm:p-8",
className,
)}
>
{!preventClose ? (
<BaseDialog.Close
className="absolute right-4 top-4 grid size-9 place-items-center rounded-xl text-muted transition hover:bg-surface-muted hover:text-foreground"
aria-label="关闭"
>
<X className="size-4" aria-hidden />
</BaseDialog.Close>
) : null}
<BaseDialog.Title className="pr-10 text-xl font-bold tracking-tight text-foreground">
{title}
</BaseDialog.Title>
{description ? (
<BaseDialog.Description className="mt-2 text-sm leading-6 text-muted">
{description}
</BaseDialog.Description>
) : null}
<div className="mt-6">{children}</div>
</BaseDialog.Popup>
</BaseDialog.Viewport>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
export function StatCard({
label,
value,
hint,
icon: Icon,
tone = "primary",
}: {
label: string;
value: string;
hint: string;
icon: LucideIcon;
tone?: "primary" | "success" | "violet" | "warning";
}) {
return (
<Card className="group relative overflow-hidden p-5 sm:p-6">
<div className={`stat-glow stat-glow--${tone}`} aria-hidden />
<div className="relative">
<div className="flex items-start justify-between gap-4">
<p className="text-sm font-medium text-muted">{label}</p>
<span className={`stat-icon stat-icon--${tone}`}>
<Icon className="size-4" aria-hidden />
</span>
</div>
<strong className="mt-5 block text-3xl font-bold tracking-[-0.05em] text-foreground tabular-nums">
{value}
</strong>
<p className="mt-2 text-xs text-muted">{hint}</p>
</div>
</Card>
);
}
@@ -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>
);
}
+57
View File
@@ -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>
);
}
-72
View File
@@ -1,72 +0,0 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { ApiError } from "../api/client";
import { escapeHtml } from "../lib/format";
export function renderLoading(container: HTMLElement, label = "正在加载"): void {
container.innerHTML = `
<div class="state-card" role="status" aria-live="polite">
<wa-spinner class="state-spinner" aria-hidden="true"></wa-spinner>
<p>${escapeHtml(label)}…</p>
</div>
`;
}
export function renderError(
container: HTMLElement,
error: unknown,
retry?: () => void,
): void {
const message =
error instanceof ApiError ? error.message : "出现未知错误,请稍后重试";
container.innerHTML = `
<wa-callout class="state-card state-card--error" variant="danger" appearance="outlined" role="alert">
<span class="state-icon" aria-hidden="true">!</span>
<h2>无法加载数据</h2>
<p>${escapeHtml(message)}</p>
${retry ? '<wa-button variant="neutral" appearance="outlined" data-retry>重试</wa-button>' : ""}
</wa-callout>
`;
container.querySelector<HTMLElement>("[data-retry]")?.addEventListener(
"click",
() => retry?.(),
);
}
export function renderEmpty(message: string): string {
return `<div class="empty-state"><p>${escapeHtml(message)}</p></div>`;
}
export function showToast(message: string, tone: "success" | "error"): void {
const toast = document.createElement("wa-callout");
toast.className = `toast toast--${tone}`;
toast.setAttribute("variant", tone === "error" ? "danger" : "success");
toast.setAttribute("appearance", "filled-outlined");
toast.setAttribute("role", tone === "error" ? "alert" : "status");
toast.textContent = message;
document.body.append(toast);
requestAnimationFrame(() => toast.classList.add("toast--visible"));
window.setTimeout(() => {
toast.classList.remove("toast--visible");
window.setTimeout(() => toast.remove(), 180);
}, 3_200);
}
export function setButtonBusy(
button: HTMLButtonElement | WaButton,
busy: boolean,
busyLabel = "处理中…",
): void {
if (button.tagName === "WA-BUTTON") {
(button as WaButton).loading = busy;
}
if (busy) {
button.dataset.label = button.textContent ?? "";
button.textContent = busyLabel;
button.disabled = true;
button.setAttribute("aria-busy", "true");
} else {
button.textContent = button.dataset.label ?? button.textContent;
button.disabled = false;
button.removeAttribute("aria-busy");
}
}
-15
View File
@@ -1,15 +0,0 @@
import "@awesome.me/webawesome/dist/styles/webawesome.css";
import "@awesome.me/webawesome/dist/components/button/button.js";
import "@awesome.me/webawesome/dist/components/callout/callout.js";
import "@awesome.me/webawesome/dist/components/input/input.js";
import "@awesome.me/webawesome/dist/components/spinner/spinner.js";
const darkMode = window.matchMedia("(prefers-color-scheme: dark)");
function syncColorScheme(event: MediaQueryList | MediaQueryListEvent): void {
document.documentElement.classList.toggle("wa-dark", event.matches);
document.documentElement.classList.toggle("wa-light", !event.matches);
}
syncColorScheme(darkMode);
darkMode.addEventListener("change", syncColorScheme);
@@ -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: "13 秒",
S3_TO_10: "310 秒",
S10_TO_30: "1030 秒",
GTE_30S: "≥ 30 秒",
}[bucket] ?? bucket;
}
+237
View File
@@ -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";
}
+178
View File
@@ -0,0 +1,178 @@
import { ArrowRight, Fingerprint, LockKeyhole, ShieldCheck, Sparkles } from "lucide-react";
import { useState, type FormEvent } from "react";
import { ApiError } from "../../api/client";
import { Button, Input } from "../../components/primitives";
import { useAuth } from "./auth-context";
export function LoginPage() {
const { login } = useAuth();
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const username = data.get("username")?.toString().trim() ?? "";
const password = data.get("password")?.toString() ?? "";
const totpCode = data.get("totpCode")?.toString().trim() ?? "";
if (username.length < 3 || password.length < 12 || !/^\d{6}$/.test(totpCode)) {
setError("请输入有效的用户名、密码和 6 位动态验证码");
return;
}
setSubmitting(true);
setError("");
try {
await login(username, password, totpCode);
form.reset();
} catch (requestError) {
setError(requestError instanceof ApiError ? requestError.message : "登录验证失败");
setSubmitting(false);
}
}
return (
<main className="relative min-h-screen overflow-hidden bg-auth">
<div className="auth-orb auth-orb--one" aria-hidden />
<div className="auth-orb auth-orb--two" aria-hidden />
<div className="relative mx-auto grid min-h-screen max-w-7xl items-center gap-16 px-5 py-10 lg:grid-cols-[1.1fr_0.9fr] lg:px-12">
<section className="animate-page-in hidden lg:block" aria-label="OSG 运营后台">
<BrandMark size="large" />
<p className="mt-10 inline-flex items-center gap-2 rounded-full border border-primary/15 bg-primary-soft px-3 py-1.5 text-xs font-semibold text-primary">
<Sparkles className="size-3.5" aria-hidden />
OSG Account Intelligence
</p>
<h1 className="mt-6 max-w-xl text-5xl font-bold leading-[1.08] tracking-[-0.06em] text-foreground">
<br />
</h1>
<p className="mt-6 max-w-lg text-base leading-7 text-muted">
</p>
<div className="mt-12 grid max-w-xl grid-cols-3 gap-4">
<TrustItem icon={ShieldCheck} label="角色权限" />
<TrustItem icon={Fingerprint} label="双重认证" />
<TrustItem icon={LockKeyhole} label="不可变审计" />
</div>
</section>
<section className="animate-card-in mx-auto w-full max-w-md rounded-[2rem] border border-white/60 bg-surface-elevated/90 p-7 shadow-[0_30px_90px_rgb(15_23_42/0.12)] backdrop-blur-2xl sm:p-10">
<div className="lg:hidden">
<BrandMark size="small" />
</div>
<p className="mt-8 text-xs font-bold uppercase tracking-[0.18em] text-primary lg:mt-0">
访
</p>
<h2 className="mt-3 text-3xl font-bold tracking-[-0.045em] text-foreground">
</h2>
<p className="mt-2 text-sm leading-6 text-muted">
使
</p>
<form className="mt-8 space-y-5" onSubmit={handleSubmit} noValidate>
<Field label="管理员用户名" htmlFor="username">
<Input
id="username"
name="username"
autoComplete="username"
minLength={3}
maxLength={64}
autoFocus
required
/>
</Field>
<Field label="管理员密码" htmlFor="password">
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
minLength={12}
required
/>
</Field>
<Field label="动态验证码" htmlFor="totpCode">
<Input
id="totpCode"
name="totpCode"
className="text-center text-lg font-semibold tracking-[0.35em]"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
required
/>
</Field>
<p className="min-h-5 text-sm text-danger" role="alert">
{error}
</p>
<Button className="w-full" size="lg" type="submit" loading={submitting}>
{!submitting ? <ArrowRight className="size-4" aria-hidden /> : null}
</Button>
</form>
<p className="mt-6 text-center text-xs leading-5 text-muted">
</p>
</section>
</div>
</main>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor: string;
children: React.ReactNode;
}) {
return (
<label className="block" htmlFor={htmlFor}>
<span className="mb-2 block text-sm font-semibold text-foreground">{label}</span>
{children}
</label>
);
}
function BrandMark({ size }: { size: "small" | "large" }) {
return (
<div className="flex items-center gap-3">
<span
className={`brand-symbol ${size === "large" ? "size-14 rounded-2xl text-base" : "size-11 rounded-xl text-sm"}`}
>
O
</span>
<span>
<strong className="block text-sm font-bold tracking-tight text-foreground">OSG</strong>
<span className="block text-[10px] font-semibold tracking-[0.16em] text-muted">
</span>
</span>
</div>
);
}
function TrustItem({
icon: Icon,
label,
}: {
icon: typeof ShieldCheck;
label: string;
}) {
return (
<div className="flex items-center gap-2.5 text-xs font-semibold text-muted">
<span className="grid size-8 place-items-center rounded-xl bg-surface shadow-sm">
<Icon className="size-4 text-primary" aria-hidden />
</span>
{label}
</div>
);
}
@@ -0,0 +1,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">
TopHubGoogle TrendsGoogle 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 必须为 1512 个字符,且不能包含换行");
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>
);
}
+751
View File
@@ -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>
);
}
+62
View File
@@ -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), []),
};
}
+14 -2
View File
@@ -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)}`;
+21
View File
@@ -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);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
-8
View File
@@ -1,8 +0,0 @@
import "./components/webawesome";
import "./styles.css";
import { AdminApp } from "./app";
const root = document.querySelector<HTMLElement>("#app");
if (!root) throw new Error("应用挂载节点不存在");
void new AdminApp(root).start();
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./styles.css";
const root = document.querySelector<HTMLElement>("#app");
if (!root) throw new Error("应用挂载节点不存在");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
-99
View File
@@ -1,99 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type { AuditLogEntry } from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import { escapeHtml, formatDateTime, statusLabel } from "../lib/format";
export async function renderAudit(container: HTMLElement): Promise<void> {
renderLoading(container, "加载审计日志");
try {
const page = await adminApi.auditLogs();
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">安全与合规</div>
<h1>审计日志</h1>
<p>追踪管理员操作、目标和执行结果。</p>
</div>
</div>
<section class="panel">
${
page.items.length === 0
? renderEmpty("暂无审计记录")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">管理员审计事件</caption>
<thead><tr><th scope="col">时间</th><th scope="col">操作员</th><th scope="col">操作</th><th scope="col">目标</th><th scope="col">请求 ID</th><th scope="col">结果</th></tr></thead>
<tbody data-audit-body>${auditRows(page.items)}</tbody>
</table>
</div>
${
page.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-audit-more>加载更早记录</wa-button></div>'
: ""
}
`
}
</section>
`;
if (page.nextCursor) bindAuditPagination(container, page.nextCursor);
} catch (error) {
renderError(container, error, () => void renderAudit(container));
}
}
function auditRows(items: AuditLogEntry[]): string {
return items
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(entry.operatorName)}</td>
<td><code>${escapeHtml(entry.action)}</code></td>
<td>
<span>${escapeHtml(entry.targetType)}</span>
<div class="subtle mono">${escapeHtml(entry.targetId)}</div>
</td>
<td class="mono">${escapeHtml(entry.requestId || "—")}</td>
<td><span class="badge badge--${entry.result}">${statusLabel(entry.result)}</span></td>
</tr>
`,
)
.join("");
}
function bindAuditPagination(container: HTMLElement, initialCursor: string): void {
const button =
container.querySelector<HTMLButtonElement>("[data-audit-more]");
const body = container.querySelector<HTMLTableSectionElement>("[data-audit-body]");
if (!button || !body) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.auditLogs(cursor);
body.insertAdjacentHTML("beforeend", auditRows(page.items));
cursor = page.nextCursor;
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载审计记录失败",
"error",
);
setButtonBusy(button, false);
}
});
}
-75
View File
@@ -1,75 +0,0 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { adminApi, ApiError, setCsrfToken } from "../api/client";
import type { AuthState } from "../api/types";
import { setButtonBusy } from "../components/ui";
interface AuthCallbacks {
onChange: (state: AuthState) => void;
}
function authFrame(content: string): string {
return `
<main class="auth-shell">
<section class="auth-brand" aria-label="OSG 运营后台">
<div class="brand-mark">OSG</div>
<p>安全、克制、可追溯的运营管理</p>
</section>
<section class="auth-card">${content}</section>
</main>
`;
}
export function renderPasswordLogin(
root: HTMLElement,
callbacks: AuthCallbacks,
): void {
root.innerHTML = authFrame(`
<div class="eyebrow">运营后台</div>
<h1>管理员登录</h1>
<p class="muted">使用管理员凭据和认证器动态验证码登录。凭据不会保存到浏览器。</p>
<form class="form-stack" data-login-form novalidate>
<wa-input name="username" label="管理员用户名" type="text" autocomplete="username" minlength="3" maxlength="64" appearance="outlined" required autofocus></wa-input>
<wa-input name="password" label="管理员密码" type="password" autocomplete="current-password" minlength="12" appearance="outlined" password-toggle required></wa-input>
<wa-input class="totp-input" name="totpCode" label="动态验证码" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" appearance="outlined" required></wa-input>
<p class="form-error" data-error role="alert"></p>
<wa-button class="button--wide" variant="brand" appearance="accent" type="submit">安全登录</wa-button>
</form>
`);
const form = root.querySelector<HTMLFormElement>("[data-login-form]");
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector<WaButton>('wa-button[type="submit"]');
const errorNode = form.querySelector<HTMLElement>("[data-error]");
const username = new FormData(form).get("username")?.toString().trim() ?? "";
const password = new FormData(form).get("password")?.toString() ?? "";
const totpCode = new FormData(form).get("totpCode")?.toString().trim() ?? "";
if (
username.length < 3 ||
password.length < 12 ||
!/^\d{6}$/.test(totpCode) ||
!button ||
!errorNode
) {
if (errorNode) errorNode.textContent = "请输入有效的用户名、密码和 6 位动态验证码";
return;
}
setButtonBusy(button, true, "验证中…");
errorNode.textContent = "";
try {
const response = await adminApi.login(username, password, totpCode);
form.reset();
setCsrfToken(response.csrfToken);
callbacks.onChange({
status: "authenticated",
operatorName: response.operatorName,
role: response.role,
});
} catch (error) {
errorNode.textContent =
error instanceof ApiError ? error.message : "验证码验证失败";
setButtonBusy(button, false);
}
});
}
-78
View File
@@ -1,78 +0,0 @@
import { adminApi } from "../api/client";
import { renderEmpty, renderError, renderLoading } from "../components/ui";
import {
escapeHtml,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
} from "../lib/format";
export function renderCredits(container: HTMLElement): void {
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">积分账本</div>
<h1>积分流水</h1>
<p>按内部用户 ID 查询不可变积分流水。</p>
</div>
</div>
<section class="panel">
<form class="search-form" data-ledger-form>
<label class="search-box">
<span class="sr-only">内部用户 ID</span>
<span aria-hidden="true">⌕</span>
<input name="userId" type="search" placeholder="输入完整用户 ID" autocomplete="off" maxlength="36" required />
</label>
<wa-button variant="brand" appearance="accent" type="submit">查询流水</wa-button>
</form>
<div data-ledger-results>${renderEmpty("输入内部用户 ID 开始查询")}</div>
</section>
`;
const form = container.querySelector<HTMLFormElement>("[data-ledger-form]");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const userId = new FormData(form).get("userId")?.toString().trim() ?? "";
if (userId) void loadLedger(container, userId);
});
}
async function loadLedger(container: HTMLElement, userId: string): Promise<void> {
const results = container.querySelector<HTMLElement>("[data-ledger-results]");
if (!results) return;
renderLoading(results, "加载积分流水");
try {
const page = await adminApi.ledger(userId);
results.innerHTML =
page.items.length === 0
? renderEmpty("该用户暂无积分流水")
: `
<div class="result-summary">用户 <span class="mono">${escapeHtml(userId)}</span> · ${formatNumber(page.items.length)} 条记录</div>
<div class="table-wrap">
<table>
<caption class="sr-only">用户 ${escapeHtml(userId)} 的积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">流水号</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody>
${page.items
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td class="mono">${escapeHtml(entry.entryId)}</td>
<td>${statusLabel(entry.type)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`;
} catch (error) {
renderError(results, error, () => void loadLedger(container, userId));
}
}
-102
View File
@@ -1,102 +0,0 @@
import { adminApi } from "../api/client";
import { trendChart } from "../components/charts";
import { renderError, renderLoading } from "../components/ui";
import { escapeHtml, formatNumber } from "../lib/format";
export async function renderOverview(
container: HTMLElement,
range = "30d",
): Promise<void> {
renderLoading(container, "加载总览");
try {
const data = await adminApi.overview(range);
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">核心指标</div>
<h1>运营总览</h1>
<p>快速掌握增长与积分消耗情况。</p>
</div>
<fieldset class="segmented-control">
<legend class="sr-only">统计周期</legend>
${rangeOption("7d", "7 天", range)}
${rangeOption("30d", "30 天", range)}
${rangeOption("90d", "90 天", range)}
</fieldset>
</div>
<section class="metric-grid" aria-label="关键指标">
${metric("用户总数", data.totalUsers, "累计账户")}
${metric("活跃用户", data.activeUsers, "当前周期")}
${metric("新增用户", data.newUsers, "当前周期")}
${metric("积分余额", data.totalCreditBalance, "所有账户")}
${metric("赠送积分", data.creditsGranted, "整数积分")}
${metric("消耗积分", data.creditsUsed, "已结算")}
</section>
<section class="panel">
<div class="panel-heading">
<div><h2>新增用户趋势</h2><p>按 UTC 日期统计注册数</p></div>
<span class="legend"><i></i>新增用户</span>
</div>
${trendChart(data.trend)}
</section>
<section class="panel">
<div class="panel-heading"><div><h2>使用汇总</h2><p>按类型统计,不包含用户内容</p></div></div>
${
data.usage.length === 0
? '<p class="muted">当前周期暂无使用记录</p>'
: `
<div class="table-wrap">
<table>
<caption class="sr-only">当前周期使用统计</caption>
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音毫秒</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
<tbody>
${data.usage
.map(
(item) => `
<tr>
<td><code>${escapeHtml(item.kind)}</code></td>
<td>${formatNumber(item.requests)}</td>
<td>${formatNumber(item.chargedCredits)}</td>
<td>${formatNumber(item.asrMillis)}</td>
<td>${formatNumber(item.inputTokens)}</td>
<td>${formatNumber(item.outputTokens)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
}
</section>
`;
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
input.addEventListener("change", (event) => {
void renderOverview(container, (event.target as HTMLInputElement).value);
});
});
} catch (error) {
renderError(container, error, () => void renderOverview(container, range));
}
}
function rangeOption(value: string, label: string, selected: string): string {
return `
<label>
<input data-range name="overview-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
<span>${label}</span>
</label>
`;
}
function metric(label: string, value: number, hint: string): string {
return `
<article class="metric-card">
<p>${label}</p>
<strong>${formatNumber(value)}</strong>
<span>${hint}</span>
</article>
`;
}
-97
View File
@@ -1,97 +0,0 @@
import { adminApi } from "../api/client";
import { funnelChart } from "../components/charts";
import { renderEmpty, renderError, renderLoading } from "../components/ui";
import { escapeHtml, formatNumber } from "../lib/format";
export async function renderReferrals(
container: HTMLElement,
range = "30d",
): Promise<void> {
renderLoading(container, "加载裂变数据");
try {
const data = await adminApi.referrals(range);
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">增长分析</div>
<h1>裂变与排行</h1>
<p>奖励以有效使用为前提,不以注册量代替真实转化。</p>
</div>
<fieldset class="segmented-control">
<legend class="sr-only">统计周期</legend>
${rangeOption("7d", "7 天", range)}
${rangeOption("30d", "30 天", range)}
${rangeOption("90d", "90 天", range)}
</fieldset>
</div>
<section class="metric-grid metric-grid--two" aria-label="邀请资格状态">
<article class="metric-card">
<p>待资格确认</p>
<strong>${formatNumber(data.pendingBindings)}</strong>
<span>等待有效使用</span>
</article>
<article class="metric-card">
<p>不符合奖励条件</p>
<strong>${formatNumber(data.ineligibleBindings)}</strong>
<span>未发放积分</span>
</article>
</section>
<div class="two-column">
<section class="panel">
<div class="panel-heading"><div><h2>裂变漏斗</h2><p>从分享至有效使用</p></div></div>
${funnelChart(data.funnel)}
</section>
<section class="panel">
<div class="panel-heading"><div><h2>邀请排行</h2><p>按有效邀请数排序</p></div></div>
${
data.ranking.length === 0
? renderEmpty("当前周期暂无排行数据")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">有效邀请用户排行</caption>
<thead><tr><th scope="col">名次</th><th scope="col">用户 ID</th><th scope="col">邀请</th><th scope="col">有效</th><th scope="col">奖励积分</th></tr></thead>
<tbody>
${data.ranking
.map(
(item, index) => `
<tr>
<td><span class="rank rank--${index + 1}">${index + 1}</span></td>
<td class="mono">${escapeHtml(item.userId)}</td>
<td>${formatNumber(item.invited)}</td>
<td>${formatNumber(item.qualified)}</td>
<td>${formatNumber(item.creditsEarned)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
}
</section>
</div>
`;
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
input.addEventListener("change", (event) => {
void renderReferrals(
container,
(event.target as HTMLInputElement).value,
);
});
});
} catch (error) {
renderError(container, error, () => void renderReferrals(container, range));
}
}
function rangeOption(value: string, label: string, selected: string): string {
return `
<label>
<input data-range name="referral-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
<span>${label}</span>
</label>
`;
}
-585
View File
@@ -1,585 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type {
AdminOperator,
AdminOperatorProvisioning,
AdminRole,
AdminSecuritySummary,
} from "../api/types";
import {
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import { escapeHtml, formatDateTime } from "../lib/format";
export async function renderSecurity(
container: HTMLElement,
currentUsername: string,
): Promise<void> {
renderLoading(container, "加载安全中心");
try {
const [page, summary] = await Promise.all([
adminApi.operators(),
adminApi.operatorSummary(),
]);
const operators = [...page.items];
container.innerHTML = securityTemplate(
operators,
currentUsername,
summary,
page.nextCursor,
);
bindSecurityActions(container, operators, currentUsername);
bindOperatorPagination(
container,
operators,
currentUsername,
page.nextCursor,
);
} catch (error) {
renderError(container, error, () => {
void renderSecurity(container, currentUsername);
});
}
}
function securityTemplate(
operators: AdminOperator[],
currentUsername: string,
summary: AdminSecuritySummary,
nextCursor?: string,
): string {
return `
<div class="page-heading">
<div>
<div class="eyebrow">访问控制</div>
<h1>安全中心</h1>
<p>管理运营人员、角色、登录锁定、会话与双重认证。</p>
</div>
<wa-button variant="brand" appearance="accent" data-create-operator>添加管理员</wa-button>
</div>
<aside class="security-notice" aria-label="安全说明">
<strong>双重边界已启用</strong>
<span>访问仍需受信客户端证书;所有管理员变更都会写入不可变审计日志。</span>
</aside>
<section class="metric-grid metric-grid--three" aria-label="安全指标">
${securityMetric("已启用管理员", summary.enabledOperators, "可登录账户")}
${securityMetric("已锁定账户", summary.lockedOperators, "等待解锁或锁定到期")}
${securityMetric("活动会话", summary.activeSessions, "尚未过期且未撤销")}
</section>
<section class="panel">
<div class="panel-heading">
<div><h2>管理员</h2><p data-operator-count>已加载 ${operators.length} 个账户</p></div>
</div>
<div class="table-wrap">
<table>
<caption class="sr-only">管理员账户与安全状态</caption>
<thead>
<tr><th scope="col">管理员</th><th scope="col">角色</th><th scope="col">状态</th><th scope="col">最近登录</th><th scope="col">操作</th></tr>
</thead>
<tbody data-operator-body>
${operators.map((operator) => operatorRow(operator, currentUsername)).join("")}
</tbody>
</table>
</div>
${
nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-operator-more aria-describedby="operator-pagination-status">加载更多管理员</wa-button></div>'
: ""
}
<p class="sr-only" id="operator-pagination-status" data-operator-status role="status" aria-live="polite"></p>
</section>
${operatorFormDialog()}
${credentialResetDialog()}
`;
}
function securityMetric(label: string, value: number, hint: string): string {
return `
<article class="metric-card">
<span>${escapeHtml(label)}</span>
<strong>${value.toLocaleString("zh-CN")}</strong>
<small>${escapeHtml(hint)}</small>
</article>
`;
}
function operatorRow(
operator: AdminOperator,
currentUsername: string,
): string {
const current = operator.username === currentUsername;
const locked = Boolean(
operator.lockedUntil && new Date(operator.lockedUntil).getTime() > Date.now(),
);
return `
<tr>
<td>
<strong>${escapeHtml(operator.username)} ${current ? '<span class="badge">当前</span>' : ""}</strong>
<div class="subtle mono">${escapeHtml(operator.operatorId)}</div>
</td>
<td>${roleLabel(operator.role)}</td>
<td>
<span class="badge ${operator.enabled ? "badge--success" : "badge--rejected"}">${operator.enabled ? "已启用" : "已停用"}</span>
${locked ? '<span class="badge badge--rejected">已锁定</span>' : ""}
</td>
<td>${formatDateTime(operator.lastLoginAt)}</td>
<td>
<div class="row-actions" data-operator-id="${escapeHtml(operator.operatorId)}">
${
locked
? '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="unlock">解锁</wa-button>'
: ""
}
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="sessions">撤销会话</wa-button>
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="reset">重置凭据</wa-button>
${
operator.enabled
? `<wa-button variant="danger" appearance="filled" size="s" data-operator-action="disable" ${current ? "disabled" : ""}>停用</wa-button>`
: '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="enable">启用</wa-button>'
}
</div>
</td>
</tr>
`;
}
function bindOperatorPagination(
container: HTMLElement,
operators: AdminOperator[],
currentUsername: string,
initialCursor?: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-operator-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-operator-body]");
const count = container.querySelector<HTMLElement>("[data-operator-count]");
const status = container.querySelector<HTMLElement>("[data-operator-status]");
if (!button || !body || !initialCursor) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.operators(cursor);
body.insertAdjacentHTML(
"beforeend",
page.items.map((operator) => operatorRow(operator, currentUsername)).join(""),
);
operators.push(...page.items);
cursor = page.nextCursor;
if (count) count.textContent = `已加载 ${operators.length} 个账户`;
if (status) {
status.textContent = cursor
? `已加载 ${page.items.length} 个更多管理员`
: `已加载 ${page.items.length} 个管理员,全部账户已加载`;
}
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(errorMessage(error, "加载管理员失败"), "error");
setButtonBusy(button, false);
button.focus();
}
});
}
function operatorFormDialog(): string {
return `
<dialog class="dialog" data-operator-dialog aria-labelledby="operator-dialog-title">
<form class="dialog-card form-stack" data-operator-form novalidate>
<div class="eyebrow">访问授权</div>
<h2 id="operator-dialog-title">添加管理员</h2>
<p class="muted">创建后,TOTP 密钥只显示一次。</p>
<label><span>用户名</span><input name="username" autocomplete="off" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._@-]{2,63}" required /></label>
<label>
<span>角色</span>
<select name="role" required>
<option value="ANALYST">分析员 · 仅统计</option>
<option value="SUPPORT">支持人员 · 用户与流水只读</option>
<option value="SUPER_ADMIN">超级管理员 · 完整权限</option>
</select>
</label>
<label><span>初始密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<label><span>确认密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<p class="form-error" data-operator-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-close-operator>取消</button>
<button class="button button--primary" type="submit">创建并生成 TOTP</button>
</div>
</form>
</dialog>
`;
}
function credentialResetDialog(): string {
return `
<dialog class="dialog" data-reset-dialog aria-labelledby="reset-dialog-title">
<form class="dialog-card form-stack" data-reset-form novalidate>
<div class="eyebrow">高风险操作</div>
<h2 id="reset-dialog-title">重置登录凭据</h2>
<p>将为 <strong data-reset-username></strong> 重置密码和 TOTP,并立即撤销其全部会话。</p>
<label><span>新密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<label><span>确认新密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<p class="form-error" data-reset-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-close-reset>取消</button>
<button class="button button--danger" type="submit">重置并撤销会话</button>
</div>
</form>
</dialog>
`;
}
function bindSecurityActions(
container: HTMLElement,
operators: AdminOperator[],
currentUsername: string,
): void {
const createDialog =
container.querySelector<HTMLDialogElement>("[data-operator-dialog]");
const createForm =
container.querySelector<HTMLFormElement>("[data-operator-form]");
container
.querySelector<HTMLButtonElement>("[data-create-operator]")
?.addEventListener("click", () => {
createForm?.reset();
clearError(createForm, "[data-operator-error]");
createDialog?.showModal();
createForm?.querySelector<HTMLInputElement>('input[name="username"]')?.focus();
});
container
.querySelector<HTMLButtonElement>("[data-close-operator]")
?.addEventListener("click", () => createDialog?.close());
createForm?.addEventListener("submit", (event) => {
event.preventDefault();
void createOperator(container, createDialog, createForm, currentUsername);
});
const resetDialog =
container.querySelector<HTMLDialogElement>("[data-reset-dialog]");
const resetForm = container.querySelector<HTMLFormElement>("[data-reset-form]");
container
.querySelector<HTMLButtonElement>("[data-close-reset]")
?.addEventListener("click", () => resetDialog?.close());
resetForm?.addEventListener("submit", (event) => {
event.preventDefault();
void resetCredentials(container, resetDialog, resetForm, currentUsername);
});
container.querySelector("tbody")?.addEventListener("click", (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>(
"[data-operator-action]",
);
const operatorId = button?.closest<HTMLElement>("[data-operator-id]")?.dataset
.operatorId;
const operator = operators.find((item) => item.operatorId === operatorId);
if (!button || !operator) return;
void handleOperatorAction(
container,
button,
operator,
resetDialog,
resetForm,
currentUsername,
);
});
}
async function createOperator(
container: HTMLElement,
dialog: HTMLDialogElement | null,
form: HTMLFormElement,
currentUsername: string,
): Promise<void> {
const data = new FormData(form);
const username = data.get("username")?.toString().trim() ?? "";
const role = data.get("role")?.toString() as AdminRole;
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-operator-error]");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (
!/^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/.test(username) ||
!["SUPER_ADMIN", "SUPPORT", "ANALYST"].includes(role) ||
password.length < 12 ||
password !== confirmation ||
!submit
) {
if (errorNode) errorNode.textContent = "请检查用户名、角色及两次输入的密码";
return;
}
setButtonBusy(submit, true, "创建中…");
clearError(form, "[data-operator-error]");
try {
const provisioning = await adminApi.createOperator({
username,
password,
role,
});
form.reset();
dialog?.close();
await showProvisioning(container, provisioning, "管理员已创建");
await renderSecurity(container, currentUsername);
} catch (error) {
if (errorNode) errorNode.textContent = errorMessage(error, "创建管理员失败");
setButtonBusy(submit, false);
}
}
async function handleOperatorAction(
container: HTMLElement,
button: HTMLButtonElement,
operator: AdminOperator,
resetDialog: HTMLDialogElement | null,
resetForm: HTMLFormElement | null,
currentUsername: string,
): Promise<void> {
const action = button.dataset.operatorAction;
if (action === "reset" && resetDialog && resetForm) {
resetForm.reset();
resetForm.dataset.operatorId = operator.operatorId;
const username = resetForm.querySelector<HTMLElement>("[data-reset-username]");
if (username) username.textContent = operator.username;
clearError(resetForm, "[data-reset-error]");
resetDialog.showModal();
resetForm.querySelector<HTMLInputElement>('input[name="password"]')?.focus();
return;
}
const confirmation = actionConfirmation(action, operator.username);
if (!confirmation || !(await confirmAction(container, confirmation))) return;
setButtonBusy(button, true);
try {
if (action === "enable") {
await adminApi.setOperatorEnabled(operator.operatorId, true);
} else if (action === "disable") {
await adminApi.setOperatorEnabled(operator.operatorId, false);
} else if (action === "unlock") {
await adminApi.unlockOperator(operator.operatorId);
} else if (action === "sessions") {
await adminApi.revokeOperatorSessions(operator.operatorId);
}
showToast("安全设置已更新", "success");
await renderSecurity(container, currentUsername);
} catch (error) {
showToast(errorMessage(error, "安全设置更新失败"), "error");
setButtonBusy(button, false);
}
}
async function resetCredentials(
container: HTMLElement,
dialog: HTMLDialogElement | null,
form: HTMLFormElement,
currentUsername: string,
): Promise<void> {
const operatorId = form.dataset.operatorId;
const data = new FormData(form);
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-reset-error]");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (!operatorId || password.length < 12 || password !== confirmation || !submit) {
if (errorNode) errorNode.textContent = "请输入至少 12 位且两次一致的新密码";
return;
}
setButtonBusy(submit, true, "重置中…");
clearError(form, "[data-reset-error]");
try {
const provisioning = await adminApi.resetOperatorCredentials(
operatorId,
password,
);
form.reset();
dialog?.close();
await showProvisioning(container, provisioning, "登录凭据已重置");
await renderSecurity(container, currentUsername);
} catch (error) {
if (errorNode) errorNode.textContent = errorMessage(error, "凭据重置失败");
setButtonBusy(submit, false);
}
}
interface Confirmation {
title: string;
message: string;
label: string;
dangerous?: boolean;
}
function actionConfirmation(
action: string | undefined,
username: string,
): Confirmation | null {
const target = `${username}`;
if (action === "disable") {
return {
title: "停用管理员?",
message: `${target} 将无法登录,全部活动会话会立即失效。`,
label: "停用管理员",
dangerous: true,
};
}
if (action === "enable") {
return {
title: "启用管理员?",
message: `${target} 将恢复登录权限。`,
label: "确认启用",
};
}
if (action === "unlock") {
return {
title: "解除登录锁定?",
message: `${target} 可以立即重新尝试登录。`,
label: "确认解锁",
};
}
if (action === "sessions") {
return {
title: "撤销全部会话?",
message: `${target} 已登录的所有设备都需要重新认证。`,
label: "撤销会话",
dangerous: true,
};
}
return null;
}
function confirmAction(
container: HTMLElement,
confirmation: Confirmation,
): Promise<boolean> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "dialog";
dialog.setAttribute("aria-labelledby", "confirm-action-title");
dialog.innerHTML = `
<form method="dialog" class="dialog-card">
<div class="eyebrow">确认操作</div>
<h2 id="confirm-action-title">${escapeHtml(confirmation.title)}</h2>
<p>${escapeHtml(confirmation.message)}</p>
<div class="dialog-actions">
<button class="button button--secondary" value="cancel">取消</button>
<button class="button ${confirmation.dangerous ? "button--danger" : "button--primary"}" value="confirm">${escapeHtml(confirmation.label)}</button>
</div>
</form>
`;
container.append(dialog);
dialog.addEventListener(
"close",
() => {
const confirmed = dialog.returnValue === "confirm";
dialog.remove();
resolve(confirmed);
},
{ once: true },
);
dialog.showModal();
});
}
function showProvisioning(
container: HTMLElement,
provisioning: AdminOperatorProvisioning,
title: string,
): Promise<void> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "dialog dialog--wide";
dialog.setAttribute("aria-labelledby", "provisioning-title");
dialog.innerHTML = `
<div class="dialog-card provisioning-card">
<div class="eyebrow">仅显示一次</div>
<h2 id="provisioning-title">${escapeHtml(title)}</h2>
<p>请立即把 TOTP 密钥交给对应管理员,并确认已添加到认证器。</p>
<div class="secret-panel">
<span>Base32 密钥</span>
<code>${escapeHtml(provisioning.totpSecret)}</code>
<button class="button button--secondary" type="button" data-copy-secret>复制密钥</button>
</div>
<div class="dialog-actions dialog-actions--spread">
<a class="button button--secondary" href="${escapeHtml(provisioning.otpauthUri)}">在认证器中打开</a>
<button class="button button--secondary" type="button" data-copy-uri>复制配置链接</button>
</div>
<label class="confirmation-check">
<input type="checkbox" data-provisioning-saved />
<span>我已安全保存密钥,理解关闭后无法再次查看</span>
</label>
<button class="button button--primary button--wide" type="button" data-close-provisioning disabled>完成</button>
</div>
`;
const close = dialog.querySelector<HTMLButtonElement>("[data-close-provisioning]");
dialog
.querySelector<HTMLInputElement>("[data-provisioning-saved]")
?.addEventListener("change", (event) => {
if (close) close.disabled = !(event.target as HTMLInputElement).checked;
});
dialog.addEventListener("cancel", (event) => event.preventDefault());
dialog
.querySelector<HTMLButtonElement>("[data-copy-secret]")
?.addEventListener("click", () => {
void copySensitiveValue(provisioning.totpSecret, "TOTP 密钥");
});
dialog
.querySelector<HTMLButtonElement>("[data-copy-uri]")
?.addEventListener("click", () => {
void copySensitiveValue(provisioning.otpauthUri, "认证器配置链接");
});
close?.addEventListener("click", () => dialog.close());
dialog.addEventListener(
"close",
() => {
dialog.remove();
resolve();
},
{ once: true },
);
container.append(dialog);
dialog.showModal();
});
}
async function copySensitiveValue(value: string, label: string): Promise<void> {
try {
await navigator.clipboard.writeText(value);
showToast(`${label}已复制,请妥善保管`, "success");
} catch {
showToast(`无法复制${label},请手动选择`, "error");
}
}
function clearError(
container: ParentNode | null,
selector: string,
): void {
const node = container?.querySelector<HTMLElement>(selector);
if (node) node.textContent = "";
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof ApiError ? error.message : fallback;
}
function roleLabel(role: AdminRole): string {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return labels[role];
}
-454
View File
@@ -1,454 +0,0 @@
import { adminApi, ApiError } from "../api/client";
import type {
AdminRole,
LedgerEntry,
PageResult,
UserDetail,
UserSummary,
UserUsageAggregate,
} from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import {
createIdempotencyKey,
escapeHtml,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
} from "../lib/format";
export function renderUsers(container: HTMLElement, role: AdminRole): void {
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">账户管理</div>
<h1>用户查询</h1>
<p>按内部用户 ID 精确查询,不展示 Apple 身份标识。</p>
</div>
</div>
<section class="panel">
<form class="search-form" data-search-form>
<label class="search-box">
<span class="sr-only">内部用户 ID</span>
<span aria-hidden="true">⌕</span>
<input name="query" type="search" placeholder="输入完整内部用户 ID" autocomplete="off" maxlength="36" required />
</label>
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-button>
</form>
<div data-results>${renderEmpty("输入查询条件开始搜索")}</div>
</section>
`;
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const search = new FormData(form).get("query")?.toString().trim() ?? "";
if (search) void searchUsers(container, search, role);
});
}
async function searchUsers(
container: HTMLElement,
search: string,
role: AdminRole,
): Promise<void> {
const results = container.querySelector<HTMLElement>("[data-results]");
if (!results) return;
renderLoading(results, "搜索用户");
try {
const page = await adminApi.users(search);
if (page.items.length === 0) {
results.innerHTML = renderEmpty("未找到匹配用户");
return;
}
results.innerHTML = `
<div class="table-wrap">
<table>
<caption class="sr-only">用户查询结果</caption>
<thead><tr><th scope="col">用户</th><th scope="col">状态</th><th scope="col">积分余额</th><th scope="col">注册时间</th><th scope="col">操作</th></tr></thead>
<tbody>
${page.items.map(userRow).join("")}
</tbody>
</table>
</div>
`;
results.querySelectorAll<HTMLButtonElement>("[data-user-id]").forEach(
(button) => {
button.addEventListener("click", () => {
const userId = button.dataset.userId;
if (userId) void renderUserDetail(container, userId, role);
});
},
);
} catch (error) {
renderError(results, error, () => void searchUsers(container, search, role));
}
}
function userRow(user: UserSummary): string {
return `
<tr>
<td>
<strong>${escapeHtml(user.displayName || "未命名用户")}</strong>
<div class="subtle mono">${escapeHtml(user.userId)}</div>
</td>
<td><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></td>
<td>${formatNumber(user.creditBalance)}</td>
<td>${formatDateTime(user.createdAt)}</td>
<td><wa-button variant="neutral" appearance="outlined" size="s" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</wa-button></td>
</tr>
`;
}
async function renderUserDetail(
container: HTMLElement,
userId: string,
role: AdminRole,
): Promise<void> {
renderLoading(container, "加载用户详情");
try {
const [user, ledger] = await Promise.all([
adminApi.user(userId),
adminApi.ledger(userId),
]);
container.innerHTML = detailTemplate(user, ledger, role);
container
.querySelector<HTMLButtonElement>("[data-back]")
?.addEventListener("click", () => {
renderUsers(container, role);
container.querySelector<HTMLInputElement>('input[name="query"]')?.focus();
});
bindLedgerPagination(container, user, ledger.nextCursor);
if (role === "SUPER_ADMIN") bindGrantDialog(container, user, role);
const heading = container.querySelector<HTMLElement>("h1");
if (heading) {
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
}
} catch (error) {
renderError(container, error, () =>
void renderUserDetail(container, userId, role),
);
}
}
function detailTemplate(
user: UserDetail,
ledger: PageResult<LedgerEntry>,
role: AdminRole,
): string {
const usage = user.usage ?? [];
const usageRequests = usage.reduce((total, item) => total + item.requests, 0);
const chargedCredits = usage.reduce(
(total, item) => total + item.chargedCredits,
0,
);
const referral = user.referral;
const inviterUserId = referral?.inviterUserId ?? user.referredByUserId;
const qualifiedUsage = user.qualifiedUsage || usageRequests > 0;
return `
<div class="page-heading page-heading--detail">
<div>
<wa-button class="back-link" appearance="plain" data-back>← 返回用户查询</wa-button>
<div class="eyebrow">用户详情</div>
<h1>${escapeHtml(user.displayName || "未命名用户")}</h1>
<p class="mono">${escapeHtml(user.userId)}</p>
</div>
${
role === "SUPER_ADMIN"
? '<wa-button variant="brand" appearance="accent" data-open-grant>人工赠送积分</wa-button>'
: ""
}
</div>
<section class="detail-grid">
<article class="panel profile-card">
<div class="profile-header">
<div class="avatar" aria-hidden="true">${escapeHtml((user.displayName || "用").slice(0, 1))}</div>
<div><strong>${escapeHtml(user.displayName || "未命名用户")}</strong><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></div>
</div>
<dl class="detail-list">
<div><dt>积分余额</dt><dd class="credit-value">${formatNumber(user.creditBalance)}</dd></div>
<div><dt>注册时间</dt><dd>${formatDateTime(user.createdAt)}</dd></div>
<div><dt>最近活跃</dt><dd>${formatDateTime(user.lastActiveAt)}</dd></div>
<div><dt>有效使用</dt><dd>${qualifiedUsage ? "已达成" : "未达成"}${usage.length > 0 ? ` · ${formatNumber(usageRequests)} 次请求` : ""}</dd></div>
${
usage.length > 0
? `<div><dt>累计消耗</dt><dd>${formatNumber(chargedCredits)} 积分</dd></div>`
: ""
}
<div><dt>邀请码</dt><dd class="mono">${escapeHtml(user.referralCode || "—")}</dd></div>
<div><dt>邀请来源</dt><dd class="mono">${escapeHtml(inviterUserId || "—")}</dd></div>
${
referral
? `<div><dt>邀请成效</dt><dd>${formatNumber(referral.rewardedInvites)} / ${formatNumber(referral.invitedUsers)} 已奖励</dd></div>`
: ""
}
</dl>
</article>
<section class="panel">
<div class="panel-heading"><div><h2>积分流水</h2><p>不可变账本记录</p></div></div>
${
ledger.items.length === 0
? renderEmpty("暂无积分流水")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">${escapeHtml(user.displayName || user.userId)} 的积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody data-ledger-body>${ledgerRows(ledger.items)}</tbody>
</table>
</div>
${
ledger.nextCursor
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-ledger-more aria-describedby="ledger-pagination-status">加载更多流水</wa-button></div>'
: ""
}
<p class="sr-only" id="ledger-pagination-status" data-ledger-status role="status" aria-live="polite"></p>
`
}
</section>
${usagePanel(usage)}
</section>
${role === "SUPER_ADMIN" ? grantDialog(user) : ""}
`;
}
function ledgerRows(entries: LedgerEntry[]): string {
return entries
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(statusLabel(entry.type))}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("");
}
function usagePanel(usage: UserUsageAggregate[]): string {
if (usage.length === 0) return "";
return `
<section class="panel detail-panel--wide">
<div class="panel-heading"><div><h2>使用统计</h2><p>按使用类型汇总,不包含用户内容</p></div></div>
<div class="table-wrap">
<table>
<caption class="sr-only">用户使用统计</caption>
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音时长(毫秒)</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
<tbody>
${usage
.map(
(item) => `
<tr>
<td><code>${escapeHtml(item.kind)}</code></td>
<td>${formatNumber(item.requests)}</td>
<td>${formatNumber(item.chargedCredits)}</td>
<td>${formatNumber(item.asrMillis)}</td>
<td>${formatNumber(item.inputTokens)}</td>
<td>${formatNumber(item.outputTokens)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</section>
`;
}
function bindLedgerPagination(
container: HTMLElement,
user: UserDetail,
initialCursor?: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-ledger-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-ledger-body]");
const status = container.querySelector<HTMLElement>("[data-ledger-status]");
if (!button || !body || !initialCursor) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.ledger(user.userId, cursor);
body.insertAdjacentHTML("beforeend", ledgerRows(page.items));
cursor = page.nextCursor;
if (status) {
status.textContent = cursor
? `已加载 ${formatNumber(page.items.length)} 条更多流水`
: `已加载 ${formatNumber(page.items.length)} 条流水,全部记录已加载`;
}
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载积分流水失败",
"error",
);
setButtonBusy(button, false);
button.focus();
}
});
}
function grantDialog(user: UserDetail): string {
return `
<dialog class="dialog" data-grant-dialog aria-labelledby="grant-dialog-title">
<form method="dialog" class="dialog-card" data-grant-form>
<div data-grant-inputs>
<div class="eyebrow">高风险操作</div>
<h2 id="grant-dialog-title">人工赠送积分</h2>
<p class="muted">赠送将写入不可变账本,并记录管理员审计日志。</p>
<label><span>用户 ID</span><input value="${escapeHtml(user.userId)}" disabled /></label>
<label><span>赠送积分</span><input name="amount" type="number" inputmode="numeric" min="1" max="100000" step="1" aria-describedby="grant-input-error" required /></label>
<label><span>赠送原因</span><textarea name="reason" minlength="4" maxlength="200" placeholder="请填写可审计的业务原因" aria-describedby="grant-input-error" required></textarea></label>
<p class="form-error" id="grant-input-error" data-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" value="cancel">取消</button>
<button class="button button--primary" type="button" data-review-grant>下一步</button>
</div>
</div>
<div data-grant-confirm hidden>
<div class="confirm-icon" aria-hidden="true">!</div>
<h2 data-confirm-title tabindex="-1">确认赠送?</h2>
<p>将向 <strong>${escapeHtml(user.displayName || user.userId)}</strong> 赠送 <strong data-confirm-amount></strong> 积分。</p>
<p class="confirm-reason" data-confirm-reason></p>
<p class="form-error" id="grant-confirm-error" data-confirm-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-edit-grant>返回修改</button>
<button class="button button--danger" type="button" data-submit-grant aria-describedby="grant-confirm-error">确认赠送</button>
</div>
</div>
</form>
</dialog>
`;
}
function bindGrantDialog(
container: HTMLElement,
user: UserDetail,
role: AdminRole,
): void {
const dialog = container.querySelector<HTMLDialogElement>("[data-grant-dialog]");
const form = container.querySelector<HTMLFormElement>("[data-grant-form]");
const inputs = container.querySelector<HTMLElement>("[data-grant-inputs]");
const confirm = container.querySelector<HTMLElement>("[data-grant-confirm]");
if (!dialog || !form || !inputs || !confirm) return;
let idempotencyKey = createIdempotencyKey();
let outcomeUnknown = false;
container
.querySelector<HTMLButtonElement>("[data-open-grant]")
?.addEventListener("click", () => {
idempotencyKey = createIdempotencyKey();
outcomeUnknown = false;
form.reset();
inputs.hidden = false;
confirm.hidden = true;
form.querySelectorAll<HTMLElement>(".form-error").forEach((node) => {
node.textContent = "";
});
dialog.showModal();
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
});
dialog.addEventListener("cancel", (event) => {
if (!outcomeUnknown) return;
event.preventDefault();
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
if (errorNode) {
errorNode.textContent = "赠送结果尚未确认,请使用当前窗口安全重试";
}
});
container
.querySelector<HTMLButtonElement>("[data-review-grant]")
?.addEventListener("click", () => {
const data = new FormData(form);
const amount = Number(data.get("amount"));
const reason = data.get("reason")?.toString().trim() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-error]");
if (!Number.isSafeInteger(amount) || amount < 1 || amount > 100_000) {
if (errorNode) errorNode.textContent = "积分必须是 1 至 100,000 的整数";
return;
}
if (reason.length < 4) {
if (errorNode) errorNode.textContent = "请填写至少 4 个字符的赠送原因";
return;
}
if (errorNode) errorNode.textContent = "";
const amountNode = form.querySelector<HTMLElement>("[data-confirm-amount]");
const reasonNode = form.querySelector<HTMLElement>("[data-confirm-reason]");
if (amountNode) amountNode.textContent = formatNumber(amount);
if (reasonNode) reasonNode.textContent = `原因:${reason}`;
inputs.hidden = true;
confirm.hidden = false;
form.querySelector<HTMLElement>("[data-confirm-title]")?.focus();
});
container
.querySelector<HTMLButtonElement>("[data-edit-grant]")
?.addEventListener("click", () => {
if (outcomeUnknown) {
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
if (errorNode) {
errorNode.textContent = "结果尚未确认,不能修改本次赠送内容;请直接重试";
}
return;
}
confirm.hidden = true;
inputs.hidden = false;
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
});
const submit = container.querySelector<HTMLButtonElement>("[data-submit-grant]");
submit?.addEventListener("click", async () => {
const data = new FormData(form);
const amount = Number(data.get("amount"));
const reason = data.get("reason")?.toString().trim() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
setButtonBusy(submit, true, "赠送中…");
if (errorNode) errorNode.textContent = "";
try {
await adminApi.grantCredits({
userId: user.userId,
amount,
reason,
idempotencyKey,
});
dialog.close();
showToast("积分赠送成功,账本已更新", "success");
await renderUserDetail(container, user.userId, role);
} catch (error) {
const message =
error instanceof ApiError ? error.message : "赠送失败,请重试";
outcomeUnknown =
!(error instanceof ApiError) || error.status === 0 || error.status >= 500;
if (errorNode) {
errorNode.textContent = outcomeUnknown
? "赠送结果尚未确认;重试会复用同一请求,不会重复到账"
: message;
}
setButtonBusy(submit, false);
}
});
}
+348 -1309
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { funnelChart, trendChart } from "../components/charts";
describe("accessible charts", () => {
it("provides a titled SVG and complete data table for trends", () => {
const html = trendChart([
{ date: "2026-08-16", registrations: 12, creditsUsed: 345 },
]);
expect(html).toContain('aria-labelledby="trend-chart-title trend-chart-description"');
expect(html).toContain("<caption>新增用户与积分消耗趋势完整数据</caption>");
expect(html).toContain("<td>345</td>");
});
it("renders the funnel without CSP-sensitive inline styles", () => {
const html = funnelChart([
{ label: "访问", count: 20 },
{ label: "注册", count: 10 },
]);
expect(html).toContain("<progress");
expect(html).not.toContain('style="');
});
});
+138 -1
View File
@@ -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");
});
});
+180
View File
@@ -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",
},
},
},
],
};
}
+115
View File
@@ -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",
});
});
});
+50
View File
@@ -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");
});
});
-191
View File
@@ -1,191 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../api/client";
import type {
AdminOperator,
AdminSecuritySummary,
UserDetail,
UserSummary,
} from "../api/types";
import { renderSecurity } from "../pages/security";
import { renderUsers } from "../pages/users";
const userId = "11111111-1111-4111-8111-111111111111";
const userSummary: UserSummary = {
userId,
displayName: "测试用户",
status: "active",
creditBalance: 120,
createdAt: "2026-08-01T08:00:00Z",
};
const userDetail: UserDetail = {
...userSummary,
lastActiveAt: "2026-08-16T08:00:00Z",
qualifiedUsage: true,
referralCode: "OSG-TEST",
usage: [
{
kind: "ASR",
requests: 3,
chargedCredits: 18,
asrMillis: 12_000,
inputTokens: 0,
outputTokens: 0,
},
],
referral: {
inviterUserId: "22222222-2222-4222-8222-222222222222",
invitedUsers: 4,
rewardedInvites: 2,
},
};
afterEach(() => {
vi.restoreAllMocks();
document.body.replaceChildren();
});
describe("用户页", () => {
it("支持人员可查看详情但不显示人工赠送", async () => {
mockUserRequests();
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
const container = document.createElement("main");
document.body.append(container);
await openUserDetail(container, "SUPPORT");
expect(container.querySelector("[data-open-grant]")).toBeNull();
expect(container.querySelector("[data-grant-dialog]")).toBeNull();
});
it("超级管理员可赠送,并展示 usage/referral 与游标流水", async () => {
mockUserRequests();
vi.spyOn(adminApi, "ledger")
.mockResolvedValueOnce({
items: [
{
entryId: "ledger-1",
type: "grant",
amount: 100,
balanceAfter: 100,
reasonCode: "SIGNUP_TRIAL",
createdAt: "2026-08-01T08:00:00Z",
},
],
nextCursor: "ledger-next",
})
.mockResolvedValueOnce({
items: [
{
entryId: "ledger-2",
type: "settle",
amount: -18,
balanceAfter: 82,
reasonCode: "USAGE_SETTLE",
createdAt: "2026-08-02T08:00:00Z",
},
],
});
const container = document.createElement("main");
document.body.append(container);
await openUserDetail(container, "SUPER_ADMIN");
expect(container.querySelector("[data-open-grant]")).not.toBeNull();
expect(container.textContent).toContain("OSG-TEST");
expect(container.textContent).toContain("3 次请求");
expect(container.textContent).toContain("18 积分");
expect(container.textContent).toContain("2 / 4 已奖励");
expect(container.textContent).toContain(
"22222222-2222-4222-8222-222222222222",
);
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.ledger).toHaveBeenNthCalledWith(
2,
userId,
"ledger-next",
);
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-ledger-more]")).toBeNull();
expect(container.querySelector("[data-ledger-status]")?.textContent).toContain(
"全部记录已加载",
);
});
});
describe("安全中心", () => {
it("按后端 nextCursor 加载更多管理员", async () => {
const firstOperator = operator("operator-1", "owner");
const secondOperator = operator("operator-2", "support");
vi.spyOn(adminApi, "operators")
.mockResolvedValueOnce({
items: [firstOperator],
nextCursor: "operator-next",
})
.mockResolvedValueOnce({ items: [secondOperator] });
vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary());
const container = document.createElement("main");
document.body.append(container);
await renderSecurity(container, "owner");
container.querySelector<HTMLButtonElement>("[data-operator-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
expect(container.querySelectorAll("[data-operator-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-operator-count]")?.textContent).toBe(
"已加载 2 个账户",
);
expect(container.querySelector("[data-operator-more]")).toBeNull();
expect(
container.querySelector("[data-operator-status]")?.textContent,
).toContain("全部账户已加载");
});
});
function mockUserRequests(): void {
vi.spyOn(adminApi, "users").mockResolvedValue({ items: [userSummary] });
vi.spyOn(adminApi, "user").mockResolvedValue(userDetail);
}
async function openUserDetail(
container: HTMLElement,
role: "SUPER_ADMIN" | "SUPPORT",
): Promise<void> {
renderUsers(container, role);
const input = container.querySelector<HTMLInputElement>('input[name="query"]');
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
if (!input || !form) throw new Error("用户查询表单未渲染");
input.value = userId;
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(container.querySelector("[data-user-id]")).not.toBeNull();
});
container.querySelector<HTMLButtonElement>("[data-user-id]")?.click();
await vi.waitFor(() => {
expect(container.querySelector("h1")?.textContent).toBe("测试用户");
});
}
function operator(operatorId: string, username: string): AdminOperator {
return {
operatorId,
username,
role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT",
enabled: true,
failedLoginCount: 0,
createdAt: "2026-08-01T08:00:00Z",
updatedAt: "2026-08-01T08:00:00Z",
};
}
function summary(): AdminSecuritySummary {
return {
enabledOperators: 2,
lockedOperators: 0,
activeSessions: 1,
};
}
+410
View File
@@ -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,
};
}
+110
View File
@@ -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",
},
];
}
+111
View File
@@ -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,
};
}
-35
View File
@@ -1,35 +0,0 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { describe, expect, it, vi } from "vitest";
import { renderError, renderLoading, setButtonBusy } from "../components/ui";
describe("Web Awesome UI states", () => {
it("renders accessible loading and retry components", () => {
const container = document.createElement("div");
const retry = vi.fn();
renderLoading(container, "读取用户");
expect(container.querySelector("wa-spinner")).not.toBeNull();
expect(container.textContent).toContain("读取用户");
renderError(container, new Error("failed"), retry);
const retryButton = container.querySelector<HTMLElement>("wa-button[data-retry]");
expect(container.querySelector('wa-callout[variant="danger"]')).not.toBeNull();
retryButton?.click();
expect(retry).toHaveBeenCalledOnce();
});
it("uses the Web Awesome loading state and restores the label", () => {
const button = document.createElement("wa-button") as WaButton;
button.textContent = "提交";
setButtonBusy(button, true, "提交中…");
expect(button.loading).toBe(true);
expect(button.disabled).toBe(true);
expect(button.textContent).toBe("提交中…");
setButtonBusy(button, false);
expect(button.loading).toBe(false);
expect(button.disabled).toBe(false);
expect(button.textContent).toBe("提交");
});
});
+1
View File
@@ -6,6 +6,7 @@
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["node"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
+18
View File
@@ -1,7 +1,10 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
base: "/admin/",
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/v1/admin": {
@@ -12,6 +15,21 @@ export default defineConfig({
},
build: {
target: "es2022",
rollupOptions: {
output: {
manualChunks(id) {
if (
id.includes("/node_modules/react/") ||
id.includes("/node_modules/react-dom/") ||
id.includes("/node_modules/react-router")
) {
return "react-vendor";
}
if (id.includes("/node_modules/@base-ui/")) return "base-ui";
return undefined;
},
},
},
},
test: {
environment: "jsdom",
+1
View File
@@ -134,6 +134,7 @@ dependencies {
implementation("com.auth0:java-jwt:4.6.0")
implementation("com.nimbusds:nimbus-jose-jwt:10.9.1")
implementation("com.apple.itunes.storekit:app-store-server-library:5.2.0")
implementation("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
implementation("com.upokecenter:cbor:4.5.6")
+15 -2
View File
@@ -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
View File
@@ -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
+4 -8
View File
@@ -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
View File
@@ -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"
+44
View File
@@ -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'@'%';
+16
View File
@@ -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.
+251
View File
@@ -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.
+9 -2
View File
@@ -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
View File
@@ -97,6 +97,10 @@ Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 `.p8
v3 WSS endpoint、资源 ID 和 API KeyDeepSeek 使用 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
+42
View File
@@ -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.
+230
View File
@@ -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).
+60
View File
@@ -0,0 +1,60 @@
# StoreKit credit product
The voluntary `ByRockyACoffee` tip remains independent and never grants credits.
Credit products are separate consumables:
- Product ID `500tks`: 500 integer credits at USD 0.99
- Product ID `1500tks`: 1,500 integer credits at USD 1.99 / CNY 18
- Product ID `3000tks`: 3,000 integer credits at USD 2.99 / CNY 28
- Territory prices remain controlled by App Store Connect.
- Restore Purchases: not offered for this consumable
## Cost basis
Reviewed on 2026-08-18 against the provider pricing pages:
- DeepSeek V4 Flash peak pricing is CNY 3 per million cache-miss input tokens
and CNY 9 per million output tokens. Off-peak pricing is half.
<https://api-docs.deepseek.com/zh-cn/quick_start/pricing>
- Doubao SeedASR 2.0 streaming recognition is CNY 4.5 per hour.
<https://ai.volcengine.com/model>
The V10 immutable rate card charges:
- ASR: one credit per started three-second interval. The 3,000-credit pack
provides up to 150 minutes and has a worst-case provider cost of CNY 11.25.
- DeepSeek: one credit per 1,000 input tokens plus one credit per 400 output
tokens, with each dimension rounded upward. At peak pricing, using all 3,000
credits exclusively on input or output costs at most about CNY 9.00 or
CNY 10.80 respectively.
- New signup, inviter and invitee grants are 1,000 credits each. Existing
immutable balances are adjusted only through explicit admin grants.
- Existing immutable ledger balances are grandfathered and are not rewritten
during the denomination change.
At a CNY 28 sale price, the ASR-heavy worst case leaves CNY 12.55 after a 15%
App Store commission, or CNY 8.35 after a 30% commission, before tax and
infrastructure costs. USD 2.99 territories are tighter at the worst-case ASR
mix and require ongoing margin monitoring.
## Transaction rules
- The app supplies the authenticated account UUID as StoreKit `appAccountToken`.
- The server verifies Apple's JWS signature, certificate chain, bundle ID,
App Apple ID, environment, consumable type, account token, and product ID.
- The App Store transaction ID is globally unique and idempotent.
- Credit balance and append-only purchase/ledger records commit in one database
transaction. A client retry returns the original grant.
- The app finishes the StoreKit transaction only after server acknowledgement.
- Signed transaction bodies and Apple certificate contents are never logged.
## Purchase history
- `GET /v1/storekit/transactions` requires the existing Bearer access token.
- The account comes only from the authenticated session; clients never submit it.
- History is read from the existing purchase audit and immutable credit ledger,
so purchases credited before the endpoint was deployed are included.
- Results are ordered by `purchasedAt` and then `transactionId`, both descending,
with opaque cursor pagination (`limit` defaults to 50 and is bounded to 1100).
- Responses omit JWS data, Apple identifiers, account IDs, and internal record IDs.
- History queries never call the App Store.
+45
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+190 -41
View File
@@ -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,
)
@@ -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,
@@ -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