Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c9e5feec0 | |||
| 36a926f12f | |||
| e522788867 | |||
| 51c37e6206 | |||
| 03eac71905 | |||
| 4f5b6eafbd | |||
| f95d09f303 | |||
| b4064126fe | |||
| 544e0d7356 | |||
| 636a8541bc | |||
| 10ba4f0a0c | |||
| 9fb947aa7d | |||
| f8fa93dc48 | |||
| 0d236f57fb | |||
| edd0d9feca | |||
| b25f5ae6e9 | |||
| 454ba8ddc5 | |||
| d0abe27623 | |||
| b5212dcdc2 | |||
| 74c3fcd45f | |||
| 034a3e8745 | |||
| 0b4acb5978 | |||
| 4b465e0e5e | |||
| 231c5040a5 | |||
| 11ec34dacb | |||
| 3edc86a9a0 | |||
| e1fd35b1ff | |||
| 2194e69bb8 | |||
| 75c046d91d | |||
| 58445dd880 | |||
| c592f426be | |||
| 1737106560 | |||
| 25cfbfa4e6 | |||
| babc80044d | |||
| 0dff35a0f7 | |||
| e6ce70117b |
+22
-2
@@ -23,6 +23,7 @@ JWT_AUDIENCE=osgkeyboard-ios
|
|||||||
JWT_SECRET=replace-with-at-least-32-random-bytes
|
JWT_SECRET=replace-with-at-least-32-random-bytes
|
||||||
ACCESS_TOKEN_MINUTES=15
|
ACCESS_TOKEN_MINUTES=15
|
||||||
REFRESH_TOKEN_DAYS=30
|
REFRESH_TOKEN_DAYS=30
|
||||||
|
LEGACY_REFRESH_REPLAY_SECONDS=30
|
||||||
GATEWAY_GRANT_DAYS=30
|
GATEWAY_GRANT_DAYS=30
|
||||||
FIELD_ENCRYPTION_KEY=replace-with-exactly-32-random-bytes-as-base64
|
FIELD_ENCRYPTION_KEY=replace-with-exactly-32-random-bytes-as-base64
|
||||||
IDENTITY_HMAC_KEY=replace-with-a-distinct-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
|
# Admin console. Enable bootstrap for the first successful startup only, then
|
||||||
# set it back to false and remove all four ADMIN_BOOTSTRAP_* credential values.
|
# set it back to false and remove all four ADMIN_BOOTSTRAP_* credential values.
|
||||||
ADMIN_ENABLED=false
|
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_ENABLED=false
|
||||||
ADMIN_BOOTSTRAP_OPERATOR_ID=replace-with-random-uuid
|
ADMIN_BOOTSTRAP_OPERATOR_ID=replace-with-random-uuid
|
||||||
ADMIN_BOOTSTRAP_USERNAME=owner
|
ADMIN_BOOTSTRAP_USERNAME=owner
|
||||||
@@ -39,6 +42,12 @@ ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32=replace-with-random-base32-secret
|
|||||||
ADMIN_SESSION_HOURS=8
|
ADMIN_SESSION_HOURS=8
|
||||||
ADMIN_MAXIMUM_MANUAL_GRANT=100000
|
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 identifiers are not secrets, but use the values from your own developer account.
|
||||||
APPLE_TEAM_ID=replace-with-apple-team-id
|
APPLE_TEAM_ID=replace-with-apple-team-id
|
||||||
APPLE_KEY_ID=replace-with-apple-key-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_REVOKE_URL=https://appleid.apple.com/auth/revoke
|
||||||
APPLE_INTEGRITY_ENVIRONMENT=development
|
APPLE_INTEGRITY_ENVIRONMENT=development
|
||||||
APP_ATTEST_CHALLENGE_TTL_SECONDS=300
|
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.
|
# 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.
|
# 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_API_KEY=replace-with-deepseek-api-key
|
||||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||||
|
# Optional; defaults to DEEPSEEK_MODEL when omitted.
|
||||||
|
DEEPSEEK_REASONING_MODEL=
|
||||||
DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1
|
DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1
|
||||||
|
|
||||||
SIGNUP_TRIAL_CREDITS=1000
|
SIGNUP_TRIAL_CREDITS=1000
|
||||||
REFERRAL_INVITER_CREDITS=3000
|
REFERRAL_INVITER_CREDITS=1000
|
||||||
REFERRAL_INVITEE_CREDITS=3000
|
REFERRAL_INVITEE_CREDITS=1000
|
||||||
REFERRAL_BINDING_DAYS=7
|
REFERRAL_BINDING_DAYS=7
|
||||||
|
|
||||||
|
# Keep voluntary tips separate. Every entry must be a dedicated consumable in
|
||||||
|
# productId:credits format and use an appAccountToken supplied by the app.
|
||||||
|
STOREKIT_ENABLED=false
|
||||||
|
STOREKIT_BUNDLE_ID=com.osgkeyboard.ios
|
||||||
|
STOREKIT_APP_APPLE_ID=6781553267
|
||||||
|
STOREKIT_PRODUCTS=500tks:500,1500tks:1500,3000tks:3000
|
||||||
|
|
||||||
# Production startup requires both flags and the production Apple environment.
|
# Production startup requires both flags and the production Apple environment.
|
||||||
ENFORCE_DEVICE_CHECK=false
|
ENFORCE_DEVICE_CHECK=false
|
||||||
ENFORCE_APP_ATTEST=false
|
ENFORCE_APP_ATTEST=false
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: admin-web/package-lock.json
|
cache-dependency-path: admin-web/package-lock.json
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v6
|
||||||
- name: Verify Docker for MySQL integration tests
|
- name: Verify Docker for MySQL integration tests
|
||||||
run: docker info
|
run: docker info
|
||||||
- name: Validate production Compose
|
- name: Validate production Compose
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -89,6 +89,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
APP_BUILD_SHA=${{ github.sha }}
|
||||||
tags: ${{ steps.metadata.outputs.tags }}
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
labels: ${{ steps.metadata.outputs.labels }}
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
|
|||||||
./gradlew --no-daemon --no-configuration-cache --stacktrace installDist
|
./gradlew --no-daemon --no-configuration-cache --stacktrace installDist
|
||||||
|
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
ARG APP_BUILD_SHA=unknown
|
||||||
RUN addgroup -S -g 10001 app \
|
RUN addgroup -S -g 10001 app \
|
||||||
&& adduser -S -D -H -u 10001 -G app -h /app app
|
&& adduser -S -D -H -u 10001 -G app -h /app app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -17,6 +18,7 @@ WORKDIR /app
|
|||||||
COPY --from=build --chown=app:app /workspace/build/install/OSGAccountServer/ /app/
|
COPY --from=build --chown=app:app /workspace/build/install/OSGAccountServer/ /app/
|
||||||
|
|
||||||
ENV HOME=/tmp \
|
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"
|
JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=/tmp -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
|
||||||
|
|
||||||
USER 10001:10001
|
USER 10001:10001
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ Kotlin 2.4.10 / Ktor 3.5.2 managed AI gateway and invitation page.
|
|||||||
- Volcengine SAUC v3 ASR over WebSocket with a strict binary frame codec.
|
- Volcengine SAUC v3 ASR over WebSocket with a strict binary frame codec.
|
||||||
- Credit reservation, settlement, release, identity, grant, and metadata persistence ports.
|
- Credit reservation, settlement, release, identity, grant, and metadata persistence ports.
|
||||||
- Apple DeviceCheck trial enforcement and App Attest attestation/assertion validation.
|
- 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.
|
- 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.
|
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/integrity/assert`
|
||||||
- `POST /v1/auth/apple`
|
- `POST /v1/auth/apple`
|
||||||
- `POST /v1/auth/refresh`, `POST /v1/auth/logout`
|
- `POST /v1/auth/refresh`, `POST /v1/auth/logout`
|
||||||
|
- `POST /v1/analytics/events`
|
||||||
- `GET/DELETE /v1/account`
|
- `GET/DELETE /v1/account`
|
||||||
- `POST /v1/apple/events`
|
- `POST /v1/apple/events`
|
||||||
- `GET /v1/credits/{balance|ledger|rates}`
|
- `GET /v1/credits/{balance|ledger|rates}`
|
||||||
@@ -34,6 +38,7 @@ Application logging must also keep request/response body logging disabled.
|
|||||||
- `GET /i/{code}`
|
- `GET /i/{code}`
|
||||||
- `GET /.well-known/apple-app-site-association`
|
- `GET /.well-known/apple-app-site-association`
|
||||||
- `GET /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.
|
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
|
official Apple App Attestation Root CA bundled from Apple Certificate Authority. The server stores the
|
||||||
validated public key, receipt, and strictly increasing assertion counter.
|
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
|
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
|
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
|
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:
|
Existing `application.yaml` values can be mapped into:
|
||||||
|
|
||||||
- `DeepSeekConfig(endpoint, apiKey, model)`
|
- `DeepSeekConfig(endpoint, apiKey, model, reasoningModel)`
|
||||||
- `VolcengineAsrConfig(endpoint, resourceId, appId, accessToken)`
|
- `VolcengineAsrConfig(endpoint, resourceId, appId, accessToken)`
|
||||||
- `InviteWebConfig(appStoreUrl, appleAppId, universalLinkBaseUrl)`
|
- `InviteWebConfig(appStoreUrl, appleAppId, universalLinkBaseUrl)`
|
||||||
|
|
||||||
@@ -152,8 +162,18 @@ Configuration ownership:
|
|||||||
`APPLE_INTEGRITY_ENVIRONMENT`, plus the two integrity enforcement flags.
|
`APPLE_INTEGRITY_ENVIRONMENT`, plus the two integrity enforcement flags.
|
||||||
- Volcengine: prefer `VOLCENGINE_API_KEY`; set the SAUC v3 `VOLCENGINE_RESOURCE_ID` and WSS
|
- Volcengine: prefer `VOLCENGINE_API_KEY`; set the SAUC v3 `VOLCENGINE_RESOURCE_ID` and WSS
|
||||||
`VOLCENGINE_ASR_ENDPOINT`. The legacy app ID/access token pair remains optional.
|
`VOLCENGINE_ASR_ENDPOINT`. The legacy app ID/access token pair remains optional.
|
||||||
- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned `DEEPSEEK_MODEL`, and HTTPS
|
- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned low-latency `DEEPSEEK_MODEL`,
|
||||||
`DEEPSEEK_ENDPOINT`.
|
and HTTPS `DEEPSEEK_ENDPOINT`. `DEEPSEEK_REASONING_MODEL` is optional and
|
||||||
|
falls back to `DEEPSEEK_MODEL`.
|
||||||
|
|
||||||
|
Gateway text requests may include the optional stable `taskKind` values documented in
|
||||||
|
`docs/openapi.yaml`. The server maps `capability + taskKind` to a deterministic execution policy;
|
||||||
|
it never infers task type from user content. Polish and transform tasks explicitly disable DeepSeek
|
||||||
|
thinking and do not retry an empty buffered result. AI questions and agent planning explicitly use
|
||||||
|
high-effort thinking. 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
|
Store production values in 1Panel's secret/environment facility. The Compose environment receives
|
||||||
them at runtime because this application does not read Docker `/run/secrets/*` files directly.
|
them at runtime because this application does not read Docker `/run/secrets/*` files directly.
|
||||||
@@ -236,7 +256,7 @@ Internet.
|
|||||||
- Apple: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`,
|
- Apple: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`,
|
||||||
`APPLE_INTEGRITY_ENVIRONMENT`.
|
`APPLE_INTEGRITY_ENVIRONMENT`.
|
||||||
- Providers: `VOLCENGINE_API_KEY`, `VOLCENGINE_RESOURCE_ID`, `DEEPSEEK_API_KEY`,
|
- Providers: `VOLCENGINE_API_KEY`, `VOLCENGINE_RESOURCE_ID`, `DEEPSEEK_API_KEY`,
|
||||||
`DEEPSEEK_MODEL`.
|
`DEEPSEEK_MODEL`; optionally `DEEPSEEK_REASONING_MODEL`.
|
||||||
- Production controls: `APP_ENV=production`, `ENFORCE_DEVICE_CHECK=true`,
|
- Production controls: `APP_ENV=production`, `ENFORCE_DEVICE_CHECK=true`,
|
||||||
`ENFORCE_APP_ATTEST=true`.
|
`ENFORCE_APP_ATTEST=true`.
|
||||||
- Optional tuning: token lifetimes, gateway grant days, credit values, binding window and pool size;
|
- Optional tuning: token lifetimes, gateway grant days, credit values, binding window and pool size;
|
||||||
@@ -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
|
settlement pending behavior, reconciliation, whole-call timeout, forged audio duration, malformed SAUC
|
||||||
sequences, and final-frame-only duration.
|
sequences, and final-frame-only duration.
|
||||||
|
|
||||||
The MySQL Testcontainers suite validates all migrations, account-deletion cascades, stale access-token
|
The MySQL Testcontainers suite validates all migrations, account-deletion
|
||||||
rejection, real InnoDB concurrent balance locking, and exactly-once referral rewards. It is automatically
|
cascades (including linked analytics events), stale access-token rejection, real
|
||||||
skipped when Docker is unavailable; CI requires Docker and runs it on every build.
|
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
|
## Staged acceptance
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="theme-color" content="#f5f7fb" />
|
||||||
<meta name="referrer" content="same-origin" />
|
<meta name="referrer" content="same-origin" />
|
||||||
<title>OSG 运营后台</title>
|
<title>OSG 运营后台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+1629
-330
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -9,13 +9,29 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.5",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^7.1.2",
|
"vite": "^7.1.2",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@awesome.me/webawesome": "^3.11.0"
|
"@base-ui/react": "^1.7.0",
|
||||||
|
"@tanstack/react-table": "^9.1.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.33.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-14
@@ -3,17 +3,39 @@ import type {
|
|||||||
AdminOperatorCreateRequest,
|
AdminOperatorCreateRequest,
|
||||||
AdminOperatorProvisioning,
|
AdminOperatorProvisioning,
|
||||||
AdminSecuritySummary,
|
AdminSecuritySummary,
|
||||||
|
AdminHintPack,
|
||||||
|
HintFeedGenerationResponse,
|
||||||
|
HintFeedGenerationStatus,
|
||||||
|
HintFeedSettings,
|
||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
|
AuditQuery,
|
||||||
AuditLogEntry,
|
AuditLogEntry,
|
||||||
CreditGrantRequest,
|
CreditGrantRequest,
|
||||||
CreditGrantResponse,
|
CreditGrantResponse,
|
||||||
|
LedgerQuery,
|
||||||
LedgerEntry,
|
LedgerEntry,
|
||||||
|
ManagedProviderId,
|
||||||
|
ManagedProviderOverview,
|
||||||
|
ManagedProviderStatus,
|
||||||
|
OperatorsQuery,
|
||||||
Overview,
|
Overview,
|
||||||
PageResult,
|
PageResult,
|
||||||
|
ProductAnalyticsOverview,
|
||||||
|
CreateOfficialSkillRequest,
|
||||||
|
OfficialSkill,
|
||||||
|
OfficialSkillCatalog,
|
||||||
|
RevealProviderApiKeyRequest,
|
||||||
|
RevealProviderApiKeyResponse,
|
||||||
|
ReferralsQuery,
|
||||||
ReferralOverview,
|
ReferralOverview,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
UserDetail,
|
UserDetail,
|
||||||
|
UsersQuery,
|
||||||
UserSummary,
|
UserSummary,
|
||||||
|
UpdateHintPackRequest,
|
||||||
|
UpdateHintFeedSettingsRequest,
|
||||||
|
UpdateOfficialSkillRequest,
|
||||||
|
UpdateProviderApiKeyRequest,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const API_BASE = "/v1/admin";
|
const API_BASE = "/v1/admin";
|
||||||
@@ -67,6 +89,15 @@ function safeMessage(status: number, code?: string): string {
|
|||||||
ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在",
|
ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在",
|
||||||
CANNOT_DISABLE_SELF: "不能停用当前登录的管理员",
|
CANNOT_DISABLE_SELF: "不能停用当前登录的管理员",
|
||||||
LAST_SUPER_ADMIN_REQUIRED: "必须至少保留一名启用的超级管理员",
|
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: "操作过于频繁,请稍后再试",
|
RATE_LIMITED: "操作过于频繁,请稍后再试",
|
||||||
};
|
};
|
||||||
if (code && messages[code]) return messages[code];
|
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();
|
const search = new URLSearchParams();
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
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();
|
const result = search.toString();
|
||||||
return result ? `?${result}` : "";
|
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 = {
|
export const adminApi = {
|
||||||
session: () => request<SessionResponse>("/auth/session"),
|
session: () => request<SessionResponse>("/auth/session"),
|
||||||
|
|
||||||
@@ -160,22 +202,85 @@ export const adminApi = {
|
|||||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||||
|
|
||||||
overview: (range: string) =>
|
overview: (range: string) =>
|
||||||
request<Overview>(`/overview${query({ range })}`),
|
request<Overview>(`/overview${encodeQuery({ range })}`),
|
||||||
|
|
||||||
referrals: (range: string) =>
|
referrals: (value: string | ReferralsQuery) => {
|
||||||
request<ReferralOverview>(`/referrals${query({ range })}`),
|
const params = typeof value === "string" ? { range: value } : value;
|
||||||
|
return request<ReferralOverview>(`/referrals${encodeQuery(params)}`);
|
||||||
|
},
|
||||||
|
|
||||||
users: (search: string, cursor?: string) =>
|
productAnalytics: (range: string) =>
|
||||||
request<PageResult<UserSummary>>(
|
request<ProductAnalyticsOverview>(`/analytics${encodeQuery({ range })}`),
|
||||||
`/users${query({ q: search.trim(), cursor })}`,
|
|
||||||
|
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) =>
|
user: (userId: string) =>
|
||||||
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
|
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
|
||||||
|
|
||||||
ledger: (userId: string, cursor?: string) =>
|
latestLedger: (value?: string | LedgerQuery) =>
|
||||||
request<PageResult<LedgerEntry>>(
|
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) =>
|
grantCredits: (payload: CreditGrantRequest) =>
|
||||||
@@ -189,11 +294,41 @@ export const adminApi = {
|
|||||||
headers: { "Idempotency-Key": payload.idempotencyKey },
|
headers: { "Idempotency-Key": payload.idempotencyKey },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
auditLogs: (cursor?: string) =>
|
providers: () => request<ManagedProviderOverview>("/providers"),
|
||||||
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`),
|
|
||||||
|
|
||||||
operators: (cursor?: string) =>
|
revealProviderApiKey: (
|
||||||
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`),
|
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: () =>
|
operatorSummary: () =>
|
||||||
request<AdminSecuritySummary>("/operators/summary"),
|
request<AdminSecuritySummary>("/operators/summary"),
|
||||||
|
|||||||
+357
-4
@@ -1,5 +1,197 @@
|
|||||||
export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST";
|
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 =
|
export type AuthState =
|
||||||
| { status: "anonymous" }
|
| { status: "anonymous" }
|
||||||
| { status: "authenticated"; operatorName: string; role: AdminRole };
|
| { status: "authenticated"; operatorName: string; role: AdminRole };
|
||||||
@@ -23,6 +215,10 @@ export interface TrendPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Overview {
|
export interface Overview {
|
||||||
|
period: {
|
||||||
|
from: string;
|
||||||
|
until: string;
|
||||||
|
};
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
activeUsers: number;
|
activeUsers: number;
|
||||||
newUsers: number;
|
newUsers: number;
|
||||||
@@ -46,18 +242,133 @@ export interface ReferralRankingItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ReferralOverview {
|
export interface ReferralOverview {
|
||||||
|
period: {
|
||||||
|
from: string;
|
||||||
|
until: string;
|
||||||
|
};
|
||||||
pendingBindings: number;
|
pendingBindings: number;
|
||||||
ineligibleBindings: number;
|
ineligibleBindings: number;
|
||||||
funnel: FunnelStep[];
|
funnel: FunnelStep[];
|
||||||
ranking: ReferralRankingItem[];
|
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 {
|
export interface UserSummary {
|
||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
maskedEmail?: string;
|
maskedEmail?: string;
|
||||||
status: "active" | "suspended" | "closed";
|
status: "active" | "suspended" | "closed";
|
||||||
creditBalance: number;
|
creditBalance: number;
|
||||||
|
consumedCredits: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,19 +401,61 @@ export interface PageResult<T> {
|
|||||||
nextCursor?: string;
|
nextCursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LedgerEntryType =
|
export type LedgerAction =
|
||||||
| "grant"
|
| "grant"
|
||||||
| "reserve"
|
| "reserve"
|
||||||
| "settle"
|
| "settle"
|
||||||
| "refund"
|
| "refund";
|
||||||
| "adjustment";
|
|
||||||
|
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 {
|
export interface LedgerEntry {
|
||||||
entryId: string;
|
entryId: string;
|
||||||
type: LedgerEntryType;
|
userId: string;
|
||||||
|
type: LedgerAction;
|
||||||
|
entryType: LedgerEntryType;
|
||||||
amount: number;
|
amount: number;
|
||||||
balanceAfter: number;
|
balanceAfter: number;
|
||||||
reasonCode: string;
|
reasonCode: string;
|
||||||
|
usageType?: UsageType;
|
||||||
|
referenceId?: string;
|
||||||
|
details?: LedgerEntryDetails;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
import { adminApi, ApiError, setCsrfToken } from "./api/client";
|
|
||||||
import type { AdminRole, AuthState } from "./api/types";
|
|
||||||
import { renderLoading, showToast } from "./components/ui";
|
|
||||||
import { escapeHtml } from "./lib/format";
|
|
||||||
import { renderAudit } from "./pages/audit";
|
|
||||||
import { renderPasswordLogin } from "./pages/auth";
|
|
||||||
import { renderCredits } from "./pages/credits";
|
|
||||||
import { renderOverview } from "./pages/overview";
|
|
||||||
import { renderReferrals } from "./pages/referrals";
|
|
||||||
import { renderSecurity } from "./pages/security";
|
|
||||||
import { renderUsers } from "./pages/users";
|
|
||||||
|
|
||||||
type Route =
|
|
||||||
| "overview"
|
|
||||||
| "referrals"
|
|
||||||
| "users"
|
|
||||||
| "credits"
|
|
||||||
| "audit"
|
|
||||||
| "security";
|
|
||||||
|
|
||||||
interface RouteDefinition {
|
|
||||||
id: Route;
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
roles: AdminRole[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"];
|
|
||||||
const SUPPORT_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"];
|
|
||||||
const SUPER_ADMIN_ONLY: AdminRole[] = ["SUPER_ADMIN"];
|
|
||||||
|
|
||||||
const routes: RouteDefinition[] = [
|
|
||||||
{
|
|
||||||
id: "overview",
|
|
||||||
label: "运营总览",
|
|
||||||
roles: ALL_ROLES,
|
|
||||||
icon: '<path d="M4 18V10M10 18V6M16 18V3M2 18h18" />',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "referrals",
|
|
||||||
label: "裂变分析",
|
|
||||||
roles: ALL_ROLES,
|
|
||||||
icon:
|
|
||||||
'<circle cx="5" cy="11" r="2.5" /><circle cx="17" cy="5" r="2.5" /><circle cx="17" cy="17" r="2.5" /><path d="m7.3 9.8 7.4-3.6M7.3 12.2l7.4 3.6" />',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "users",
|
|
||||||
label: "用户查询",
|
|
||||||
roles: SUPPORT_ROLES,
|
|
||||||
icon:
|
|
||||||
'<circle cx="8" cy="7" r="3" /><circle cx="16" cy="8" r="2.5" /><path d="M2.5 18c.5-3.5 2.3-5.5 5.5-5.5s5 2 5.5 5.5M13 13.5c3.8-.7 6 1 6.5 4.5" />',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "credits",
|
|
||||||
label: "积分流水",
|
|
||||||
roles: SUPPORT_ROLES,
|
|
||||||
icon:
|
|
||||||
'<rect x="3" y="4" width="16" height="15" rx="2" /><path d="M7 8h8M7 12h8M7 16h5" />',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "audit",
|
|
||||||
label: "审计日志",
|
|
||||||
roles: SUPER_ADMIN_ONLY,
|
|
||||||
icon:
|
|
||||||
'<path d="M11 2.5 18 5v5.5c0 4.5-2.7 7.3-7 9-4.3-1.7-7-4.5-7-9V5l7-2.5Z" /><path d="m8 11 2 2 4-4" />',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "security",
|
|
||||||
label: "安全中心",
|
|
||||||
roles: SUPER_ADMIN_ONLY,
|
|
||||||
icon:
|
|
||||||
'<rect x="4" y="9" width="14" height="10" rx="2" /><path d="M7.5 9V6.5a3.5 3.5 0 0 1 7 0V9M11 13v2.5" />',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export class AdminApp {
|
|
||||||
private auth: AuthState = { status: "anonymous" };
|
|
||||||
private renderVersion = 0;
|
|
||||||
|
|
||||||
constructor(private readonly root: HTMLElement) {
|
|
||||||
window.addEventListener("hashchange", () => void this.renderRoute());
|
|
||||||
window.addEventListener("admin:unauthorized", () => {
|
|
||||||
setCsrfToken();
|
|
||||||
this.auth = { status: "anonymous" };
|
|
||||||
this.render();
|
|
||||||
showToast("登录状态已失效,请重新登录", "error");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(): Promise<void> {
|
|
||||||
renderLoading(this.root, "检查登录状态");
|
|
||||||
try {
|
|
||||||
const session = await adminApi.session();
|
|
||||||
if (session.authenticated && session.operatorName && session.role) {
|
|
||||||
this.auth = {
|
|
||||||
status: "authenticated",
|
|
||||||
operatorName: session.operatorName,
|
|
||||||
role: session.role,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
||||||
showToast("暂时无法确认登录状态", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
private render(): void {
|
|
||||||
if (this.auth.status === "anonymous") {
|
|
||||||
renderPasswordLogin(this.root, { onChange: (state) => this.setAuth(state) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.renderShell();
|
|
||||||
void this.renderRoute();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setAuth(state: AuthState): void {
|
|
||||||
this.auth = state;
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
private currentRoute(): Route {
|
|
||||||
const value = window.location.hash.replace(/^#\/?/, "") as Route;
|
|
||||||
return this.availableRoutes().some((route) => route.id === value)
|
|
||||||
? value
|
|
||||||
: "overview";
|
|
||||||
}
|
|
||||||
|
|
||||||
private availableRoutes(): RouteDefinition[] {
|
|
||||||
if (this.auth.status !== "authenticated") return [];
|
|
||||||
const role = this.auth.role;
|
|
||||||
return routes.filter((route) => route.roles.includes(role));
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderShell(): void {
|
|
||||||
if (this.auth.status !== "authenticated") return;
|
|
||||||
this.root.innerHTML = `
|
|
||||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
|
||||||
<div class="app-shell">
|
|
||||||
<aside class="sidebar" id="primary-navigation" data-sidebar>
|
|
||||||
<div class="sidebar-brand">
|
|
||||||
<div class="brand-mark brand-mark--small">OSG</div>
|
|
||||||
<div><strong>管理中心</strong><span>OSG ACCOUNT</span></div>
|
|
||||||
</div>
|
|
||||||
<nav class="nav-list" aria-label="主导航">
|
|
||||||
${this.availableRoutes()
|
|
||||||
.map(
|
|
||||||
(route) => `
|
|
||||||
<a href="#/${route.id}" data-route="${route.id}">
|
|
||||||
<svg class="nav-icon" viewBox="0 0 22 22" aria-hidden="true">${route.icon}</svg>
|
|
||||||
${route.label}
|
|
||||||
</a>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</nav>
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<div class="operator">
|
|
||||||
<span class="operator-avatar" aria-hidden="true">管</span>
|
|
||||||
<div><strong>${escapeHtml(this.auth.operatorName)}</strong><span>${roleLabel(this.auth.role)}</span></div>
|
|
||||||
</div>
|
|
||||||
<wa-button class="logout-button" appearance="plain" data-logout title="安全退出">
|
|
||||||
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M9 4H4v14h5M13 7l4 4-4 4M17 11H8" /></svg>
|
|
||||||
<span>退出</span>
|
|
||||||
</wa-button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<div class="main-column">
|
|
||||||
<header class="mobile-header">
|
|
||||||
<wa-button class="icon-button" appearance="plain" data-menu aria-label="打开导航" aria-expanded="false" aria-controls="primary-navigation">
|
|
||||||
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M3 6h16M3 11h16M3 16h16" /></svg>
|
|
||||||
</wa-button>
|
|
||||||
<strong data-mobile-title>运营总览</strong>
|
|
||||||
</header>
|
|
||||||
<main class="page-content" id="main-content" data-content tabindex="-1"></main>
|
|
||||||
</div>
|
|
||||||
<button class="sidebar-scrim" data-scrim aria-label="关闭导航"></button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const sidebar = this.root.querySelector<HTMLElement>("[data-sidebar]");
|
|
||||||
const menuButton =
|
|
||||||
this.root.querySelector<HTMLButtonElement>("[data-menu]");
|
|
||||||
const mobileLayout = window.matchMedia("(max-width: 1023px)");
|
|
||||||
const syncSidebarState = () => {
|
|
||||||
if (!sidebar) return;
|
|
||||||
sidebar.inert =
|
|
||||||
mobileLayout.matches && !sidebar.classList.contains("sidebar--open");
|
|
||||||
};
|
|
||||||
const closeMenu = (restoreFocus = false) => {
|
|
||||||
sidebar?.classList.remove("sidebar--open");
|
|
||||||
menuButton?.setAttribute("aria-expanded", "false");
|
|
||||||
syncSidebarState();
|
|
||||||
if (restoreFocus) menuButton?.focus();
|
|
||||||
};
|
|
||||||
const openMenu = () => {
|
|
||||||
sidebar?.classList.add("sidebar--open");
|
|
||||||
menuButton?.setAttribute("aria-expanded", "true");
|
|
||||||
syncSidebarState();
|
|
||||||
sidebar
|
|
||||||
?.querySelector<HTMLAnchorElement>('a[aria-current="page"], a')
|
|
||||||
?.focus();
|
|
||||||
};
|
|
||||||
menuButton?.addEventListener("click", openMenu);
|
|
||||||
this.root
|
|
||||||
.querySelector<HTMLButtonElement>("[data-scrim]")
|
|
||||||
?.addEventListener("click", () => closeMenu(true));
|
|
||||||
this.root.querySelectorAll("[data-route]").forEach((link) => {
|
|
||||||
link.addEventListener("click", () => closeMenu());
|
|
||||||
});
|
|
||||||
sidebar?.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Escape") closeMenu(true);
|
|
||||||
});
|
|
||||||
mobileLayout.addEventListener("change", syncSidebarState);
|
|
||||||
syncSidebarState();
|
|
||||||
this.root
|
|
||||||
.querySelector<HTMLButtonElement>("[data-logout]")
|
|
||||||
?.addEventListener("click", () => void this.logout());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async renderRoute(): Promise<void> {
|
|
||||||
if (this.auth.status !== "authenticated") return;
|
|
||||||
const route = this.currentRoute();
|
|
||||||
this.root.querySelectorAll<HTMLElement>("[data-route]").forEach((link) => {
|
|
||||||
const active = link.dataset.route === route;
|
|
||||||
link.classList.toggle("active", active);
|
|
||||||
if (active) {
|
|
||||||
link.setAttribute("aria-current", "page");
|
|
||||||
} else {
|
|
||||||
link.removeAttribute("aria-current");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const routeDefinition = routes.find((item) => item.id === route);
|
|
||||||
document.title = `${routeDefinition?.label ?? "管理中心"} · OSG`;
|
|
||||||
const mobileTitle =
|
|
||||||
this.root.querySelector<HTMLElement>("[data-mobile-title]");
|
|
||||||
if (mobileTitle) mobileTitle.textContent = routeDefinition?.label ?? "管理中心";
|
|
||||||
|
|
||||||
const content = this.root.querySelector<HTMLElement>("[data-content]");
|
|
||||||
if (!content) return;
|
|
||||||
const version = ++this.renderVersion;
|
|
||||||
const stage = document.createElement("div");
|
|
||||||
stage.className = "page-stage";
|
|
||||||
renderLoading(content);
|
|
||||||
|
|
||||||
if (route === "overview") await renderOverview(stage);
|
|
||||||
if (route === "referrals") await renderReferrals(stage);
|
|
||||||
if (route === "users") renderUsers(stage, this.auth.role);
|
|
||||||
if (route === "credits") renderCredits(stage);
|
|
||||||
if (route === "audit") await renderAudit(stage);
|
|
||||||
if (route === "security") {
|
|
||||||
await renderSecurity(stage, this.auth.operatorName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (version === this.renderVersion) {
|
|
||||||
content.replaceChildren(stage);
|
|
||||||
const heading = content.querySelector<HTMLElement>("h1");
|
|
||||||
if (heading) {
|
|
||||||
heading.tabIndex = -1;
|
|
||||||
heading.focus({ preventScroll: true });
|
|
||||||
} else {
|
|
||||||
content.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async logout(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await adminApi.logout();
|
|
||||||
} catch {
|
|
||||||
// 即使服务端退出失败,也立即清理前端认证状态。
|
|
||||||
} finally {
|
|
||||||
setCsrfToken();
|
|
||||||
this.auth = { status: "anonymous" };
|
|
||||||
window.history.replaceState(null, "", window.location.pathname);
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleLabel(role: AdminRole): string {
|
|
||||||
const labels: Record<AdminRole, string> = {
|
|
||||||
SUPER_ADMIN: "超级管理员",
|
|
||||||
SUPPORT: "支持人员",
|
|
||||||
ANALYST: "分析员",
|
|
||||||
};
|
|
||||||
return labels[role];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BookOpenCheck,
|
||||||
|
ChartNoAxesCombined,
|
||||||
|
ChevronRight,
|
||||||
|
Coins,
|
||||||
|
GitBranch,
|
||||||
|
KeyRound,
|
||||||
|
LibraryBig,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
Moon,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
Sun,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
HashRouter,
|
||||||
|
Navigate,
|
||||||
|
NavLink,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import { Toaster, toast } from "sonner";
|
||||||
|
import type { AdminRole } from "./api/types";
|
||||||
|
import { ErrorBoundary } from "./components/error-boundary";
|
||||||
|
import { Button, LoadingState } from "./components/primitives";
|
||||||
|
import { AuthProvider, useAuth } from "./features/auth/auth-context";
|
||||||
|
import { LoginPage } from "./features/auth/login-page";
|
||||||
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
|
const OverviewPage = lazy(() =>
|
||||||
|
import("./features/overview/overview-page").then((module) => ({
|
||||||
|
default: module.OverviewPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ReferralsPage = lazy(() =>
|
||||||
|
import("./features/referrals/referrals-page").then((module) => ({
|
||||||
|
default: module.ReferralsPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const AnalyticsPage = lazy(() =>
|
||||||
|
import("./features/analytics/analytics-page").then((module) => ({
|
||||||
|
default: module.AnalyticsPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const UsersPage = lazy(() =>
|
||||||
|
import("./features/users/users-page").then((module) => ({
|
||||||
|
default: module.UsersPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const CreditsPage = lazy(() =>
|
||||||
|
import("./features/credits/credits-page").then((module) => ({
|
||||||
|
default: module.CreditsPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ContentPage = lazy(() =>
|
||||||
|
import("./features/content/content-page").then((module) => ({
|
||||||
|
default: module.ContentPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const AuditPage = lazy(() =>
|
||||||
|
import("./features/audit/audit-page").then((module) => ({
|
||||||
|
default: module.AuditPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const SecurityPage = lazy(() =>
|
||||||
|
import("./features/security/security-page").then((module) => ({
|
||||||
|
default: module.SecurityPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ProvidersPage = lazy(() =>
|
||||||
|
import("./features/providers/providers-page").then((module) => ({
|
||||||
|
default: module.ProvidersPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
roles: AdminRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRoles: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"];
|
||||||
|
const supportRoles: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"];
|
||||||
|
|
||||||
|
const navigation: NavItem[] = [
|
||||||
|
{
|
||||||
|
path: "/overview",
|
||||||
|
label: "运营总览",
|
||||||
|
description: "增长与消耗",
|
||||||
|
icon: Activity,
|
||||||
|
roles: allRoles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/analytics",
|
||||||
|
label: "产品分析",
|
||||||
|
description: "增长、留存与付费",
|
||||||
|
icon: ChartNoAxesCombined,
|
||||||
|
roles: allRoles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/referrals",
|
||||||
|
label: "裂变分析",
|
||||||
|
description: "转化与排行",
|
||||||
|
icon: GitBranch,
|
||||||
|
roles: allRoles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/users",
|
||||||
|
label: "用户查询",
|
||||||
|
description: "账户与使用",
|
||||||
|
icon: Users,
|
||||||
|
roles: supportRoles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/credits",
|
||||||
|
label: "积分流水",
|
||||||
|
description: "不可变账本",
|
||||||
|
icon: Coins,
|
||||||
|
roles: supportRoles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/content",
|
||||||
|
label: "内容管理",
|
||||||
|
description: "Skill 与 Hint",
|
||||||
|
icon: LibraryBig,
|
||||||
|
roles: supportRoles,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/audit",
|
||||||
|
label: "审计日志",
|
||||||
|
description: "操作追踪",
|
||||||
|
icon: BookOpenCheck,
|
||||||
|
roles: ["SUPER_ADMIN"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/security",
|
||||||
|
label: "安全中心",
|
||||||
|
description: "权限与会话",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
roles: ["SUPER_ADMIN"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/providers",
|
||||||
|
label: "Provider 配置",
|
||||||
|
description: "上游密钥管理",
|
||||||
|
icon: KeyRound,
|
||||||
|
roles: ["SUPER_ADMIN"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<HashRouter>
|
||||||
|
<AppContent />
|
||||||
|
</HashRouter>
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "osg-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { auth, checking } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleToast(event: Event) {
|
||||||
|
const detail = (event as CustomEvent<{ message: string; tone: "success" | "error" }>)
|
||||||
|
.detail;
|
||||||
|
if (!detail) return;
|
||||||
|
if (detail.tone === "error") toast.error(detail.message);
|
||||||
|
else toast.success(detail.message);
|
||||||
|
}
|
||||||
|
window.addEventListener("admin:toast", handleToast);
|
||||||
|
return () => window.removeEventListener("admin:toast", handleToast);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (checking) {
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-screen place-items-center bg-page">
|
||||||
|
<LoadingState label="检查登录状态" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (auth.status === "anonymous") return <LoginPage />;
|
||||||
|
|
||||||
|
return <AuthenticatedApp role={auth.role} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedApp({ role }: { role: AdminRole }) {
|
||||||
|
const available = useMemo(
|
||||||
|
() => navigation.filter((item) => item.roles.includes(role)),
|
||||||
|
[role],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell navigationItems={available}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={<LoadingState label="加载页面" />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/overview" element={<OverviewPage />} />
|
||||||
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/referrals" element={<ReferralsPage />} />
|
||||||
|
{supportRoles.includes(role) ? (
|
||||||
|
<>
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="/credits" element={<CreditsPage />} />
|
||||||
|
<Route path="/content" element={<ContentPage />} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{role === "SUPER_ADMIN" ? (
|
||||||
|
<>
|
||||||
|
<Route path="/audit" element={<AuditPage />} />
|
||||||
|
<Route path="/security" element={<SecurityPage />} />
|
||||||
|
<Route path="/providers" element={<ProvidersPage />} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Route path="*" element={<Navigate to="/overview" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppShell({
|
||||||
|
navigationItems,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
navigationItems: NavItem[];
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { auth, logout } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [mobile, setMobile] = useState(
|
||||||
|
() =>
|
||||||
|
typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia("(max-width: 1023px)").matches,
|
||||||
|
);
|
||||||
|
const sidebarRef = useRef<HTMLElement>(null);
|
||||||
|
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [dark, setDark] = useState(() => {
|
||||||
|
const saved = localStorage.getItem("osg-admin-theme");
|
||||||
|
return saved
|
||||||
|
? saved === "dark"
|
||||||
|
: typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
localStorage.setItem("osg-admin-theme", dark ? "dark" : "light");
|
||||||
|
document
|
||||||
|
.querySelector('meta[name="theme-color"]')
|
||||||
|
?.setAttribute("content", dark ? "#0a0c10" : "#f5f7fb");
|
||||||
|
}, [dark]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
const active = navigation.find((item) => item.path === location.pathname);
|
||||||
|
document.title = `${active?.label ?? "管理中心"} · OSG`;
|
||||||
|
const main = document.querySelector<HTMLElement>("#main-content");
|
||||||
|
const focusHeading = () => {
|
||||||
|
const heading = main?.querySelector<HTMLElement>("h1");
|
||||||
|
if (!heading) return false;
|
||||||
|
heading.tabIndex = -1;
|
||||||
|
heading.focus({ preventScroll: true });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if (focusHeading()) return;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (focusHeading()) observer.disconnect();
|
||||||
|
});
|
||||||
|
if (main) observer.observe(main, { childList: true, subtree: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sidebarOpen) return;
|
||||||
|
function close(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
menuButtonRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", close);
|
||||||
|
return () => window.removeEventListener("keydown", close);
|
||||||
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== "function") return;
|
||||||
|
const query = window.matchMedia("(max-width: 1023px)");
|
||||||
|
const update = () => setMobile(query.matches);
|
||||||
|
query.addEventListener("change", update);
|
||||||
|
return () => query.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sidebarOpen || !mobile) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
sidebarRef.current
|
||||||
|
?.querySelector<HTMLAnchorElement>('a[aria-current="page"], a[href]')
|
||||||
|
?.focus();
|
||||||
|
});
|
||||||
|
}, [mobile, sidebarOpen]);
|
||||||
|
|
||||||
|
if (auth.status !== "authenticated") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-page text-foreground">
|
||||||
|
<a
|
||||||
|
className="fixed left-3 top-3 z-[100] -translate-y-24 rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white transition focus:translate-y-0"
|
||||||
|
href="#main-content"
|
||||||
|
>
|
||||||
|
跳到主要内容
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
ref={sidebarRef}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 left-0 z-40 flex w-[272px] flex-col border-r border-border bg-sidebar/95 p-4 backdrop-blur-2xl transition-transform duration-300 lg:translate-x-0",
|
||||||
|
sidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||||
|
)}
|
||||||
|
inert={mobile && !sidebarOpen}
|
||||||
|
aria-hidden={mobile && !sidebarOpen}
|
||||||
|
aria-label="主导航"
|
||||||
|
>
|
||||||
|
<div className="flex h-16 items-center justify-between px-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="brand-symbol size-10 rounded-xl text-sm">O</span>
|
||||||
|
<span>
|
||||||
|
<strong className="block text-sm font-bold tracking-tight">OSG</strong>
|
||||||
|
<span className="block text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
|
||||||
|
Account Intelligence
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
ref={menuButtonRef}
|
||||||
|
className="lg:hidden"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
aria-label="关闭导航"
|
||||||
|
>
|
||||||
|
<X className="size-5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="mt-5 flex-1 space-y-1.5">
|
||||||
|
<p className="mb-3 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-muted/80">
|
||||||
|
工作台
|
||||||
|
</p>
|
||||||
|
{navigationItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"group flex min-h-14 items-center gap-3 rounded-2xl px-3 transition-all",
|
||||||
|
isActive
|
||||||
|
? "bg-surface text-primary shadow-[0_1px_2px_rgb(15_23_42/0.06),0_8px_25px_rgb(15_23_42/0.05)]"
|
||||||
|
: "text-muted hover:bg-surface-muted hover:text-foreground",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid size-9 shrink-0 place-items-center rounded-xl transition",
|
||||||
|
isActive ? "bg-primary-soft text-primary" : "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="size-[18px]" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-sm font-semibold">{item.label}</span>
|
||||||
|
<span className="mt-0.5 block text-[10px] text-muted">{item.description}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"size-4 opacity-0 transition group-hover:opacity-60",
|
||||||
|
isActive && "opacity-60",
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border bg-surface/70 p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-primary to-violet text-sm font-bold text-white shadow-md">
|
||||||
|
{auth.operatorName.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<strong className="block truncate text-xs">{auth.operatorName}</strong>
|
||||||
|
<span className="mt-0.5 block text-[10px] text-muted">{roleLabel(auth.role)}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void logout()}
|
||||||
|
aria-label="安全退出"
|
||||||
|
title="安全退出"
|
||||||
|
>
|
||||||
|
<LogOut className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="lg:pl-[272px]">
|
||||||
|
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border bg-page/85 px-4 backdrop-blur-xl sm:px-6 lg:px-10">
|
||||||
|
<Button
|
||||||
|
className="lg:hidden"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
aria-label="打开导航"
|
||||||
|
aria-expanded={sidebarOpen}
|
||||||
|
>
|
||||||
|
<Menu className="size-5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<div className="hidden items-center gap-2 text-xs text-muted sm:flex">
|
||||||
|
<span>OSG 管理中心</span>
|
||||||
|
<ChevronRight className="size-3.5" aria-hidden />
|
||||||
|
<strong className="font-semibold text-foreground">
|
||||||
|
{navigation.find((item) => item.path === location.pathname)?.label ?? "运营总览"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
className="hidden sm:inline-flex"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="全局搜索即将提供"
|
||||||
|
title="全局搜索即将提供"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Search className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDark((current) => !current)}
|
||||||
|
aria-label={dark ? "切换浅色模式" : "切换深色模式"}
|
||||||
|
title={dark ? "浅色模式" : "深色模式"}
|
||||||
|
>
|
||||||
|
{dark ? (
|
||||||
|
<Sun className="size-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Moon className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main-content" className="mx-auto w-full max-w-[1500px] px-4 py-7 sm:px-6 sm:py-9 lg:px-10 lg:py-11">
|
||||||
|
<div className="animate-page-in" key={location.pathname}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sidebarOpen ? (
|
||||||
|
<button
|
||||||
|
className="fixed inset-0 z-30 bg-slate-950/45 backdrop-blur-[2px] lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
aria-label="关闭导航"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: AdminRole): string {
|
||||||
|
return {
|
||||||
|
SUPER_ADMIN: "超级管理员",
|
||||||
|
SUPPORT: "支持人员",
|
||||||
|
ANALYST: "分析员",
|
||||||
|
}[role];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function ChartToolbar({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 px-5 py-4"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import type { FunnelStep, TrendPoint } from "../api/types";
|
|
||||||
import { escapeHtml, formatNumber } from "../lib/format";
|
|
||||||
|
|
||||||
export function trendChart(points: TrendPoint[]): string {
|
|
||||||
if (points.length === 0) return '<div class="empty-state">暂无趋势数据</div>';
|
|
||||||
|
|
||||||
const width = 720;
|
|
||||||
const height = 240;
|
|
||||||
const padding = 28;
|
|
||||||
const values = points.map((point) => point.registrations);
|
|
||||||
const max = Math.max(...values, 1);
|
|
||||||
const step = points.length > 1 ? (width - padding * 2) / (points.length - 1) : 0;
|
|
||||||
const coordinates = points.map((point, index) => ({
|
|
||||||
x: points.length === 1 ? width / 2 : padding + index * step,
|
|
||||||
y: height - padding - (point.registrations / max) * (height - padding * 2),
|
|
||||||
point,
|
|
||||||
}));
|
|
||||||
const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(" ");
|
|
||||||
const midpoint = Math.ceil(max / 2);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="chart-scroll">
|
|
||||||
<svg class="trend-chart" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-chart-title trend-chart-description">
|
|
||||||
<title id="trend-chart-title">新增用户趋势</title>
|
|
||||||
<desc id="trend-chart-description">横轴为 UTC 日期,纵轴为每日新增用户数。图表后提供完整数据表。</desc>
|
|
||||||
<line x1="${padding}" y1="${padding}" x2="${width - padding}" y2="${padding}" class="chart-axis" />
|
|
||||||
<line x1="${padding}" y1="${height / 2}" x2="${width - padding}" y2="${height / 2}" class="chart-axis" />
|
|
||||||
<line x1="${padding}" y1="${height - padding}" x2="${width - padding}" y2="${height - padding}" class="chart-axis" />
|
|
||||||
<text x="${padding}" y="${padding - 8}" class="chart-label">${formatNumber(max)}</text>
|
|
||||||
<text x="${padding}" y="${height / 2 - 8}" class="chart-label">${formatNumber(midpoint)}</text>
|
|
||||||
<text x="${padding}" y="${height - 8}" class="chart-label">0</text>
|
|
||||||
<polyline points="${polyline}" class="chart-line" />
|
|
||||||
${coordinates
|
|
||||||
.map(
|
|
||||||
({ x, y, point }) => `
|
|
||||||
<circle cx="${x}" cy="${y}" r="4" class="chart-dot">
|
|
||||||
<title>${escapeHtml(point.date)}:${formatNumber(point.registrations)} 位新增用户</title>
|
|
||||||
</circle>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<table class="sr-only">
|
|
||||||
<caption>新增用户与积分消耗趋势完整数据</caption>
|
|
||||||
<thead><tr><th scope="col">UTC 日期</th><th scope="col">新增用户</th><th scope="col">消耗积分</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${points
|
|
||||||
.map(
|
|
||||||
(point) =>
|
|
||||||
`<tr><td>${escapeHtml(point.date)}</td><td>${formatNumber(point.registrations)}</td><td>${formatNumber(point.creditsUsed)}</td></tr>`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function funnelChart(steps: FunnelStep[]): string {
|
|
||||||
if (steps.length === 0) return '<div class="empty-state">暂无漏斗数据</div>';
|
|
||||||
const max = Math.max(...steps.map((step) => step.count), 1);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="funnel" aria-label="裂变漏斗">
|
|
||||||
${steps
|
|
||||||
.map((step, index) => {
|
|
||||||
return `
|
|
||||||
<div class="funnel-step">
|
|
||||||
<div class="funnel-label">
|
|
||||||
<span>${escapeHtml(step.label)}</span>
|
|
||||||
<strong>${formatNumber(step.count)}</strong>
|
|
||||||
</div>
|
|
||||||
<progress class="funnel-progress funnel-progress--${(index % 4) + 1}" max="${max}" value="${step.count}" aria-label="${escapeHtml(step.label)}:${formatNumber(step.count)}"></progress>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
})
|
|
||||||
.join("")}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function ChartLegend({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: Array<{ label: string; tone: "primary" | "violet" | "success" | "warning" }>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
|
||||||
|
{items.map((item) => (
|
||||||
|
<span className="flex items-center gap-2" key={`${item.label}-${item.tone}`}>
|
||||||
|
<i className={`chart-legend-dot chart-legend-dot--${item.tone}`} aria-hidden />
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { AnalyticsCohort, AnalyticsRate } from "../../api/types";
|
||||||
|
import { formatNumber } from "../../lib/format";
|
||||||
|
|
||||||
|
export function CohortHeatmap({ cohorts }: { cohorts: AnalyticsCohort[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[560px] border-separate border-spacing-x-1 border-spacing-y-1.5 px-4 py-4 text-sm">
|
||||||
|
<caption className="sr-only">D1、D7、D30 价值留存 cohort 热力图</caption>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-muted">
|
||||||
|
<th className="px-2 py-2 font-semibold" scope="col">激活日期</th>
|
||||||
|
<th className="px-2 py-2 text-right font-semibold" scope="col">用户</th>
|
||||||
|
<th className="px-2 py-2 text-center font-semibold" scope="col">D1</th>
|
||||||
|
<th className="px-2 py-2 text-center font-semibold" scope="col">D7</th>
|
||||||
|
<th className="px-2 py-2 text-center font-semibold" scope="col">D30</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cohorts.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-2 py-10 text-center text-muted" colSpan={5}>
|
||||||
|
当前周期暂无已激活 cohort
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
cohorts.map((cohort) => (
|
||||||
|
<tr key={cohort.cohortDate}>
|
||||||
|
<th className="px-2 py-2 text-left font-medium" scope="row">
|
||||||
|
{cohort.cohortDate}
|
||||||
|
</th>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums">{formatNumber(cohort.size)}</td>
|
||||||
|
<HeatmapCell rate={cohort.d1} />
|
||||||
|
<HeatmapCell rate={cohort.d7} />
|
||||||
|
<HeatmapCell rate={cohort.d30} />
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-3 px-5 pb-5 text-xs text-muted" aria-hidden>
|
||||||
|
<span>低</span>
|
||||||
|
<i className="heatmap-key heatmap-key--low" />
|
||||||
|
<i className="heatmap-key heatmap-key--medium" />
|
||||||
|
<i className="heatmap-key heatmap-key--high" />
|
||||||
|
<span>高</span>
|
||||||
|
<i className="heatmap-key heatmap-key--unavailable" />
|
||||||
|
<span>窗口未成熟</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeatmapCell({ rate }: { rate?: AnalyticsRate }) {
|
||||||
|
const label = rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`;
|
||||||
|
return (
|
||||||
|
<td className="p-0.5 text-center">
|
||||||
|
<span
|
||||||
|
className={`heatmap-cell ${heatmapTone(rate?.percent)}`}
|
||||||
|
title={
|
||||||
|
rate == null
|
||||||
|
? "留存窗口尚未成熟"
|
||||||
|
: `${formatNumber(rate.numerator)} / ${formatNumber(rate.denominator)}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatmapTone(percent?: number): string {
|
||||||
|
if (percent == null) return "heatmap-cell--unavailable";
|
||||||
|
if (percent >= 40) return "heatmap-cell--high";
|
||||||
|
if (percent >= 20) return "heatmap-cell--medium";
|
||||||
|
return "heatmap-cell--low";
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { formatNumber } from "../../lib/format";
|
||||||
|
import { ChartLegend } from "./chart-legend";
|
||||||
|
|
||||||
|
type ChartTone = "primary" | "violet" | "success" | "warning";
|
||||||
|
|
||||||
|
export interface ComparisonBarItem {
|
||||||
|
id?: string;
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
secondaryValue?: number | null;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComparisonBarChart({
|
||||||
|
items,
|
||||||
|
primaryLabel,
|
||||||
|
secondaryLabel,
|
||||||
|
primaryTone = "primary",
|
||||||
|
secondaryTone = "violet",
|
||||||
|
valueFormatter = formatNumber,
|
||||||
|
emptyText = "当前周期暂无可比较数据",
|
||||||
|
}: {
|
||||||
|
items: ComparisonBarItem[];
|
||||||
|
primaryLabel: string;
|
||||||
|
secondaryLabel?: string;
|
||||||
|
primaryTone?: ChartTone;
|
||||||
|
secondaryTone?: ChartTone;
|
||||||
|
valueFormatter?: (value: number) => string;
|
||||||
|
emptyText?: string;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="p-8 text-center text-sm text-muted">{emptyText}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maximum = Math.max(
|
||||||
|
...items.flatMap((item) => [item.value ?? 0, item.secondaryValue ?? 0]),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const legend = [
|
||||||
|
{ label: primaryLabel, tone: primaryTone },
|
||||||
|
...(secondaryLabel ? [{ label: secondaryLabel, tone: secondaryTone }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 sm:p-6">
|
||||||
|
<ChartLegend items={legend} />
|
||||||
|
<div className="mt-6 space-y-5">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={item.id ?? `${item.label}-${index}`}>
|
||||||
|
<div className="mb-2 flex items-baseline justify-between gap-4">
|
||||||
|
<span className="text-sm font-medium text-foreground">{item.label}</span>
|
||||||
|
{item.hint ? <span className="text-xs text-muted">{item.hint}</span> : null}
|
||||||
|
</div>
|
||||||
|
<Bar
|
||||||
|
label={`${item.label} ${primaryLabel}`}
|
||||||
|
maximum={maximum}
|
||||||
|
tone={primaryTone}
|
||||||
|
value={item.value}
|
||||||
|
valueLabel={item.value == null ? "—" : valueFormatter(item.value)}
|
||||||
|
/>
|
||||||
|
{secondaryLabel != null && item.secondaryValue !== undefined ? (
|
||||||
|
<Bar
|
||||||
|
label={`${item.label} ${secondaryLabel}`}
|
||||||
|
maximum={maximum}
|
||||||
|
tone={secondaryTone}
|
||||||
|
value={item.secondaryValue}
|
||||||
|
valueLabel={
|
||||||
|
item.secondaryValue == null ? "—" : valueFormatter(item.secondaryValue)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bar({
|
||||||
|
label,
|
||||||
|
maximum,
|
||||||
|
tone,
|
||||||
|
value,
|
||||||
|
valueLabel,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
maximum: number;
|
||||||
|
tone: ChartTone;
|
||||||
|
value: number | null;
|
||||||
|
valueLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-1.5 flex items-center gap-3">
|
||||||
|
<progress
|
||||||
|
className={`bar-progress bar-progress--${tone} block h-2.5 min-w-0 flex-1 overflow-hidden rounded-full`}
|
||||||
|
max={maximum}
|
||||||
|
value={value ?? 0}
|
||||||
|
aria-label={value == null ? `${label}:暂无数据` : `${label}:${valueLabel}`}
|
||||||
|
/>
|
||||||
|
<strong className="w-16 shrink-0 text-right text-xs font-semibold tabular-nums text-foreground">
|
||||||
|
{valueLabel}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { FunnelStep } from "../../api/types";
|
||||||
|
import { formatNumber } from "../../lib/format";
|
||||||
|
|
||||||
|
export function FunnelChart({
|
||||||
|
steps,
|
||||||
|
emptyText = "当前周期暂无漏斗数据",
|
||||||
|
}: {
|
||||||
|
steps: FunnelStep[];
|
||||||
|
emptyText?: string;
|
||||||
|
}) {
|
||||||
|
if (steps.length === 0) {
|
||||||
|
return <p className="p-8 text-center text-sm text-muted">{emptyText}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maximum = Math.max(steps[0]?.count ?? 0, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 p-5 sm:p-6">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const previous = steps[index - 1]?.count;
|
||||||
|
const stepConversion =
|
||||||
|
previous == null || previous === 0 ? undefined : (step.count / previous) * 100;
|
||||||
|
const totalConversion = maximum === 0 ? 0 : (step.count / maximum) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${step.label}-${index}`}>
|
||||||
|
<div className="mb-2 flex items-baseline justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-medium text-foreground">{step.label}</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-muted">
|
||||||
|
{index === 0
|
||||||
|
? "漏斗起点"
|
||||||
|
: `上一步转化 ${stepConversion == null ? "—" : `${stepConversion.toFixed(1)}%`}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<strong className="block text-sm tabular-nums">{formatNumber(step.count)}</strong>
|
||||||
|
<span className="text-xs tabular-nums text-muted">
|
||||||
|
占起点 {totalConversion.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
className="funnel-progress block h-3 w-full overflow-hidden rounded-full"
|
||||||
|
max={maximum}
|
||||||
|
value={step.count}
|
||||||
|
aria-label={`${step.label}:${formatNumber(step.count)},占起点 ${totalConversion.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
type RadialTone = "primary" | "success" | "violet" | "warning";
|
||||||
|
|
||||||
|
export function RadialMetric({
|
||||||
|
label,
|
||||||
|
percent,
|
||||||
|
detail,
|
||||||
|
tone = "primary",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
percent: number | null;
|
||||||
|
detail?: string;
|
||||||
|
tone?: RadialTone;
|
||||||
|
}) {
|
||||||
|
const value = percent == null ? null : Math.min(Math.max(percent, 0), 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<svg
|
||||||
|
className="size-28 shrink-0"
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
role="img"
|
||||||
|
aria-label={value == null ? `${label}:暂无数据` : `${label}:${value.toFixed(1)}%`}
|
||||||
|
>
|
||||||
|
<circle className="radial-track" cx="60" cy="60" r="48" pathLength="100" />
|
||||||
|
<circle
|
||||||
|
className={`radial-value radial-value--${tone}`}
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r="48"
|
||||||
|
pathLength="100"
|
||||||
|
strokeDasharray={`${value ?? 0} ${100 - (value ?? 0)}`}
|
||||||
|
transform="rotate(-90 60 60)"
|
||||||
|
/>
|
||||||
|
<text className="radial-label" x="60" y="65" textAnchor="middle">
|
||||||
|
{value == null ? "—" : `${Math.round(value)}%`}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong className="text-sm text-foreground">{label}</strong>
|
||||||
|
{detail ? <p className="mt-1 text-xs leading-5 text-muted">{detail}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { TrendPoint } from "../../api/types";
|
||||||
|
import { formatNumber } from "../../lib/format";
|
||||||
|
|
||||||
|
export function TrendChart({
|
||||||
|
points,
|
||||||
|
showRegistrations = true,
|
||||||
|
showCredits = true,
|
||||||
|
}: {
|
||||||
|
points: TrendPoint[];
|
||||||
|
showRegistrations?: boolean;
|
||||||
|
showCredits?: boolean;
|
||||||
|
}) {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return <div className="grid h-full place-items-center text-sm text-muted">暂无趋势数据</div>;
|
||||||
|
}
|
||||||
|
if (!showRegistrations && !showCredits) {
|
||||||
|
return <div className="grid h-full place-items-center text-sm text-muted">请选择至少一个趋势系列</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 760;
|
||||||
|
const height = 300;
|
||||||
|
const paddingX = 42;
|
||||||
|
const paddingY = 30;
|
||||||
|
const plotHeight = height - paddingY * 2;
|
||||||
|
const step = points.length > 1 ? (width - paddingX * 2) / (points.length - 1) : 0;
|
||||||
|
const registrationMax = Math.max(...points.map((point) => point.registrations), 1);
|
||||||
|
const creditMax = Math.max(...points.map((point) => point.creditsUsed), 1);
|
||||||
|
const coordinates = points.map((point, index) => {
|
||||||
|
const x = points.length === 1 ? width / 2 : paddingX + index * step;
|
||||||
|
return {
|
||||||
|
point,
|
||||||
|
x,
|
||||||
|
registrationY:
|
||||||
|
height - paddingY - (point.registrations / registrationMax) * plotHeight,
|
||||||
|
creditY: height - paddingY - (point.creditsUsed / creditMax) * plotHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const registrationLine = coordinates
|
||||||
|
.map(({ x, registrationY }) => `${x},${registrationY}`)
|
||||||
|
.join(" ");
|
||||||
|
const creditLine = coordinates.map(({ x, creditY }) => `${x},${creditY}`).join(" ");
|
||||||
|
const labelEvery = Math.max(Math.ceil(points.length / 6), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-full overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
className="trend-chart block h-full min-w-[620px] overflow-visible"
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="overview-trend-title overview-trend-description"
|
||||||
|
>
|
||||||
|
<title id="overview-trend-title">新增用户与积分消耗趋势</title>
|
||||||
|
<desc id="overview-trend-description">
|
||||||
|
横轴为 UTC 日期,两条曲线分别表示新增用户数与积分消耗。
|
||||||
|
</desc>
|
||||||
|
{[paddingY, height / 2, height - paddingY].map((y) => (
|
||||||
|
<line
|
||||||
|
key={y}
|
||||||
|
x1={paddingX}
|
||||||
|
x2={width - paddingX}
|
||||||
|
y1={y}
|
||||||
|
y2={y}
|
||||||
|
className="chart-grid-line"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{showRegistrations ? (
|
||||||
|
<text x={paddingX} y={paddingY - 9} className="chart-label">
|
||||||
|
{formatNumber(registrationMax)} 用户
|
||||||
|
</text>
|
||||||
|
) : null}
|
||||||
|
{showCredits ? (
|
||||||
|
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
|
||||||
|
{formatNumber(creditMax)} 积分
|
||||||
|
</text>
|
||||||
|
) : null}
|
||||||
|
{showRegistrations ? (
|
||||||
|
<polyline points={registrationLine} className="chart-line chart-line--primary" />
|
||||||
|
) : null}
|
||||||
|
{showCredits ? (
|
||||||
|
<polyline points={creditLine} className="chart-line chart-line--violet" />
|
||||||
|
) : null}
|
||||||
|
{coordinates.map(({ point, x, registrationY, creditY }, index) => (
|
||||||
|
<g key={point.date}>
|
||||||
|
{showRegistrations ? (
|
||||||
|
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
|
||||||
|
<title>
|
||||||
|
{point.date}:新增 {formatNumber(point.registrations)} 位用户
|
||||||
|
</title>
|
||||||
|
</circle>
|
||||||
|
) : null}
|
||||||
|
{showCredits ? (
|
||||||
|
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
|
||||||
|
<title>
|
||||||
|
{point.date}:消耗 {formatNumber(point.creditsUsed)} 积分
|
||||||
|
</title>
|
||||||
|
</circle>
|
||||||
|
) : null}
|
||||||
|
{index % labelEvery === 0 || index === points.length - 1 ? (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={height - 5}
|
||||||
|
textAnchor={index === 0 ? "start" : index === points.length - 1 ? "end" : "middle"}
|
||||||
|
className="chart-label"
|
||||||
|
>
|
||||||
|
{point.date.slice(5)}
|
||||||
|
</text>
|
||||||
|
) : null}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<table className="sr-only">
|
||||||
|
<caption>新增用户与积分消耗趋势完整数据</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">UTC 日期</th>
|
||||||
|
{showRegistrations ? <th scope="col">新增用户</th> : null}
|
||||||
|
{showCredits ? <th scope="col">消耗积分</th> : null}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{points.map((point) => (
|
||||||
|
<tr key={point.date}>
|
||||||
|
<td>{point.date}</td>
|
||||||
|
{showRegistrations ? <td>{formatNumber(point.registrations)}</td> : null}
|
||||||
|
{showCredits ? <td>{formatNumber(point.creditsUsed)}</td> : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { flexRender } from "@tanstack/react-table";
|
||||||
|
import type { RowData } from "@tanstack/table-core";
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
type LegacyColumnDef,
|
||||||
|
useLegacyTable,
|
||||||
|
} from "@tanstack/react-table/legacy";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowUpDown,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Fragment, type ReactNode, useState } from "react";
|
||||||
|
import type { SortOrder } from "../api/types";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { EmptyState } from "./primitives";
|
||||||
|
|
||||||
|
export type DataColumn<T extends RowData> = LegacyColumnDef<T, unknown>;
|
||||||
|
|
||||||
|
export function DataTable<T extends RowData>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
caption,
|
||||||
|
emptyTitle = "暂无数据",
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
sort,
|
||||||
|
sortableColumns,
|
||||||
|
onSortChange,
|
||||||
|
renderExpandedRow,
|
||||||
|
getRowId,
|
||||||
|
expandLabel = "详情",
|
||||||
|
}: {
|
||||||
|
data: T[];
|
||||||
|
columns: DataColumn<T>[];
|
||||||
|
caption: string;
|
||||||
|
emptyTitle?: string;
|
||||||
|
footer?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
sort?: { key: string; order: SortOrder };
|
||||||
|
sortableColumns?: Record<string, string>;
|
||||||
|
onSortChange?: (key: string, order: SortOrder) => void;
|
||||||
|
renderExpandedRow?: (row: T) => ReactNode;
|
||||||
|
getRowId?: (row: T) => string;
|
||||||
|
expandLabel?: string;
|
||||||
|
}) {
|
||||||
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(() => new Set());
|
||||||
|
const table = useLegacyTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.length === 0) return <EmptyState title={emptyTitle} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("overflow-hidden", className)}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-max border-separate border-spacing-0 text-sm">
|
||||||
|
<caption className="sr-only">{caption}</caption>
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const sortKey = sortableColumns?.[header.column.id];
|
||||||
|
const active = sortKey != null && sort?.key === sortKey;
|
||||||
|
const ariaSort = active
|
||||||
|
? sort.order === "asc"
|
||||||
|
? "ascending"
|
||||||
|
: "descending"
|
||||||
|
: sortKey
|
||||||
|
? "none"
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
|
||||||
|
aria-sort={ariaSort}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : sortKey && onSortChange ? (
|
||||||
|
<button
|
||||||
|
className="inline-flex min-h-8 items-center gap-1.5 rounded-lg px-1 text-left transition hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onSortChange(
|
||||||
|
sortKey,
|
||||||
|
active && sort.order === "desc" ? "asc" : "desc",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{active ? (
|
||||||
|
sort.order === "asc" ? (
|
||||||
|
<ArrowUp className="size-3.5" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="size-3.5" aria-hidden />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="size-3.5 opacity-60" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
flexRender(header.column.columnDef.header, header.getContext())
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{renderExpandedRow ? (
|
||||||
|
<th
|
||||||
|
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
|
||||||
|
scope="col"
|
||||||
|
>
|
||||||
|
{expandLabel}
|
||||||
|
</th>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{table.getRowModel().rows.map((row) => {
|
||||||
|
const expansionKey = getRowId?.(row.original) ?? row.id;
|
||||||
|
const expanded = expandedRows.has(expansionKey);
|
||||||
|
const panelId = `expanded-row-${expansionKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||||
|
return (
|
||||||
|
<Fragment key={row.id}>
|
||||||
|
<tr className="group transition-colors hover:bg-surface-muted/70">
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
className="border-b border-border/70 px-5 py-4 align-middle text-foreground last:text-right group-last:border-b-0"
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{renderExpandedRow ? (
|
||||||
|
<td className="border-b border-border/70 px-5 py-4 text-right align-middle">
|
||||||
|
<button
|
||||||
|
className="inline-flex min-h-8 items-center gap-1.5 rounded-lg px-2 text-xs font-semibold text-primary transition hover:bg-primary-soft focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={panelId}
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedRows((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(expansionKey)) next.delete(expansionKey);
|
||||||
|
else next.add(expansionKey);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="size-3.5" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
{expanded ? "收起" : "展开"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
{renderExpandedRow && expanded ? (
|
||||||
|
<tr id={panelId}>
|
||||||
|
<td
|
||||||
|
className="border-b border-border bg-surface-muted/45 px-5 py-4"
|
||||||
|
colSpan={row.getVisibleCells().length + 1}
|
||||||
|
>
|
||||||
|
{renderExpandedRow(row.original)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export interface DateRangeValue {
|
||||||
|
from?: string;
|
||||||
|
until?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function datePart(value?: string, exclusiveEnd = false): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (!exclusiveEnd) return value.slice(0, 10);
|
||||||
|
const date = new Date(value);
|
||||||
|
date.setUTCDate(date.getUTCDate() - 1);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDate(value: string, exclusiveEnd: boolean): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const date = new Date(`${value}T00:00:00.000Z`);
|
||||||
|
if (exclusiveEnd) date.setUTCDate(date.getUTCDate() + 1);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateRangeControl({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: DateRangeValue;
|
||||||
|
onChange: (value: DateRangeValue) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
|
||||||
|
<legend className="sr-only">日期范围</legend>
|
||||||
|
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||||
|
<span>开始日期</span>
|
||||||
|
<input
|
||||||
|
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||||
|
type="date"
|
||||||
|
value={datePart(value.from)}
|
||||||
|
max={datePart(value.until, true) || undefined}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...value, from: utcDate(event.target.value, false) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||||
|
<span>结束日期</span>
|
||||||
|
<input
|
||||||
|
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||||
|
type="date"
|
||||||
|
value={datePart(value.until, true)}
|
||||||
|
min={datePart(value.from) || undefined}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({ ...value, until: utcDate(event.target.value, true) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { Button, Card } from "./primitives";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
failed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||||
|
state: State = { failed: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): State {
|
||||||
|
return { failed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(_error: Error, _info: ErrorInfo): void {
|
||||||
|
// 管理端禁止记录可能包含用户或运营数据的渲染上下文。
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.failed) return this.props.children;
|
||||||
|
return (
|
||||||
|
<Card className="mx-auto grid min-h-80 max-w-lg place-items-center border-danger/20 p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl bg-danger-soft text-danger">
|
||||||
|
<AlertTriangle className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-lg font-bold">页面暂时无法显示</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted">
|
||||||
|
页面渲染时出现异常。请重新加载,未提交的操作不会自动执行。
|
||||||
|
</p>
|
||||||
|
<Button className="mt-6" variant="secondary" onClick={() => window.location.reload()}>
|
||||||
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export interface FilterOption<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterControl<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
options: readonly FilterOption<T>[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||||
|
<span>{label}</span>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value as T)}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleFilter({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex min-h-9 cursor-pointer items-center gap-2 rounded-lg border border-border bg-input px-3 text-xs font-semibold text-foreground">
|
||||||
|
<input
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => onChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function PeriodCaption({
|
||||||
|
from,
|
||||||
|
until,
|
||||||
|
}: {
|
||||||
|
from: string;
|
||||||
|
until: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-muted" aria-label="实际统计周期">
|
||||||
|
统计周期:{utcLabel(from)} 至 {utcLabel(until)}(UTC,包含今日未完整数据)
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcLabel(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return date.toISOString().replace("T", " ").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Inbox,
|
||||||
|
LoaderCircle,
|
||||||
|
RefreshCw,
|
||||||
|
X,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex min-h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary:
|
||||||
|
"bg-primary text-primary-foreground shadow-[0_8px_24px_-10px_var(--primary)] hover:-translate-y-0.5 hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border border-border bg-surface text-foreground shadow-sm hover:border-border-strong hover:bg-surface-muted",
|
||||||
|
ghost: "text-muted hover:bg-surface-muted hover:text-foreground",
|
||||||
|
danger:
|
||||||
|
"bg-danger text-white shadow-[0_8px_24px_-10px_var(--danger)] hover:-translate-y-0.5 hover:bg-danger/90",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "min-h-8 rounded-lg px-3 text-xs",
|
||||||
|
md: "min-h-10 px-4",
|
||||||
|
lg: "min-h-12 px-5",
|
||||||
|
icon: "size-10 px-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "primary",
|
||||||
|
size: "md",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, loading, children, disabled, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-xl border border-border bg-input px-3.5 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:bg-surface-muted disabled:text-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export const Textarea = forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"min-h-24 w-full resize-y rounded-xl border border-border bg-input px-3.5 py-3 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border border-border bg-surface shadow-[0_1px_2px_rgb(15_23_42/0.02),0_10px_35px_rgb(15_23_42/0.035)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
tone: {
|
||||||
|
neutral: "bg-surface-muted text-muted",
|
||||||
|
success: "bg-success-soft text-success",
|
||||||
|
danger: "bg-danger-soft text-danger",
|
||||||
|
warning: "bg-warning-soft text-warning",
|
||||||
|
info: "bg-primary-soft text-primary",
|
||||||
|
violet: "bg-violet-soft text-violet",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { tone: "neutral" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
className,
|
||||||
|
tone,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-primary">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-balance text-3xl font-bold tracking-[-0.045em] text-foreground sm:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingState({ label = "正在加载" }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-72 place-items-center" role="status" aria-live="polite">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-sm text-muted">
|
||||||
|
<span className="grid size-11 place-items-center rounded-2xl bg-primary-soft text-primary">
|
||||||
|
<LoaderCircle className="size-5 animate-spin" aria-hidden />
|
||||||
|
</span>
|
||||||
|
{label}…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title = "暂无数据",
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-56 place-items-center px-6 text-center">
|
||||||
|
<div>
|
||||||
|
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-surface-muted text-muted">
|
||||||
|
<Inbox className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||||
|
{description ? <p className="mt-1 text-sm text-muted">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
}: {
|
||||||
|
error: unknown;
|
||||||
|
retry?: () => void;
|
||||||
|
}) {
|
||||||
|
const message = error instanceof Error ? error.message : "出现未知错误,请稍后重试";
|
||||||
|
return (
|
||||||
|
<Card className="mx-auto grid min-h-72 max-w-lg place-items-center border-danger/20 p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-danger-soft text-danger">
|
||||||
|
<AlertTriangle className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<h2 className="font-semibold text-foreground">无法加载数据</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted">{message}</p>
|
||||||
|
{retry ? (
|
||||||
|
<Button className="mt-5" variant="secondary" onClick={retry}>
|
||||||
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
preventClose = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
preventClose?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<BaseDialog.Root
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen && preventClose) return;
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BaseDialog.Portal>
|
||||||
|
<BaseDialog.Backdrop className="fixed inset-0 z-50 bg-slate-950/45 backdrop-blur-[3px] transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0" />
|
||||||
|
<BaseDialog.Viewport className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4">
|
||||||
|
<BaseDialog.Popup
|
||||||
|
className={cn(
|
||||||
|
"relative my-8 w-full max-w-lg rounded-3xl border border-white/10 bg-surface-elevated p-6 shadow-2xl outline-none transition-all data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 sm:p-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!preventClose ? (
|
||||||
|
<BaseDialog.Close
|
||||||
|
className="absolute right-4 top-4 grid size-9 place-items-center rounded-xl text-muted transition hover:bg-surface-muted hover:text-foreground"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X className="size-4" aria-hidden />
|
||||||
|
</BaseDialog.Close>
|
||||||
|
) : null}
|
||||||
|
<BaseDialog.Title className="pr-10 text-xl font-bold tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</BaseDialog.Title>
|
||||||
|
{description ? (
|
||||||
|
<BaseDialog.Description className="mt-2 text-sm leading-6 text-muted">
|
||||||
|
{description}
|
||||||
|
</BaseDialog.Description>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-6">{children}</div>
|
||||||
|
</BaseDialog.Popup>
|
||||||
|
</BaseDialog.Viewport>
|
||||||
|
</BaseDialog.Portal>
|
||||||
|
</BaseDialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
icon: Icon,
|
||||||
|
tone = "primary",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
tone?: "primary" | "success" | "violet" | "warning";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="group relative overflow-hidden p-5 sm:p-6">
|
||||||
|
<div className={`stat-glow stat-glow--${tone}`} aria-hidden />
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<p className="text-sm font-medium text-muted">{label}</p>
|
||||||
|
<span className={`stat-icon stat-icon--${tone}`}>
|
||||||
|
<Icon className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<strong className="mt-5 block text-3xl font-bold tracking-[-0.05em] text-foreground tabular-nums">
|
||||||
|
{value}
|
||||||
|
</strong>
|
||||||
|
<p className="mt-2 text-xs text-muted">{hint}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const ranges = [
|
||||||
|
{ value: "7d", label: "7 天" },
|
||||||
|
{ value: "30d", label: "30 天" },
|
||||||
|
{ value: "90d", label: "90 天" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function RangeControl({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
||||||
|
{ranges.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.value}
|
||||||
|
className={`min-h-8 rounded-lg px-3 text-xs font-semibold transition ${
|
||||||
|
value === range.value
|
||||||
|
? "bg-surface text-foreground shadow-sm"
|
||||||
|
: "text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange(range.value)}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === range.value}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { SortOrder } from "../api/types";
|
||||||
|
|
||||||
|
export interface SortOption<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortControl<T extends string>({
|
||||||
|
value,
|
||||||
|
order,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
label = "排序",
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
order: SortOrder;
|
||||||
|
options: readonly SortOption<T>[];
|
||||||
|
onChange: (value: T, order: SortOrder) => void;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
|
||||||
|
<legend className="sr-only">{label}</legend>
|
||||||
|
<label className="grid gap-1 text-xs font-semibold text-muted">
|
||||||
|
<span>{label}</span>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value as T, order)}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
|
||||||
|
{(["desc", "asc"] as const).map((direction) => (
|
||||||
|
<button
|
||||||
|
key={direction}
|
||||||
|
className={`min-h-7 rounded-lg px-3 text-xs font-semibold transition ${
|
||||||
|
order === direction
|
||||||
|
? "bg-surface text-foreground shadow-sm"
|
||||||
|
: "text-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={order === direction}
|
||||||
|
onClick={() => onChange(value, direction)}
|
||||||
|
>
|
||||||
|
{direction === "desc" ? "降序" : "升序"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Button } from "./primitives";
|
||||||
|
|
||||||
|
export function TableToolbar({
|
||||||
|
children,
|
||||||
|
active,
|
||||||
|
onClear,
|
||||||
|
clearLabel = "清除筛选",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
active: boolean;
|
||||||
|
onClear: () => void;
|
||||||
|
clearLabel?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 p-4 sm:p-5"
|
||||||
|
aria-label="表格筛选与排序"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Button
|
||||||
|
className="sm:ml-auto"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
type="button"
|
||||||
|
disabled={!active}
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
{clearLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
|
|
||||||
import { ApiError } from "../api/client";
|
|
||||||
import { escapeHtml } from "../lib/format";
|
|
||||||
|
|
||||||
export function renderLoading(container: HTMLElement, label = "正在加载"): void {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="state-card" role="status" aria-live="polite">
|
|
||||||
<wa-spinner class="state-spinner" aria-hidden="true"></wa-spinner>
|
|
||||||
<p>${escapeHtml(label)}…</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderError(
|
|
||||||
container: HTMLElement,
|
|
||||||
error: unknown,
|
|
||||||
retry?: () => void,
|
|
||||||
): void {
|
|
||||||
const message =
|
|
||||||
error instanceof ApiError ? error.message : "出现未知错误,请稍后重试";
|
|
||||||
container.innerHTML = `
|
|
||||||
<wa-callout class="state-card state-card--error" variant="danger" appearance="outlined" role="alert">
|
|
||||||
<span class="state-icon" aria-hidden="true">!</span>
|
|
||||||
<h2>无法加载数据</h2>
|
|
||||||
<p>${escapeHtml(message)}</p>
|
|
||||||
${retry ? '<wa-button variant="neutral" appearance="outlined" data-retry>重试</wa-button>' : ""}
|
|
||||||
</wa-callout>
|
|
||||||
`;
|
|
||||||
container.querySelector<HTMLElement>("[data-retry]")?.addEventListener(
|
|
||||||
"click",
|
|
||||||
() => retry?.(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderEmpty(message: string): string {
|
|
||||||
return `<div class="empty-state"><p>${escapeHtml(message)}</p></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showToast(message: string, tone: "success" | "error"): void {
|
|
||||||
const toast = document.createElement("wa-callout");
|
|
||||||
toast.className = `toast toast--${tone}`;
|
|
||||||
toast.setAttribute("variant", tone === "error" ? "danger" : "success");
|
|
||||||
toast.setAttribute("appearance", "filled-outlined");
|
|
||||||
toast.setAttribute("role", tone === "error" ? "alert" : "status");
|
|
||||||
toast.textContent = message;
|
|
||||||
document.body.append(toast);
|
|
||||||
requestAnimationFrame(() => toast.classList.add("toast--visible"));
|
|
||||||
window.setTimeout(() => {
|
|
||||||
toast.classList.remove("toast--visible");
|
|
||||||
window.setTimeout(() => toast.remove(), 180);
|
|
||||||
}, 3_200);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setButtonBusy(
|
|
||||||
button: HTMLButtonElement | WaButton,
|
|
||||||
busy: boolean,
|
|
||||||
busyLabel = "处理中…",
|
|
||||||
): void {
|
|
||||||
if (button.tagName === "WA-BUTTON") {
|
|
||||||
(button as WaButton).loading = busy;
|
|
||||||
}
|
|
||||||
if (busy) {
|
|
||||||
button.dataset.label = button.textContent ?? "";
|
|
||||||
button.textContent = busyLabel;
|
|
||||||
button.disabled = true;
|
|
||||||
button.setAttribute("aria-busy", "true");
|
|
||||||
} else {
|
|
||||||
button.textContent = button.dataset.label ?? button.textContent;
|
|
||||||
button.disabled = false;
|
|
||||||
button.removeAttribute("aria-busy");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import "@awesome.me/webawesome/dist/styles/webawesome.css";
|
|
||||||
import "@awesome.me/webawesome/dist/components/button/button.js";
|
|
||||||
import "@awesome.me/webawesome/dist/components/callout/callout.js";
|
|
||||||
import "@awesome.me/webawesome/dist/components/input/input.js";
|
|
||||||
import "@awesome.me/webawesome/dist/components/spinner/spinner.js";
|
|
||||||
|
|
||||||
const darkMode = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
|
|
||||||
function syncColorScheme(event: MediaQueryList | MediaQueryListEvent): void {
|
|
||||||
document.documentElement.classList.toggle("wa-dark", event.matches);
|
|
||||||
document.documentElement.classList.toggle("wa-light", !event.matches);
|
|
||||||
}
|
|
||||||
|
|
||||||
syncColorScheme(darkMode);
|
|
||||||
darkMode.addEventListener("change", syncColorScheme);
|
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BadgeDollarSign,
|
||||||
|
BrainCircuit,
|
||||||
|
ChartNoAxesCombined,
|
||||||
|
Gauge,
|
||||||
|
Keyboard,
|
||||||
|
Repeat2,
|
||||||
|
Sparkles,
|
||||||
|
Target,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { adminApi } from "../../api/client";
|
||||||
|
import type {
|
||||||
|
AnalyticsRate,
|
||||||
|
ProductAnalyticsOverview,
|
||||||
|
SortOrder,
|
||||||
|
} from "../../api/types";
|
||||||
|
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||||
|
import { CohortHeatmap } from "../../components/charts/cohort-heatmap";
|
||||||
|
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||||
|
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||||
|
import { FilterControl, ToggleFilter } from "../../components/filter-control";
|
||||||
|
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||||
|
import { PeriodCaption } from "../../components/period-caption";
|
||||||
|
import { RangeControl } from "../../components/range-control";
|
||||||
|
import { SortControl } from "../../components/sort-control";
|
||||||
|
import { formatNumber } from "../../lib/format";
|
||||||
|
import { stableSort } from "../../lib/sort";
|
||||||
|
|
||||||
|
export function AnalyticsPage() {
|
||||||
|
const [range, setRange] = useState("30d");
|
||||||
|
const [data, setData] = useState<ProductAnalyticsOverview>();
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const [executionMode, setExecutionMode] = useState("");
|
||||||
|
const [aiSort, setAiSort] = useState<"successes" | "users">("successes");
|
||||||
|
const [aiOrder, setAiOrder] = useState<SortOrder>("desc");
|
||||||
|
const [channelSort, setChannelSort] = useState<"installs" | "activated" | "rate">("installs");
|
||||||
|
const [channelOrder, setChannelOrder] = useState<SortOrder>("desc");
|
||||||
|
const [hideUnknown, setHideUnknown] = useState(false);
|
||||||
|
const [cohortOrder, setCohortOrder] = useState<SortOrder>("desc");
|
||||||
|
const [minimumCohortSize, setMinimumCohortSize] = useState(0);
|
||||||
|
const [matureOnly, setMatureOnly] = useState(false);
|
||||||
|
const requestVersion = useRef(0);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const version = ++requestVersion.current;
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await adminApi.productAnalytics(range);
|
||||||
|
if (requestVersion.current === version) setData(response);
|
||||||
|
} catch (requestError) {
|
||||||
|
if (requestVersion.current === version) setError(requestError);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const visibleAiFeatures = useMemo(
|
||||||
|
() =>
|
||||||
|
stableSort(
|
||||||
|
(data?.aiFeatures ?? []).filter(
|
||||||
|
(item) => !executionMode || item.executionMode === executionMode,
|
||||||
|
),
|
||||||
|
(item) => (aiSort === "successes" ? item.successes : item.users),
|
||||||
|
aiOrder,
|
||||||
|
),
|
||||||
|
[aiOrder, aiSort, data?.aiFeatures, executionMode],
|
||||||
|
);
|
||||||
|
const visibleChannels = useMemo(
|
||||||
|
() =>
|
||||||
|
stableSort(
|
||||||
|
(data?.growth.channels ?? []).filter(
|
||||||
|
(channel) => !hideUnknown || channel.channel !== "UNKNOWN",
|
||||||
|
),
|
||||||
|
(channel) =>
|
||||||
|
channelSort === "installs"
|
||||||
|
? channel.installations
|
||||||
|
: channelSort === "activated"
|
||||||
|
? channel.activated
|
||||||
|
: channel.activationRate.percent ?? -1,
|
||||||
|
channelOrder,
|
||||||
|
),
|
||||||
|
[channelOrder, channelSort, data?.growth.channels, hideUnknown],
|
||||||
|
);
|
||||||
|
const visibleCohorts = useMemo(
|
||||||
|
() =>
|
||||||
|
stableSort(
|
||||||
|
(data?.retention ?? []).filter(
|
||||||
|
(cohort) =>
|
||||||
|
cohort.size >= minimumCohortSize &&
|
||||||
|
(!matureOnly || cohort.d30?.percent != null),
|
||||||
|
),
|
||||||
|
(cohort) => cohort.cohortDate,
|
||||||
|
cohortOrder,
|
||||||
|
),
|
||||||
|
[cohortOrder, data?.retention, matureOnly, minimumCohortSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||||
|
if (!data) return <LoadingState label="加载产品数据" />;
|
||||||
|
|
||||||
|
const weeklyGrowth = data.northStar.weekOverWeekPercent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="CEO Dashboard"
|
||||||
|
title="产品增长与留存"
|
||||||
|
description="围绕成功使用 AI 的核心价值事件,观察增长质量、留存、消耗与付费。"
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<RangeControl value={range} onChange={setRange} />
|
||||||
|
<PeriodCaption from={data.period.from} until={data.period.until} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="北极星指标">
|
||||||
|
<StatCard
|
||||||
|
label="周 AI 活跃用户"
|
||||||
|
value={formatNumber(data.northStar.weeklyAiActiveUsers)}
|
||||||
|
hint={
|
||||||
|
weeklyGrowth == null
|
||||||
|
? "暂无可比上周数据"
|
||||||
|
: `较上周 ${signedPercent(weeklyGrowth)}`
|
||||||
|
}
|
||||||
|
icon={Sparkles}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="24 小时激活率"
|
||||||
|
value={rateLabel(data.growth.activation24h)}
|
||||||
|
hint={`${formatNumber(data.growth.activation24h.numerator)} / ${formatNumber(data.growth.activation24h.denominator)} 位新用户`}
|
||||||
|
icon={Target}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="D7 价值留存"
|
||||||
|
value={latestMatureRetention(data, "d7")}
|
||||||
|
hint="激活后第 7 天再次成功使用 AI"
|
||||||
|
icon={Repeat2}
|
||||||
|
tone="violet"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="每活跃用户日均消耗"
|
||||||
|
value={optionalNumber(data.consumption.averageDailyCreditsPerActiveUser)}
|
||||||
|
hint={`周期总消耗 ${formatNumber(data.consumption.totalCredits)} 积分`}
|
||||||
|
icon={Gauge}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="增长激活漏斗"
|
||||||
|
description="同一批已完成 24 小时观察的新安装,所有步骤必须在首次启动后 24 小时内完成"
|
||||||
|
icon={Target}
|
||||||
|
/>
|
||||||
|
<FunnelChart steps={data.growthFunnel} />
|
||||||
|
</Card>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="留存 Cohort 热力图"
|
||||||
|
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
|
||||||
|
icon={ChartNoAxesCombined}
|
||||||
|
/>
|
||||||
|
<ChartToolbar label="留存 cohort 筛选与排序">
|
||||||
|
<SortControl
|
||||||
|
label="激活日期"
|
||||||
|
value="date"
|
||||||
|
order={cohortOrder}
|
||||||
|
options={[{ value: "date", label: "激活日期" }]}
|
||||||
|
onChange={(_, order) => setCohortOrder(order)}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="最小样本量"
|
||||||
|
value={String(minimumCohortSize)}
|
||||||
|
options={[
|
||||||
|
{ value: "0", label: "不限" },
|
||||||
|
{ value: "10", label: "至少 10 人" },
|
||||||
|
{ value: "50", label: "至少 50 人" },
|
||||||
|
{ value: "100", label: "至少 100 人" },
|
||||||
|
]}
|
||||||
|
onChange={(value) => setMinimumCohortSize(Number(value))}
|
||||||
|
/>
|
||||||
|
<ToggleFilter label="仅显示 D30 已成熟" checked={matureOnly} onChange={setMatureOnly} />
|
||||||
|
</ChartToolbar>
|
||||||
|
<CohortHeatmap cohorts={visibleCohorts} />
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="AI 使用结构"
|
||||||
|
description="客户端功能与执行模式,仅包含白名单元数据"
|
||||||
|
icon={BrainCircuit}
|
||||||
|
/>
|
||||||
|
<ChartToolbar label="AI 使用结构筛选与排序">
|
||||||
|
<FilterControl
|
||||||
|
label="执行模式"
|
||||||
|
value={executionMode}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "全部模式" },
|
||||||
|
{ value: "MANAGED", label: "托管" },
|
||||||
|
{ value: "LOCAL", label: "本地" },
|
||||||
|
{ value: "BYOK", label: "BYOK" },
|
||||||
|
]}
|
||||||
|
onChange={setExecutionMode}
|
||||||
|
/>
|
||||||
|
<SortControl
|
||||||
|
value={aiSort}
|
||||||
|
order={aiOrder}
|
||||||
|
options={[
|
||||||
|
{ value: "successes", label: "成功次数" },
|
||||||
|
{ value: "users", label: "成功用户" },
|
||||||
|
]}
|
||||||
|
onChange={(value, order) => {
|
||||||
|
setAiSort(value);
|
||||||
|
setAiOrder(order);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartToolbar>
|
||||||
|
{visibleAiFeatures.length === 0 ? (
|
||||||
|
<p className="p-8 text-center text-sm text-muted">当前筛选条件下暂无 AI 使用数据</p>
|
||||||
|
) : (
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={visibleAiFeatures.map((item) => ({
|
||||||
|
id: `${item.feature}-${item.executionMode}`,
|
||||||
|
label: featureLabel(item.feature),
|
||||||
|
value: item.successes,
|
||||||
|
secondaryValue: item.users,
|
||||||
|
hint: executionModeLabel(item.executionMode),
|
||||||
|
}))}
|
||||||
|
primaryLabel="成功次数"
|
||||||
|
secondaryLabel="成功用户"
|
||||||
|
primaryTone="violet"
|
||||||
|
secondaryTone="success"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="活跃与消耗"
|
||||||
|
description="DAU / WAU / MAU 为截至统计截止时刻的滚动 1 / 7 / 30 日 AI 价值活跃用户"
|
||||||
|
icon={Activity}
|
||||||
|
/>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{ label: "DAU", value: data.activity.dau },
|
||||||
|
{ label: "WAU", value: data.activity.wau },
|
||||||
|
{ label: "MAU", value: data.activity.mau },
|
||||||
|
]}
|
||||||
|
primaryLabel="AI 活跃用户"
|
||||||
|
primaryTone="success"
|
||||||
|
/>
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["DAU / MAU", optionalPercent(data.activity.stickinessPercent)],
|
||||||
|
["成功 AI 次数", formatNumber(data.activity.successfulAiRequests)],
|
||||||
|
["人均成功次数", optionalDecimal(data.activity.successfulRequestsPerActiveUser)],
|
||||||
|
["用户日消耗中位数", optionalNumber(data.consumption.medianUserDailyCredits)],
|
||||||
|
["单次托管请求积分", optionalDecimal(data.consumption.averageCreditsPerManagedRequest)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-2" aria-label="键盘输入统计">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="键盘中英文输入"
|
||||||
|
description="仅统计 OSGKeyboard 本地聚合后的手动提交字符,不包含输入内容"
|
||||||
|
icon={Keyboard}
|
||||||
|
/>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "中文",
|
||||||
|
value: data.keyboardUsage.chineseCharacters,
|
||||||
|
hint: `占中英文 ${optionalPercent(data.keyboardUsage.chineseSharePercent)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "英文",
|
||||||
|
value: data.keyboardUsage.englishCharacters,
|
||||||
|
hint: `占中英文 ${optionalPercent(data.keyboardUsage.englishSharePercent)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "其他",
|
||||||
|
value: data.keyboardUsage.otherCharacters,
|
||||||
|
hint: "数字、标点与 Emoji",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
primaryLabel="提交字符数"
|
||||||
|
primaryTone="primary"
|
||||||
|
/>
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["输入活跃用户", formatNumber(data.keyboardUsage.activeUsers)],
|
||||||
|
["激活到输入转化", rateLabel(data.keyboardUsage.activationToInput)],
|
||||||
|
["中文活跃用户", formatNumber(data.keyboardUsage.chineseActiveUsers)],
|
||||||
|
["英文活跃用户", formatNumber(data.keyboardUsage.englishActiveUsers)],
|
||||||
|
["中英双语用户", formatNumber(data.keyboardUsage.bilingualActiveUsers)],
|
||||||
|
["总提交字符", formatNumber(data.keyboardUsage.totalCharacters)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="输入会话结构"
|
||||||
|
description="一次键盘激活期间至少提交一个字符才计为输入会话"
|
||||||
|
icon={Activity}
|
||||||
|
/>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{ label: "仅中文", value: data.keyboardUsage.chineseOnlySessions },
|
||||||
|
{ label: "仅英文", value: data.keyboardUsage.englishOnlySessions },
|
||||||
|
{ label: "中英混合", value: data.keyboardUsage.mixedLanguageSessions },
|
||||||
|
{ label: "仅其他", value: data.keyboardUsage.otherOnlySessions },
|
||||||
|
]}
|
||||||
|
primaryLabel="输入会话"
|
||||||
|
primaryTone="success"
|
||||||
|
/>
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["输入会话总数", formatNumber(data.keyboardUsage.inputSessions)],
|
||||||
|
[
|
||||||
|
"平均每会话字符",
|
||||||
|
optionalDecimal(data.keyboardUsage.averageCharactersPerInputSession),
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-3">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="付费转化"
|
||||||
|
description="所选周期内完成 7 / 30 天观察窗的注册 cohort;购买以 StoreKit 验证为准"
|
||||||
|
icon={BadgeDollarSign}
|
||||||
|
/>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "7 天免费转付费",
|
||||||
|
value: data.monetization.conversion7d.percent ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "30 天免费转付费",
|
||||||
|
value: data.monetization.conversion30d.percent ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "复购率",
|
||||||
|
value: data.monetization.repeatPurchaseRate.percent ?? null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
primaryLabel="转化率"
|
||||||
|
primaryTone="success"
|
||||||
|
valueFormatter={(value) => `${value.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["付费用户", formatNumber(data.monetization.payingUsers)],
|
||||||
|
["购买次数", formatNumber(data.monetization.purchases)],
|
||||||
|
["购买积分", formatNumber(data.monetization.creditsPurchased)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="推荐增长漏斗"
|
||||||
|
description="严格绑定 cohort;分享和打开仅作为独立方向信号"
|
||||||
|
icon={Target}
|
||||||
|
/>
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["客户端分享信号", formatNumber(data.referralSignals.shared)],
|
||||||
|
["邀请打开信号", formatNumber(data.referralSignals.opened)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<FunnelChart steps={data.referralFunnel} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader title="体验护栏" description="避免增长被失败体验抵消" icon={Users} />
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "客户端 AI",
|
||||||
|
value: data.guardrails.clientAiSuccessRate.percent ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "托管请求",
|
||||||
|
value: data.guardrails.managedSuccessRate.percent ?? null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
primaryLabel="成功率"
|
||||||
|
primaryTone="primary"
|
||||||
|
valueFormatter={(value) => `${value.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["积分不足阻断用户", formatNumber(data.guardrails.creditBlockedUsers)],
|
||||||
|
["首次价值耗时中位数", optionalMinutes(data.growth.medianTimeToValueMinutes)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="购买意向漏斗"
|
||||||
|
description="同一安装依次浏览、发起购买,并由 StoreKit 服务端验证"
|
||||||
|
icon={BadgeDollarSign}
|
||||||
|
/>
|
||||||
|
<FunnelChart
|
||||||
|
steps={data.monetization.purchaseFunnel}
|
||||||
|
emptyText="客户端购买事件暂无样本"
|
||||||
|
/>
|
||||||
|
<MetricRows
|
||||||
|
rows={[["取消购买用户", formatNumber(data.monetization.cancelledUsers)]]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader
|
||||||
|
title="AI 终态延迟分布"
|
||||||
|
description="按客户端白名单耗时桶聚合;不推算虚假的精确分位数"
|
||||||
|
icon={Gauge}
|
||||||
|
/>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={data.guardrails.latencyBuckets.map((item) => ({
|
||||||
|
id: item.bucket,
|
||||||
|
label: latencyBucketLabel(item.bucket),
|
||||||
|
value: item.successful,
|
||||||
|
secondaryValue: item.failed,
|
||||||
|
}))}
|
||||||
|
primaryLabel="成功"
|
||||||
|
secondaryLabel="失败"
|
||||||
|
primaryTone="success"
|
||||||
|
secondaryTone="warning"
|
||||||
|
emptyText="客户端 AI 终态事件暂无样本"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
|
||||||
|
<ChartToolbar label="渠道质量筛选与排序">
|
||||||
|
<SortControl
|
||||||
|
value={channelSort}
|
||||||
|
order={channelOrder}
|
||||||
|
options={[
|
||||||
|
{ value: "installs", label: "已完成观察安装" },
|
||||||
|
{ value: "activated", label: "激活人数" },
|
||||||
|
{ value: "rate", label: "激活率" },
|
||||||
|
]}
|
||||||
|
onChange={(value, order) => {
|
||||||
|
setChannelSort(value);
|
||||||
|
setChannelOrder(order);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ToggleFilter label="隐藏未知来源" checked={hideUnknown} onChange={setHideUnknown} />
|
||||||
|
</ChartToolbar>
|
||||||
|
<div className="grid gap-0 xl:grid-cols-[1fr_260px]">
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={visibleChannels.map((channel) => ({
|
||||||
|
label: channelLabel(channel.channel),
|
||||||
|
value: channel.installations,
|
||||||
|
secondaryValue: channel.activated,
|
||||||
|
hint: `激活率 ${rateLabel(channel.activationRate)}`,
|
||||||
|
}))}
|
||||||
|
primaryLabel="已完成 24h 观察安装"
|
||||||
|
secondaryLabel="24 小时激活"
|
||||||
|
emptyText="当前筛选条件下暂无渠道归因数据"
|
||||||
|
/>
|
||||||
|
<MetricRows
|
||||||
|
rows={[
|
||||||
|
["新增安装", formatNumber(data.growth.newInstallations)],
|
||||||
|
["新增账号", formatNumber(data.growth.newAccounts)],
|
||||||
|
["整体激活率", rateLabel(data.growth.activation24h)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof Activity;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">{title}</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
|
||||||
|
<Icon className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricRows({ rows }: { rows: Array<[string, string]> }) {
|
||||||
|
return (
|
||||||
|
<dl className="divide-y divide-border">
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<div className="flex items-center justify-between gap-4 px-5 py-3.5" key={label}>
|
||||||
|
<dt className="text-sm text-muted">{label}</dt>
|
||||||
|
<dd className="text-right text-sm font-semibold tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLabel(rate?: AnalyticsRate): string {
|
||||||
|
return rate?.percent == null ? "—" : `${rate.percent.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalPercent(value?: number): string {
|
||||||
|
return value == null ? "—" : `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalDecimal(value?: number): string {
|
||||||
|
return value == null ? "—" : value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNumber(value?: number): string {
|
||||||
|
return value == null ? "—" : formatNumber(Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalMinutes(value?: number): string {
|
||||||
|
return value == null ? "—" : `${Math.round(value)} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedPercent(value: number): string {
|
||||||
|
return `${value >= 0 ? "+" : ""}${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestMatureRetention(
|
||||||
|
data: ProductAnalyticsOverview,
|
||||||
|
key: "d1" | "d7" | "d30",
|
||||||
|
): string {
|
||||||
|
return rateLabel(data.retention.find((cohort) => cohort[key]?.percent != null)?.[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(channel: string): string {
|
||||||
|
return {
|
||||||
|
APP_STORE_ORGANIC: "App Store 自然量",
|
||||||
|
REFERRAL: "用户邀请",
|
||||||
|
SOCIAL_CONTENT: "社交 / 内容",
|
||||||
|
UNKNOWN: "未知来源",
|
||||||
|
}[channel] ?? channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function featureLabel(feature: string): string {
|
||||||
|
return {
|
||||||
|
TRANSCRIPTION: "语音转写",
|
||||||
|
POLISH: "文字润色",
|
||||||
|
AI_ASSISTANT: "AI 助手",
|
||||||
|
AGENT: "Agent",
|
||||||
|
HOTWORD: "快捷指令",
|
||||||
|
OTHER: "其他",
|
||||||
|
}[feature] ?? feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function executionModeLabel(mode: string): string {
|
||||||
|
return {
|
||||||
|
MANAGED: "托管",
|
||||||
|
LOCAL: "本地",
|
||||||
|
BYOK: "BYOK",
|
||||||
|
}[mode] ?? mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyBucketLabel(bucket: string): string {
|
||||||
|
return {
|
||||||
|
LT_1S: "< 1 秒",
|
||||||
|
S1_TO_3: "1–3 秒",
|
||||||
|
S3_TO_10: "3–10 秒",
|
||||||
|
S10_TO_30: "10–30 秒",
|
||||||
|
GTE_30S: "≥ 30 秒",
|
||||||
|
}[bucket] ?? bucket;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { FileCheck2, ShieldCheck } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { adminApi } from "../../api/client";
|
||||||
|
import type {
|
||||||
|
AdminAuditAction,
|
||||||
|
AuditLogEntry,
|
||||||
|
AuditQuery,
|
||||||
|
SortOrder,
|
||||||
|
} from "../../api/types";
|
||||||
|
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||||
|
import { DateRangeControl } from "../../components/date-range-control";
|
||||||
|
import { FilterControl } from "../../components/filter-control";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ErrorState,
|
||||||
|
LoadingState,
|
||||||
|
PageHeader,
|
||||||
|
} from "../../components/primitives";
|
||||||
|
import { TableToolbar } from "../../components/table-toolbar";
|
||||||
|
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||||
|
import { formatDateTime, statusLabel } from "../../lib/format";
|
||||||
|
|
||||||
|
export function AuditPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||||
|
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||||
|
const [action, setAction] = useState<"" | AdminAuditAction>(
|
||||||
|
(searchParams.get("action") as AdminAuditAction | null) ?? "",
|
||||||
|
);
|
||||||
|
const [result, setResult] = useState<"" | AuditLogEntry["result"]>(
|
||||||
|
(searchParams.get("result") as AuditLogEntry["result"] | null) ?? "",
|
||||||
|
);
|
||||||
|
const [order, setOrder] = useState<SortOrder>(
|
||||||
|
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||||
|
);
|
||||||
|
const auditQuery = useMemo<AuditQuery>(
|
||||||
|
() => ({
|
||||||
|
from: from || undefined,
|
||||||
|
until: until || undefined,
|
||||||
|
action: action || undefined,
|
||||||
|
result: result || undefined,
|
||||||
|
sort: "createdAt",
|
||||||
|
order,
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
[action, from, order, result, until],
|
||||||
|
);
|
||||||
|
const fetchAudit = useCallback((value: AuditQuery) => adminApi.auditLogs(value), []);
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
nextCursor,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
loadMore,
|
||||||
|
reload,
|
||||||
|
} = useCursorPage(auditQuery, fetchAudit);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (from) params.set("from", from);
|
||||||
|
if (until) params.set("until", until);
|
||||||
|
if (action) params.set("action", action);
|
||||||
|
if (result) params.set("result", result);
|
||||||
|
params.set("sort", "createdAt");
|
||||||
|
params.set("order", order);
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
}, [action, from, order, result, setSearchParams, until]);
|
||||||
|
|
||||||
|
const columns = useMemo<DataColumn<AuditLogEntry>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: "时间",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted">
|
||||||
|
{formatDateTime(String(getValue()))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "operatorName",
|
||||||
|
header: "操作员",
|
||||||
|
cell: ({ getValue }) => <span className="font-semibold">{String(getValue())}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "action",
|
||||||
|
header: "操作",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="rounded-lg bg-primary-soft px-2 py-1 font-mono text-[11px] text-primary">
|
||||||
|
{String(getValue())}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "target",
|
||||||
|
header: "目标",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span>
|
||||||
|
<span className="block text-xs font-medium">{row.original.targetType}</span>
|
||||||
|
<span className="mt-1 block max-w-48 truncate font-mono text-[11px] text-muted">
|
||||||
|
{row.original.targetId}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "requestId",
|
||||||
|
header: "请求 ID",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-mono text-[11px] text-muted">{String(getValue() || "—")}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "result",
|
||||||
|
header: "结果",
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const result = String(getValue());
|
||||||
|
return (
|
||||||
|
<Badge tone={result === "success" ? "success" : "danger"}>
|
||||||
|
{statusLabel(result)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="安全与合规"
|
||||||
|
title="审计日志"
|
||||||
|
description="追踪管理员操作、目标与执行结果,形成完整可追溯链路。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="flex flex-col gap-4 border-primary/15 bg-gradient-to-r from-primary-soft/80 to-surface p-5 sm:flex-row sm:items-center sm:p-6">
|
||||||
|
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-surface text-primary shadow-sm">
|
||||||
|
<ShieldCheck className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">不可变审计保护</h2>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted">
|
||||||
|
管理员变更、积分赠送和安全操作都会记录请求标识与处理结果。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="sm:ml-auto" tone="info">
|
||||||
|
<FileCheck2 className="size-3.5" aria-hidden />
|
||||||
|
已加载 {items.length} 条
|
||||||
|
</Badge>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<TableToolbar
|
||||||
|
active={Boolean(from || until || action || result || order !== "desc")}
|
||||||
|
onClear={() => {
|
||||||
|
setFrom("");
|
||||||
|
setUntil("");
|
||||||
|
setAction("");
|
||||||
|
setResult("");
|
||||||
|
setOrder("desc");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DateRangeControl
|
||||||
|
value={{ from: from || undefined, until: until || undefined }}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFrom(value.from ?? "");
|
||||||
|
setUntil(value.until ?? "");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="操作类型"
|
||||||
|
value={action}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "全部操作" },
|
||||||
|
{ value: "LOGIN_SUCCEEDED", label: "登录成功" },
|
||||||
|
{ value: "LOGIN_FAILED", label: "登录失败" },
|
||||||
|
{ value: "SESSION_REVOKED", label: "会话撤销" },
|
||||||
|
{ value: "OPERATOR_CREATED", label: "创建管理员" },
|
||||||
|
{ value: "OPERATOR_ENABLED", label: "启用管理员" },
|
||||||
|
{ value: "OPERATOR_DISABLED", label: "停用管理员" },
|
||||||
|
{ value: "OPERATOR_UNLOCKED", label: "解锁管理员" },
|
||||||
|
{ value: "OPERATOR_CREDENTIALS_RESET", label: "重置管理员凭据" },
|
||||||
|
{ value: "OPERATOR_SESSIONS_REVOKED", label: "撤销管理员会话" },
|
||||||
|
{ value: "MANUAL_CREDIT_GRANTED", label: "人工赠送积分" },
|
||||||
|
]}
|
||||||
|
onChange={setAction}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="执行结果"
|
||||||
|
value={result}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "全部结果" },
|
||||||
|
{ value: "success", label: "成功" },
|
||||||
|
{ value: "rejected", label: "已拒绝" },
|
||||||
|
]}
|
||||||
|
onChange={setResult}
|
||||||
|
/>
|
||||||
|
</TableToolbar>
|
||||||
|
{error ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorState error={error} retry={reload} />
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<LoadingState label="加载审计日志" />
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={items}
|
||||||
|
columns={columns}
|
||||||
|
caption="管理员审计事件"
|
||||||
|
emptyTitle={
|
||||||
|
from || until || action || result
|
||||||
|
? "没有符合当前筛选条件的审计记录"
|
||||||
|
: "暂无审计记录"
|
||||||
|
}
|
||||||
|
sort={{ key: "createdAt", order }}
|
||||||
|
sortableColumns={{ createdAt: "createdAt" }}
|
||||||
|
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||||
|
footer={
|
||||||
|
nextCursor ? (
|
||||||
|
<div className="flex justify-center border-t border-border p-5">
|
||||||
|
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
|
||||||
|
加载更早记录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { adminApi, ApiError, setCsrfToken } from "../../api/client";
|
||||||
|
import type { AdminRole, AuthState } from "../../api/types";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
auth: AuthState;
|
||||||
|
checking: boolean;
|
||||||
|
login: (username: string, password: string, totpCode: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [auth, setAuth] = useState<AuthState>({ status: "anonymous" });
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
|
||||||
|
const expireSession = useCallback(() => {
|
||||||
|
setCsrfToken();
|
||||||
|
setAuth({ status: "anonymous" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void adminApi
|
||||||
|
.session()
|
||||||
|
.then((session) => {
|
||||||
|
if (
|
||||||
|
active &&
|
||||||
|
session.authenticated &&
|
||||||
|
session.operatorName &&
|
||||||
|
isAdminRole(session.role)
|
||||||
|
) {
|
||||||
|
setAuth({
|
||||||
|
status: "authenticated",
|
||||||
|
operatorName: session.operatorName,
|
||||||
|
role: session.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!(error instanceof ApiError) || error.status !== 401) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("admin:toast", {
|
||||||
|
detail: { message: "暂时无法确认登录状态", tone: "error" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setChecking(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("admin:unauthorized", expireSession);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.removeEventListener("admin:unauthorized", expireSession);
|
||||||
|
};
|
||||||
|
}, [expireSession]);
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (username: string, password: string, totpCode: string) => {
|
||||||
|
const response = await adminApi.login(username, password, totpCode);
|
||||||
|
setCsrfToken(response.csrfToken);
|
||||||
|
setAuth({
|
||||||
|
status: "authenticated",
|
||||||
|
operatorName: response.operatorName,
|
||||||
|
role: response.role,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await adminApi.logout();
|
||||||
|
} catch {
|
||||||
|
// 即使服务端退出失败,也立即清理前端认证状态。
|
||||||
|
} finally {
|
||||||
|
expireSession();
|
||||||
|
window.history.replaceState(null, "", window.location.pathname);
|
||||||
|
}
|
||||||
|
}, [expireSession]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ auth, checking, login, logout }),
|
||||||
|
[auth, checking, login, logout],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) throw new Error("useAuth 必须在 AuthProvider 中使用");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminRole(role?: AdminRole): role is AdminRole {
|
||||||
|
return role === "SUPER_ADMIN" || role === "SUPPORT" || role === "ANALYST";
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { ArrowRight, Fingerprint, LockKeyhole, ShieldCheck, Sparkles } from "lucide-react";
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import { Button, Input } from "../../components/primitives";
|
||||||
|
import { useAuth } from "./auth-context";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const data = new FormData(form);
|
||||||
|
const username = data.get("username")?.toString().trim() ?? "";
|
||||||
|
const password = data.get("password")?.toString() ?? "";
|
||||||
|
const totpCode = data.get("totpCode")?.toString().trim() ?? "";
|
||||||
|
|
||||||
|
if (username.length < 3 || password.length < 12 || !/^\d{6}$/.test(totpCode)) {
|
||||||
|
setError("请输入有效的用户名、密码和 6 位动态验证码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await login(username, password, totpCode);
|
||||||
|
form.reset();
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof ApiError ? requestError.message : "登录验证失败");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative min-h-screen overflow-hidden bg-auth">
|
||||||
|
<div className="auth-orb auth-orb--one" aria-hidden />
|
||||||
|
<div className="auth-orb auth-orb--two" aria-hidden />
|
||||||
|
<div className="relative mx-auto grid min-h-screen max-w-7xl items-center gap-16 px-5 py-10 lg:grid-cols-[1.1fr_0.9fr] lg:px-12">
|
||||||
|
<section className="animate-page-in hidden lg:block" aria-label="OSG 运营后台">
|
||||||
|
<BrandMark size="large" />
|
||||||
|
<p className="mt-10 inline-flex items-center gap-2 rounded-full border border-primary/15 bg-primary-soft px-3 py-1.5 text-xs font-semibold text-primary">
|
||||||
|
<Sparkles className="size-3.5" aria-hidden />
|
||||||
|
OSG Account Intelligence
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-6 max-w-xl text-5xl font-bold leading-[1.08] tracking-[-0.06em] text-foreground">
|
||||||
|
看见增长,
|
||||||
|
<br />
|
||||||
|
守护每一笔价值。
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-lg text-base leading-7 text-muted">
|
||||||
|
聚合用户、积分、裂变与安全数据,为运营判断提供清晰、可信、可追溯的依据。
|
||||||
|
</p>
|
||||||
|
<div className="mt-12 grid max-w-xl grid-cols-3 gap-4">
|
||||||
|
<TrustItem icon={ShieldCheck} label="角色权限" />
|
||||||
|
<TrustItem icon={Fingerprint} label="双重认证" />
|
||||||
|
<TrustItem icon={LockKeyhole} label="不可变审计" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="animate-card-in mx-auto w-full max-w-md rounded-[2rem] border border-white/60 bg-surface-elevated/90 p-7 shadow-[0_30px_90px_rgb(15_23_42/0.12)] backdrop-blur-2xl sm:p-10">
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<BrandMark size="small" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-8 text-xs font-bold uppercase tracking-[0.18em] text-primary lg:mt-0">
|
||||||
|
安全访问
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-bold tracking-[-0.045em] text-foreground">
|
||||||
|
欢迎回来
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-muted">
|
||||||
|
使用管理员凭据与认证器动态验证码登录。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="mt-8 space-y-5" onSubmit={handleSubmit} noValidate>
|
||||||
|
<Field label="管理员用户名" htmlFor="username">
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
minLength={3}
|
||||||
|
maxLength={64}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="管理员密码" htmlFor="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
minLength={12}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="动态验证码" htmlFor="totpCode">
|
||||||
|
<Input
|
||||||
|
id="totpCode"
|
||||||
|
name="totpCode"
|
||||||
|
className="text-center text-lg font-semibold tracking-[0.35em]"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="min-h-5 text-sm text-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<Button className="w-full" size="lg" type="submit" loading={submitting}>
|
||||||
|
安全登录
|
||||||
|
{!submitting ? <ArrowRight className="size-4" aria-hidden /> : null}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-6 text-center text-xs leading-5 text-muted">
|
||||||
|
凭据不会保存在浏览器中。所有管理操作均受审计保护。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block" htmlFor={htmlFor}>
|
||||||
|
<span className="mb-2 block text-sm font-semibold text-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandMark({ size }: { size: "small" | "large" }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`brand-symbol ${size === "large" ? "size-14 rounded-2xl text-base" : "size-11 rounded-xl text-sm"}`}
|
||||||
|
>
|
||||||
|
O
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className="block text-sm font-bold tracking-tight text-foreground">OSG</strong>
|
||||||
|
<span className="block text-[10px] font-semibold tracking-[0.16em] text-muted">
|
||||||
|
管理中心
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrustItem({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
icon: typeof ShieldCheck;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5 text-xs font-semibold text-muted">
|
||||||
|
<span className="grid size-8 place-items-center rounded-xl bg-surface shadow-sm">
|
||||||
|
<Icon className="size-4 text-primary" aria-hidden />
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
import { FileJson, Pencil, Plus, Power, RefreshCw, Sparkles } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { adminApi, ApiError } from "../../api/client";
|
||||||
|
import type {
|
||||||
|
AdminHintPack,
|
||||||
|
CreateOfficialSkillRequest,
|
||||||
|
OfficialSkill,
|
||||||
|
OfficialSkillCatalog,
|
||||||
|
SkillLocalization,
|
||||||
|
UpdateHintPackRequest,
|
||||||
|
} from "../../api/types";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Dialog,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
Input,
|
||||||
|
LoadingState,
|
||||||
|
PageHeader,
|
||||||
|
Textarea,
|
||||||
|
} from "../../components/primitives";
|
||||||
|
import { useAuth } from "../auth/auth-context";
|
||||||
|
import { HintAutoSection } from "./hint-auto-section";
|
||||||
|
|
||||||
|
type HintLocale = "zh" | "en";
|
||||||
|
|
||||||
|
export function ContentPage() {
|
||||||
|
const { auth } = useAuth();
|
||||||
|
const canEdit = auth.status === "authenticated" && auth.role === "SUPER_ADMIN";
|
||||||
|
const [catalog, setCatalog] = useState<OfficialSkillCatalog>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const [editingSkill, setEditingSkill] = useState<OfficialSkill | "new">();
|
||||||
|
const [busySkillId, setBusySkillId] = useState<string>();
|
||||||
|
const [locale, setLocale] = useState<HintLocale>("zh");
|
||||||
|
const [hintText, setHintText] = useState("");
|
||||||
|
const [hintVersion, setHintVersion] = useState(0);
|
||||||
|
const [hintLoading, setHintLoading] = useState(true);
|
||||||
|
const [hintSaving, setHintSaving] = useState(false);
|
||||||
|
|
||||||
|
const loadSkills = useCallback(async () => {
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
setCatalog(await adminApi.contentSkills());
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadHint = useCallback(async (nextLocale: HintLocale) => {
|
||||||
|
setHintLoading(true);
|
||||||
|
try {
|
||||||
|
const pack = await adminApi.contentHintPack(nextLocale);
|
||||||
|
setHintVersion(pack.version);
|
||||||
|
setHintText(formatHintPack(pack));
|
||||||
|
} catch (requestError) {
|
||||||
|
toast.error(errorMessage(requestError, "Hint pack 加载失败"));
|
||||||
|
} finally {
|
||||||
|
setHintLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSkills();
|
||||||
|
}, [loadSkills]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadHint(locale);
|
||||||
|
}, [loadHint, locale]);
|
||||||
|
|
||||||
|
async function setSkillEnabled(skill: OfficialSkill) {
|
||||||
|
setBusySkillId(skill.id);
|
||||||
|
try {
|
||||||
|
await adminApi.setContentSkillEnabled(skill.id, !skill.enabled);
|
||||||
|
toast.success(skill.enabled ? "Skill 已停用" : "Skill 已启用");
|
||||||
|
await loadSkills();
|
||||||
|
} catch (requestError) {
|
||||||
|
toast.error(errorMessage(requestError, "Skill 状态更新失败"));
|
||||||
|
} finally {
|
||||||
|
setBusySkillId(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHint() {
|
||||||
|
let payload: UpdateHintPackRequest;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(hintText) as Partial<UpdateHintPackRequest>;
|
||||||
|
if (!Array.isArray(parsed.cards)) throw new Error("cards 必须是数组");
|
||||||
|
payload = {
|
||||||
|
generatedAt: optionalString(parsed.generatedAt),
|
||||||
|
expiresAt: optionalString(parsed.expiresAt),
|
||||||
|
intervalHours:
|
||||||
|
parsed.intervalHours === undefined ? undefined : Number(parsed.intervalHours),
|
||||||
|
cards: parsed.cards,
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
toast.error(parseError instanceof Error ? parseError.message : "JSON 格式无效");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHintSaving(true);
|
||||||
|
try {
|
||||||
|
const saved = await adminApi.updateContentHintPack(locale, payload);
|
||||||
|
setHintVersion(saved.version);
|
||||||
|
setHintText(formatHintPack(saved));
|
||||||
|
toast.success(`${locale} Hint pack 已保存并生效`);
|
||||||
|
} catch (requestError) {
|
||||||
|
toast.error(errorMessage(requestError, "Hint pack 保存失败"));
|
||||||
|
} finally {
|
||||||
|
setHintSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState error={error} retry={() => void loadSkills()} />;
|
||||||
|
if (loading || !catalog) return <LoadingState label="加载内容管理" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Official Content"
|
||||||
|
title="内容管理"
|
||||||
|
description="维护客户端官方 Skill、AI Hint 自动生成与 zh/en 内容。所有变更都会写入审计。"
|
||||||
|
actions={
|
||||||
|
canEdit ? (
|
||||||
|
<Button onClick={() => setEditingSkill("new")}>
|
||||||
|
<Plus className="size-4" aria-hidden />
|
||||||
|
新增 Skill
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Badge tone="info">只读访问</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section aria-labelledby="skill-heading" className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="skill-heading" className="text-xl font-bold">
|
||||||
|
官方 Skill
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
当前 revision {catalog.revision} · 共 {catalog.skills.length} 项
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={() => void loadSkills()}>
|
||||||
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{catalog.skills.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState title="暂无官方 Skill" description="新增后可单独启用发布。" />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
{catalog.skills.map((skill) => (
|
||||||
|
<Card key={skill.id} className="p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary-soft text-primary">
|
||||||
|
<Sparkles className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="font-semibold">{skill.localizations["zh-Hans"].name}</h3>
|
||||||
|
<Badge tone={skill.enabled ? "success" : "neutral"}>
|
||||||
|
{skill.enabled ? "已启用" : "已停用"}
|
||||||
|
</Badge>
|
||||||
|
{skill.thinkingEnabled ? <Badge tone="violet">思考</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
{skill.localizations["zh-Hans"].summary}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 break-all font-mono text-[11px] text-muted">
|
||||||
|
{skill.id} · {skill.systemImage} · 排序 {skill.sortOrder}
|
||||||
|
</p>
|
||||||
|
{canEdit ? (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setEditingSkill(skill)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" aria-hidden />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={skill.enabled ? "danger" : "primary"}
|
||||||
|
loading={busySkillId === skill.id}
|
||||||
|
onClick={() => void setSkillEnabled(skill)}
|
||||||
|
>
|
||||||
|
<Power className="size-3.5" aria-hidden />
|
||||||
|
{skill.enabled ? "停用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<HintAutoSection canEdit={canEdit} />
|
||||||
|
|
||||||
|
<section aria-labelledby="hint-heading" className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 id="hint-heading" className="text-xl font-bold">
|
||||||
|
AI Hint packs
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
可查看并编辑当前生效的 AIHintPack;保存后立即生效,服务端自动递增 version。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-border p-4">
|
||||||
|
{(["zh", "en"] as HintLocale[]).map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
size="sm"
|
||||||
|
variant={locale === item ? "primary" : "secondary"}
|
||||||
|
onClick={() => setLocale(item)}
|
||||||
|
aria-pressed={locale === item}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Badge className="ml-auto" tone="info">
|
||||||
|
version {hintVersion}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
{hintLoading ? (
|
||||||
|
<LoadingState label={`加载 ${locale} Hint pack`} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="block text-sm font-semibold" htmlFor="hint-pack-json">
|
||||||
|
<span className="mb-2 flex items-center gap-2">
|
||||||
|
<FileJson className="size-4 text-primary" aria-hidden />
|
||||||
|
{locale} JSON
|
||||||
|
</span>
|
||||||
|
<Textarea
|
||||||
|
id="hint-pack-json"
|
||||||
|
className="min-h-[420px] font-mono text-xs leading-5"
|
||||||
|
value={hintText}
|
||||||
|
onChange={(event) => setHintText(event.target.value)}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{canEdit ? (
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button loading={hintSaving} onClick={() => void saveHint()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SkillDialog
|
||||||
|
skill={editingSkill}
|
||||||
|
onClose={() => setEditingSkill(undefined)}
|
||||||
|
onSaved={async () => {
|
||||||
|
setEditingSkill(undefined);
|
||||||
|
await loadSkills();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillDialog({
|
||||||
|
skill,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
skill?: OfficialSkill | "new";
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState<CreateOfficialSkillRequest>(() => emptySkill());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm(skill && skill !== "new" ? skillToRequest(skill) : emptySkill());
|
||||||
|
}, [skill]);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (skill === "new") {
|
||||||
|
await adminApi.createContentSkill(form);
|
||||||
|
} else if (skill) {
|
||||||
|
const { id: _id, ...payload } = form;
|
||||||
|
await adminApi.updateContentSkill(skill.id, payload);
|
||||||
|
}
|
||||||
|
toast.success(skill === "new" ? "Skill 已创建(默认停用)" : "Skill 已更新");
|
||||||
|
await onSaved();
|
||||||
|
} catch (requestError) {
|
||||||
|
toast.error(errorMessage(requestError, "Skill 保存失败"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(skill)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
}}
|
||||||
|
title={skill === "new" ? "新增官方 Skill" : "编辑官方 Skill"}
|
||||||
|
description="ID 创建后不可修改;新 Skill 默认停用,确认内容后再启用。"
|
||||||
|
preventClose={saving}
|
||||||
|
className="max-w-3xl"
|
||||||
|
>
|
||||||
|
<form className="space-y-5" onSubmit={(event) => void submit(event)}>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Skill ID">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
pattern={"official\\.[a-z0-9._-]+"}
|
||||||
|
minLength={10}
|
||||||
|
maxLength={100}
|
||||||
|
value={form.id}
|
||||||
|
disabled={skill !== "new"}
|
||||||
|
onChange={(event) => setForm({ ...form, id: event.target.value })}
|
||||||
|
placeholder="official.polish"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="SF Symbol">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={100}
|
||||||
|
value={form.systemImage}
|
||||||
|
onChange={(event) => setForm({ ...form, systemImage: event.target.value })}
|
||||||
|
placeholder="wand.and.sparkles"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="排序">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100000}
|
||||||
|
value={form.sortOrder}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm({ ...form, sortOrder: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<label className="flex min-h-11 items-center gap-3 self-end rounded-xl border border-border px-4 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.thinkingEnabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm({ ...form, thinkingEnabled: event.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
启用思考模式
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<LocalizationFields
|
||||||
|
title="简体中文"
|
||||||
|
value={form.localizations["zh-Hans"]}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
localizations: { ...form.localizations, "zh-Hans": value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<LocalizationFields
|
||||||
|
title="English"
|
||||||
|
value={form.localizations.en}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
localizations: { ...form.localizations, en: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={saving}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalizationFields({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: SkillLocalization;
|
||||||
|
onChange: (value: SkillLocalization) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset className="space-y-3 rounded-2xl border border-border p-4">
|
||||||
|
<legend className="px-2 text-sm font-semibold">{title}</legend>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="名称">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={40}
|
||||||
|
value={value.name}
|
||||||
|
onChange={(event) => onChange({ ...value, name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="摘要">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={200}
|
||||||
|
value={value.summary}
|
||||||
|
onChange={(event) => onChange({ ...value, summary: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Prompt">
|
||||||
|
<Textarea
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={6000}
|
||||||
|
value={value.prompt}
|
||||||
|
onChange={(event) => onChange({ ...value, prompt: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm font-medium">
|
||||||
|
<span className="mb-1.5 block">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySkill(): CreateOfficialSkillRequest {
|
||||||
|
return {
|
||||||
|
id: "official.",
|
||||||
|
systemImage: "sparkles",
|
||||||
|
sortOrder: 0,
|
||||||
|
thinkingEnabled: false,
|
||||||
|
localizations: {
|
||||||
|
"zh-Hans": { name: "", summary: "", prompt: "" },
|
||||||
|
en: { name: "", summary: "", prompt: "" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillToRequest(skill: OfficialSkill): CreateOfficialSkillRequest {
|
||||||
|
return {
|
||||||
|
id: skill.id,
|
||||||
|
systemImage: skill.systemImage,
|
||||||
|
sortOrder: skill.sortOrder,
|
||||||
|
thinkingEnabled: skill.thinkingEnabled,
|
||||||
|
localizations: skill.localizations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHintPack(pack: AdminHintPack): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
generatedAt: pack.generatedAt,
|
||||||
|
expiresAt: pack.expiresAt,
|
||||||
|
intervalHours: pack.intervalHours,
|
||||||
|
cards: pack.cards,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof ApiError ? error.message : fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { Play, RefreshCw, Save, Settings2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { adminApi, ApiError } from "../../api/client";
|
||||||
|
import type {
|
||||||
|
HintFeedGenerationStatus,
|
||||||
|
HintFeedSettings,
|
||||||
|
UpdateHintFeedSettingsRequest,
|
||||||
|
} from "../../api/types";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Input,
|
||||||
|
LoadingState,
|
||||||
|
Textarea,
|
||||||
|
} from "../../components/primitives";
|
||||||
|
|
||||||
|
interface HintAutoSectionProps {
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HintAutoSection({ canEdit }: HintAutoSectionProps) {
|
||||||
|
const [settings, setSettings] = useState<HintFeedSettings>();
|
||||||
|
const [status, setStatus] = useState<HintFeedGenerationStatus>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [nextSettings, nextStatus] = await Promise.all([
|
||||||
|
adminApi.hintFeedSettings(),
|
||||||
|
adminApi.hintFeedStatus(),
|
||||||
|
]);
|
||||||
|
setSettings(nextSettings);
|
||||||
|
setStatus(nextStatus);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(message(error, "Hint 自动生成状态加载失败"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function save(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!settings) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: UpdateHintFeedSettingsRequest = {
|
||||||
|
generationIntervalHours: settings.generationIntervalHours,
|
||||||
|
holidayCountriesZh: settings.holidayCountriesZh,
|
||||||
|
holidayCountriesEn: settings.holidayCountriesEn,
|
||||||
|
weatherCitiesZh: settings.weatherCitiesZh,
|
||||||
|
weatherCitiesEn: settings.weatherCitiesEn,
|
||||||
|
googleTrendsGeos: settings.googleTrendsGeos,
|
||||||
|
};
|
||||||
|
setSettings(await adminApi.updateHintFeedSettings(payload));
|
||||||
|
setStatus(await adminApi.hintFeedStatus());
|
||||||
|
toast.success("Hint 自动生成配置已保存");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(message(error, "Hint 自动生成配置保存失败"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerate() {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"将立即抓取外部数据,并原子覆盖 zh/en 提示包。旧版本会保留到新一代全部生成成功。是否继续?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
const result = await adminApi.regenerateHintFeed();
|
||||||
|
toast.success(
|
||||||
|
`生成完成:zh ${result.zh.cardCount} 条,en ${result.en.cardCount} 条`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(message(error, "Hint 提示包生成失败,旧版本仍保持可用"));
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !settings || !status) {
|
||||||
|
return <LoadingState label="加载 Hint 自动生成配置" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="hint-auto-heading" className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="hint-auto-heading" className="text-xl font-bold">
|
||||||
|
Hint 自动生成
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
从 TopHub、Google Trends、Google News、节日和天气来源生成双语提示包。
|
||||||
|
任一来源失败不会影响其他来源,整代生成失败时继续提供旧版本。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => void load()}>
|
||||||
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
|
刷新状态
|
||||||
|
</Button>
|
||||||
|
{canEdit ? (
|
||||||
|
<Button loading={generating} onClick={() => void regenerate()}>
|
||||||
|
<Play className="size-4" aria-hidden />
|
||||||
|
立即生成
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatusCard
|
||||||
|
label="调度"
|
||||||
|
value={settings.enabled ? `每 ${status.intervalHours} 小时` : "未启用"}
|
||||||
|
tone={settings.enabled ? "success" : "neutral"}
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="上次结果"
|
||||||
|
value={outcomeLabel(status.outcome)}
|
||||||
|
detail={formatInstant(status.lastCompletedAt ?? status.lastStartedAt)}
|
||||||
|
tone={outcomeTone(status.outcome)}
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="中文包"
|
||||||
|
value={status.zhVersion ? `v${status.zhVersion}` : "未发布"}
|
||||||
|
detail={
|
||||||
|
status.zhCardCount === undefined ? undefined : `${status.zhCardCount} 条`
|
||||||
|
}
|
||||||
|
tone={status.zhVersion ? "success" : "warning"}
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="英文包"
|
||||||
|
value={status.enVersion ? `v${status.enVersion}` : "未发布"}
|
||||||
|
detail={
|
||||||
|
status.enCardCount === undefined ? undefined : `${status.enCardCount} 条`
|
||||||
|
}
|
||||||
|
tone={status.enVersion ? "success" : "warning"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-5 sm:p-6">
|
||||||
|
<form className="space-y-5" onSubmit={(event) => void save(event)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings2 className="size-4 text-primary" aria-hidden />
|
||||||
|
<h3 className="font-semibold">生成设置</h3>
|
||||||
|
<Badge className="ml-auto" tone={settings.topHubApiKeyConfigured ? "success" : "neutral"}>
|
||||||
|
TopHub Key {settings.topHubApiKeyConfigured ? "已配置" : "未配置"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Field label="生成间隔(小时)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={168}
|
||||||
|
value={settings.generationIntervalHours}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
generationIntervalHours: Number(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="中文节日国家">
|
||||||
|
<Input
|
||||||
|
value={settings.holidayCountriesZh}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings({ ...settings, holidayCountriesZh: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="英文节日国家">
|
||||||
|
<Input
|
||||||
|
value={settings.holidayCountriesEn}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings({ ...settings, holidayCountriesEn: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Google Trends 地区(逗号分隔)">
|
||||||
|
<Input
|
||||||
|
value={settings.googleTrendsGeos}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings({ ...settings, googleTrendsGeos: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Field label="中文天气城市(城市:纬度,经度;…)">
|
||||||
|
<Textarea
|
||||||
|
className="min-h-24 font-mono text-xs"
|
||||||
|
value={settings.weatherCitiesZh}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings({ ...settings, weatherCitiesZh: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="英文天气城市(城市:纬度,经度;…)">
|
||||||
|
<Textarea
|
||||||
|
className="min-h-24 font-mono text-xs"
|
||||||
|
value={settings.weatherCitiesEn}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings({ ...settings, weatherCitiesEn: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
TopHub API Key 仅从服务器环境变量读取,不会保存到数据库或返回浏览器。
|
||||||
|
手动 JSON 编辑与保存仍保留;下一次自动生成会更新双语版本。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{canEdit ? (
|
||||||
|
<Button type="submit" loading={saving}>
|
||||||
|
<Save className="size-4" aria-hidden />
|
||||||
|
保存生成设置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail?: string;
|
||||||
|
tone: "success" | "warning" | "danger" | "neutral" | "info" | "violet";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="font-semibold">{value}</span>
|
||||||
|
<Badge tone={tone}>{detail ?? value}</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm font-semibold">
|
||||||
|
<span className="mb-2 block">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeLabel(outcome: HintFeedGenerationStatus["outcome"]): string {
|
||||||
|
return {
|
||||||
|
IDLE: "尚未运行",
|
||||||
|
RUNNING: "生成中",
|
||||||
|
SUCCEEDED: "成功",
|
||||||
|
FAILED: "失败",
|
||||||
|
}[outcome];
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeTone(
|
||||||
|
outcome: HintFeedGenerationStatus["outcome"],
|
||||||
|
): "success" | "warning" | "danger" | "neutral" {
|
||||||
|
return {
|
||||||
|
IDLE: "neutral",
|
||||||
|
RUNNING: "warning",
|
||||||
|
SUCCEEDED: "success",
|
||||||
|
FAILED: "danger",
|
||||||
|
}[outcome] as "success" | "warning" | "danger" | "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInstant(value?: string): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof ApiError ? error.message : fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { ArrowDownUp, Coins, Search } from "lucide-react";
|
||||||
|
import { useCallback, useState, type FormEvent } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { adminApi } from "../../api/client";
|
||||||
|
import type { LedgerQuery } from "../../api/types";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ErrorState,
|
||||||
|
Input,
|
||||||
|
LoadingState,
|
||||||
|
PageHeader,
|
||||||
|
} from "../../components/primitives";
|
||||||
|
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||||
|
import { formatNumber } from "../../lib/format";
|
||||||
|
import { LedgerFilters } from "./ledger-filters";
|
||||||
|
import { LedgerTable } from "./ledger-table";
|
||||||
|
import { useLedgerFilters } from "./use-ledger-filters";
|
||||||
|
|
||||||
|
export function CreditsPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const initialUserId = searchParams.get("userId") ?? "";
|
||||||
|
const [query, setQuery] = useState(initialUserId);
|
||||||
|
const [activeUserId, setActiveUserId] = useState(initialUserId);
|
||||||
|
const filters = useLedgerFilters();
|
||||||
|
const fetchLedger = useCallback(
|
||||||
|
(value: LedgerQuery) =>
|
||||||
|
activeUserId ? adminApi.ledger(activeUserId, value) : adminApi.latestLedger(value),
|
||||||
|
[activeUserId],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
nextCursor,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
loadMore,
|
||||||
|
reload,
|
||||||
|
} = useCursorPage(filters.query, fetchLedger);
|
||||||
|
|
||||||
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const userId = query.trim();
|
||||||
|
setActiveUserId(userId);
|
||||||
|
setSearchParams((current) => {
|
||||||
|
const params = new URLSearchParams(current);
|
||||||
|
if (userId) params.set("userId", userId);
|
||||||
|
else params.delete("userId");
|
||||||
|
return params;
|
||||||
|
}, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBusinessFilter = Boolean(
|
||||||
|
filters.state.from ||
|
||||||
|
filters.state.until ||
|
||||||
|
filters.state.type ||
|
||||||
|
filters.state.entryType ||
|
||||||
|
filters.state.usageType ||
|
||||||
|
filters.state.referenceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="积分账本"
|
||||||
|
title="积分流水"
|
||||||
|
description="从账务动作、业务来源和消费能力三个维度查看不可变积分账本。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="border-b border-border p-5 sm:p-6">
|
||||||
|
<form className="flex max-w-2xl flex-col gap-3 sm:flex-row" onSubmit={submit}>
|
||||||
|
<label className="relative flex-1">
|
||||||
|
<span className="sr-only">内部用户 ID</span>
|
||||||
|
<Search
|
||||||
|
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="pl-10"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
type="search"
|
||||||
|
placeholder="输入完整用户 ID,留空查看全部"
|
||||||
|
maxLength={36}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit">
|
||||||
|
<ArrowDownUp className="size-4" aria-hidden />
|
||||||
|
查询流水
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LedgerFilters
|
||||||
|
value={filters.state}
|
||||||
|
onChange={filters.update}
|
||||||
|
onClear={filters.clear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
|
||||||
|
<Coins className="size-4 text-primary" aria-hidden />
|
||||||
|
<span>{activeUserId ? "指定用户流水" : "最新积分流水"}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<strong className="font-semibold text-foreground">{formatNumber(items.length)} 条</strong>
|
||||||
|
{activeUserId ? (
|
||||||
|
<span className="ml-auto max-w-64 truncate font-mono" title={activeUserId}>
|
||||||
|
{activeUserId}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorState error={error} retry={reload} />
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<LoadingState label="加载积分流水" />
|
||||||
|
) : (
|
||||||
|
<LedgerTable
|
||||||
|
entries={items}
|
||||||
|
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
|
||||||
|
emptyTitle={
|
||||||
|
activeUserId || hasBusinessFilter
|
||||||
|
? "没有符合当前用户和筛选条件的流水"
|
||||||
|
: "暂无积分流水"
|
||||||
|
}
|
||||||
|
showUser={!activeUserId}
|
||||||
|
sort={filters.state.sort}
|
||||||
|
order={filters.state.order}
|
||||||
|
onSortChange={(sort, order) => filters.update({ sort, order })}
|
||||||
|
onReferenceFilter={(referenceId) => filters.update({ referenceId })}
|
||||||
|
footer={
|
||||||
|
nextCursor ? (
|
||||||
|
<div className="flex justify-center border-t border-border p-5">
|
||||||
|
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
|
||||||
|
加载更多流水
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { CalendarRange, Link2 } from "lucide-react";
|
||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { DateRangeControl } from "../../components/date-range-control";
|
||||||
|
import { FilterControl } from "../../components/filter-control";
|
||||||
|
import { Button, Input } from "../../components/primitives";
|
||||||
|
import { TableToolbar } from "../../components/table-toolbar";
|
||||||
|
import {
|
||||||
|
ledgerActionLabel,
|
||||||
|
ledgerActionOptions,
|
||||||
|
ledgerEntryTypeLabel,
|
||||||
|
ledgerEntryTypeOptions,
|
||||||
|
ledgerUsageLabel,
|
||||||
|
ledgerUsageOptions,
|
||||||
|
} from "./ledger-format";
|
||||||
|
import {
|
||||||
|
ledgerDatePreset,
|
||||||
|
type LedgerDatePreset,
|
||||||
|
type LedgerFilterState,
|
||||||
|
} from "./use-ledger-filters";
|
||||||
|
|
||||||
|
export const ledgerDatePresetOptions: ReadonlyArray<{
|
||||||
|
value: LedgerDatePreset;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "all", label: "全部时间" },
|
||||||
|
{ value: "today", label: "今天" },
|
||||||
|
{ value: "7d", label: "最近 7 天" },
|
||||||
|
{ value: "30d", label: "最近 30 天" },
|
||||||
|
{ value: "90d", label: "最近 90 天" },
|
||||||
|
{ value: "custom", label: "自定义" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UUID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function LedgerFilters({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
value: LedgerFilterState;
|
||||||
|
onChange: (patch: Partial<LedgerFilterState>) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const [referenceDraft, setReferenceDraft] = useState(value.referenceId);
|
||||||
|
const normalizedReference = referenceDraft.trim();
|
||||||
|
const referenceValid =
|
||||||
|
normalizedReference.length === 0 || UUID_PATTERN.test(normalizedReference);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReferenceDraft(value.referenceId);
|
||||||
|
}, [value.referenceId]);
|
||||||
|
|
||||||
|
function applyReference(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (referenceValid) onChange({ referenceId: normalizedReference });
|
||||||
|
}
|
||||||
|
|
||||||
|
const active =
|
||||||
|
value.range !== "all" ||
|
||||||
|
Boolean(value.type || value.entryType || value.usageType || value.referenceId) ||
|
||||||
|
value.sort !== "createdAt" ||
|
||||||
|
value.order !== "desc";
|
||||||
|
const summary = [
|
||||||
|
value.type ? `账务动作:${ledgerActionLabel(value.type)}` : "",
|
||||||
|
value.entryType ? `业务来源:${ledgerEntryTypeLabel(value.entryType)}` : "",
|
||||||
|
value.usageType ? `消费能力:${ledgerUsageLabel(value.usageType)}` : "",
|
||||||
|
value.referenceId ? `关联 ID:${value.referenceId}` : "",
|
||||||
|
value.range !== "all"
|
||||||
|
? `时间:${ledgerDatePresetOptions.find((option) => option.value === value.range)?.label}`
|
||||||
|
: "",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableToolbar
|
||||||
|
active={active}
|
||||||
|
onClear={onClear}
|
||||||
|
clearLabel="清除全部"
|
||||||
|
>
|
||||||
|
<FilterControl
|
||||||
|
label="时间范围"
|
||||||
|
value={value.range}
|
||||||
|
options={ledgerDatePresetOptions}
|
||||||
|
onChange={(range) => {
|
||||||
|
if (range === "custom") onChange({ range });
|
||||||
|
else onChange(ledgerDatePreset(range));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{value.range === "custom" ? (
|
||||||
|
<DateRangeControl
|
||||||
|
value={{ from: value.from || undefined, until: value.until || undefined }}
|
||||||
|
onChange={(range) =>
|
||||||
|
onChange({
|
||||||
|
from: range.from ?? "",
|
||||||
|
until: range.until ?? "",
|
||||||
|
range: "custom",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<FilterControl
|
||||||
|
label="账务动作"
|
||||||
|
value={value.type}
|
||||||
|
options={ledgerActionOptions}
|
||||||
|
onChange={(type) => onChange({ type })}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="业务来源"
|
||||||
|
value={value.entryType}
|
||||||
|
options={ledgerEntryTypeOptions}
|
||||||
|
onChange={(entryType) => onChange({ entryType })}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="消费能力"
|
||||||
|
value={value.usageType}
|
||||||
|
options={ledgerUsageOptions}
|
||||||
|
onChange={(usageType) => onChange({ usageType })}
|
||||||
|
/>
|
||||||
|
<form className="grid min-w-64 flex-1 gap-1" onSubmit={applyReference}>
|
||||||
|
<label className="text-xs font-semibold text-muted" htmlFor="ledger-reference-id">
|
||||||
|
关联 ID
|
||||||
|
</label>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="relative flex-1">
|
||||||
|
<Link2
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="ledger-reference-id"
|
||||||
|
className="h-9 rounded-lg pl-9 font-mono text-xs"
|
||||||
|
value={referenceDraft}
|
||||||
|
onChange={(event) => setReferenceDraft(event.target.value)}
|
||||||
|
placeholder="输入 referenceId"
|
||||||
|
maxLength={36}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-invalid={!referenceValid}
|
||||||
|
aria-describedby={!referenceValid ? "ledger-reference-error" : undefined}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!referenceValid || normalizedReference === value.referenceId}
|
||||||
|
>
|
||||||
|
应用
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
{!referenceValid ? (
|
||||||
|
<span id="ledger-reference-error" className="text-xs font-medium text-danger">
|
||||||
|
请输入完整 UUID
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</TableToolbar>
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<CalendarRange className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden />
|
||||||
|
<span className="font-semibold text-foreground">筛选摘要</span>
|
||||||
|
<span className="min-w-0 break-all">
|
||||||
|
{summary.length > 0 ? summary.join(" · ") : "全部流水"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type {
|
||||||
|
LedgerAction,
|
||||||
|
LedgerEntryType,
|
||||||
|
UsageType,
|
||||||
|
} from "../../api/types";
|
||||||
|
|
||||||
|
export type LedgerTone = "neutral" | "success" | "danger" | "warning" | "info" | "violet";
|
||||||
|
|
||||||
|
export const ledgerActionOptions: ReadonlyArray<{
|
||||||
|
value: "" | LedgerAction;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "", label: "全部账务动作" },
|
||||||
|
{ value: "grant", label: "入账" },
|
||||||
|
{ value: "reserve", label: "预留" },
|
||||||
|
{ value: "settle", label: "结算" },
|
||||||
|
{ value: "refund", label: "退还" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ledgerEntryTypeOptions: ReadonlyArray<{
|
||||||
|
value: "" | LedgerEntryType;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "", label: "全部业务来源" },
|
||||||
|
{ value: "SIGNUP_TRIAL", label: "注册试用赠送" },
|
||||||
|
{ value: "MANUAL_GRANT", label: "人工赠送" },
|
||||||
|
{ value: "USAGE_RESERVE", label: "消费预留" },
|
||||||
|
{ value: "USAGE_SETTLE", label: "消费结算" },
|
||||||
|
{ value: "USAGE_RELEASE", label: "消费释放" },
|
||||||
|
{ value: "USAGE_REFUND", label: "消费退款" },
|
||||||
|
{ value: "REFERRAL_INVITER", label: "邀请人奖励" },
|
||||||
|
{ value: "REFERRAL_INVITEE", label: "受邀人奖励" },
|
||||||
|
{ value: "STOREKIT_PURCHASE", label: "App Store 购买" },
|
||||||
|
{ value: "SUBSCRIPTION_GRANT", label: "订阅赠送" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ledgerUsageOptions: ReadonlyArray<{
|
||||||
|
value: "" | UsageType;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "", label: "全部消费能力" },
|
||||||
|
{ value: "polish", label: "文字润色" },
|
||||||
|
{ value: "asr", label: "语音转写" },
|
||||||
|
{ value: "ai", label: "AI 助手" },
|
||||||
|
{ value: "agent", label: "智能代理" },
|
||||||
|
{ value: "hotword", label: "热词" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const entryTypeLabels = Object.fromEntries(
|
||||||
|
ledgerEntryTypeOptions
|
||||||
|
.filter((option) => option.value)
|
||||||
|
.map((option) => [option.value, option.label]),
|
||||||
|
) as Record<LedgerEntryType, string>;
|
||||||
|
|
||||||
|
const usageLabels = Object.fromEntries(
|
||||||
|
ledgerUsageOptions
|
||||||
|
.filter((option) => option.value)
|
||||||
|
.map((option) => [option.value, option.label]),
|
||||||
|
) as Record<UsageType, string>;
|
||||||
|
|
||||||
|
const actionPresentation: Record<LedgerAction, { label: string; tone: LedgerTone }> = {
|
||||||
|
grant: { label: "入账", tone: "success" },
|
||||||
|
reserve: { label: "预留", tone: "warning" },
|
||||||
|
settle: { label: "结算", tone: "info" },
|
||||||
|
refund: { label: "退还", tone: "violet" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const usageClasses: Record<UsageType, string> = {
|
||||||
|
polish: "bg-violet-soft text-violet",
|
||||||
|
asr: "bg-primary-soft text-primary",
|
||||||
|
ai: "bg-success-soft text-success",
|
||||||
|
agent: "bg-warning-soft text-warning",
|
||||||
|
hotword: "bg-pink-500/10 text-pink-700 dark:text-pink-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ledgerEntryTypeLabel(value?: string): string {
|
||||||
|
if (!value) return "未知类型(空值)";
|
||||||
|
return entryTypeLabels[value as LedgerEntryType] ?? `未知类型(${value})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ledgerActionLabel(value?: string): string {
|
||||||
|
return actionPresentation[value as LedgerAction]?.label ?? `未知动作(${value || "空值"})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ledgerActionTone(value?: string): LedgerTone {
|
||||||
|
return actionPresentation[value as LedgerAction]?.tone ?? "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ledgerUsageLabel(value?: string): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return usageLabels[value as UsageType] ?? `未知能力(${value})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ledgerUsageClass(value?: string): string {
|
||||||
|
return usageClasses[value as UsageType] ?? "bg-surface-muted text-muted";
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { Clipboard, Filter, UserRound } from "lucide-react";
|
||||||
|
import { type ReactNode, useMemo } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { LedgerEntry, LedgerQuery, SortOrder } from "../../api/types";
|
||||||
|
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||||
|
import { Badge, Button } from "../../components/primitives";
|
||||||
|
import { formatDateTime, formatNumber, formatSignedCredits } from "../../lib/format";
|
||||||
|
import {
|
||||||
|
ledgerActionLabel,
|
||||||
|
ledgerActionTone,
|
||||||
|
ledgerEntryTypeLabel,
|
||||||
|
ledgerUsageClass,
|
||||||
|
ledgerUsageLabel,
|
||||||
|
} from "./ledger-format";
|
||||||
|
|
||||||
|
export function LedgerTable({
|
||||||
|
entries,
|
||||||
|
caption,
|
||||||
|
emptyTitle,
|
||||||
|
showUser = false,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
onSortChange,
|
||||||
|
onReferenceFilter,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
entries: LedgerEntry[];
|
||||||
|
caption: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
showUser?: boolean;
|
||||||
|
sort: NonNullable<LedgerQuery["sort"]>;
|
||||||
|
order: SortOrder;
|
||||||
|
onSortChange: (sort: NonNullable<LedgerQuery["sort"]>, order: SortOrder) => void;
|
||||||
|
onReferenceFilter: (referenceId: string) => void;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo<DataColumn<LedgerEntry>[]>(() => {
|
||||||
|
const result: DataColumn<LedgerEntry>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: "时间",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted">
|
||||||
|
{formatDateTime(String(getValue()))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (showUser) {
|
||||||
|
result.push({
|
||||||
|
accessorKey: "userId",
|
||||||
|
header: "用户",
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const userId = String(getValue());
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="group/user inline-flex max-w-52 items-center gap-2 text-left text-primary"
|
||||||
|
href={`#/users?userId=${encodeURIComponent(userId)}`}
|
||||||
|
title={userId}
|
||||||
|
>
|
||||||
|
<UserRound className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
<span className="truncate font-mono text-xs underline-offset-4 group-hover/user:underline">
|
||||||
|
{userId}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.push(
|
||||||
|
{
|
||||||
|
accessorKey: "type",
|
||||||
|
header: "账务动作",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Badge tone={ledgerActionTone(String(getValue()))}>
|
||||||
|
{ledgerActionLabel(String(getValue()))}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "entryType",
|
||||||
|
header: "业务来源",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="whitespace-nowrap text-xs font-semibold">
|
||||||
|
{ledgerEntryTypeLabel(String(getValue() ?? ""))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "usageType",
|
||||||
|
header: "消费能力",
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const usageType = getValue() ? String(getValue()) : undefined;
|
||||||
|
return usageType ? (
|
||||||
|
<Badge className={ledgerUsageClass(usageType)}>
|
||||||
|
{ledgerUsageLabel(usageType)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">—</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "amount",
|
||||||
|
header: "变动",
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = Number(getValue());
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`font-bold tabular-nums ${value >= 0 ? "text-success" : "text-danger"}`}
|
||||||
|
>
|
||||||
|
{formatSignedCredits(value)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "balanceAfter",
|
||||||
|
header: "结余",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}, [showUser]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
data={entries}
|
||||||
|
columns={columns}
|
||||||
|
caption={caption}
|
||||||
|
emptyTitle={emptyTitle}
|
||||||
|
sort={{ key: sort, order }}
|
||||||
|
sortableColumns={{ createdAt: "createdAt", amount: "amount" }}
|
||||||
|
onSortChange={(nextSort, nextOrder) =>
|
||||||
|
onSortChange(nextSort as NonNullable<LedgerQuery["sort"]>, nextOrder)
|
||||||
|
}
|
||||||
|
renderExpandedRow={(entry) => (
|
||||||
|
<LedgerDetails entry={entry} onReferenceFilter={onReferenceFilter} />
|
||||||
|
)}
|
||||||
|
getRowId={(entry) => entry.entryId}
|
||||||
|
footer={footer}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LedgerDetails({
|
||||||
|
entry,
|
||||||
|
onReferenceFilter,
|
||||||
|
}: {
|
||||||
|
entry: LedgerEntry;
|
||||||
|
onReferenceFilter: (referenceId: string) => void;
|
||||||
|
}) {
|
||||||
|
const details = entry.details;
|
||||||
|
return (
|
||||||
|
<section className="space-y-4" aria-label="流水安全详情">
|
||||||
|
<dl className="grid gap-x-8 gap-y-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<DetailItem label="流水 ID">
|
||||||
|
<Identifier value={entry.entryId} />
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="关联 ID">
|
||||||
|
{entry.referenceId ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Identifier value={entry.referenceId} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onReferenceFilter(entry.referenceId!)}
|
||||||
|
>
|
||||||
|
<Filter className="size-3.5" aria-hidden />
|
||||||
|
筛选同笔生命周期
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="原因代码">
|
||||||
|
<span className="font-mono text-[11px] text-muted">{entry.reasonCode || "—"}</span>
|
||||||
|
</DetailItem>
|
||||||
|
{details?.kind === "manualGrant" ? (
|
||||||
|
<>
|
||||||
|
<DetailItem label="人工原因">{details.reason || "—"}</DetailItem>
|
||||||
|
<DetailItem label="操作员">{details.operatorName || "—"}</DetailItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{details?.kind === "storeKit" ? (
|
||||||
|
<>
|
||||||
|
<DetailItem label="商品">{details.productId || "—"}</DetailItem>
|
||||||
|
<DetailItem label="交易 ID">
|
||||||
|
<Identifier value={details.transactionId} />
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="原始交易 ID">
|
||||||
|
<Identifier value={details.originalTransactionId} />
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="环境">{details.environment || "—"}</DetailItem>
|
||||||
|
<DetailItem label="购买时间">{formatDateTime(details.purchasedAt)}</DetailItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{details?.kind === "referral" ? (
|
||||||
|
<>
|
||||||
|
<DetailItem label="裂变角色">{referralRoleLabel(details.role)}</DetailItem>
|
||||||
|
<DetailItem label="关联用户">
|
||||||
|
<Identifier value={details.relatedUserId} />
|
||||||
|
</DetailItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{details?.kind === "usage" ? (
|
||||||
|
<DetailItem label="预留 ID">
|
||||||
|
<Identifier value={details.reservationId} />
|
||||||
|
</DetailItem>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailItem({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-[11px] font-bold uppercase tracking-wide text-muted">{label}</dt>
|
||||||
|
<dd className="mt-1.5 break-words text-xs font-medium text-foreground">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Identifier({ value }: { value?: string }) {
|
||||||
|
if (!value) return <span className="text-muted">—</span>;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex max-w-full items-center gap-1.5">
|
||||||
|
<code className="break-all text-[11px]">{value}</code>
|
||||||
|
<button
|
||||||
|
className="grid size-7 shrink-0 place-items-center rounded-lg text-muted transition hover:bg-surface hover:text-primary focus-visible:ring-4 focus-visible:ring-primary/15"
|
||||||
|
type="button"
|
||||||
|
title="复制 ID"
|
||||||
|
aria-label={`复制 ID ${value}`}
|
||||||
|
onClick={() => void copyIdentifier(value)}
|
||||||
|
>
|
||||||
|
<Clipboard className="size-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyIdentifier(value: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast.success("ID 已复制");
|
||||||
|
} catch {
|
||||||
|
toast.error("无法复制,请手动选择 ID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function referralRoleLabel(role?: string): string {
|
||||||
|
if (!role) return "—";
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
inviter: "邀请人",
|
||||||
|
invitee: "受邀人",
|
||||||
|
INVITER: "邀请人",
|
||||||
|
INVITEE: "受邀人",
|
||||||
|
};
|
||||||
|
return labels[role] ?? role;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import type {
|
||||||
|
LedgerAction,
|
||||||
|
LedgerEntryType,
|
||||||
|
LedgerQuery,
|
||||||
|
SortOrder,
|
||||||
|
UsageType,
|
||||||
|
} from "../../api/types";
|
||||||
|
import {
|
||||||
|
ledgerActionOptions,
|
||||||
|
ledgerEntryTypeOptions,
|
||||||
|
ledgerUsageOptions,
|
||||||
|
} from "./ledger-format";
|
||||||
|
|
||||||
|
export type LedgerDatePreset = "all" | "today" | "7d" | "30d" | "90d" | "custom";
|
||||||
|
|
||||||
|
export interface LedgerFilterState {
|
||||||
|
from: string;
|
||||||
|
until: string;
|
||||||
|
type: "" | LedgerAction;
|
||||||
|
entryType: "" | LedgerEntryType;
|
||||||
|
usageType: "" | UsageType;
|
||||||
|
referenceId: string;
|
||||||
|
range: LedgerDatePreset;
|
||||||
|
sort: NonNullable<LedgerQuery["sort"]>;
|
||||||
|
order: SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultState: LedgerFilterState = {
|
||||||
|
from: "",
|
||||||
|
until: "",
|
||||||
|
type: "",
|
||||||
|
entryType: "",
|
||||||
|
usageType: "",
|
||||||
|
referenceId: "",
|
||||||
|
range: "all",
|
||||||
|
sort: "createdAt",
|
||||||
|
order: "desc",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ranges = new Set<LedgerDatePreset>(["all", "today", "7d", "30d", "90d", "custom"]);
|
||||||
|
const sorts = new Set<LedgerFilterState["sort"]>(["createdAt", "amount"]);
|
||||||
|
const orders = new Set<SortOrder>(["asc", "desc"]);
|
||||||
|
|
||||||
|
function parameterName(prefix: string, name: string): string {
|
||||||
|
if (!prefix) return name;
|
||||||
|
return `${prefix}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validOption<T extends string>(
|
||||||
|
value: string | null,
|
||||||
|
options: ReadonlyArray<{ value: "" | T }>,
|
||||||
|
): "" | T {
|
||||||
|
return options.some((option) => option.value === value) ? (value as T) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLedgerFilterState(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
prefix = "",
|
||||||
|
): LedgerFilterState {
|
||||||
|
const read = (name: string) => searchParams.get(parameterName(prefix, name));
|
||||||
|
const from = read("from") ?? "";
|
||||||
|
const until = read("until") ?? "";
|
||||||
|
const storedRange = read("range") as LedgerDatePreset | null;
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
until,
|
||||||
|
type: validOption(read("type"), ledgerActionOptions),
|
||||||
|
entryType: validOption(read("entryType"), ledgerEntryTypeOptions),
|
||||||
|
usageType: validOption(read("usageType"), ledgerUsageOptions),
|
||||||
|
referenceId: read("referenceId") ?? "",
|
||||||
|
range: storedRange && ranges.has(storedRange) ? storedRange : from || until ? "custom" : "all",
|
||||||
|
sort: sorts.has(read("sort") as LedgerFilterState["sort"])
|
||||||
|
? (read("sort") as LedgerFilterState["sort"])
|
||||||
|
: "createdAt",
|
||||||
|
order: orders.has(read("order") as SortOrder) ? (read("order") as SortOrder) : "desc",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ledgerDatePreset(
|
||||||
|
preset: Exclude<LedgerDatePreset, "custom">,
|
||||||
|
now = new Date(),
|
||||||
|
): Pick<LedgerFilterState, "from" | "until" | "range"> {
|
||||||
|
if (preset === "all") return { from: "", until: "", range: "all" };
|
||||||
|
const today = new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||||
|
);
|
||||||
|
const until = new Date(today);
|
||||||
|
until.setUTCDate(until.getUTCDate() + 1);
|
||||||
|
const from = new Date(today);
|
||||||
|
const days = preset === "today" ? 1 : Number.parseInt(preset, 10);
|
||||||
|
from.setUTCDate(from.getUTCDate() - (days - 1));
|
||||||
|
return { from: from.toISOString(), until: until.toISOString(), range: preset };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLedgerFilters(prefix = "") {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [state, setState] = useState<LedgerFilterState>(() =>
|
||||||
|
readLedgerFilterState(searchParams, prefix),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchParams((current) => {
|
||||||
|
const params = new URLSearchParams(current);
|
||||||
|
const keys: Array<keyof LedgerFilterState> = [
|
||||||
|
"from",
|
||||||
|
"until",
|
||||||
|
"type",
|
||||||
|
"entryType",
|
||||||
|
"usageType",
|
||||||
|
"referenceId",
|
||||||
|
"range",
|
||||||
|
"sort",
|
||||||
|
"order",
|
||||||
|
];
|
||||||
|
keys.forEach((key) => params.delete(parameterName(prefix, key)));
|
||||||
|
if (state.from) params.set(parameterName(prefix, "from"), state.from);
|
||||||
|
if (state.until) params.set(parameterName(prefix, "until"), state.until);
|
||||||
|
if (state.type) params.set(parameterName(prefix, "type"), state.type);
|
||||||
|
if (state.entryType) params.set(parameterName(prefix, "entryType"), state.entryType);
|
||||||
|
if (state.usageType) params.set(parameterName(prefix, "usageType"), state.usageType);
|
||||||
|
if (state.referenceId) {
|
||||||
|
params.set(parameterName(prefix, "referenceId"), state.referenceId);
|
||||||
|
}
|
||||||
|
if (state.range !== "all") params.set(parameterName(prefix, "range"), state.range);
|
||||||
|
if (state.sort !== "createdAt") params.set(parameterName(prefix, "sort"), state.sort);
|
||||||
|
if (state.order !== "desc") params.set(parameterName(prefix, "order"), state.order);
|
||||||
|
return params;
|
||||||
|
}, { replace: true });
|
||||||
|
}, [prefix, setSearchParams, state]);
|
||||||
|
|
||||||
|
const query = useMemo<LedgerQuery>(
|
||||||
|
() => ({
|
||||||
|
from: state.from || undefined,
|
||||||
|
until: state.until || undefined,
|
||||||
|
type: state.type || undefined,
|
||||||
|
entryType: state.entryType || undefined,
|
||||||
|
usageType: state.usageType || undefined,
|
||||||
|
referenceId: state.referenceId.trim() || undefined,
|
||||||
|
sort: state.sort,
|
||||||
|
order: state.order,
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
[state],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
query,
|
||||||
|
setState,
|
||||||
|
update: (patch: Partial<LedgerFilterState>) =>
|
||||||
|
setState((current) => ({ ...current, ...patch })),
|
||||||
|
clear: () => setState(defaultState),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Coins,
|
||||||
|
CreditCard,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { adminApi } from "../../api/client";
|
||||||
|
import type { Overview, SortOrder } from "../../api/types";
|
||||||
|
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||||
|
import { ChartLegend } from "../../components/charts/chart-legend";
|
||||||
|
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||||
|
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||||
|
import { TrendChart } from "../../components/charts/trend-chart";
|
||||||
|
import { ToggleFilter } from "../../components/filter-control";
|
||||||
|
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
|
||||||
|
import { PeriodCaption } from "../../components/period-caption";
|
||||||
|
import { RangeControl } from "../../components/range-control";
|
||||||
|
import { SortControl } from "../../components/sort-control";
|
||||||
|
import { formatNumber, usageTypeLabel } from "../../lib/format";
|
||||||
|
import { stableSort } from "../../lib/sort";
|
||||||
|
|
||||||
|
export function OverviewPage() {
|
||||||
|
const [range, setRange] = useState("30d");
|
||||||
|
const [data, setData] = useState<Overview>();
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const [showRegistrations, setShowRegistrations] = useState(true);
|
||||||
|
const [showCredits, setShowCredits] = useState(true);
|
||||||
|
const [usageSort, setUsageSort] = useState<"credits" | "requests">("credits");
|
||||||
|
const [usageOrder, setUsageOrder] = useState<SortOrder>("desc");
|
||||||
|
const requestVersion = useRef(0);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const version = ++requestVersion.current;
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await adminApi.overview(range);
|
||||||
|
if (requestVersion.current === version) setData(response);
|
||||||
|
} catch (requestError) {
|
||||||
|
if (requestVersion.current === version) setError(requestError);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const sortedUsage = useMemo(
|
||||||
|
() =>
|
||||||
|
stableSort(
|
||||||
|
data?.usage ?? [],
|
||||||
|
(item) => (usageSort === "credits" ? item.chargedCredits : item.requests),
|
||||||
|
usageOrder,
|
||||||
|
),
|
||||||
|
[data?.usage, usageOrder, usageSort],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||||
|
if (!data) return <LoadingState label="加载运营总览" />;
|
||||||
|
|
||||||
|
const activeRate =
|
||||||
|
data.totalUsers > 0 ? Math.round((data.activeUsers / data.totalUsers) * 100) : null;
|
||||||
|
const usageRequests = data.usage.reduce((sum, item) => sum + item.requests, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="核心指标"
|
||||||
|
title="运营总览"
|
||||||
|
description="活跃用户指周期内成功使用 AI 或产生手动键盘输入的注册用户。"
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<RangeControl value={range} onChange={setRange} />
|
||||||
|
<PeriodCaption from={data.period.from} until={data.period.until} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="关键指标">
|
||||||
|
<StatCard
|
||||||
|
label="用户总数"
|
||||||
|
value={formatNumber(data.totalUsers)}
|
||||||
|
hint={`当前周期新增 ${formatNumber(data.newUsers)}`}
|
||||||
|
icon={Users}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="活跃用户"
|
||||||
|
value={formatNumber(data.activeUsers)}
|
||||||
|
hint={`活跃率 ${activeRate == null ? "—" : `${activeRate}%`}`}
|
||||||
|
icon={Activity}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="积分消耗"
|
||||||
|
value={formatNumber(data.creditsUsed)}
|
||||||
|
hint={`${formatNumber(usageRequests)} 次已结算请求`}
|
||||||
|
icon={CreditCard}
|
||||||
|
tone="violet"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="积分余额"
|
||||||
|
value={formatNumber(data.totalCreditBalance)}
|
||||||
|
hint={`周期赠送 ${formatNumber(data.creditsGranted)}`}
|
||||||
|
icon={Coins}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[minmax(0,1.65fr)_minmax(280px,0.65fr)]">
|
||||||
|
<Card className="min-w-0 p-5 sm:p-6">
|
||||||
|
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-foreground">增长与消耗趋势</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
按 UTC 日期统计,恰好覆盖所选日期数;今日数据尚未完整
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChartLegend
|
||||||
|
items={[
|
||||||
|
...(showRegistrations ? [{ label: "新增用户", tone: "primary" as const }] : []),
|
||||||
|
...(showCredits ? [{ label: "积分消耗", tone: "violet" as const }] : []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2" aria-label="趋势系列显隐">
|
||||||
|
<ToggleFilter
|
||||||
|
label="新增用户"
|
||||||
|
checked={showRegistrations}
|
||||||
|
onChange={setShowRegistrations}
|
||||||
|
/>
|
||||||
|
<ToggleFilter
|
||||||
|
label="积分消耗"
|
||||||
|
checked={showCredits}
|
||||||
|
onChange={setShowCredits}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-[320px] w-full">
|
||||||
|
<TrendChart
|
||||||
|
points={data.trend}
|
||||||
|
showRegistrations={showRegistrations}
|
||||||
|
showCredits={showCredits}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="border-b border-border p-5 sm:p-6">
|
||||||
|
<h2 className="text-base font-bold text-foreground">运营效率</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">活跃覆盖与积分流转对比</p>
|
||||||
|
<div className="mt-5">
|
||||||
|
<RadialMetric
|
||||||
|
label="用户活跃率"
|
||||||
|
percent={activeRate}
|
||||||
|
detail={`${formatNumber(data.activeUsers)} / ${formatNumber(data.totalUsers)} 位用户活跃`}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{ label: "周期赠送", value: data.creditsGranted },
|
||||||
|
{ label: "周期消耗", value: data.creditsUsed },
|
||||||
|
]}
|
||||||
|
primaryLabel="积分"
|
||||||
|
primaryTone="warning"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-foreground">使用构成</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">仅聚合计量数据,不包含任何用户内容</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChartToolbar label="使用构成排序">
|
||||||
|
<SortControl
|
||||||
|
value={usageSort}
|
||||||
|
order={usageOrder}
|
||||||
|
options={[
|
||||||
|
{ value: "credits", label: "消耗积分" },
|
||||||
|
{ value: "requests", label: "请求次数" },
|
||||||
|
]}
|
||||||
|
onChange={(value, order) => {
|
||||||
|
setUsageSort(value);
|
||||||
|
setUsageOrder(order);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartToolbar>
|
||||||
|
{data.usage.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-muted">当前周期暂无使用记录</div>
|
||||||
|
) : (
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={sortedUsage.map((item) => ({
|
||||||
|
label: usageTypeLabel(item.kind),
|
||||||
|
value: item.chargedCredits,
|
||||||
|
hint: `${formatNumber(item.requests)} 次请求`,
|
||||||
|
}))}
|
||||||
|
primaryLabel="已消耗积分"
|
||||||
|
primaryTone="violet"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { Eye, EyeOff, KeyRound, RefreshCw, ServerCog } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { adminApi, ApiError } from "../../api/client";
|
||||||
|
import type {
|
||||||
|
ManagedProviderId,
|
||||||
|
ManagedProviderOverview,
|
||||||
|
ManagedProviderStatus,
|
||||||
|
} from "../../api/types";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Dialog,
|
||||||
|
ErrorState,
|
||||||
|
Input,
|
||||||
|
LoadingState,
|
||||||
|
PageHeader,
|
||||||
|
} from "../../components/primitives";
|
||||||
|
import { formatDateTime } from "../../lib/format";
|
||||||
|
|
||||||
|
const PROVIDERS: Array<{
|
||||||
|
id: ManagedProviderId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "deepseek",
|
||||||
|
name: "DeepSeek",
|
||||||
|
description: "用于润色、AI 和 Agent 等托管文本能力。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "volcengine",
|
||||||
|
name: "火山引擎",
|
||||||
|
description: "用于托管语音识别能力,仅替换 API Key。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProvidersPage() {
|
||||||
|
const [overview, setOverview] = useState<ManagedProviderOverview>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
setOverview(await adminApi.providers());
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const updateStatus = useCallback((updated: ManagedProviderStatus) => {
|
||||||
|
setOverview((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
const providers = current.filter(
|
||||||
|
(item) => item.providerId !== updated.providerId,
|
||||||
|
);
|
||||||
|
return [...providers, updated];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <LoadingState label="加载 Provider 配置" />;
|
||||||
|
if (error || !overview) return <ErrorState error={error} retry={() => void load()} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Managed Providers"
|
||||||
|
title="Provider 配置"
|
||||||
|
description="查看或替换托管网关使用的上游 API Key。新配置只影响之后开始的请求。"
|
||||||
|
actions={
|
||||||
|
<Button variant="secondary" onClick={() => void load()}>
|
||||||
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
|
刷新状态
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="border-warning/25 bg-warning-soft/30 p-5 text-sm leading-6 text-foreground sm:p-6">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-xl bg-warning-soft text-warning">
|
||||||
|
<KeyRound className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<p>
|
||||||
|
查看当前 Key 需要再次验证动态验证码;查看与替换都会写入审计日志,但不会记录密钥内容。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
{PROVIDERS.map((provider) => (
|
||||||
|
<ProviderCard
|
||||||
|
key={provider.id}
|
||||||
|
providerId={provider.id}
|
||||||
|
name={provider.name}
|
||||||
|
description={provider.description}
|
||||||
|
status={overview.find((item) => item.providerId === provider.id)}
|
||||||
|
onUpdated={updateStatus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderCard({
|
||||||
|
providerId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
onUpdated,
|
||||||
|
}: {
|
||||||
|
providerId: ManagedProviderId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status?: ManagedProviderStatus;
|
||||||
|
onUpdated: (status: ManagedProviderStatus) => void;
|
||||||
|
}) {
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [currentApiKey, setCurrentApiKey] = useState<string>();
|
||||||
|
const [revealOpen, setRevealOpen] = useState(false);
|
||||||
|
const [totpCode, setTotpCode] = useState("");
|
||||||
|
const [revealError, setRevealError] = useState("");
|
||||||
|
const [revealing, setRevealing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
function toggleCurrentApiKey() {
|
||||||
|
if (currentApiKey) {
|
||||||
|
setCurrentApiKey(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTotpCode("");
|
||||||
|
setRevealError("");
|
||||||
|
setRevealOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRevealOpenChange(open: boolean) {
|
||||||
|
setRevealOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setTotpCode("");
|
||||||
|
setRevealError("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reveal(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!/^\d{6}$/.test(totpCode)) {
|
||||||
|
setRevealError("请输入 6 位动态验证码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRevealing(true);
|
||||||
|
setRevealError("");
|
||||||
|
try {
|
||||||
|
const response = await adminApi.revealProviderApiKey(providerId, { totpCode });
|
||||||
|
setCurrentApiKey(response.apiKey);
|
||||||
|
handleRevealOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
setRevealError(error instanceof ApiError ? error.message : "当前 API Key 读取失败");
|
||||||
|
} finally {
|
||||||
|
setRevealing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const normalized = apiKey.trim();
|
||||||
|
if (!validApiKey(normalized)) {
|
||||||
|
toast.error("API Key 必须为 1–512 个字符,且不能包含换行");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`确定替换 ${name} API Key?保存后,新请求将立即使用该 Key。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.updateProviderApiKey(providerId, {
|
||||||
|
apiKey: normalized,
|
||||||
|
});
|
||||||
|
setApiKey("");
|
||||||
|
setCurrentApiKey((current) => (current ? normalized : undefined));
|
||||||
|
onUpdated(updated);
|
||||||
|
toast.success(`${name} API Key 已替换`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : `${name} API Key 替换失败`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="border-b border-border bg-surface-muted/35 p-5 sm:p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-primary-soft text-primary">
|
||||||
|
<ServerCog className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-lg font-bold">{name}</h2>
|
||||||
|
<Badge tone={status?.configured ? "success" : "danger"}>
|
||||||
|
{status?.configured ? "已配置" : "未配置"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-5 grid gap-3 text-sm sm:grid-cols-2">
|
||||||
|
<StatusItem label="当前来源" value={sourceLabel(status)} />
|
||||||
|
<StatusItem
|
||||||
|
label="最后替换"
|
||||||
|
value={status?.updatedAt ? formatDateTime(status.updatedAt) : "随服务器部署"}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b border-border p-5 sm:p-6">
|
||||||
|
<label
|
||||||
|
className="block text-sm font-semibold"
|
||||||
|
htmlFor={`${providerId}-current-api-key`}
|
||||||
|
>
|
||||||
|
<span className="mb-2 block">{name} 当前 API Key</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id={`${providerId}-current-api-key`}
|
||||||
|
className="pr-12 font-mono"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
readOnly
|
||||||
|
spellCheck={false}
|
||||||
|
value={
|
||||||
|
status?.configured
|
||||||
|
? currentApiKey ?? "••••••••••••••••"
|
||||||
|
: "未配置"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="absolute right-0.5 top-0.5"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!status?.configured || revealing}
|
||||||
|
aria-label={currentApiKey ? `隐藏 ${name} 当前 API Key` : `显示 ${name} 当前 API Key`}
|
||||||
|
onClick={toggleCurrentApiKey}
|
||||||
|
>
|
||||||
|
{currentApiKey ? (
|
||||||
|
<EyeOff className="size-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Eye className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-muted">
|
||||||
|
点击眼睛并通过动态验证码验证后显示;再次点击或离开页面时隐藏。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="space-y-4 p-5 sm:p-6" onSubmit={(event) => void submit(event)}>
|
||||||
|
<label className="block text-sm font-semibold" htmlFor={`${providerId}-api-key`}>
|
||||||
|
<span className="mb-2 block">{name} 新 API Key</span>
|
||||||
|
<Input
|
||||||
|
id={`${providerId}-api-key`}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
maxLength={512}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(event) => setApiKey(event.target.value)}
|
||||||
|
placeholder={`输入新的 ${name} API Key`}
|
||||||
|
aria-describedby={`${providerId}-api-key-help`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p id={`${providerId}-api-key-help`} className="text-xs leading-5 text-muted">
|
||||||
|
留空不会修改配置。若当前 Key 已显示,替换成功后会同步显示新 Key。
|
||||||
|
</p>
|
||||||
|
<Button type="submit" loading={saving} disabled={!apiKey.trim()}>
|
||||||
|
<KeyRound className="size-4" aria-hidden />
|
||||||
|
替换 {name} API Key
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={revealOpen}
|
||||||
|
onOpenChange={handleRevealOpenChange}
|
||||||
|
title={`验证后显示 ${name} API Key`}
|
||||||
|
description="请输入当前管理员的 6 位动态验证码。本次查看会写入审计日志。"
|
||||||
|
>
|
||||||
|
<form className="space-y-5" onSubmit={(event) => void reveal(event)} noValidate>
|
||||||
|
<label className="block text-sm font-semibold" htmlFor={`${providerId}-reveal-totp`}>
|
||||||
|
<span className="mb-2 block">动态验证码</span>
|
||||||
|
<Input
|
||||||
|
id={`${providerId}-reveal-totp`}
|
||||||
|
className="text-center text-lg font-semibold tracking-[0.35em]"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
maxLength={6}
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, ""))}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{revealError ? (
|
||||||
|
<p className="text-sm text-danger" role="alert">
|
||||||
|
{revealError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleRevealOpenChange(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={revealing} disabled={totpCode.length !== 6}>
|
||||||
|
验证并显示
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold text-muted">{label}</dt>
|
||||||
|
<dd className="mt-1 font-medium text-foreground">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(status?: ManagedProviderStatus): string {
|
||||||
|
if (!status?.configured) return "尚未配置";
|
||||||
|
return status.source === "RUNTIME_OVERRIDE" ? "管理后台" : "环境变量";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validApiKey(value: string): boolean {
|
||||||
|
return (
|
||||||
|
value.length >= 1 &&
|
||||||
|
value.length <= 512 &&
|
||||||
|
!value.includes("\n") &&
|
||||||
|
!value.includes("\r")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { adminApi } from "../../api/client";
|
||||||
|
import type {
|
||||||
|
ReferralOverview,
|
||||||
|
ReferralRankingItem,
|
||||||
|
ReferralsQuery,
|
||||||
|
SortOrder,
|
||||||
|
} from "../../api/types";
|
||||||
|
import { ChartToolbar } from "../../components/chart-toolbar";
|
||||||
|
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
|
||||||
|
import { FunnelChart } from "../../components/charts/funnel-chart";
|
||||||
|
import { RadialMetric } from "../../components/charts/radial-metric";
|
||||||
|
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||||
|
import { FilterControl } from "../../components/filter-control";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
ErrorState,
|
||||||
|
LoadingState,
|
||||||
|
PageHeader,
|
||||||
|
StatCard,
|
||||||
|
} from "../../components/primitives";
|
||||||
|
import { PeriodCaption } from "../../components/period-caption";
|
||||||
|
import { RangeControl } from "../../components/range-control";
|
||||||
|
import { SortControl } from "../../components/sort-control";
|
||||||
|
import { formatNumber } from "../../lib/format";
|
||||||
|
|
||||||
|
export function ReferralsPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [range, setRange] = useState(searchParams.get("range") ?? "30d");
|
||||||
|
const [sort, setSort] = useState<NonNullable<ReferralsQuery["sort"]>>(
|
||||||
|
(searchParams.get("sort") as ReferralsQuery["sort"] | null) ?? "qualified",
|
||||||
|
);
|
||||||
|
const [order, setOrder] = useState<SortOrder>(
|
||||||
|
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||||
|
);
|
||||||
|
const [limit, setLimit] = useState(() => {
|
||||||
|
const value = Number(searchParams.get("limit"));
|
||||||
|
return [20, 50, 100].includes(value) ? value : 20;
|
||||||
|
});
|
||||||
|
const [data, setData] = useState<ReferralOverview>();
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const requestVersion = useRef(0);
|
||||||
|
const query = useMemo<ReferralsQuery>(
|
||||||
|
() => ({ range, sort, order, limit }),
|
||||||
|
[limit, order, range, sort],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const version = ++requestVersion.current;
|
||||||
|
setError(undefined);
|
||||||
|
setData(undefined);
|
||||||
|
try {
|
||||||
|
const response = await adminApi.referrals(query);
|
||||||
|
if (requestVersion.current === version) setData(response);
|
||||||
|
} catch (requestError) {
|
||||||
|
if (requestVersion.current === version) setError(requestError);
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams({ range, sort, order, limit: String(limit) });
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
}, [limit, order, range, setSearchParams, sort]);
|
||||||
|
|
||||||
|
const columns = useMemo<DataColumn<ReferralRankingItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "rank",
|
||||||
|
header: "名次",
|
||||||
|
cell: ({ row }) => <Rank value={row.index + 1} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "userId",
|
||||||
|
header: "用户 ID",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-mono text-xs text-muted">{String(getValue())}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "invited",
|
||||||
|
header: "邀请",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="tabular-nums">{formatNumber(Number(getValue()))}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "qualified",
|
||||||
|
header: "有效",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-semibold tabular-nums text-success">
|
||||||
|
{formatNumber(Number(getValue()))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "creditsEarned",
|
||||||
|
header: "奖励积分",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{formatNumber(Number(getValue()))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) return <ErrorState error={error} retry={() => void load()} />;
|
||||||
|
if (!data) return <LoadingState label="加载裂变分析" />;
|
||||||
|
|
||||||
|
const first = data.funnel.at(0)?.count ?? 0;
|
||||||
|
const last = data.funnel.at(-1)?.count ?? 0;
|
||||||
|
const conversion = first > 0 ? Math.round((last / first) * 100) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="增长分析"
|
||||||
|
title="裂变与排行"
|
||||||
|
description="奖励以有效使用为前提,关注真实转化而不是单纯注册量。"
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<RangeControl value={range} onChange={setRange} />
|
||||||
|
<PeriodCaption from={data.period.from} until={data.period.until} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
label="待资格确认"
|
||||||
|
value={formatNumber(data.pendingBindings)}
|
||||||
|
hint="等待首次有效使用"
|
||||||
|
icon={Clock3}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="未达奖励条件"
|
||||||
|
value={formatNumber(data.ineligibleBindings)}
|
||||||
|
hint="未产生积分奖励"
|
||||||
|
icon={CircleOff}
|
||||||
|
tone="violet"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="漏斗转化率"
|
||||||
|
value={conversion == null ? "—" : `${conversion}%`}
|
||||||
|
hint="首环节至最终有效使用"
|
||||||
|
icon={TrendingUp}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<ChartToolbar label="邀请排行设置">
|
||||||
|
<SortControl
|
||||||
|
label="排行指标"
|
||||||
|
value={sort}
|
||||||
|
order={order}
|
||||||
|
options={[
|
||||||
|
{ value: "invited", label: "邀请人数" },
|
||||||
|
{ value: "qualified", label: "有效邀请" },
|
||||||
|
{ value: "creditsEarned", label: "奖励积分" },
|
||||||
|
]}
|
||||||
|
onChange={(value, nextOrder) => {
|
||||||
|
setSort(value);
|
||||||
|
setOrder(nextOrder);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="排行数量"
|
||||||
|
value={String(limit)}
|
||||||
|
options={[
|
||||||
|
{ value: "20", label: "前 20 名" },
|
||||||
|
{ value: "50", label: "前 50 名" },
|
||||||
|
{ value: "100", label: "前 100 名" },
|
||||||
|
]}
|
||||||
|
onChange={(value) => setLimit(Number(value))}
|
||||||
|
/>
|
||||||
|
</ChartToolbar>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border p-5 sm:p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">裂变漏斗</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">同一批绑定用户从首次 AI 成功到完成奖励</p>
|
||||||
|
</div>
|
||||||
|
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
|
||||||
|
<GitBranch className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border p-5 sm:p-6">
|
||||||
|
<RadialMetric
|
||||||
|
label="整体有效转化"
|
||||||
|
percent={conversion}
|
||||||
|
detail={`${formatNumber(first)} 个绑定,最终形成 ${formatNumber(last)} 次奖励`}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FunnelChart steps={data.funnel} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">头部邀请贡献</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
当前请求结果 Top 8;完整 {limit} 名见下方明细
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
|
||||||
|
<Award className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={data.ranking.slice(0, 8).map((item, index) => ({
|
||||||
|
label: `第 ${index + 1} 名 · ${shortUserId(item.userId)}`,
|
||||||
|
value: item.invited,
|
||||||
|
secondaryValue: item.qualified,
|
||||||
|
hint: `${formatNumber(item.creditsEarned)} 奖励积分`,
|
||||||
|
}))}
|
||||||
|
primaryLabel="邀请"
|
||||||
|
secondaryLabel="有效"
|
||||||
|
primaryTone="warning"
|
||||||
|
secondaryTone="success"
|
||||||
|
emptyText="当前周期暂无排行数据"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">邀请排行明细</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">保留完整用户维度,便于运营核对</p>
|
||||||
|
</div>
|
||||||
|
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
|
||||||
|
<Award className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={data.ranking}
|
||||||
|
columns={columns}
|
||||||
|
caption="有效邀请用户排行"
|
||||||
|
emptyTitle="当前周期暂无排行数据"
|
||||||
|
sort={{ key: sort, order }}
|
||||||
|
sortableColumns={{
|
||||||
|
invited: "invited",
|
||||||
|
qualified: "qualified",
|
||||||
|
creditsEarned: "creditsEarned",
|
||||||
|
}}
|
||||||
|
onSortChange={(key, nextOrder) => {
|
||||||
|
setSort(key as NonNullable<ReferralsQuery["sort"]>);
|
||||||
|
setOrder(nextOrder);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:p-6">
|
||||||
|
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-success-soft text-success">
|
||||||
|
<Users className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">奖励资格保护已启用</h2>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted">
|
||||||
|
注册不会直接触发主要奖励,只有达到有效使用条件后才会进入奖励结算。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rank({ value }: { value: number }) {
|
||||||
|
const tone =
|
||||||
|
value === 1
|
||||||
|
? "bg-warning-soft text-warning"
|
||||||
|
: value === 2
|
||||||
|
? "bg-surface-muted text-muted"
|
||||||
|
: value === 3
|
||||||
|
? "bg-danger-soft text-danger"
|
||||||
|
: "bg-surface-muted text-muted";
|
||||||
|
return (
|
||||||
|
<span className={`grid size-7 place-items-center rounded-lg text-xs font-bold ${tone}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortUserId(userId: string): string {
|
||||||
|
return userId.length <= 12 ? userId : `${userId.slice(0, 6)}…${userId.slice(-4)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,831 @@
|
|||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
Check,
|
||||||
|
Clipboard,
|
||||||
|
KeyRound,
|
||||||
|
LockKeyhole,
|
||||||
|
Plus,
|
||||||
|
RotateCcwKey,
|
||||||
|
ShieldCheck,
|
||||||
|
Unlock,
|
||||||
|
UserCheck,
|
||||||
|
UsersRound,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
} from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { adminApi, ApiError } from "../../api/client";
|
||||||
|
import type {
|
||||||
|
AdminOperator,
|
||||||
|
AdminOperatorProvisioning,
|
||||||
|
AdminRole,
|
||||||
|
AdminSecuritySummary,
|
||||||
|
OperatorsQuery,
|
||||||
|
SortOrder,
|
||||||
|
} from "../../api/types";
|
||||||
|
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||||
|
import { DateRangeControl } from "../../components/date-range-control";
|
||||||
|
import { FilterControl } from "../../components/filter-control";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Dialog,
|
||||||
|
ErrorState,
|
||||||
|
Input,
|
||||||
|
LoadingState,
|
||||||
|
PageHeader,
|
||||||
|
StatCard,
|
||||||
|
} from "../../components/primitives";
|
||||||
|
import { TableToolbar } from "../../components/table-toolbar";
|
||||||
|
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||||
|
import { formatDateTime, formatNumber } from "../../lib/format";
|
||||||
|
import { useAuth } from "../auth/auth-context";
|
||||||
|
|
||||||
|
type OperatorAction = "enable" | "disable" | "unlock" | "sessions";
|
||||||
|
|
||||||
|
interface ConfirmationState {
|
||||||
|
operator: AdminOperator;
|
||||||
|
action: OperatorAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecurityPage() {
|
||||||
|
const { auth } = useAuth();
|
||||||
|
const currentUsername = auth.status === "authenticated" ? auth.operatorName : "";
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [summary, setSummary] = useState<AdminSecuritySummary>();
|
||||||
|
const [summaryError, setSummaryError] = useState<unknown>();
|
||||||
|
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||||
|
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||||
|
const [role, setRole] = useState<"" | AdminRole>(
|
||||||
|
(searchParams.get("role") as AdminRole | null) ?? "",
|
||||||
|
);
|
||||||
|
const [enabled, setEnabled] = useState(searchParams.get("enabled") ?? "");
|
||||||
|
const [locked, setLocked] = useState(searchParams.get("locked") ?? "");
|
||||||
|
const [sort, setSort] = useState<NonNullable<OperatorsQuery["sort"]>>(
|
||||||
|
(searchParams.get("sort") as OperatorsQuery["sort"] | null) ?? "createdAt",
|
||||||
|
);
|
||||||
|
const [order, setOrder] = useState<SortOrder>(
|
||||||
|
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||||
|
);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [resetOperator, setResetOperator] = useState<AdminOperator>();
|
||||||
|
const [confirmation, setConfirmation] = useState<ConfirmationState>();
|
||||||
|
const [provisioning, setProvisioning] = useState<{
|
||||||
|
data: AdminOperatorProvisioning;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
const operatorsQuery = useMemo<OperatorsQuery>(
|
||||||
|
() => ({
|
||||||
|
from: from || undefined,
|
||||||
|
until: until || undefined,
|
||||||
|
role: role || undefined,
|
||||||
|
enabled: enabled === "" ? undefined : enabled === "true",
|
||||||
|
locked: locked === "" ? undefined : locked === "true",
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
[enabled, from, locked, order, role, sort, until],
|
||||||
|
);
|
||||||
|
const fetchOperators = useCallback(
|
||||||
|
(value: OperatorsQuery) => adminApi.operators(value),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
items: operators,
|
||||||
|
nextCursor,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
loadMore,
|
||||||
|
reload,
|
||||||
|
} = useCursorPage(operatorsQuery, fetchOperators);
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
setSummaryError(undefined);
|
||||||
|
try {
|
||||||
|
setSummary(await adminApi.operatorSummary());
|
||||||
|
} catch (requestError) {
|
||||||
|
setSummaryError(requestError);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
reload();
|
||||||
|
await loadSummary();
|
||||||
|
}, [loadSummary, reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSummary();
|
||||||
|
}, [loadSummary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (from) params.set("from", from);
|
||||||
|
if (until) params.set("until", until);
|
||||||
|
if (role) params.set("role", role);
|
||||||
|
if (enabled) params.set("enabled", enabled);
|
||||||
|
if (locked) params.set("locked", locked);
|
||||||
|
params.set("sort", sort);
|
||||||
|
params.set("order", order);
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
}, [enabled, from, locked, order, role, setSearchParams, sort, until]);
|
||||||
|
|
||||||
|
async function handleConfirmedAction() {
|
||||||
|
if (!confirmation) return;
|
||||||
|
const { operator, action } = confirmation;
|
||||||
|
setConfirmation(undefined);
|
||||||
|
try {
|
||||||
|
if (action === "enable") {
|
||||||
|
await adminApi.setOperatorEnabled(operator.operatorId, true);
|
||||||
|
} else if (action === "disable") {
|
||||||
|
await adminApi.setOperatorEnabled(operator.operatorId, false);
|
||||||
|
} else if (action === "unlock") {
|
||||||
|
await adminApi.unlockOperator(operator.operatorId);
|
||||||
|
} else {
|
||||||
|
await adminApi.revokeOperatorSessions(operator.operatorId);
|
||||||
|
}
|
||||||
|
toast.success("安全设置已更新");
|
||||||
|
await load();
|
||||||
|
} catch (requestError) {
|
||||||
|
toast.error(
|
||||||
|
requestError instanceof ApiError ? requestError.message : "安全设置更新失败",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<DataColumn<AdminOperator>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "username",
|
||||||
|
header: "管理员",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const current = row.original.username === currentUsername;
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
|
{row.original.username}
|
||||||
|
{current ? <Badge>当前</Badge> : null}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block font-mono text-[11px] text-muted">
|
||||||
|
{row.original.operatorId}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "role",
|
||||||
|
header: "角色",
|
||||||
|
cell: ({ getValue }) => <RoleBadge role={String(getValue()) as AdminRole} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "状态",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const locked =
|
||||||
|
Boolean(row.original.lockedUntil) &&
|
||||||
|
new Date(row.original.lockedUntil ?? "").getTime() > Date.now();
|
||||||
|
return (
|
||||||
|
<span className="flex gap-1.5">
|
||||||
|
<Badge tone={row.original.enabled ? "success" : "danger"}>
|
||||||
|
{row.original.enabled ? "已启用" : "已停用"}
|
||||||
|
</Badge>
|
||||||
|
{locked ? <Badge tone="warning">已锁定</Badge> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "lastLoginAt",
|
||||||
|
header: "最近登录",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted">
|
||||||
|
{formatDateTime(getValue() ? String(getValue()) : undefined)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: "创建时间",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted">
|
||||||
|
{formatDateTime(String(getValue()))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "操作",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<OperatorActions
|
||||||
|
operator={row.original}
|
||||||
|
currentUsername={currentUsername}
|
||||||
|
onAction={(action) => setConfirmation({ operator: row.original, action })}
|
||||||
|
onReset={() => setResetOperator(row.original)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[currentUsername],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error || summaryError) {
|
||||||
|
return <ErrorState error={error ?? summaryError} retry={() => void load()} />;
|
||||||
|
}
|
||||||
|
if (loading || !summary) return <LoadingState label="加载安全中心" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="访问控制"
|
||||||
|
title="安全中心"
|
||||||
|
description="管理运营人员、角色、登录锁定、活动会话与双重认证。"
|
||||||
|
actions={
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="size-4" aria-hidden />
|
||||||
|
添加管理员
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="flex flex-col gap-4 border-primary/15 bg-gradient-to-r from-primary-soft/80 to-surface p-5 sm:flex-row sm:items-center sm:p-6">
|
||||||
|
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-surface text-primary shadow-sm">
|
||||||
|
<ShieldCheck className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">双重安全边界已启用</h2>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted">
|
||||||
|
访问仍需受信客户端证书;所有管理员变更都会写入不可变审计日志。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="sm:ml-auto" tone="success">
|
||||||
|
<Check className="size-3.5" aria-hidden />
|
||||||
|
保护中
|
||||||
|
</Badge>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
label="已启用管理员"
|
||||||
|
value={formatNumber(summary.enabledOperators)}
|
||||||
|
hint="具备登录资格的账户"
|
||||||
|
icon={UserCheck}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="已锁定账户"
|
||||||
|
value={formatNumber(summary.lockedOperators)}
|
||||||
|
hint="等待解锁或锁定到期"
|
||||||
|
icon={LockKeyhole}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="活动会话"
|
||||||
|
value={formatNumber(summary.activeSessions)}
|
||||||
|
hint="尚未过期且未撤销"
|
||||||
|
icon={UsersRound}
|
||||||
|
tone="violet"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">管理员账户</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">已加载 {formatNumber(operators.length)} 个账户</p>
|
||||||
|
</div>
|
||||||
|
<KeyRound className="size-5 text-muted" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<TableToolbar
|
||||||
|
active={Boolean(
|
||||||
|
from ||
|
||||||
|
until ||
|
||||||
|
role ||
|
||||||
|
enabled ||
|
||||||
|
locked ||
|
||||||
|
sort !== "createdAt" ||
|
||||||
|
order !== "desc",
|
||||||
|
)}
|
||||||
|
onClear={() => {
|
||||||
|
setFrom("");
|
||||||
|
setUntil("");
|
||||||
|
setRole("");
|
||||||
|
setEnabled("");
|
||||||
|
setLocked("");
|
||||||
|
setSort("createdAt");
|
||||||
|
setOrder("desc");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DateRangeControl
|
||||||
|
value={{ from: from || undefined, until: until || undefined }}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFrom(value.from ?? "");
|
||||||
|
setUntil(value.until ?? "");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="角色"
|
||||||
|
value={role}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "全部角色" },
|
||||||
|
{ value: "SUPER_ADMIN", label: "超级管理员" },
|
||||||
|
{ value: "SUPPORT", label: "支持人员" },
|
||||||
|
{ value: "ANALYST", label: "分析员" },
|
||||||
|
]}
|
||||||
|
onChange={setRole}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="启用状态"
|
||||||
|
value={enabled}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "全部" },
|
||||||
|
{ value: "true", label: "已启用" },
|
||||||
|
{ value: "false", label: "已停用" },
|
||||||
|
]}
|
||||||
|
onChange={setEnabled}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="锁定状态"
|
||||||
|
value={locked}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "全部" },
|
||||||
|
{ value: "true", label: "已锁定" },
|
||||||
|
{ value: "false", label: "未锁定" },
|
||||||
|
]}
|
||||||
|
onChange={setLocked}
|
||||||
|
/>
|
||||||
|
</TableToolbar>
|
||||||
|
<DataTable
|
||||||
|
data={operators}
|
||||||
|
columns={columns}
|
||||||
|
caption="管理员账户与安全状态"
|
||||||
|
emptyTitle={
|
||||||
|
from || until || role || enabled || locked
|
||||||
|
? "没有符合当前筛选条件的管理员"
|
||||||
|
: "暂无管理员账户"
|
||||||
|
}
|
||||||
|
sort={{ key: sort, order }}
|
||||||
|
sortableColumns={{
|
||||||
|
username: "username",
|
||||||
|
lastLoginAt: "lastLoginAt",
|
||||||
|
createdAt: "createdAt",
|
||||||
|
}}
|
||||||
|
onSortChange={(key, nextOrder) => {
|
||||||
|
setSort(key as NonNullable<OperatorsQuery["sort"]>);
|
||||||
|
setOrder(nextOrder);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
nextCursor ? (
|
||||||
|
<div className="flex justify-center border-t border-border p-5">
|
||||||
|
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
|
||||||
|
加载更多管理员
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CreateOperatorDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onCreated={(data) => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setProvisioning({ data, title: "管理员已创建" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ResetCredentialsDialog
|
||||||
|
operator={resetOperator}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setResetOperator(undefined);
|
||||||
|
}}
|
||||||
|
onReset={(data) => {
|
||||||
|
setResetOperator(undefined);
|
||||||
|
setProvisioning({ data, title: "登录凭据已重置" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConfirmationDialog
|
||||||
|
state={confirmation}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setConfirmation(undefined);
|
||||||
|
}}
|
||||||
|
onConfirm={() => void handleConfirmedAction()}
|
||||||
|
/>
|
||||||
|
<ProvisioningDialog
|
||||||
|
value={provisioning}
|
||||||
|
onComplete={() => {
|
||||||
|
setProvisioning(undefined);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperatorActions({
|
||||||
|
operator,
|
||||||
|
currentUsername,
|
||||||
|
onAction,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
operator: AdminOperator;
|
||||||
|
currentUsername: string;
|
||||||
|
onAction: (action: OperatorAction) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}) {
|
||||||
|
const locked =
|
||||||
|
Boolean(operator.lockedUntil) &&
|
||||||
|
new Date(operator.lockedUntil ?? "").getTime() > Date.now();
|
||||||
|
const current = operator.username === currentUsername;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-md flex-wrap justify-end gap-2">
|
||||||
|
{locked ? (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => onAction("unlock")}>
|
||||||
|
<Unlock className="size-3.5" aria-hidden />
|
||||||
|
解锁
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => onAction("sessions")}>
|
||||||
|
撤销会话
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={onReset}>
|
||||||
|
<RotateCcwKey className="size-3.5" aria-hidden />
|
||||||
|
重置凭据
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={operator.enabled ? "danger" : "secondary"}
|
||||||
|
disabled={current && operator.enabled}
|
||||||
|
onClick={() => onAction(operator.enabled ? "disable" : "enable")}
|
||||||
|
>
|
||||||
|
{operator.enabled ? <Ban className="size-3.5" aria-hidden /> : null}
|
||||||
|
{operator.enabled ? "停用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateOperatorDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCreated: (data: AdminOperatorProvisioning) => void;
|
||||||
|
}) {
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const data = new FormData(form);
|
||||||
|
const username = data.get("username")?.toString().trim() ?? "";
|
||||||
|
const role = data.get("role")?.toString() as AdminRole;
|
||||||
|
const password = data.get("password")?.toString() ?? "";
|
||||||
|
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
|
||||||
|
if (
|
||||||
|
!/^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/.test(username) ||
|
||||||
|
!["SUPER_ADMIN", "SUPPORT", "ANALYST"].includes(role) ||
|
||||||
|
password.length < 12 ||
|
||||||
|
password !== confirmation
|
||||||
|
) {
|
||||||
|
setError("请检查用户名、角色及两次输入的密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await adminApi.createOperator({ username, role, password });
|
||||||
|
form.reset();
|
||||||
|
setSubmitting(false);
|
||||||
|
onCreated(result);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof ApiError ? requestError.message : "创建管理员失败");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setError("");
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
}}
|
||||||
|
title="添加管理员"
|
||||||
|
description="创建后,TOTP 密钥只显示一次,请准备安全交付。"
|
||||||
|
>
|
||||||
|
<form className="space-y-5" onSubmit={submit} noValidate>
|
||||||
|
<Field label="用户名">
|
||||||
|
<Input
|
||||||
|
name="username"
|
||||||
|
autoComplete="off"
|
||||||
|
minLength={3}
|
||||||
|
maxLength={64}
|
||||||
|
pattern="[A-Za-z0-9][A-Za-z0-9._@-]{2,63}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="角色">
|
||||||
|
<select
|
||||||
|
className="h-11 w-full rounded-xl border border-border bg-input px-3.5 text-sm outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
|
||||||
|
name="role"
|
||||||
|
defaultValue="ANALYST"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="ANALYST">分析员 · 仅统计</option>
|
||||||
|
<option value="SUPPORT">支持人员 · 用户与流水只读</option>
|
||||||
|
<option value="SUPER_ADMIN">超级管理员 · 完整权限</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="初始密码">
|
||||||
|
<Input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={12}
|
||||||
|
maxLength={128}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="确认密码">
|
||||||
|
<Input
|
||||||
|
name="passwordConfirmation"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={12}
|
||||||
|
maxLength={128}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="min-h-5 text-sm text-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={submitting}>
|
||||||
|
创建并生成 TOTP
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetCredentialsDialog({
|
||||||
|
operator,
|
||||||
|
onOpenChange,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
operator?: AdminOperator;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onReset: (data: AdminOperatorProvisioning) => void;
|
||||||
|
}) {
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!operator) return;
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
const password = data.get("password")?.toString() ?? "";
|
||||||
|
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
|
||||||
|
if (password.length < 12 || password !== confirmation) {
|
||||||
|
setError("请输入至少 12 位且两次一致的新密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await adminApi.resetOperatorCredentials(operator.operatorId, password);
|
||||||
|
setSubmitting(false);
|
||||||
|
onReset(result);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof ApiError ? requestError.message : "凭据重置失败");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(operator)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setError("");
|
||||||
|
onOpenChange(open);
|
||||||
|
}}
|
||||||
|
title="重置登录凭据"
|
||||||
|
description={`将为“${operator?.username ?? ""}”重置密码与 TOTP,并立即撤销全部会话。`}
|
||||||
|
>
|
||||||
|
<form className="space-y-5" onSubmit={submit}>
|
||||||
|
<Field label="新密码">
|
||||||
|
<Input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={12}
|
||||||
|
maxLength={128}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="确认新密码">
|
||||||
|
<Input
|
||||||
|
name="passwordConfirmation"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={12}
|
||||||
|
maxLength={128}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="min-h-5 text-sm text-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="danger" loading={submitting}>
|
||||||
|
重置并撤销会话
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmationDialog({
|
||||||
|
state,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
state?: ConfirmationState;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
if (!state) return null;
|
||||||
|
const content = confirmationContent(state.action, state.operator.username);
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={content.title}
|
||||||
|
description={content.message}
|
||||||
|
>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant={content.dangerous ? "danger" : "primary"} onClick={onConfirm}>
|
||||||
|
{content.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProvisioningDialog({
|
||||||
|
value,
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
value?: { data: AdminOperatorProvisioning; title: string };
|
||||||
|
onComplete: () => void;
|
||||||
|
}) {
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
async function copy(text: string, label: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(`${label}已复制,请妥善保管`);
|
||||||
|
} catch {
|
||||||
|
toast.error(`无法复制${label},请手动选择`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onOpenChange={() => undefined}
|
||||||
|
title={value.title}
|
||||||
|
description="TOTP 配置仅显示一次,请立即安全保存并交付给对应管理员。"
|
||||||
|
preventClose
|
||||||
|
className="max-w-xl"
|
||||||
|
>
|
||||||
|
<div className="rounded-2xl border border-warning/20 bg-warning-soft/60 p-5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.12em] text-warning">Base32 密钥</p>
|
||||||
|
<code className="mt-3 block select-all break-all font-mono text-base font-semibold tracking-wider">
|
||||||
|
{value.data.totpSecret}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void copy(value.data.totpSecret, "TOTP 密钥")}
|
||||||
|
>
|
||||||
|
<Clipboard className="size-3.5" aria-hidden />
|
||||||
|
复制密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
className="inline-flex min-h-10 items-center rounded-xl border border-border bg-surface px-4 text-sm font-semibold transition hover:bg-surface-muted"
|
||||||
|
href={value.data.otpauthUri}
|
||||||
|
>
|
||||||
|
在认证器中打开
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void copy(value.data.otpauthUri, "认证器配置链接")}
|
||||||
|
>
|
||||||
|
复制配置链接
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-border p-4 text-sm">
|
||||||
|
<input
|
||||||
|
className="mt-0.5 size-4 accent-primary"
|
||||||
|
type="checkbox"
|
||||||
|
checked={saved}
|
||||||
|
onChange={(event) => setSaved(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>我已安全保存密钥,理解关闭后无法再次查看</span>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
className="mt-5 w-full"
|
||||||
|
disabled={!saved}
|
||||||
|
onClick={() => {
|
||||||
|
setSaved(false);
|
||||||
|
onComplete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmationContent(action: OperatorAction, username: string) {
|
||||||
|
const target = `“${username}”`;
|
||||||
|
if (action === "disable") {
|
||||||
|
return {
|
||||||
|
title: "停用管理员?",
|
||||||
|
message: `${target} 将无法登录,全部活动会话会立即失效。`,
|
||||||
|
label: "停用管理员",
|
||||||
|
dangerous: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === "enable") {
|
||||||
|
return {
|
||||||
|
title: "启用管理员?",
|
||||||
|
message: `${target} 将恢复登录权限。`,
|
||||||
|
label: "确认启用",
|
||||||
|
dangerous: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action === "unlock") {
|
||||||
|
return {
|
||||||
|
title: "解除登录锁定?",
|
||||||
|
message: `${target} 可以立即重新尝试登录。`,
|
||||||
|
label: "确认解锁",
|
||||||
|
dangerous: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: "撤销全部会话?",
|
||||||
|
message: `${target} 已登录的所有设备都需要重新认证。`,
|
||||||
|
label: "撤销会话",
|
||||||
|
dangerous: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleBadge({ role }: { role: AdminRole }) {
|
||||||
|
const labels: Record<AdminRole, string> = {
|
||||||
|
SUPER_ADMIN: "超级管理员",
|
||||||
|
SUPPORT: "支持人员",
|
||||||
|
ANALYST: "分析员",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge tone={role === "SUPER_ADMIN" ? "violet" : role === "SUPPORT" ? "info" : "neutral"}>
|
||||||
|
{labels[role]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,751 @@
|
|||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
Coins,
|
||||||
|
Gift,
|
||||||
|
Search,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
} from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { adminApi, ApiError } from "../../api/client";
|
||||||
|
import type {
|
||||||
|
LedgerQuery,
|
||||||
|
SortOrder,
|
||||||
|
UserDetail,
|
||||||
|
UsersQuery,
|
||||||
|
UserSummary,
|
||||||
|
UserUsageAggregate,
|
||||||
|
} from "../../api/types";
|
||||||
|
import { DataTable, type DataColumn } from "../../components/data-table";
|
||||||
|
import { DateRangeControl } from "../../components/date-range-control";
|
||||||
|
import { FilterControl } from "../../components/filter-control";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Dialog,
|
||||||
|
ErrorState,
|
||||||
|
Input,
|
||||||
|
LoadingState,
|
||||||
|
PageHeader,
|
||||||
|
Textarea,
|
||||||
|
} from "../../components/primitives";
|
||||||
|
import { TableToolbar } from "../../components/table-toolbar";
|
||||||
|
import { useCursorPage } from "../../hooks/use-cursor-page";
|
||||||
|
import {
|
||||||
|
createIdempotencyKey,
|
||||||
|
formatDateTime,
|
||||||
|
formatNumber,
|
||||||
|
statusLabel,
|
||||||
|
usageTypeLabel,
|
||||||
|
} from "../../lib/format";
|
||||||
|
import { useAuth } from "../auth/auth-context";
|
||||||
|
import { LedgerFilters } from "../credits/ledger-filters";
|
||||||
|
import { LedgerTable } from "../credits/ledger-table";
|
||||||
|
import { useLedgerFilters } from "../credits/use-ledger-filters";
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const { auth } = useAuth();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
|
||||||
|
const selectedUserId = searchParams.get("userId") ?? undefined;
|
||||||
|
|
||||||
|
function selectUser(userId?: string) {
|
||||||
|
setSearchParams((current) => {
|
||||||
|
const params = new URLSearchParams(current);
|
||||||
|
if (userId) params.set("userId", userId);
|
||||||
|
else params.delete("userId");
|
||||||
|
return params;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedUserId ? (
|
||||||
|
<UserDetailView
|
||||||
|
userId={selectedUserId}
|
||||||
|
canGrant={role === "SUPER_ADMIN"}
|
||||||
|
onBack={() => selectUser()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserList onSelect={selectUser} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const initialQuery = searchParams.get("q") ?? "";
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [activeQuery, setActiveQuery] = useState(initialQuery);
|
||||||
|
const [from, setFrom] = useState(searchParams.get("from") ?? "");
|
||||||
|
const [until, setUntil] = useState(searchParams.get("until") ?? "");
|
||||||
|
const [status, setStatus] = useState<"" | UserSummary["status"]>(
|
||||||
|
(searchParams.get("status") as UserSummary["status"] | null) ?? "",
|
||||||
|
);
|
||||||
|
const [order, setOrder] = useState<SortOrder>(
|
||||||
|
searchParams.get("order") === "asc" ? "asc" : "desc",
|
||||||
|
);
|
||||||
|
const usersQuery = useMemo<UsersQuery>(
|
||||||
|
() => ({
|
||||||
|
q: activeQuery || undefined,
|
||||||
|
from: from || undefined,
|
||||||
|
until: until || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
sort: "createdAt",
|
||||||
|
order,
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
[activeQuery, from, order, status, until],
|
||||||
|
);
|
||||||
|
const fetchUsers = useCallback((value: UsersQuery) => adminApi.users(value), []);
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
nextCursor,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
loadMore,
|
||||||
|
reload,
|
||||||
|
} = useCursorPage(usersQuery, fetchUsers);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (activeQuery) params.set("q", activeQuery);
|
||||||
|
if (from) params.set("from", from);
|
||||||
|
if (until) params.set("until", until);
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
params.set("sort", "createdAt");
|
||||||
|
params.set("order", order);
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
}, [activeQuery, from, order, setSearchParams, status, until]);
|
||||||
|
|
||||||
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const search = query.trim();
|
||||||
|
setActiveQuery(search);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<DataColumn<UserSummary>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
header: "用户",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<button
|
||||||
|
className="group/user max-w-64 text-left"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(row.original.userId)}
|
||||||
|
>
|
||||||
|
<span className="block font-semibold transition group-hover/user:text-primary">
|
||||||
|
{row.original.displayName || "未命名用户"}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block truncate font-mono text-[11px] text-muted">
|
||||||
|
{row.original.userId}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "状态",
|
||||||
|
cell: ({ getValue }) => <UserStatus status={String(getValue())} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "consumedCredits",
|
||||||
|
header: "累计使用",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="tabular-nums">{formatNumber(Number(getValue()))}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "creditBalance",
|
||||||
|
header: "当前积分",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-semibold tabular-nums">{formatNumber(Number(getValue()))}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: "注册时间",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted">
|
||||||
|
{formatDateTime(String(getValue()))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "操作",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => onSelect(row.original.userId)}>
|
||||||
|
查看详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="账户管理"
|
||||||
|
title="用户列表"
|
||||||
|
description="按注册时间浏览账户,支持完整或后 8 位内部用户 ID 查询。"
|
||||||
|
/>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="border-b border-border p-5 sm:p-6">
|
||||||
|
<form className="flex max-w-2xl flex-col gap-3 sm:flex-row" onSubmit={submit}>
|
||||||
|
<label className="relative flex-1">
|
||||||
|
<span className="sr-only">内部用户 ID</span>
|
||||||
|
<Search
|
||||||
|
className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="pl-10"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
type="search"
|
||||||
|
placeholder="输入完整或后 8 位用户 ID"
|
||||||
|
maxLength={36}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit">
|
||||||
|
<Search className="size-4" aria-hidden />
|
||||||
|
搜索用户
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<TableToolbar
|
||||||
|
active={Boolean(activeQuery || from || until || status || order !== "desc")}
|
||||||
|
onClear={() => {
|
||||||
|
setQuery("");
|
||||||
|
setActiveQuery("");
|
||||||
|
setFrom("");
|
||||||
|
setUntil("");
|
||||||
|
setStatus("");
|
||||||
|
setOrder("desc");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DateRangeControl
|
||||||
|
value={{ from: from || undefined, until: until || undefined }}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFrom(value.from ?? "");
|
||||||
|
setUntil(value.until ?? "");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FilterControl
|
||||||
|
label="账户状态"
|
||||||
|
value={status}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "全部状态" },
|
||||||
|
{ value: "active", label: "正常" },
|
||||||
|
{ value: "suspended", label: "反滥用受限" },
|
||||||
|
]}
|
||||||
|
onChange={setStatus}
|
||||||
|
/>
|
||||||
|
</TableToolbar>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
|
||||||
|
<Users className="size-4 text-primary" aria-hidden />
|
||||||
|
{activeQuery ? "查询结果" : "全部用户"}
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<strong className="text-foreground">{formatNumber(items.length)} 位</strong>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorState error={error} retry={reload} />
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<LoadingState label="加载用户列表" />
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={items}
|
||||||
|
columns={columns}
|
||||||
|
caption={activeQuery ? "用户查询结果" : "全部用户"}
|
||||||
|
emptyTitle={
|
||||||
|
activeQuery || from || until || status
|
||||||
|
? "没有符合当前筛选条件的用户"
|
||||||
|
: "暂无用户"
|
||||||
|
}
|
||||||
|
sort={{ key: "createdAt", order }}
|
||||||
|
sortableColumns={{ createdAt: "createdAt" }}
|
||||||
|
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||||
|
footer={
|
||||||
|
nextCursor ? (
|
||||||
|
<div className="flex justify-center border-t border-border p-5">
|
||||||
|
<Button variant="secondary" onClick={() => void loadMore()} loading={loadingMore}>
|
||||||
|
加载更多用户
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserDetailView({
|
||||||
|
userId,
|
||||||
|
canGrant,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
canGrant: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const [user, setUser] = useState<UserDetail>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const [grantOpen, setGrantOpen] = useState(false);
|
||||||
|
const filters = useLedgerFilters("ledger");
|
||||||
|
const requestVersion = useRef(0);
|
||||||
|
const fetchLedger = useCallback(
|
||||||
|
(value: LedgerQuery) => adminApi.ledger(userId, value),
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
const ledgerPage = useCursorPage(filters.query, fetchLedger);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const version = ++requestVersion.current;
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const detail = await adminApi.user(userId);
|
||||||
|
if (requestVersion.current !== version) return;
|
||||||
|
setUser(detail);
|
||||||
|
} catch (requestError) {
|
||||||
|
if (requestVersion.current === version) setError(requestError);
|
||||||
|
} finally {
|
||||||
|
if (requestVersion.current === version) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (error || ledgerPage.error) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
error={error ?? ledgerPage.error}
|
||||||
|
retry={() => {
|
||||||
|
void load();
|
||||||
|
ledgerPage.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loading || ledgerPage.loading || !user) return <LoadingState label="加载用户详情" />;
|
||||||
|
|
||||||
|
const usage = user.usage ?? [];
|
||||||
|
const requests = usage.reduce((total, item) => total + item.requests, 0);
|
||||||
|
const chargedCredits = usage.reduce((total, item) => total + item.chargedCredits, 0);
|
||||||
|
const inviterUserId = user.referral?.inviterUserId ?? user.referredByUserId;
|
||||||
|
const qualifiedUsage = user.qualifiedUsage || requests > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Button className="-ml-3 mb-4" variant="ghost" onClick={onBack}>
|
||||||
|
<ArrowLeft className="size-4" aria-hidden />
|
||||||
|
返回用户列表
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="用户详情"
|
||||||
|
title={user.displayName || "未命名用户"}
|
||||||
|
description={user.userId}
|
||||||
|
actions={
|
||||||
|
canGrant ? (
|
||||||
|
<Button onClick={() => setGrantOpen(true)}>
|
||||||
|
<Gift className="size-4" aria-hidden />
|
||||||
|
人工赠送积分
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[340px_minmax(0,1fr)]">
|
||||||
|
<Card className="h-fit overflow-hidden">
|
||||||
|
<div className="bg-gradient-to-br from-primary-soft to-violet-soft/40 p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="grid size-14 place-items-center rounded-2xl bg-surface text-xl font-bold text-primary shadow-sm">
|
||||||
|
{(user.displayName || "用").slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<strong className="block truncate">{user.displayName || "未命名用户"}</strong>
|
||||||
|
<div className="mt-2">
|
||||||
|
<UserStatus status={user.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-xs font-medium text-muted">当前积分</p>
|
||||||
|
<strong className="mt-2 block text-4xl font-bold tracking-[-0.06em] tabular-nums">
|
||||||
|
{formatNumber(user.creditBalance)}
|
||||||
|
</strong>
|
||||||
|
<dl className="mt-6 divide-y divide-border">
|
||||||
|
<DetailRow label="注册时间" value={formatDateTime(user.createdAt)} />
|
||||||
|
<DetailRow label="最近活跃" value={formatDateTime(user.lastActiveAt)} />
|
||||||
|
<DetailRow
|
||||||
|
label="有效使用"
|
||||||
|
value={qualifiedUsage ? `已达成 · ${formatNumber(requests)} 次` : "未达成"}
|
||||||
|
/>
|
||||||
|
<DetailRow label="累计消耗" value={`${formatNumber(chargedCredits)} 积分`} />
|
||||||
|
<DetailRow label="邀请码" value={user.referralCode || "—"} mono />
|
||||||
|
<DetailRow label="邀请来源" value={inviterUserId || "—"} mono />
|
||||||
|
{user.referral ? (
|
||||||
|
<DetailRow
|
||||||
|
label="邀请成效"
|
||||||
|
value={`${formatNumber(user.referral.rewardedInvites)} / ${formatNumber(user.referral.invitedUsers)} 已奖励`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeading
|
||||||
|
icon={Coins}
|
||||||
|
title="积分流水"
|
||||||
|
description="所有变动均来自不可变账本"
|
||||||
|
/>
|
||||||
|
<LedgerFilters
|
||||||
|
value={filters.state}
|
||||||
|
onChange={filters.update}
|
||||||
|
onClear={filters.clear}
|
||||||
|
/>
|
||||||
|
<LedgerTable
|
||||||
|
entries={ledgerPage.items}
|
||||||
|
caption={`${user.displayName || user.userId} 的积分流水`}
|
||||||
|
emptyTitle={
|
||||||
|
filters.state.from ||
|
||||||
|
filters.state.until ||
|
||||||
|
filters.state.type ||
|
||||||
|
filters.state.entryType ||
|
||||||
|
filters.state.usageType ||
|
||||||
|
filters.state.referenceId
|
||||||
|
? "没有符合当前筛选条件的流水"
|
||||||
|
: "暂无积分流水"
|
||||||
|
}
|
||||||
|
sort={filters.state.sort}
|
||||||
|
order={filters.state.order}
|
||||||
|
onSortChange={(sort, order) => filters.update({ sort, order })}
|
||||||
|
onReferenceFilter={(referenceId) => filters.update({ referenceId })}
|
||||||
|
footer={
|
||||||
|
ledgerPage.nextCursor ? (
|
||||||
|
<div className="flex justify-center border-t border-border p-5">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void ledgerPage.loadMore()}
|
||||||
|
loading={ledgerPage.loadingMore}
|
||||||
|
>
|
||||||
|
加载更多流水
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{usage.length > 0 ? <UsagePanel usage={usage} /> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canGrant ? (
|
||||||
|
<GrantDialog
|
||||||
|
open={grantOpen}
|
||||||
|
user={user}
|
||||||
|
onOpenChange={setGrantOpen}
|
||||||
|
onSuccess={async () => {
|
||||||
|
await load();
|
||||||
|
ledgerPage.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GrantDialog({
|
||||||
|
open,
|
||||||
|
user,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
user: UserDetail;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState<"input" | "confirm">("input");
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [outcomeUnknown, setOutcomeUnknown] = useState(false);
|
||||||
|
const [idempotencyKey, setIdempotencyKey] = useState(createIdempotencyKey);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setStep("input");
|
||||||
|
setAmount("");
|
||||||
|
setReason("");
|
||||||
|
setError("");
|
||||||
|
setSubmitting(false);
|
||||||
|
setOutcomeUnknown(false);
|
||||||
|
setIdempotencyKey(createIdempotencyKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
if (!nextOpen && outcomeUnknown) {
|
||||||
|
setError("赠送结果尚未确认,请在当前窗口使用同一请求安全重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
if (!nextOpen) reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function review() {
|
||||||
|
const numericAmount = Number(amount);
|
||||||
|
if (!Number.isSafeInteger(numericAmount) || numericAmount < 1 || numericAmount > 100_000) {
|
||||||
|
setError("积分必须是 1 至 100,000 的整数");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reason.trim().length < 4) {
|
||||||
|
setError("请填写至少 4 个字符的赠送原因");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setStep("confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await adminApi.grantCredits({
|
||||||
|
userId: user.userId,
|
||||||
|
amount: Number(amount),
|
||||||
|
reason: reason.trim(),
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
reset();
|
||||||
|
toast.success("积分赠送成功,账本已更新");
|
||||||
|
await onSuccess();
|
||||||
|
} catch (requestError) {
|
||||||
|
const unknown =
|
||||||
|
!(requestError instanceof ApiError) ||
|
||||||
|
requestError.status === 0 ||
|
||||||
|
requestError.status >= 500;
|
||||||
|
setOutcomeUnknown(unknown);
|
||||||
|
setError(
|
||||||
|
unknown
|
||||||
|
? "赠送结果尚未确认;重试会复用同一请求,不会重复到账"
|
||||||
|
: requestError instanceof ApiError
|
||||||
|
? requestError.message
|
||||||
|
: "赠送失败,请重试",
|
||||||
|
);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
title={step === "input" ? "人工赠送积分" : "确认赠送"}
|
||||||
|
description="赠送将写入不可变账本,并记录完整管理员审计日志。"
|
||||||
|
preventClose={outcomeUnknown}
|
||||||
|
>
|
||||||
|
{step === "input" ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Field label="用户 ID">
|
||||||
|
<Input value={user.userId} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field label="赠送积分">
|
||||||
|
<Input
|
||||||
|
value={amount}
|
||||||
|
onChange={(event) => setAmount(event.target.value)}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100000}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="赠送原因">
|
||||||
|
<Textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
minLength={4}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder="填写可审计的业务原因"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="min-h-5 text-sm text-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={review}>下一步</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="rounded-2xl border border-danger/15 bg-danger-soft/60 p-5">
|
||||||
|
<p className="text-sm text-muted">即将向以下用户赠送积分</p>
|
||||||
|
<strong className="mt-2 block text-2xl font-bold tabular-nums">
|
||||||
|
{formatNumber(Number(amount))} 积分
|
||||||
|
</strong>
|
||||||
|
<p className="mt-3 text-sm">{user.displayName || user.userId}</p>
|
||||||
|
<p className="mt-2 rounded-xl bg-surface/70 p-3 text-xs leading-5 text-muted">
|
||||||
|
原因:{reason.trim()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 min-h-5 text-sm text-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={outcomeUnknown || submitting}
|
||||||
|
onClick={() => setStep("input")}
|
||||||
|
>
|
||||||
|
返回修改
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" loading={submitting} onClick={() => void submit()}>
|
||||||
|
确认赠送
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsagePanel({ usage }: { usage: UserUsageAggregate[] }) {
|
||||||
|
const columns = useMemo<DataColumn<UserUsageAggregate>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "kind",
|
||||||
|
header: "类型",
|
||||||
|
cell: ({ getValue }) => <Badge tone="violet">{usageTypeLabel(String(getValue()))}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "requests",
|
||||||
|
header: "请求",
|
||||||
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "chargedCredits",
|
||||||
|
header: "消耗积分",
|
||||||
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "asrMillis",
|
||||||
|
header: "语音毫秒",
|
||||||
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "inputTokens",
|
||||||
|
header: "输入 Token",
|
||||||
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "outputTokens",
|
||||||
|
header: "输出 Token",
|
||||||
|
cell: ({ getValue }) => formatNumber(Number(getValue())),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<SectionHeading
|
||||||
|
icon={Sparkles}
|
||||||
|
title="使用统计"
|
||||||
|
description="按使用类型汇总,不包含任何用户内容"
|
||||||
|
/>
|
||||||
|
<DataTable data={usage} columns={columns} caption="用户使用统计" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeading({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
icon: typeof Coins;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">{title}</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
|
||||||
|
<Icon className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 py-3 text-xs">
|
||||||
|
<dt className="shrink-0 text-muted">{label}</dt>
|
||||||
|
<dd
|
||||||
|
className={`m-0 max-w-[190px] break-all text-right font-medium ${mono ? "font-mono text-[11px]" : ""}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserStatus({ status }: { status: string }) {
|
||||||
|
const tone = status === "active" ? "success" : status === "suspended" ? "danger" : "neutral";
|
||||||
|
return (
|
||||||
|
<Badge tone={tone}>
|
||||||
|
{status === "active" ? <CheckCircle2 className="size-3" aria-hidden /> : null}
|
||||||
|
{statusLabel(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-sm font-semibold">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { PageResult } from "../api/types";
|
||||||
|
|
||||||
|
export function useCursorPage<T, Q extends { cursor?: string }>(
|
||||||
|
query: Q,
|
||||||
|
fetchPage: (query: Q) => Promise<PageResult<T>>,
|
||||||
|
) {
|
||||||
|
const [items, setItems] = useState<T[]>([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState<string>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const requestVersion = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const version = ++requestVersion.current;
|
||||||
|
setItems([]);
|
||||||
|
setNextCursor(undefined);
|
||||||
|
setLoading(true);
|
||||||
|
setLoadingMore(false);
|
||||||
|
setError(undefined);
|
||||||
|
void fetchPage(query)
|
||||||
|
.then((page) => {
|
||||||
|
if (requestVersion.current !== version) return;
|
||||||
|
setItems(page.items);
|
||||||
|
setNextCursor(page.nextCursor);
|
||||||
|
})
|
||||||
|
.catch((requestError) => {
|
||||||
|
if (requestVersion.current === version) setError(requestError);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (requestVersion.current === version) setLoading(false);
|
||||||
|
});
|
||||||
|
}, [fetchPage, query, refreshKey]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (!nextCursor || loadingMore) return;
|
||||||
|
const version = requestVersion.current;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const page = await fetchPage({ ...query, cursor: nextCursor });
|
||||||
|
if (requestVersion.current !== version) return;
|
||||||
|
setItems((current) => [...current, ...page.items]);
|
||||||
|
setNextCursor(page.nextCursor);
|
||||||
|
} catch (requestError) {
|
||||||
|
if (requestVersion.current === version) setError(requestError);
|
||||||
|
} finally {
|
||||||
|
if (requestVersion.current === version) setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [fetchPage, loadingMore, nextCursor, query]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
nextCursor,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
loadMore,
|
||||||
|
reload: useCallback(() => setRefreshKey((current) => current + 1), []),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export function escapeHtml(value: unknown): string {
|
|||||||
export function statusLabel(status: string): string {
|
export function statusLabel(status: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
active: "正常",
|
active: "正常",
|
||||||
suspended: "已停用",
|
suspended: "反滥用受限",
|
||||||
closed: "已关闭",
|
closed: "已关闭",
|
||||||
success: "成功",
|
success: "成功",
|
||||||
rejected: "已拒绝",
|
rejected: "已拒绝",
|
||||||
@@ -43,11 +43,23 @@ export function statusLabel(status: string): string {
|
|||||||
reserve: "预留",
|
reserve: "预留",
|
||||||
settle: "结算",
|
settle: "结算",
|
||||||
refund: "退还",
|
refund: "退还",
|
||||||
adjustment: "调整",
|
|
||||||
};
|
};
|
||||||
return labels[status] ?? status;
|
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 {
|
export function createIdempotencyKey(): string {
|
||||||
return globalThis.crypto?.randomUUID?.() ??
|
return globalThis.crypto?.randomUUID?.() ??
|
||||||
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SortOrder } from "../api/types";
|
||||||
|
|
||||||
|
export function stableSort<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
value: (item: T) => number | string,
|
||||||
|
order: SortOrder,
|
||||||
|
): T[] {
|
||||||
|
const direction = order === "asc" ? 1 : -1;
|
||||||
|
return items
|
||||||
|
.map((item, index) => ({ item, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftValue = value(left.item);
|
||||||
|
const rightValue = value(right.item);
|
||||||
|
const compared =
|
||||||
|
typeof leftValue === "string" && typeof rightValue === "string"
|
||||||
|
? leftValue.localeCompare(rightValue)
|
||||||
|
: Number(leftValue) - Number(rightValue);
|
||||||
|
return compared === 0 ? left.index - right.index : compared * direction;
|
||||||
|
})
|
||||||
|
.map(({ item }) => item);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import "./components/webawesome";
|
|
||||||
import "./styles.css";
|
|
||||||
import { AdminApp } from "./app";
|
|
||||||
|
|
||||||
const root = document.querySelector<HTMLElement>("#app");
|
|
||||||
if (!root) throw new Error("应用挂载节点不存在");
|
|
||||||
|
|
||||||
void new AdminApp(root).start();
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./app";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const root = document.querySelector<HTMLElement>("#app");
|
||||||
|
if (!root) throw new Error("应用挂载节点不存在");
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { adminApi, ApiError } from "../api/client";
|
|
||||||
import type { AuditLogEntry } from "../api/types";
|
|
||||||
import {
|
|
||||||
renderEmpty,
|
|
||||||
renderError,
|
|
||||||
renderLoading,
|
|
||||||
setButtonBusy,
|
|
||||||
showToast,
|
|
||||||
} from "../components/ui";
|
|
||||||
import { escapeHtml, formatDateTime, statusLabel } from "../lib/format";
|
|
||||||
|
|
||||||
export async function renderAudit(container: HTMLElement): Promise<void> {
|
|
||||||
renderLoading(container, "加载审计日志");
|
|
||||||
try {
|
|
||||||
const page = await adminApi.auditLogs();
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-heading">
|
|
||||||
<div>
|
|
||||||
<div class="eyebrow">安全与合规</div>
|
|
||||||
<h1>审计日志</h1>
|
|
||||||
<p>追踪管理员操作、目标和执行结果。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section class="panel">
|
|
||||||
${
|
|
||||||
page.items.length === 0
|
|
||||||
? renderEmpty("暂无审计记录")
|
|
||||||
: `
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<caption class="sr-only">管理员审计事件</caption>
|
|
||||||
<thead><tr><th scope="col">时间</th><th scope="col">操作员</th><th scope="col">操作</th><th scope="col">目标</th><th scope="col">请求 ID</th><th scope="col">结果</th></tr></thead>
|
|
||||||
<tbody data-audit-body>${auditRows(page.items)}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
page.nextCursor
|
|
||||||
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-audit-more>加载更早记录</wa-button></div>'
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
if (page.nextCursor) bindAuditPagination(container, page.nextCursor);
|
|
||||||
} catch (error) {
|
|
||||||
renderError(container, error, () => void renderAudit(container));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function auditRows(items: AuditLogEntry[]): string {
|
|
||||||
return items
|
|
||||||
.map(
|
|
||||||
(entry) => `
|
|
||||||
<tr>
|
|
||||||
<td>${formatDateTime(entry.createdAt)}</td>
|
|
||||||
<td>${escapeHtml(entry.operatorName)}</td>
|
|
||||||
<td><code>${escapeHtml(entry.action)}</code></td>
|
|
||||||
<td>
|
|
||||||
<span>${escapeHtml(entry.targetType)}</span>
|
|
||||||
<div class="subtle mono">${escapeHtml(entry.targetId)}</div>
|
|
||||||
</td>
|
|
||||||
<td class="mono">${escapeHtml(entry.requestId || "—")}</td>
|
|
||||||
<td><span class="badge badge--${entry.result}">${statusLabel(entry.result)}</span></td>
|
|
||||||
</tr>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindAuditPagination(container: HTMLElement, initialCursor: string): void {
|
|
||||||
const button =
|
|
||||||
container.querySelector<HTMLButtonElement>("[data-audit-more]");
|
|
||||||
const body = container.querySelector<HTMLTableSectionElement>("[data-audit-body]");
|
|
||||||
if (!button || !body) return;
|
|
||||||
let cursor: string | undefined = initialCursor;
|
|
||||||
|
|
||||||
button.addEventListener("click", async () => {
|
|
||||||
if (!cursor) return;
|
|
||||||
setButtonBusy(button, true, "加载中…");
|
|
||||||
try {
|
|
||||||
const page = await adminApi.auditLogs(cursor);
|
|
||||||
body.insertAdjacentHTML("beforeend", auditRows(page.items));
|
|
||||||
cursor = page.nextCursor;
|
|
||||||
if (!cursor) {
|
|
||||||
button.closest(".pagination-actions")?.remove();
|
|
||||||
} else {
|
|
||||||
setButtonBusy(button, false);
|
|
||||||
button.focus();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast(
|
|
||||||
error instanceof ApiError ? error.message : "加载审计记录失败",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
setButtonBusy(button, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
|
|
||||||
import { adminApi, ApiError, setCsrfToken } from "../api/client";
|
|
||||||
import type { AuthState } from "../api/types";
|
|
||||||
import { setButtonBusy } from "../components/ui";
|
|
||||||
|
|
||||||
interface AuthCallbacks {
|
|
||||||
onChange: (state: AuthState) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function authFrame(content: string): string {
|
|
||||||
return `
|
|
||||||
<main class="auth-shell">
|
|
||||||
<section class="auth-brand" aria-label="OSG 运营后台">
|
|
||||||
<div class="brand-mark">OSG</div>
|
|
||||||
<p>安全、克制、可追溯的运营管理</p>
|
|
||||||
</section>
|
|
||||||
<section class="auth-card">${content}</section>
|
|
||||||
</main>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderPasswordLogin(
|
|
||||||
root: HTMLElement,
|
|
||||||
callbacks: AuthCallbacks,
|
|
||||||
): void {
|
|
||||||
root.innerHTML = authFrame(`
|
|
||||||
<div class="eyebrow">运营后台</div>
|
|
||||||
<h1>管理员登录</h1>
|
|
||||||
<p class="muted">使用管理员凭据和认证器动态验证码登录。凭据不会保存到浏览器。</p>
|
|
||||||
<form class="form-stack" data-login-form novalidate>
|
|
||||||
<wa-input name="username" label="管理员用户名" type="text" autocomplete="username" minlength="3" maxlength="64" appearance="outlined" required autofocus></wa-input>
|
|
||||||
<wa-input name="password" label="管理员密码" type="password" autocomplete="current-password" minlength="12" appearance="outlined" password-toggle required></wa-input>
|
|
||||||
<wa-input class="totp-input" name="totpCode" label="动态验证码" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" appearance="outlined" required></wa-input>
|
|
||||||
<p class="form-error" data-error role="alert"></p>
|
|
||||||
<wa-button class="button--wide" variant="brand" appearance="accent" type="submit">安全登录</wa-button>
|
|
||||||
</form>
|
|
||||||
`);
|
|
||||||
|
|
||||||
const form = root.querySelector<HTMLFormElement>("[data-login-form]");
|
|
||||||
form?.addEventListener("submit", async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const button = form.querySelector<WaButton>('wa-button[type="submit"]');
|
|
||||||
const errorNode = form.querySelector<HTMLElement>("[data-error]");
|
|
||||||
const username = new FormData(form).get("username")?.toString().trim() ?? "";
|
|
||||||
const password = new FormData(form).get("password")?.toString() ?? "";
|
|
||||||
const totpCode = new FormData(form).get("totpCode")?.toString().trim() ?? "";
|
|
||||||
if (
|
|
||||||
username.length < 3 ||
|
|
||||||
password.length < 12 ||
|
|
||||||
!/^\d{6}$/.test(totpCode) ||
|
|
||||||
!button ||
|
|
||||||
!errorNode
|
|
||||||
) {
|
|
||||||
if (errorNode) errorNode.textContent = "请输入有效的用户名、密码和 6 位动态验证码";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setButtonBusy(button, true, "验证中…");
|
|
||||||
errorNode.textContent = "";
|
|
||||||
try {
|
|
||||||
const response = await adminApi.login(username, password, totpCode);
|
|
||||||
form.reset();
|
|
||||||
setCsrfToken(response.csrfToken);
|
|
||||||
callbacks.onChange({
|
|
||||||
status: "authenticated",
|
|
||||||
operatorName: response.operatorName,
|
|
||||||
role: response.role,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
errorNode.textContent =
|
|
||||||
error instanceof ApiError ? error.message : "验证码验证失败";
|
|
||||||
setButtonBusy(button, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { adminApi } from "../api/client";
|
|
||||||
import { renderEmpty, renderError, renderLoading } from "../components/ui";
|
|
||||||
import {
|
|
||||||
escapeHtml,
|
|
||||||
formatDateTime,
|
|
||||||
formatNumber,
|
|
||||||
formatSignedCredits,
|
|
||||||
statusLabel,
|
|
||||||
} from "../lib/format";
|
|
||||||
|
|
||||||
export function renderCredits(container: HTMLElement): void {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-heading">
|
|
||||||
<div>
|
|
||||||
<div class="eyebrow">积分账本</div>
|
|
||||||
<h1>积分流水</h1>
|
|
||||||
<p>按内部用户 ID 查询不可变积分流水。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section class="panel">
|
|
||||||
<form class="search-form" data-ledger-form>
|
|
||||||
<label class="search-box">
|
|
||||||
<span class="sr-only">内部用户 ID</span>
|
|
||||||
<span aria-hidden="true">⌕</span>
|
|
||||||
<input name="userId" type="search" placeholder="输入完整用户 ID" autocomplete="off" maxlength="36" required />
|
|
||||||
</label>
|
|
||||||
<wa-button variant="brand" appearance="accent" type="submit">查询流水</wa-button>
|
|
||||||
</form>
|
|
||||||
<div data-ledger-results>${renderEmpty("输入内部用户 ID 开始查询")}</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const form = container.querySelector<HTMLFormElement>("[data-ledger-form]");
|
|
||||||
form?.addEventListener("submit", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const userId = new FormData(form).get("userId")?.toString().trim() ?? "";
|
|
||||||
if (userId) void loadLedger(container, userId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLedger(container: HTMLElement, userId: string): Promise<void> {
|
|
||||||
const results = container.querySelector<HTMLElement>("[data-ledger-results]");
|
|
||||||
if (!results) return;
|
|
||||||
renderLoading(results, "加载积分流水");
|
|
||||||
try {
|
|
||||||
const page = await adminApi.ledger(userId);
|
|
||||||
results.innerHTML =
|
|
||||||
page.items.length === 0
|
|
||||||
? renderEmpty("该用户暂无积分流水")
|
|
||||||
: `
|
|
||||||
<div class="result-summary">用户 <span class="mono">${escapeHtml(userId)}</span> · ${formatNumber(page.items.length)} 条记录</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<caption class="sr-only">用户 ${escapeHtml(userId)} 的积分流水</caption>
|
|
||||||
<thead><tr><th scope="col">时间</th><th scope="col">流水号</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${page.items
|
|
||||||
.map(
|
|
||||||
(entry) => `
|
|
||||||
<tr>
|
|
||||||
<td>${formatDateTime(entry.createdAt)}</td>
|
|
||||||
<td class="mono">${escapeHtml(entry.entryId)}</td>
|
|
||||||
<td>${statusLabel(entry.type)}</td>
|
|
||||||
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
|
|
||||||
<td>${formatNumber(entry.balanceAfter)}</td>
|
|
||||||
<td>${escapeHtml(entry.reasonCode)}</td>
|
|
||||||
</tr>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} catch (error) {
|
|
||||||
renderError(results, error, () => void loadLedger(container, userId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { adminApi } from "../api/client";
|
|
||||||
import { trendChart } from "../components/charts";
|
|
||||||
import { renderError, renderLoading } from "../components/ui";
|
|
||||||
import { escapeHtml, formatNumber } from "../lib/format";
|
|
||||||
|
|
||||||
export async function renderOverview(
|
|
||||||
container: HTMLElement,
|
|
||||||
range = "30d",
|
|
||||||
): Promise<void> {
|
|
||||||
renderLoading(container, "加载总览");
|
|
||||||
try {
|
|
||||||
const data = await adminApi.overview(range);
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-heading">
|
|
||||||
<div>
|
|
||||||
<div class="eyebrow">核心指标</div>
|
|
||||||
<h1>运营总览</h1>
|
|
||||||
<p>快速掌握增长与积分消耗情况。</p>
|
|
||||||
</div>
|
|
||||||
<fieldset class="segmented-control">
|
|
||||||
<legend class="sr-only">统计周期</legend>
|
|
||||||
${rangeOption("7d", "7 天", range)}
|
|
||||||
${rangeOption("30d", "30 天", range)}
|
|
||||||
${rangeOption("90d", "90 天", range)}
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
<section class="metric-grid" aria-label="关键指标">
|
|
||||||
${metric("用户总数", data.totalUsers, "累计账户")}
|
|
||||||
${metric("活跃用户", data.activeUsers, "当前周期")}
|
|
||||||
${metric("新增用户", data.newUsers, "当前周期")}
|
|
||||||
${metric("积分余额", data.totalCreditBalance, "所有账户")}
|
|
||||||
${metric("赠送积分", data.creditsGranted, "整数积分")}
|
|
||||||
${metric("消耗积分", data.creditsUsed, "已结算")}
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<div><h2>新增用户趋势</h2><p>按 UTC 日期统计注册数</p></div>
|
|
||||||
<span class="legend"><i></i>新增用户</span>
|
|
||||||
</div>
|
|
||||||
${trendChart(data.trend)}
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-heading"><div><h2>使用汇总</h2><p>按类型统计,不包含用户内容</p></div></div>
|
|
||||||
${
|
|
||||||
data.usage.length === 0
|
|
||||||
? '<p class="muted">当前周期暂无使用记录</p>'
|
|
||||||
: `
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<caption class="sr-only">当前周期使用统计</caption>
|
|
||||||
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音毫秒</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${data.usage
|
|
||||||
.map(
|
|
||||||
(item) => `
|
|
||||||
<tr>
|
|
||||||
<td><code>${escapeHtml(item.kind)}</code></td>
|
|
||||||
<td>${formatNumber(item.requests)}</td>
|
|
||||||
<td>${formatNumber(item.chargedCredits)}</td>
|
|
||||||
<td>${formatNumber(item.asrMillis)}</td>
|
|
||||||
<td>${formatNumber(item.inputTokens)}</td>
|
|
||||||
<td>${formatNumber(item.outputTokens)}</td>
|
|
||||||
</tr>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
|
|
||||||
input.addEventListener("change", (event) => {
|
|
||||||
void renderOverview(container, (event.target as HTMLInputElement).value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
renderError(container, error, () => void renderOverview(container, range));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rangeOption(value: string, label: string, selected: string): string {
|
|
||||||
return `
|
|
||||||
<label>
|
|
||||||
<input data-range name="overview-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
|
|
||||||
<span>${label}</span>
|
|
||||||
</label>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function metric(label: string, value: number, hint: string): string {
|
|
||||||
return `
|
|
||||||
<article class="metric-card">
|
|
||||||
<p>${label}</p>
|
|
||||||
<strong>${formatNumber(value)}</strong>
|
|
||||||
<span>${hint}</span>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { adminApi } from "../api/client";
|
|
||||||
import { funnelChart } from "../components/charts";
|
|
||||||
import { renderEmpty, renderError, renderLoading } from "../components/ui";
|
|
||||||
import { escapeHtml, formatNumber } from "../lib/format";
|
|
||||||
|
|
||||||
export async function renderReferrals(
|
|
||||||
container: HTMLElement,
|
|
||||||
range = "30d",
|
|
||||||
): Promise<void> {
|
|
||||||
renderLoading(container, "加载裂变数据");
|
|
||||||
try {
|
|
||||||
const data = await adminApi.referrals(range);
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-heading">
|
|
||||||
<div>
|
|
||||||
<div class="eyebrow">增长分析</div>
|
|
||||||
<h1>裂变与排行</h1>
|
|
||||||
<p>奖励以有效使用为前提,不以注册量代替真实转化。</p>
|
|
||||||
</div>
|
|
||||||
<fieldset class="segmented-control">
|
|
||||||
<legend class="sr-only">统计周期</legend>
|
|
||||||
${rangeOption("7d", "7 天", range)}
|
|
||||||
${rangeOption("30d", "30 天", range)}
|
|
||||||
${rangeOption("90d", "90 天", range)}
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
<section class="metric-grid metric-grid--two" aria-label="邀请资格状态">
|
|
||||||
<article class="metric-card">
|
|
||||||
<p>待资格确认</p>
|
|
||||||
<strong>${formatNumber(data.pendingBindings)}</strong>
|
|
||||||
<span>等待有效使用</span>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<p>不符合奖励条件</p>
|
|
||||||
<strong>${formatNumber(data.ineligibleBindings)}</strong>
|
|
||||||
<span>未发放积分</span>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
<div class="two-column">
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-heading"><div><h2>裂变漏斗</h2><p>从分享至有效使用</p></div></div>
|
|
||||||
${funnelChart(data.funnel)}
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-heading"><div><h2>邀请排行</h2><p>按有效邀请数排序</p></div></div>
|
|
||||||
${
|
|
||||||
data.ranking.length === 0
|
|
||||||
? renderEmpty("当前周期暂无排行数据")
|
|
||||||
: `
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<caption class="sr-only">有效邀请用户排行</caption>
|
|
||||||
<thead><tr><th scope="col">名次</th><th scope="col">用户 ID</th><th scope="col">邀请</th><th scope="col">有效</th><th scope="col">奖励积分</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${data.ranking
|
|
||||||
.map(
|
|
||||||
(item, index) => `
|
|
||||||
<tr>
|
|
||||||
<td><span class="rank rank--${index + 1}">${index + 1}</span></td>
|
|
||||||
<td class="mono">${escapeHtml(item.userId)}</td>
|
|
||||||
<td>${formatNumber(item.invited)}</td>
|
|
||||||
<td>${formatNumber(item.qualified)}</td>
|
|
||||||
<td>${formatNumber(item.creditsEarned)}</td>
|
|
||||||
</tr>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
|
|
||||||
input.addEventListener("change", (event) => {
|
|
||||||
void renderReferrals(
|
|
||||||
container,
|
|
||||||
(event.target as HTMLInputElement).value,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
renderError(container, error, () => void renderReferrals(container, range));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rangeOption(value: string, label: string, selected: string): string {
|
|
||||||
return `
|
|
||||||
<label>
|
|
||||||
<input data-range name="referral-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
|
|
||||||
<span>${label}</span>
|
|
||||||
</label>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
import { adminApi, ApiError } from "../api/client";
|
|
||||||
import type {
|
|
||||||
AdminOperator,
|
|
||||||
AdminOperatorProvisioning,
|
|
||||||
AdminRole,
|
|
||||||
AdminSecuritySummary,
|
|
||||||
} from "../api/types";
|
|
||||||
import {
|
|
||||||
renderError,
|
|
||||||
renderLoading,
|
|
||||||
setButtonBusy,
|
|
||||||
showToast,
|
|
||||||
} from "../components/ui";
|
|
||||||
import { escapeHtml, formatDateTime } from "../lib/format";
|
|
||||||
|
|
||||||
export async function renderSecurity(
|
|
||||||
container: HTMLElement,
|
|
||||||
currentUsername: string,
|
|
||||||
): Promise<void> {
|
|
||||||
renderLoading(container, "加载安全中心");
|
|
||||||
try {
|
|
||||||
const [page, summary] = await Promise.all([
|
|
||||||
adminApi.operators(),
|
|
||||||
adminApi.operatorSummary(),
|
|
||||||
]);
|
|
||||||
const operators = [...page.items];
|
|
||||||
container.innerHTML = securityTemplate(
|
|
||||||
operators,
|
|
||||||
currentUsername,
|
|
||||||
summary,
|
|
||||||
page.nextCursor,
|
|
||||||
);
|
|
||||||
bindSecurityActions(container, operators, currentUsername);
|
|
||||||
bindOperatorPagination(
|
|
||||||
container,
|
|
||||||
operators,
|
|
||||||
currentUsername,
|
|
||||||
page.nextCursor,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
renderError(container, error, () => {
|
|
||||||
void renderSecurity(container, currentUsername);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function securityTemplate(
|
|
||||||
operators: AdminOperator[],
|
|
||||||
currentUsername: string,
|
|
||||||
summary: AdminSecuritySummary,
|
|
||||||
nextCursor?: string,
|
|
||||||
): string {
|
|
||||||
return `
|
|
||||||
<div class="page-heading">
|
|
||||||
<div>
|
|
||||||
<div class="eyebrow">访问控制</div>
|
|
||||||
<h1>安全中心</h1>
|
|
||||||
<p>管理运营人员、角色、登录锁定、会话与双重认证。</p>
|
|
||||||
</div>
|
|
||||||
<wa-button variant="brand" appearance="accent" data-create-operator>添加管理员</wa-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="security-notice" aria-label="安全说明">
|
|
||||||
<strong>双重边界已启用</strong>
|
|
||||||
<span>访问仍需受信客户端证书;所有管理员变更都会写入不可变审计日志。</span>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="metric-grid metric-grid--three" aria-label="安全指标">
|
|
||||||
${securityMetric("已启用管理员", summary.enabledOperators, "可登录账户")}
|
|
||||||
${securityMetric("已锁定账户", summary.lockedOperators, "等待解锁或锁定到期")}
|
|
||||||
${securityMetric("活动会话", summary.activeSessions, "尚未过期且未撤销")}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<div><h2>管理员</h2><p data-operator-count>已加载 ${operators.length} 个账户</p></div>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<caption class="sr-only">管理员账户与安全状态</caption>
|
|
||||||
<thead>
|
|
||||||
<tr><th scope="col">管理员</th><th scope="col">角色</th><th scope="col">状态</th><th scope="col">最近登录</th><th scope="col">操作</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody data-operator-body>
|
|
||||||
${operators.map((operator) => operatorRow(operator, currentUsername)).join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
nextCursor
|
|
||||||
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-operator-more aria-describedby="operator-pagination-status">加载更多管理员</wa-button></div>'
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
<p class="sr-only" id="operator-pagination-status" data-operator-status role="status" aria-live="polite"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
${operatorFormDialog()}
|
|
||||||
${credentialResetDialog()}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function securityMetric(label: string, value: number, hint: string): string {
|
|
||||||
return `
|
|
||||||
<article class="metric-card">
|
|
||||||
<span>${escapeHtml(label)}</span>
|
|
||||||
<strong>${value.toLocaleString("zh-CN")}</strong>
|
|
||||||
<small>${escapeHtml(hint)}</small>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function operatorRow(
|
|
||||||
operator: AdminOperator,
|
|
||||||
currentUsername: string,
|
|
||||||
): string {
|
|
||||||
const current = operator.username === currentUsername;
|
|
||||||
const locked = Boolean(
|
|
||||||
operator.lockedUntil && new Date(operator.lockedUntil).getTime() > Date.now(),
|
|
||||||
);
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong>${escapeHtml(operator.username)} ${current ? '<span class="badge">当前</span>' : ""}</strong>
|
|
||||||
<div class="subtle mono">${escapeHtml(operator.operatorId)}</div>
|
|
||||||
</td>
|
|
||||||
<td>${roleLabel(operator.role)}</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge ${operator.enabled ? "badge--success" : "badge--rejected"}">${operator.enabled ? "已启用" : "已停用"}</span>
|
|
||||||
${locked ? '<span class="badge badge--rejected">已锁定</span>' : ""}
|
|
||||||
</td>
|
|
||||||
<td>${formatDateTime(operator.lastLoginAt)}</td>
|
|
||||||
<td>
|
|
||||||
<div class="row-actions" data-operator-id="${escapeHtml(operator.operatorId)}">
|
|
||||||
${
|
|
||||||
locked
|
|
||||||
? '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="unlock">解锁</wa-button>'
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="sessions">撤销会话</wa-button>
|
|
||||||
<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="reset">重置凭据</wa-button>
|
|
||||||
${
|
|
||||||
operator.enabled
|
|
||||||
? `<wa-button variant="danger" appearance="filled" size="s" data-operator-action="disable" ${current ? "disabled" : ""}>停用</wa-button>`
|
|
||||||
: '<wa-button variant="neutral" appearance="outlined" size="s" data-operator-action="enable">启用</wa-button>'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindOperatorPagination(
|
|
||||||
container: HTMLElement,
|
|
||||||
operators: AdminOperator[],
|
|
||||||
currentUsername: string,
|
|
||||||
initialCursor?: string,
|
|
||||||
): void {
|
|
||||||
const button =
|
|
||||||
container.querySelector<HTMLButtonElement>("[data-operator-more]");
|
|
||||||
const body =
|
|
||||||
container.querySelector<HTMLTableSectionElement>("[data-operator-body]");
|
|
||||||
const count = container.querySelector<HTMLElement>("[data-operator-count]");
|
|
||||||
const status = container.querySelector<HTMLElement>("[data-operator-status]");
|
|
||||||
if (!button || !body || !initialCursor) return;
|
|
||||||
let cursor: string | undefined = initialCursor;
|
|
||||||
|
|
||||||
button.addEventListener("click", async () => {
|
|
||||||
if (!cursor) return;
|
|
||||||
setButtonBusy(button, true, "加载中…");
|
|
||||||
try {
|
|
||||||
const page = await adminApi.operators(cursor);
|
|
||||||
body.insertAdjacentHTML(
|
|
||||||
"beforeend",
|
|
||||||
page.items.map((operator) => operatorRow(operator, currentUsername)).join(""),
|
|
||||||
);
|
|
||||||
operators.push(...page.items);
|
|
||||||
cursor = page.nextCursor;
|
|
||||||
if (count) count.textContent = `已加载 ${operators.length} 个账户`;
|
|
||||||
if (status) {
|
|
||||||
status.textContent = cursor
|
|
||||||
? `已加载 ${page.items.length} 个更多管理员`
|
|
||||||
: `已加载 ${page.items.length} 个管理员,全部账户已加载`;
|
|
||||||
}
|
|
||||||
if (!cursor) {
|
|
||||||
button.closest(".pagination-actions")?.remove();
|
|
||||||
} else {
|
|
||||||
setButtonBusy(button, false);
|
|
||||||
button.focus();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast(errorMessage(error, "加载管理员失败"), "error");
|
|
||||||
setButtonBusy(button, false);
|
|
||||||
button.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function operatorFormDialog(): string {
|
|
||||||
return `
|
|
||||||
<dialog class="dialog" data-operator-dialog aria-labelledby="operator-dialog-title">
|
|
||||||
<form class="dialog-card form-stack" data-operator-form novalidate>
|
|
||||||
<div class="eyebrow">访问授权</div>
|
|
||||||
<h2 id="operator-dialog-title">添加管理员</h2>
|
|
||||||
<p class="muted">创建后,TOTP 密钥只显示一次。</p>
|
|
||||||
<label><span>用户名</span><input name="username" autocomplete="off" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._@-]{2,63}" required /></label>
|
|
||||||
<label>
|
|
||||||
<span>角色</span>
|
|
||||||
<select name="role" required>
|
|
||||||
<option value="ANALYST">分析员 · 仅统计</option>
|
|
||||||
<option value="SUPPORT">支持人员 · 用户与流水只读</option>
|
|
||||||
<option value="SUPER_ADMIN">超级管理员 · 完整权限</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label><span>初始密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
|
|
||||||
<label><span>确认密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
|
|
||||||
<p class="form-error" data-operator-error role="alert"></p>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="button button--secondary" type="button" data-close-operator>取消</button>
|
|
||||||
<button class="button button--primary" type="submit">创建并生成 TOTP</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function credentialResetDialog(): string {
|
|
||||||
return `
|
|
||||||
<dialog class="dialog" data-reset-dialog aria-labelledby="reset-dialog-title">
|
|
||||||
<form class="dialog-card form-stack" data-reset-form novalidate>
|
|
||||||
<div class="eyebrow">高风险操作</div>
|
|
||||||
<h2 id="reset-dialog-title">重置登录凭据</h2>
|
|
||||||
<p>将为 <strong data-reset-username></strong> 重置密码和 TOTP,并立即撤销其全部会话。</p>
|
|
||||||
<label><span>新密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
|
|
||||||
<label><span>确认新密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
|
|
||||||
<p class="form-error" data-reset-error role="alert"></p>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="button button--secondary" type="button" data-close-reset>取消</button>
|
|
||||||
<button class="button button--danger" type="submit">重置并撤销会话</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindSecurityActions(
|
|
||||||
container: HTMLElement,
|
|
||||||
operators: AdminOperator[],
|
|
||||||
currentUsername: string,
|
|
||||||
): void {
|
|
||||||
const createDialog =
|
|
||||||
container.querySelector<HTMLDialogElement>("[data-operator-dialog]");
|
|
||||||
const createForm =
|
|
||||||
container.querySelector<HTMLFormElement>("[data-operator-form]");
|
|
||||||
container
|
|
||||||
.querySelector<HTMLButtonElement>("[data-create-operator]")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
createForm?.reset();
|
|
||||||
clearError(createForm, "[data-operator-error]");
|
|
||||||
createDialog?.showModal();
|
|
||||||
createForm?.querySelector<HTMLInputElement>('input[name="username"]')?.focus();
|
|
||||||
});
|
|
||||||
container
|
|
||||||
.querySelector<HTMLButtonElement>("[data-close-operator]")
|
|
||||||
?.addEventListener("click", () => createDialog?.close());
|
|
||||||
createForm?.addEventListener("submit", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void createOperator(container, createDialog, createForm, currentUsername);
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetDialog =
|
|
||||||
container.querySelector<HTMLDialogElement>("[data-reset-dialog]");
|
|
||||||
const resetForm = container.querySelector<HTMLFormElement>("[data-reset-form]");
|
|
||||||
container
|
|
||||||
.querySelector<HTMLButtonElement>("[data-close-reset]")
|
|
||||||
?.addEventListener("click", () => resetDialog?.close());
|
|
||||||
resetForm?.addEventListener("submit", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void resetCredentials(container, resetDialog, resetForm, currentUsername);
|
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelector("tbody")?.addEventListener("click", (event) => {
|
|
||||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>(
|
|
||||||
"[data-operator-action]",
|
|
||||||
);
|
|
||||||
const operatorId = button?.closest<HTMLElement>("[data-operator-id]")?.dataset
|
|
||||||
.operatorId;
|
|
||||||
const operator = operators.find((item) => item.operatorId === operatorId);
|
|
||||||
if (!button || !operator) return;
|
|
||||||
void handleOperatorAction(
|
|
||||||
container,
|
|
||||||
button,
|
|
||||||
operator,
|
|
||||||
resetDialog,
|
|
||||||
resetForm,
|
|
||||||
currentUsername,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOperator(
|
|
||||||
container: HTMLElement,
|
|
||||||
dialog: HTMLDialogElement | null,
|
|
||||||
form: HTMLFormElement,
|
|
||||||
currentUsername: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const data = new FormData(form);
|
|
||||||
const username = data.get("username")?.toString().trim() ?? "";
|
|
||||||
const role = data.get("role")?.toString() as AdminRole;
|
|
||||||
const password = data.get("password")?.toString() ?? "";
|
|
||||||
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
|
|
||||||
const errorNode = form.querySelector<HTMLElement>("[data-operator-error]");
|
|
||||||
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
|
|
||||||
if (
|
|
||||||
!/^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/.test(username) ||
|
|
||||||
!["SUPER_ADMIN", "SUPPORT", "ANALYST"].includes(role) ||
|
|
||||||
password.length < 12 ||
|
|
||||||
password !== confirmation ||
|
|
||||||
!submit
|
|
||||||
) {
|
|
||||||
if (errorNode) errorNode.textContent = "请检查用户名、角色及两次输入的密码";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setButtonBusy(submit, true, "创建中…");
|
|
||||||
clearError(form, "[data-operator-error]");
|
|
||||||
try {
|
|
||||||
const provisioning = await adminApi.createOperator({
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
role,
|
|
||||||
});
|
|
||||||
form.reset();
|
|
||||||
dialog?.close();
|
|
||||||
await showProvisioning(container, provisioning, "管理员已创建");
|
|
||||||
await renderSecurity(container, currentUsername);
|
|
||||||
} catch (error) {
|
|
||||||
if (errorNode) errorNode.textContent = errorMessage(error, "创建管理员失败");
|
|
||||||
setButtonBusy(submit, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOperatorAction(
|
|
||||||
container: HTMLElement,
|
|
||||||
button: HTMLButtonElement,
|
|
||||||
operator: AdminOperator,
|
|
||||||
resetDialog: HTMLDialogElement | null,
|
|
||||||
resetForm: HTMLFormElement | null,
|
|
||||||
currentUsername: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const action = button.dataset.operatorAction;
|
|
||||||
if (action === "reset" && resetDialog && resetForm) {
|
|
||||||
resetForm.reset();
|
|
||||||
resetForm.dataset.operatorId = operator.operatorId;
|
|
||||||
const username = resetForm.querySelector<HTMLElement>("[data-reset-username]");
|
|
||||||
if (username) username.textContent = operator.username;
|
|
||||||
clearError(resetForm, "[data-reset-error]");
|
|
||||||
resetDialog.showModal();
|
|
||||||
resetForm.querySelector<HTMLInputElement>('input[name="password"]')?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmation = actionConfirmation(action, operator.username);
|
|
||||||
if (!confirmation || !(await confirmAction(container, confirmation))) return;
|
|
||||||
|
|
||||||
setButtonBusy(button, true);
|
|
||||||
try {
|
|
||||||
if (action === "enable") {
|
|
||||||
await adminApi.setOperatorEnabled(operator.operatorId, true);
|
|
||||||
} else if (action === "disable") {
|
|
||||||
await adminApi.setOperatorEnabled(operator.operatorId, false);
|
|
||||||
} else if (action === "unlock") {
|
|
||||||
await adminApi.unlockOperator(operator.operatorId);
|
|
||||||
} else if (action === "sessions") {
|
|
||||||
await adminApi.revokeOperatorSessions(operator.operatorId);
|
|
||||||
}
|
|
||||||
showToast("安全设置已更新", "success");
|
|
||||||
await renderSecurity(container, currentUsername);
|
|
||||||
} catch (error) {
|
|
||||||
showToast(errorMessage(error, "安全设置更新失败"), "error");
|
|
||||||
setButtonBusy(button, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetCredentials(
|
|
||||||
container: HTMLElement,
|
|
||||||
dialog: HTMLDialogElement | null,
|
|
||||||
form: HTMLFormElement,
|
|
||||||
currentUsername: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const operatorId = form.dataset.operatorId;
|
|
||||||
const data = new FormData(form);
|
|
||||||
const password = data.get("password")?.toString() ?? "";
|
|
||||||
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
|
|
||||||
const errorNode = form.querySelector<HTMLElement>("[data-reset-error]");
|
|
||||||
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
|
|
||||||
if (!operatorId || password.length < 12 || password !== confirmation || !submit) {
|
|
||||||
if (errorNode) errorNode.textContent = "请输入至少 12 位且两次一致的新密码";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setButtonBusy(submit, true, "重置中…");
|
|
||||||
clearError(form, "[data-reset-error]");
|
|
||||||
try {
|
|
||||||
const provisioning = await adminApi.resetOperatorCredentials(
|
|
||||||
operatorId,
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
form.reset();
|
|
||||||
dialog?.close();
|
|
||||||
await showProvisioning(container, provisioning, "登录凭据已重置");
|
|
||||||
await renderSecurity(container, currentUsername);
|
|
||||||
} catch (error) {
|
|
||||||
if (errorNode) errorNode.textContent = errorMessage(error, "凭据重置失败");
|
|
||||||
setButtonBusy(submit, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Confirmation {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
label: string;
|
|
||||||
dangerous?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionConfirmation(
|
|
||||||
action: string | undefined,
|
|
||||||
username: string,
|
|
||||||
): Confirmation | null {
|
|
||||||
const target = `“${username}”`;
|
|
||||||
if (action === "disable") {
|
|
||||||
return {
|
|
||||||
title: "停用管理员?",
|
|
||||||
message: `${target} 将无法登录,全部活动会话会立即失效。`,
|
|
||||||
label: "停用管理员",
|
|
||||||
dangerous: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (action === "enable") {
|
|
||||||
return {
|
|
||||||
title: "启用管理员?",
|
|
||||||
message: `${target} 将恢复登录权限。`,
|
|
||||||
label: "确认启用",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (action === "unlock") {
|
|
||||||
return {
|
|
||||||
title: "解除登录锁定?",
|
|
||||||
message: `${target} 可以立即重新尝试登录。`,
|
|
||||||
label: "确认解锁",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (action === "sessions") {
|
|
||||||
return {
|
|
||||||
title: "撤销全部会话?",
|
|
||||||
message: `${target} 已登录的所有设备都需要重新认证。`,
|
|
||||||
label: "撤销会话",
|
|
||||||
dangerous: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmAction(
|
|
||||||
container: HTMLElement,
|
|
||||||
confirmation: Confirmation,
|
|
||||||
): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const dialog = document.createElement("dialog");
|
|
||||||
dialog.className = "dialog";
|
|
||||||
dialog.setAttribute("aria-labelledby", "confirm-action-title");
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<form method="dialog" class="dialog-card">
|
|
||||||
<div class="eyebrow">确认操作</div>
|
|
||||||
<h2 id="confirm-action-title">${escapeHtml(confirmation.title)}</h2>
|
|
||||||
<p>${escapeHtml(confirmation.message)}</p>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="button button--secondary" value="cancel">取消</button>
|
|
||||||
<button class="button ${confirmation.dangerous ? "button--danger" : "button--primary"}" value="confirm">${escapeHtml(confirmation.label)}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
container.append(dialog);
|
|
||||||
dialog.addEventListener(
|
|
||||||
"close",
|
|
||||||
() => {
|
|
||||||
const confirmed = dialog.returnValue === "confirm";
|
|
||||||
dialog.remove();
|
|
||||||
resolve(confirmed);
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
dialog.showModal();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showProvisioning(
|
|
||||||
container: HTMLElement,
|
|
||||||
provisioning: AdminOperatorProvisioning,
|
|
||||||
title: string,
|
|
||||||
): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const dialog = document.createElement("dialog");
|
|
||||||
dialog.className = "dialog dialog--wide";
|
|
||||||
dialog.setAttribute("aria-labelledby", "provisioning-title");
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<div class="dialog-card provisioning-card">
|
|
||||||
<div class="eyebrow">仅显示一次</div>
|
|
||||||
<h2 id="provisioning-title">${escapeHtml(title)}</h2>
|
|
||||||
<p>请立即把 TOTP 密钥交给对应管理员,并确认已添加到认证器。</p>
|
|
||||||
<div class="secret-panel">
|
|
||||||
<span>Base32 密钥</span>
|
|
||||||
<code>${escapeHtml(provisioning.totpSecret)}</code>
|
|
||||||
<button class="button button--secondary" type="button" data-copy-secret>复制密钥</button>
|
|
||||||
</div>
|
|
||||||
<div class="dialog-actions dialog-actions--spread">
|
|
||||||
<a class="button button--secondary" href="${escapeHtml(provisioning.otpauthUri)}">在认证器中打开</a>
|
|
||||||
<button class="button button--secondary" type="button" data-copy-uri>复制配置链接</button>
|
|
||||||
</div>
|
|
||||||
<label class="confirmation-check">
|
|
||||||
<input type="checkbox" data-provisioning-saved />
|
|
||||||
<span>我已安全保存密钥,理解关闭后无法再次查看</span>
|
|
||||||
</label>
|
|
||||||
<button class="button button--primary button--wide" type="button" data-close-provisioning disabled>完成</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const close = dialog.querySelector<HTMLButtonElement>("[data-close-provisioning]");
|
|
||||||
dialog
|
|
||||||
.querySelector<HTMLInputElement>("[data-provisioning-saved]")
|
|
||||||
?.addEventListener("change", (event) => {
|
|
||||||
if (close) close.disabled = !(event.target as HTMLInputElement).checked;
|
|
||||||
});
|
|
||||||
dialog.addEventListener("cancel", (event) => event.preventDefault());
|
|
||||||
dialog
|
|
||||||
.querySelector<HTMLButtonElement>("[data-copy-secret]")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
void copySensitiveValue(provisioning.totpSecret, "TOTP 密钥");
|
|
||||||
});
|
|
||||||
dialog
|
|
||||||
.querySelector<HTMLButtonElement>("[data-copy-uri]")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
void copySensitiveValue(provisioning.otpauthUri, "认证器配置链接");
|
|
||||||
});
|
|
||||||
close?.addEventListener("click", () => dialog.close());
|
|
||||||
dialog.addEventListener(
|
|
||||||
"close",
|
|
||||||
() => {
|
|
||||||
dialog.remove();
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
container.append(dialog);
|
|
||||||
dialog.showModal();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copySensitiveValue(value: string, label: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
showToast(`${label}已复制,请妥善保管`, "success");
|
|
||||||
} catch {
|
|
||||||
showToast(`无法复制${label},请手动选择`, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearError(
|
|
||||||
container: ParentNode | null,
|
|
||||||
selector: string,
|
|
||||||
): void {
|
|
||||||
const node = container?.querySelector<HTMLElement>(selector);
|
|
||||||
if (node) node.textContent = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string): string {
|
|
||||||
return error instanceof ApiError ? error.message : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleLabel(role: AdminRole): string {
|
|
||||||
const labels: Record<AdminRole, string> = {
|
|
||||||
SUPER_ADMIN: "超级管理员",
|
|
||||||
SUPPORT: "支持人员",
|
|
||||||
ANALYST: "分析员",
|
|
||||||
};
|
|
||||||
return labels[role];
|
|
||||||
}
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
import { adminApi, ApiError } from "../api/client";
|
|
||||||
import type {
|
|
||||||
AdminRole,
|
|
||||||
LedgerEntry,
|
|
||||||
PageResult,
|
|
||||||
UserDetail,
|
|
||||||
UserSummary,
|
|
||||||
UserUsageAggregate,
|
|
||||||
} from "../api/types";
|
|
||||||
import {
|
|
||||||
renderEmpty,
|
|
||||||
renderError,
|
|
||||||
renderLoading,
|
|
||||||
setButtonBusy,
|
|
||||||
showToast,
|
|
||||||
} from "../components/ui";
|
|
||||||
import {
|
|
||||||
createIdempotencyKey,
|
|
||||||
escapeHtml,
|
|
||||||
formatDateTime,
|
|
||||||
formatNumber,
|
|
||||||
formatSignedCredits,
|
|
||||||
statusLabel,
|
|
||||||
} from "../lib/format";
|
|
||||||
|
|
||||||
export function renderUsers(container: HTMLElement, role: AdminRole): void {
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="page-heading">
|
|
||||||
<div>
|
|
||||||
<div class="eyebrow">账户管理</div>
|
|
||||||
<h1>用户查询</h1>
|
|
||||||
<p>按内部用户 ID 精确查询,不展示 Apple 身份标识。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section class="panel">
|
|
||||||
<form class="search-form" data-search-form>
|
|
||||||
<label class="search-box">
|
|
||||||
<span class="sr-only">内部用户 ID</span>
|
|
||||||
<span aria-hidden="true">⌕</span>
|
|
||||||
<input name="query" type="search" placeholder="输入完整内部用户 ID" autocomplete="off" maxlength="36" required />
|
|
||||||
</label>
|
|
||||||
<wa-button variant="brand" appearance="accent" type="submit">搜索</wa-button>
|
|
||||||
</form>
|
|
||||||
<div data-results>${renderEmpty("输入查询条件开始搜索")}</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
|
|
||||||
form?.addEventListener("submit", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const search = new FormData(form).get("query")?.toString().trim() ?? "";
|
|
||||||
if (search) void searchUsers(container, search, role);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchUsers(
|
|
||||||
container: HTMLElement,
|
|
||||||
search: string,
|
|
||||||
role: AdminRole,
|
|
||||||
): Promise<void> {
|
|
||||||
const results = container.querySelector<HTMLElement>("[data-results]");
|
|
||||||
if (!results) return;
|
|
||||||
renderLoading(results, "搜索用户");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const page = await adminApi.users(search);
|
|
||||||
if (page.items.length === 0) {
|
|
||||||
results.innerHTML = renderEmpty("未找到匹配用户");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
results.innerHTML = `
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<caption class="sr-only">用户查询结果</caption>
|
|
||||||
<thead><tr><th scope="col">用户</th><th scope="col">状态</th><th scope="col">积分余额</th><th scope="col">注册时间</th><th scope="col">操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${page.items.map(userRow).join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
results.querySelectorAll<HTMLButtonElement>("[data-user-id]").forEach(
|
|
||||||
(button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
const userId = button.dataset.userId;
|
|
||||||
if (userId) void renderUserDetail(container, userId, role);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
renderError(results, error, () => void searchUsers(container, search, role));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function userRow(user: UserSummary): string {
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong>${escapeHtml(user.displayName || "未命名用户")}</strong>
|
|
||||||
<div class="subtle mono">${escapeHtml(user.userId)}</div>
|
|
||||||
</td>
|
|
||||||
<td><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></td>
|
|
||||||
<td>${formatNumber(user.creditBalance)}</td>
|
|
||||||
<td>${formatDateTime(user.createdAt)}</td>
|
|
||||||
<td><wa-button variant="neutral" appearance="outlined" size="s" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</wa-button></td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderUserDetail(
|
|
||||||
container: HTMLElement,
|
|
||||||
userId: string,
|
|
||||||
role: AdminRole,
|
|
||||||
): Promise<void> {
|
|
||||||
renderLoading(container, "加载用户详情");
|
|
||||||
try {
|
|
||||||
const [user, ledger] = await Promise.all([
|
|
||||||
adminApi.user(userId),
|
|
||||||
adminApi.ledger(userId),
|
|
||||||
]);
|
|
||||||
container.innerHTML = detailTemplate(user, ledger, role);
|
|
||||||
container
|
|
||||||
.querySelector<HTMLButtonElement>("[data-back]")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
renderUsers(container, role);
|
|
||||||
container.querySelector<HTMLInputElement>('input[name="query"]')?.focus();
|
|
||||||
});
|
|
||||||
bindLedgerPagination(container, user, ledger.nextCursor);
|
|
||||||
if (role === "SUPER_ADMIN") bindGrantDialog(container, user, role);
|
|
||||||
const heading = container.querySelector<HTMLElement>("h1");
|
|
||||||
if (heading) {
|
|
||||||
heading.tabIndex = -1;
|
|
||||||
heading.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
renderError(container, error, () =>
|
|
||||||
void renderUserDetail(container, userId, role),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detailTemplate(
|
|
||||||
user: UserDetail,
|
|
||||||
ledger: PageResult<LedgerEntry>,
|
|
||||||
role: AdminRole,
|
|
||||||
): string {
|
|
||||||
const usage = user.usage ?? [];
|
|
||||||
const usageRequests = usage.reduce((total, item) => total + item.requests, 0);
|
|
||||||
const chargedCredits = usage.reduce(
|
|
||||||
(total, item) => total + item.chargedCredits,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const referral = user.referral;
|
|
||||||
const inviterUserId = referral?.inviterUserId ?? user.referredByUserId;
|
|
||||||
const qualifiedUsage = user.qualifiedUsage || usageRequests > 0;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="page-heading page-heading--detail">
|
|
||||||
<div>
|
|
||||||
<wa-button class="back-link" appearance="plain" data-back>← 返回用户查询</wa-button>
|
|
||||||
<div class="eyebrow">用户详情</div>
|
|
||||||
<h1>${escapeHtml(user.displayName || "未命名用户")}</h1>
|
|
||||||
<p class="mono">${escapeHtml(user.userId)}</p>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
role === "SUPER_ADMIN"
|
|
||||||
? '<wa-button variant="brand" appearance="accent" data-open-grant>人工赠送积分</wa-button>'
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<section class="detail-grid">
|
|
||||||
<article class="panel profile-card">
|
|
||||||
<div class="profile-header">
|
|
||||||
<div class="avatar" aria-hidden="true">${escapeHtml((user.displayName || "用").slice(0, 1))}</div>
|
|
||||||
<div><strong>${escapeHtml(user.displayName || "未命名用户")}</strong><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></div>
|
|
||||||
</div>
|
|
||||||
<dl class="detail-list">
|
|
||||||
<div><dt>积分余额</dt><dd class="credit-value">${formatNumber(user.creditBalance)}</dd></div>
|
|
||||||
<div><dt>注册时间</dt><dd>${formatDateTime(user.createdAt)}</dd></div>
|
|
||||||
<div><dt>最近活跃</dt><dd>${formatDateTime(user.lastActiveAt)}</dd></div>
|
|
||||||
<div><dt>有效使用</dt><dd>${qualifiedUsage ? "已达成" : "未达成"}${usage.length > 0 ? ` · ${formatNumber(usageRequests)} 次请求` : ""}</dd></div>
|
|
||||||
${
|
|
||||||
usage.length > 0
|
|
||||||
? `<div><dt>累计消耗</dt><dd>${formatNumber(chargedCredits)} 积分</dd></div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
<div><dt>邀请码</dt><dd class="mono">${escapeHtml(user.referralCode || "—")}</dd></div>
|
|
||||||
<div><dt>邀请来源</dt><dd class="mono">${escapeHtml(inviterUserId || "—")}</dd></div>
|
|
||||||
${
|
|
||||||
referral
|
|
||||||
? `<div><dt>邀请成效</dt><dd>${formatNumber(referral.rewardedInvites)} / ${formatNumber(referral.invitedUsers)} 已奖励</dd></div>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-heading"><div><h2>积分流水</h2><p>不可变账本记录</p></div></div>
|
|
||||||
${
|
|
||||||
ledger.items.length === 0
|
|
||||||
? renderEmpty("暂无积分流水")
|
|
||||||
: `
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<caption class="sr-only">${escapeHtml(user.displayName || user.userId)} 的积分流水</caption>
|
|
||||||
<thead><tr><th scope="col">时间</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
|
|
||||||
<tbody data-ledger-body>${ledgerRows(ledger.items)}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
ledger.nextCursor
|
|
||||||
? '<div class="pagination-actions"><wa-button variant="neutral" appearance="outlined" data-ledger-more aria-describedby="ledger-pagination-status">加载更多流水</wa-button></div>'
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
<p class="sr-only" id="ledger-pagination-status" data-ledger-status role="status" aria-live="polite"></p>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
${usagePanel(usage)}
|
|
||||||
</section>
|
|
||||||
${role === "SUPER_ADMIN" ? grantDialog(user) : ""}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ledgerRows(entries: LedgerEntry[]): string {
|
|
||||||
return entries
|
|
||||||
.map(
|
|
||||||
(entry) => `
|
|
||||||
<tr>
|
|
||||||
<td>${formatDateTime(entry.createdAt)}</td>
|
|
||||||
<td>${escapeHtml(statusLabel(entry.type))}</td>
|
|
||||||
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
|
|
||||||
<td>${formatNumber(entry.balanceAfter)}</td>
|
|
||||||
<td>${escapeHtml(entry.reasonCode)}</td>
|
|
||||||
</tr>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function usagePanel(usage: UserUsageAggregate[]): string {
|
|
||||||
if (usage.length === 0) return "";
|
|
||||||
return `
|
|
||||||
<section class="panel detail-panel--wide">
|
|
||||||
<div class="panel-heading"><div><h2>使用统计</h2><p>按使用类型汇总,不包含用户内容</p></div></div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<caption class="sr-only">用户使用统计</caption>
|
|
||||||
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音时长(毫秒)</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${usage
|
|
||||||
.map(
|
|
||||||
(item) => `
|
|
||||||
<tr>
|
|
||||||
<td><code>${escapeHtml(item.kind)}</code></td>
|
|
||||||
<td>${formatNumber(item.requests)}</td>
|
|
||||||
<td>${formatNumber(item.chargedCredits)}</td>
|
|
||||||
<td>${formatNumber(item.asrMillis)}</td>
|
|
||||||
<td>${formatNumber(item.inputTokens)}</td>
|
|
||||||
<td>${formatNumber(item.outputTokens)}</td>
|
|
||||||
</tr>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindLedgerPagination(
|
|
||||||
container: HTMLElement,
|
|
||||||
user: UserDetail,
|
|
||||||
initialCursor?: string,
|
|
||||||
): void {
|
|
||||||
const button =
|
|
||||||
container.querySelector<HTMLButtonElement>("[data-ledger-more]");
|
|
||||||
const body =
|
|
||||||
container.querySelector<HTMLTableSectionElement>("[data-ledger-body]");
|
|
||||||
const status = container.querySelector<HTMLElement>("[data-ledger-status]");
|
|
||||||
if (!button || !body || !initialCursor) return;
|
|
||||||
let cursor: string | undefined = initialCursor;
|
|
||||||
|
|
||||||
button.addEventListener("click", async () => {
|
|
||||||
if (!cursor) return;
|
|
||||||
setButtonBusy(button, true, "加载中…");
|
|
||||||
try {
|
|
||||||
const page = await adminApi.ledger(user.userId, cursor);
|
|
||||||
body.insertAdjacentHTML("beforeend", ledgerRows(page.items));
|
|
||||||
cursor = page.nextCursor;
|
|
||||||
if (status) {
|
|
||||||
status.textContent = cursor
|
|
||||||
? `已加载 ${formatNumber(page.items.length)} 条更多流水`
|
|
||||||
: `已加载 ${formatNumber(page.items.length)} 条流水,全部记录已加载`;
|
|
||||||
}
|
|
||||||
if (!cursor) {
|
|
||||||
button.closest(".pagination-actions")?.remove();
|
|
||||||
} else {
|
|
||||||
setButtonBusy(button, false);
|
|
||||||
button.focus();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast(
|
|
||||||
error instanceof ApiError ? error.message : "加载积分流水失败",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
setButtonBusy(button, false);
|
|
||||||
button.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function grantDialog(user: UserDetail): string {
|
|
||||||
return `
|
|
||||||
<dialog class="dialog" data-grant-dialog aria-labelledby="grant-dialog-title">
|
|
||||||
<form method="dialog" class="dialog-card" data-grant-form>
|
|
||||||
<div data-grant-inputs>
|
|
||||||
<div class="eyebrow">高风险操作</div>
|
|
||||||
<h2 id="grant-dialog-title">人工赠送积分</h2>
|
|
||||||
<p class="muted">赠送将写入不可变账本,并记录管理员审计日志。</p>
|
|
||||||
<label><span>用户 ID</span><input value="${escapeHtml(user.userId)}" disabled /></label>
|
|
||||||
<label><span>赠送积分</span><input name="amount" type="number" inputmode="numeric" min="1" max="100000" step="1" aria-describedby="grant-input-error" required /></label>
|
|
||||||
<label><span>赠送原因</span><textarea name="reason" minlength="4" maxlength="200" placeholder="请填写可审计的业务原因" aria-describedby="grant-input-error" required></textarea></label>
|
|
||||||
<p class="form-error" id="grant-input-error" data-error role="alert"></p>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="button button--secondary" value="cancel">取消</button>
|
|
||||||
<button class="button button--primary" type="button" data-review-grant>下一步</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div data-grant-confirm hidden>
|
|
||||||
<div class="confirm-icon" aria-hidden="true">!</div>
|
|
||||||
<h2 data-confirm-title tabindex="-1">确认赠送?</h2>
|
|
||||||
<p>将向 <strong>${escapeHtml(user.displayName || user.userId)}</strong> 赠送 <strong data-confirm-amount></strong> 积分。</p>
|
|
||||||
<p class="confirm-reason" data-confirm-reason></p>
|
|
||||||
<p class="form-error" id="grant-confirm-error" data-confirm-error role="alert"></p>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="button button--secondary" type="button" data-edit-grant>返回修改</button>
|
|
||||||
<button class="button button--danger" type="button" data-submit-grant aria-describedby="grant-confirm-error">确认赠送</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindGrantDialog(
|
|
||||||
container: HTMLElement,
|
|
||||||
user: UserDetail,
|
|
||||||
role: AdminRole,
|
|
||||||
): void {
|
|
||||||
const dialog = container.querySelector<HTMLDialogElement>("[data-grant-dialog]");
|
|
||||||
const form = container.querySelector<HTMLFormElement>("[data-grant-form]");
|
|
||||||
const inputs = container.querySelector<HTMLElement>("[data-grant-inputs]");
|
|
||||||
const confirm = container.querySelector<HTMLElement>("[data-grant-confirm]");
|
|
||||||
if (!dialog || !form || !inputs || !confirm) return;
|
|
||||||
let idempotencyKey = createIdempotencyKey();
|
|
||||||
let outcomeUnknown = false;
|
|
||||||
|
|
||||||
container
|
|
||||||
.querySelector<HTMLButtonElement>("[data-open-grant]")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
idempotencyKey = createIdempotencyKey();
|
|
||||||
outcomeUnknown = false;
|
|
||||||
form.reset();
|
|
||||||
inputs.hidden = false;
|
|
||||||
confirm.hidden = true;
|
|
||||||
form.querySelectorAll<HTMLElement>(".form-error").forEach((node) => {
|
|
||||||
node.textContent = "";
|
|
||||||
});
|
|
||||||
dialog.showModal();
|
|
||||||
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
dialog.addEventListener("cancel", (event) => {
|
|
||||||
if (!outcomeUnknown) return;
|
|
||||||
event.preventDefault();
|
|
||||||
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
|
|
||||||
if (errorNode) {
|
|
||||||
errorNode.textContent = "赠送结果尚未确认,请使用当前窗口安全重试";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
container
|
|
||||||
.querySelector<HTMLButtonElement>("[data-review-grant]")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
const data = new FormData(form);
|
|
||||||
const amount = Number(data.get("amount"));
|
|
||||||
const reason = data.get("reason")?.toString().trim() ?? "";
|
|
||||||
const errorNode = form.querySelector<HTMLElement>("[data-error]");
|
|
||||||
if (!Number.isSafeInteger(amount) || amount < 1 || amount > 100_000) {
|
|
||||||
if (errorNode) errorNode.textContent = "积分必须是 1 至 100,000 的整数";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (reason.length < 4) {
|
|
||||||
if (errorNode) errorNode.textContent = "请填写至少 4 个字符的赠送原因";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (errorNode) errorNode.textContent = "";
|
|
||||||
const amountNode = form.querySelector<HTMLElement>("[data-confirm-amount]");
|
|
||||||
const reasonNode = form.querySelector<HTMLElement>("[data-confirm-reason]");
|
|
||||||
if (amountNode) amountNode.textContent = formatNumber(amount);
|
|
||||||
if (reasonNode) reasonNode.textContent = `原因:${reason}`;
|
|
||||||
inputs.hidden = true;
|
|
||||||
confirm.hidden = false;
|
|
||||||
form.querySelector<HTMLElement>("[data-confirm-title]")?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
container
|
|
||||||
.querySelector<HTMLButtonElement>("[data-edit-grant]")
|
|
||||||
?.addEventListener("click", () => {
|
|
||||||
if (outcomeUnknown) {
|
|
||||||
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
|
|
||||||
if (errorNode) {
|
|
||||||
errorNode.textContent = "结果尚未确认,不能修改本次赠送内容;请直接重试";
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
confirm.hidden = true;
|
|
||||||
inputs.hidden = false;
|
|
||||||
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = container.querySelector<HTMLButtonElement>("[data-submit-grant]");
|
|
||||||
submit?.addEventListener("click", async () => {
|
|
||||||
const data = new FormData(form);
|
|
||||||
const amount = Number(data.get("amount"));
|
|
||||||
const reason = data.get("reason")?.toString().trim() ?? "";
|
|
||||||
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
|
|
||||||
setButtonBusy(submit, true, "赠送中…");
|
|
||||||
if (errorNode) errorNode.textContent = "";
|
|
||||||
try {
|
|
||||||
await adminApi.grantCredits({
|
|
||||||
userId: user.userId,
|
|
||||||
amount,
|
|
||||||
reason,
|
|
||||||
idempotencyKey,
|
|
||||||
});
|
|
||||||
dialog.close();
|
|
||||||
showToast("积分赠送成功,账本已更新", "success");
|
|
||||||
await renderUserDetail(container, user.userId, role);
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof ApiError ? error.message : "赠送失败,请重试";
|
|
||||||
outcomeUnknown =
|
|
||||||
!(error instanceof ApiError) || error.status === 0 || error.status >= 500;
|
|
||||||
if (errorNode) {
|
|
||||||
errorNode.textContent = outcomeUnknown
|
|
||||||
? "赠送结果尚未确认;重试会复用同一请求,不会重复到账"
|
|
||||||
: message;
|
|
||||||
}
|
|
||||||
setButtonBusy(submit, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+348
-1309
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { funnelChart, trendChart } from "../components/charts";
|
|
||||||
|
|
||||||
describe("accessible charts", () => {
|
|
||||||
it("provides a titled SVG and complete data table for trends", () => {
|
|
||||||
const html = trendChart([
|
|
||||||
{ date: "2026-08-16", registrations: 12, creditsUsed: 345 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(html).toContain('aria-labelledby="trend-chart-title trend-chart-description"');
|
|
||||||
expect(html).toContain("<caption>新增用户与积分消耗趋势完整数据</caption>");
|
|
||||||
expect(html).toContain("<td>345</td>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the funnel without CSP-sensitive inline styles", () => {
|
|
||||||
const html = funnelChart([
|
|
||||||
{ label: "访问", count: 20 },
|
|
||||||
{ label: "注册", count: 10 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(html).toContain("<progress");
|
|
||||||
expect(html).not.toContain('style="');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -57,6 +57,23 @@ describe("adminApi", () => {
|
|||||||
expect(request.credentials).toBe("include");
|
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 () => {
|
it("登录请求不依赖已有会话 CSRF", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response(
|
new Response(
|
||||||
@@ -90,17 +107,87 @@ describe("adminApi", () => {
|
|||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await adminApi.latestLedger("latest+/=");
|
||||||
await adminApi.ledger("user/with space", "ledger+/=");
|
await adminApi.ledger("user/with space", "ledger+/=");
|
||||||
await adminApi.operators("operator+/=");
|
await adminApi.operators("operator+/=");
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||||
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
|
"/v1/admin/credits/ledger?cursor=latest%2B%2F%3D",
|
||||||
);
|
);
|
||||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||||
|
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
|
||||||
|
);
|
||||||
|
expect(fetchMock.mock.calls[2]?.[0]).toBe(
|
||||||
"/v1/admin/operators?cursor=operator%2B%2F%3D",
|
"/v1/admin/operators?cursor=operator%2B%2F%3D",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("缺少 CSRF 时在发送变更请求前失败", async () => {
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
@@ -189,4 +276,54 @@ describe("adminApi", () => {
|
|||||||
);
|
);
|
||||||
expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP");
|
expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Provider API Key 替换请求携带 CSRF 且只提交新 Key", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
providerId: "deepseek",
|
||||||
|
configured: true,
|
||||||
|
source: "RUNTIME_OVERRIDE",
|
||||||
|
updatedAt: "2026-08-22T08:00:00Z",
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
setCsrfToken("csrf-provider");
|
||||||
|
|
||||||
|
await adminApi.updateProviderApiKey("deepseek", { apiKey: "new-provider-key" });
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||||
|
"/v1/admin/providers/deepseek/api-key",
|
||||||
|
);
|
||||||
|
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||||
|
expect(request.method).toBe("PUT");
|
||||||
|
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-provider");
|
||||||
|
expect(request.body).toBe(JSON.stringify({ apiKey: "new-provider-key" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Provider API Key 查看请求携带 CSRF 且只提交动态验证码", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ apiKey: "current-provider-key" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
setCsrfToken("csrf-reveal");
|
||||||
|
|
||||||
|
const result = await adminApi.revealProviderApiKey("volcengine", {
|
||||||
|
totpCode: "123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||||
|
"/v1/admin/providers/volcengine/api-key/reveal",
|
||||||
|
);
|
||||||
|
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||||
|
expect(request.method).toBe("POST");
|
||||||
|
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-reveal");
|
||||||
|
expect(request.body).toBe(JSON.stringify({ totpCode: "123456" }));
|
||||||
|
expect(result.apiKey).toBe("current-provider-key");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { adminApi } from "../api/client";
|
||||||
|
import type { AdminRole, OfficialSkillCatalog } from "../api/types";
|
||||||
|
import { App } from "../app";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.location.hash = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("内容管理", () => {
|
||||||
|
it("SUPPORT 可查看但不能修改 Skill 与 Hint", async () => {
|
||||||
|
mockSession("SUPPORT");
|
||||||
|
mockContent();
|
||||||
|
window.location.hash = "#/content";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "内容管理" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("link", { name: /内容管理/ })).toBeTruthy();
|
||||||
|
expect(await screen.findByText("润色")).toBeTruthy();
|
||||||
|
expect(screen.getByText("只读访问")).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "新增 Skill" })).toBeNull();
|
||||||
|
const editor = await screen.findByLabelText("zh JSON");
|
||||||
|
expect(editor).toHaveProperty("readOnly", true);
|
||||||
|
expect(screen.queryByRole("button", { name: "保存" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "立即生成" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "保存生成设置" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPER_ADMIN 可启停官方 Skill", async () => {
|
||||||
|
mockSession("SUPER_ADMIN");
|
||||||
|
mockContent();
|
||||||
|
const mutation = vi
|
||||||
|
.spyOn(adminApi, "setContentSkillEnabled")
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
window.location.hash = "#/content";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "停用" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mutation).toHaveBeenCalledWith("official.polish", false);
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "新增 Skill" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Skill 编辑器约束与客户端边界一致", async () => {
|
||||||
|
mockSession("SUPER_ADMIN");
|
||||||
|
mockContent();
|
||||||
|
window.location.hash = "#/content";
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "新增 Skill" }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Skill ID").getAttribute("maxlength")).toBe("100");
|
||||||
|
expect(screen.getByLabelText("SF Symbol").getAttribute("maxlength")).toBe("100");
|
||||||
|
const sortOrder = screen.getByLabelText("排序");
|
||||||
|
expect(sortOrder.getAttribute("min")).toBe("0");
|
||||||
|
expect(sortOrder.getAttribute("max")).toBe("100000");
|
||||||
|
screen.getAllByLabelText("名称").forEach((input) => {
|
||||||
|
expect(input.getAttribute("maxlength")).toBe("40");
|
||||||
|
});
|
||||||
|
screen.getAllByLabelText("摘要").forEach((input) => {
|
||||||
|
expect(input.getAttribute("maxlength")).toBe("200");
|
||||||
|
});
|
||||||
|
screen.getAllByLabelText("Prompt").forEach((input) => {
|
||||||
|
expect(input.getAttribute("maxlength")).toBe("6000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPER_ADMIN 可手动触发双语 Hint 原子生成", async () => {
|
||||||
|
mockSession("SUPER_ADMIN");
|
||||||
|
mockContent();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
const regenerate = vi.spyOn(adminApi, "regenerateHintFeed").mockResolvedValue({
|
||||||
|
generationId: "00000000-0000-0000-0000-000000000001",
|
||||||
|
generatedAt: "2026-08-21T06:00:00Z",
|
||||||
|
zh: { version: 3, cardCount: 20 },
|
||||||
|
en: { version: 3, cardCount: 25 },
|
||||||
|
});
|
||||||
|
window.location.hash = "#/content";
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "立即生成" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(regenerate).toHaveBeenCalledOnce());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPER_ADMIN 可编辑并保存当前生效的 Hint pack", async () => {
|
||||||
|
mockSession("SUPER_ADMIN");
|
||||||
|
mockContent();
|
||||||
|
const save = vi.spyOn(adminApi, "updateContentHintPack").mockResolvedValue({
|
||||||
|
locale: "zh",
|
||||||
|
generatedAt: "2026-08-21T06:00:00Z",
|
||||||
|
intervalHours: 12,
|
||||||
|
version: 3,
|
||||||
|
cards: [],
|
||||||
|
});
|
||||||
|
window.location.hash = "#/content";
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "保存" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(save).toHaveBeenCalledOnce());
|
||||||
|
expect(await screen.findByText("version 3")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockSession(role: AdminRole) {
|
||||||
|
vi.spyOn(adminApi, "session").mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
operatorName: "owner",
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockContent() {
|
||||||
|
vi.spyOn(adminApi, "contentSkills").mockResolvedValue(catalog());
|
||||||
|
vi.spyOn(adminApi, "contentHintPack").mockResolvedValue({
|
||||||
|
locale: "zh",
|
||||||
|
generatedAt: "2026-08-21T04:00:00Z",
|
||||||
|
intervalHours: 12,
|
||||||
|
version: 2,
|
||||||
|
cards: [],
|
||||||
|
});
|
||||||
|
vi.spyOn(adminApi, "hintFeedSettings").mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
topHubApiKeyConfigured: false,
|
||||||
|
generationIntervalHours: 12,
|
||||||
|
holidayCountriesZh: "CN",
|
||||||
|
holidayCountriesEn: "US,GB",
|
||||||
|
weatherCitiesZh: "北京:39.90,116.40",
|
||||||
|
weatherCitiesEn: "London:51.51,-0.13",
|
||||||
|
googleTrendsGeos: "US,GB",
|
||||||
|
});
|
||||||
|
vi.spyOn(adminApi, "hintFeedStatus").mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
outcome: "SUCCEEDED",
|
||||||
|
intervalHours: 12,
|
||||||
|
topHubApiKeyConfigured: false,
|
||||||
|
zhVersion: 2,
|
||||||
|
zhCardCount: 20,
|
||||||
|
enVersion: 2,
|
||||||
|
enCardCount: 25,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalog(): OfficialSkillCatalog {
|
||||||
|
return {
|
||||||
|
revision: 3,
|
||||||
|
generatedAt: "2026-08-21T04:00:00Z",
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
id: "official.polish",
|
||||||
|
systemImage: "wand.and.sparkles",
|
||||||
|
sortOrder: 10,
|
||||||
|
kind: "transform",
|
||||||
|
thinkingEnabled: false,
|
||||||
|
enabled: true,
|
||||||
|
localizations: {
|
||||||
|
"zh-Hans": {
|
||||||
|
name: "润色",
|
||||||
|
summary: "优化表达",
|
||||||
|
prompt: "请润色",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
name: "Polish",
|
||||||
|
summary: "Improve wording",
|
||||||
|
prompt: "Please polish",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { DataTable, type DataColumn } from "../components/data-table";
|
||||||
|
import { DateRangeControl } from "../components/date-range-control";
|
||||||
|
import type { SortOrder } from "../api/types";
|
||||||
|
import { stableSort } from "../lib/sort";
|
||||||
|
import { LedgerFilters } from "../features/credits/ledger-filters";
|
||||||
|
import {
|
||||||
|
ledgerDatePreset,
|
||||||
|
readLedgerFilterState,
|
||||||
|
} from "../features/credits/use-ledger-filters";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("筛选与排序基础能力", () => {
|
||||||
|
it("DataTable 暴露受控 aria-sort 并通过表头切换方向", async () => {
|
||||||
|
function TableHarness() {
|
||||||
|
const [order, setOrder] = useState<SortOrder>("desc");
|
||||||
|
const columns: DataColumn<{ createdAt: string }>[] = [
|
||||||
|
{ accessorKey: "createdAt", header: "时间" },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
data={[{ createdAt: "2026-08-20T00:00:00Z" }]}
|
||||||
|
columns={columns}
|
||||||
|
caption="排序测试"
|
||||||
|
sort={{ key: "createdAt", order }}
|
||||||
|
sortableColumns={{ createdAt: "createdAt" }}
|
||||||
|
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TableHarness />);
|
||||||
|
const header = screen.getByRole("columnheader", { name: /时间/ });
|
||||||
|
expect(header.getAttribute("aria-sort")).toBe("descending");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /时间/ }));
|
||||||
|
|
||||||
|
expect(header.getAttribute("aria-sort")).toBe("ascending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("稳定排序不修改输入并保留同值项目顺序", () => {
|
||||||
|
const source = [
|
||||||
|
{ id: "first", value: 2 },
|
||||||
|
{ id: "second", value: 2 },
|
||||||
|
{ id: "third", value: 1 },
|
||||||
|
];
|
||||||
|
const result = stableSort(source, (item) => item.value, "desc");
|
||||||
|
|
||||||
|
expect(result.map((item) => item.id)).toEqual(["first", "second", "third"]);
|
||||||
|
expect(source.map((item) => item.id)).toEqual(["first", "second", "third"]);
|
||||||
|
expect(result).not.toBe(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("日期范围将结束日期转换为次日 UTC 半开边界", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<DateRangeControl value={{}} onChange={onChange} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText("结束日期"), "2026-08-20");
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
|
until: "2026-08-21T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("流水时间预设生成 UTC 半开区间", () => {
|
||||||
|
const now = new Date("2026-08-20T21:30:00+08:00");
|
||||||
|
|
||||||
|
expect(ledgerDatePreset("today", now)).toEqual({
|
||||||
|
from: "2026-08-20T00:00:00.000Z",
|
||||||
|
until: "2026-08-21T00:00:00.000Z",
|
||||||
|
range: "today",
|
||||||
|
});
|
||||||
|
expect(ledgerDatePreset("7d", now)).toEqual({
|
||||||
|
from: "2026-08-14T00:00:00.000Z",
|
||||||
|
until: "2026-08-21T00:00:00.000Z",
|
||||||
|
range: "7d",
|
||||||
|
});
|
||||||
|
expect(ledgerDatePreset("all", now)).toEqual({
|
||||||
|
from: "",
|
||||||
|
until: "",
|
||||||
|
range: "all",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("关联 ID 完整且合法后才应用筛选", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<LedgerFilters
|
||||||
|
value={readLedgerFilterState(new URLSearchParams())}
|
||||||
|
onChange={onChange}
|
||||||
|
onClear={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("关联 ID");
|
||||||
|
await userEvent.type(input, "invalid");
|
||||||
|
|
||||||
|
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(screen.getByText("请输入完整 UUID")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "应用" }).hasAttribute("disabled")).toBe(true);
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "应用" }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
|
referenceId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
formatDateTime,
|
formatDateTime,
|
||||||
formatSignedCredits,
|
formatSignedCredits,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
|
usageTypeLabel,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
|
import {
|
||||||
|
ledgerEntryTypeLabel,
|
||||||
|
ledgerUsageClass,
|
||||||
|
} from "../features/credits/ledger-format";
|
||||||
|
|
||||||
describe("format helpers", () => {
|
describe("format helpers", () => {
|
||||||
it("转义服务端文本以阻止 HTML 注入", () => {
|
it("转义服务端文本以阻止 HTML 注入", () => {
|
||||||
@@ -26,4 +31,49 @@ describe("format helpers", () => {
|
|||||||
it("未知状态保持原值", () => {
|
it("未知状态保持原值", () => {
|
||||||
expect(statusLabel("custom")).toBe("custom");
|
expect(statusLabel("custom")).toBe("custom");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("反滥用限制不会误显示为账户停用", () => {
|
||||||
|
expect(statusLabel("suspended")).toBe("反滥用受限");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("消费类型使用清晰的中文标签", () => {
|
||||||
|
expect(usageTypeLabel("polish")).toBe("润色");
|
||||||
|
expect(usageTypeLabel("hotword")).toBe("热词");
|
||||||
|
expect(usageTypeLabel()).toBe("—");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("十种流水业务来源使用稳定中文标签,未知值保留原值", () => {
|
||||||
|
expect([
|
||||||
|
"SIGNUP_TRIAL",
|
||||||
|
"MANUAL_GRANT",
|
||||||
|
"USAGE_RESERVE",
|
||||||
|
"USAGE_SETTLE",
|
||||||
|
"USAGE_RELEASE",
|
||||||
|
"USAGE_REFUND",
|
||||||
|
"REFERRAL_INVITER",
|
||||||
|
"REFERRAL_INVITEE",
|
||||||
|
"STOREKIT_PURCHASE",
|
||||||
|
"SUBSCRIPTION_GRANT",
|
||||||
|
].map(ledgerEntryTypeLabel)).toEqual([
|
||||||
|
"注册试用赠送",
|
||||||
|
"人工赠送",
|
||||||
|
"消费预留",
|
||||||
|
"消费结算",
|
||||||
|
"消费释放",
|
||||||
|
"消费退款",
|
||||||
|
"邀请人奖励",
|
||||||
|
"受邀人奖励",
|
||||||
|
"App Store 购买",
|
||||||
|
"订阅赠送",
|
||||||
|
]);
|
||||||
|
expect(ledgerEntryTypeLabel("FUTURE_TYPE")).toBe("未知类型(FUTURE_TYPE)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("五种消费能力使用固定且可读的类别色", () => {
|
||||||
|
expect(ledgerUsageClass("polish")).toContain("text-violet");
|
||||||
|
expect(ledgerUsageClass("asr")).toContain("text-primary");
|
||||||
|
expect(ledgerUsageClass("ai")).toContain("text-success");
|
||||||
|
expect(ledgerUsageClass("agent")).toContain("text-warning");
|
||||||
|
expect(ledgerUsageClass("hotword")).toContain("text-pink");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { adminApi } from "../api/client";
|
|
||||||
import type {
|
|
||||||
AdminOperator,
|
|
||||||
AdminSecuritySummary,
|
|
||||||
UserDetail,
|
|
||||||
UserSummary,
|
|
||||||
} from "../api/types";
|
|
||||||
import { renderSecurity } from "../pages/security";
|
|
||||||
import { renderUsers } from "../pages/users";
|
|
||||||
|
|
||||||
const userId = "11111111-1111-4111-8111-111111111111";
|
|
||||||
const userSummary: UserSummary = {
|
|
||||||
userId,
|
|
||||||
displayName: "测试用户",
|
|
||||||
status: "active",
|
|
||||||
creditBalance: 120,
|
|
||||||
createdAt: "2026-08-01T08:00:00Z",
|
|
||||||
};
|
|
||||||
const userDetail: UserDetail = {
|
|
||||||
...userSummary,
|
|
||||||
lastActiveAt: "2026-08-16T08:00:00Z",
|
|
||||||
qualifiedUsage: true,
|
|
||||||
referralCode: "OSG-TEST",
|
|
||||||
usage: [
|
|
||||||
{
|
|
||||||
kind: "ASR",
|
|
||||||
requests: 3,
|
|
||||||
chargedCredits: 18,
|
|
||||||
asrMillis: 12_000,
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
referral: {
|
|
||||||
inviterUserId: "22222222-2222-4222-8222-222222222222",
|
|
||||||
invitedUsers: 4,
|
|
||||||
rewardedInvites: 2,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
document.body.replaceChildren();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("用户页", () => {
|
|
||||||
it("支持人员可查看详情但不显示人工赠送", async () => {
|
|
||||||
mockUserRequests();
|
|
||||||
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
|
|
||||||
const container = document.createElement("main");
|
|
||||||
document.body.append(container);
|
|
||||||
|
|
||||||
await openUserDetail(container, "SUPPORT");
|
|
||||||
|
|
||||||
expect(container.querySelector("[data-open-grant]")).toBeNull();
|
|
||||||
expect(container.querySelector("[data-grant-dialog]")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("超级管理员可赠送,并展示 usage/referral 与游标流水", async () => {
|
|
||||||
mockUserRequests();
|
|
||||||
vi.spyOn(adminApi, "ledger")
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
entryId: "ledger-1",
|
|
||||||
type: "grant",
|
|
||||||
amount: 100,
|
|
||||||
balanceAfter: 100,
|
|
||||||
reasonCode: "SIGNUP_TRIAL",
|
|
||||||
createdAt: "2026-08-01T08:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nextCursor: "ledger-next",
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
entryId: "ledger-2",
|
|
||||||
type: "settle",
|
|
||||||
amount: -18,
|
|
||||||
balanceAfter: 82,
|
|
||||||
reasonCode: "USAGE_SETTLE",
|
|
||||||
createdAt: "2026-08-02T08:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const container = document.createElement("main");
|
|
||||||
document.body.append(container);
|
|
||||||
|
|
||||||
await openUserDetail(container, "SUPER_ADMIN");
|
|
||||||
|
|
||||||
expect(container.querySelector("[data-open-grant]")).not.toBeNull();
|
|
||||||
expect(container.textContent).toContain("OSG-TEST");
|
|
||||||
expect(container.textContent).toContain("3 次请求");
|
|
||||||
expect(container.textContent).toContain("18 积分");
|
|
||||||
expect(container.textContent).toContain("2 / 4 已奖励");
|
|
||||||
expect(container.textContent).toContain(
|
|
||||||
"22222222-2222-4222-8222-222222222222",
|
|
||||||
);
|
|
||||||
|
|
||||||
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(adminApi.ledger).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
userId,
|
|
||||||
"ledger-next",
|
|
||||||
);
|
|
||||||
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
|
|
||||||
});
|
|
||||||
expect(container.querySelector("[data-ledger-more]")).toBeNull();
|
|
||||||
expect(container.querySelector("[data-ledger-status]")?.textContent).toContain(
|
|
||||||
"全部记录已加载",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("安全中心", () => {
|
|
||||||
it("按后端 nextCursor 加载更多管理员", async () => {
|
|
||||||
const firstOperator = operator("operator-1", "owner");
|
|
||||||
const secondOperator = operator("operator-2", "support");
|
|
||||||
vi.spyOn(adminApi, "operators")
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
items: [firstOperator],
|
|
||||||
nextCursor: "operator-next",
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ items: [secondOperator] });
|
|
||||||
vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary());
|
|
||||||
const container = document.createElement("main");
|
|
||||||
document.body.append(container);
|
|
||||||
|
|
||||||
await renderSecurity(container, "owner");
|
|
||||||
container.querySelector<HTMLButtonElement>("[data-operator-more]")?.click();
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
|
|
||||||
expect(container.querySelectorAll("[data-operator-body] tr")).toHaveLength(2);
|
|
||||||
});
|
|
||||||
expect(container.querySelector("[data-operator-count]")?.textContent).toBe(
|
|
||||||
"已加载 2 个账户",
|
|
||||||
);
|
|
||||||
expect(container.querySelector("[data-operator-more]")).toBeNull();
|
|
||||||
expect(
|
|
||||||
container.querySelector("[data-operator-status]")?.textContent,
|
|
||||||
).toContain("全部账户已加载");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function mockUserRequests(): void {
|
|
||||||
vi.spyOn(adminApi, "users").mockResolvedValue({ items: [userSummary] });
|
|
||||||
vi.spyOn(adminApi, "user").mockResolvedValue(userDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openUserDetail(
|
|
||||||
container: HTMLElement,
|
|
||||||
role: "SUPER_ADMIN" | "SUPPORT",
|
|
||||||
): Promise<void> {
|
|
||||||
renderUsers(container, role);
|
|
||||||
const input = container.querySelector<HTMLInputElement>('input[name="query"]');
|
|
||||||
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
|
|
||||||
if (!input || !form) throw new Error("用户查询表单未渲染");
|
|
||||||
input.value = userId;
|
|
||||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(container.querySelector("[data-user-id]")).not.toBeNull();
|
|
||||||
});
|
|
||||||
container.querySelector<HTMLButtonElement>("[data-user-id]")?.click();
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(container.querySelector("h1")?.textContent).toBe("测试用户");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function operator(operatorId: string, username: string): AdminOperator {
|
|
||||||
return {
|
|
||||||
operatorId,
|
|
||||||
username,
|
|
||||||
role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT",
|
|
||||||
enabled: true,
|
|
||||||
failedLoginCount: 0,
|
|
||||||
createdAt: "2026-08-01T08:00:00Z",
|
|
||||||
updatedAt: "2026-08-01T08:00:00Z",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function summary(): AdminSecuritySummary {
|
|
||||||
return {
|
|
||||||
enabledOperators: 2,
|
|
||||||
lockedOperators: 0,
|
|
||||||
activeSessions: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { App } from "../app";
|
||||||
|
import { adminApi } from "../api/client";
|
||||||
|
import type {
|
||||||
|
AdminOperator,
|
||||||
|
AdminSecuritySummary,
|
||||||
|
LedgerEntry,
|
||||||
|
ProductAnalyticsOverview,
|
||||||
|
UserSummary,
|
||||||
|
} from "../api/types";
|
||||||
|
|
||||||
|
const userId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.location.hash = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("React 管理页面", () => {
|
||||||
|
it("用户列表保留游标分页与角色权限", async () => {
|
||||||
|
mockSession("SUPPORT");
|
||||||
|
const first = user("测试用户", userId);
|
||||||
|
const second = user("第二位用户", "22222222-2222-4222-8222-222222222222");
|
||||||
|
vi.spyOn(adminApi, "users")
|
||||||
|
.mockResolvedValueOnce({ items: [first], nextCursor: "user-next" })
|
||||||
|
.mockResolvedValueOnce({ items: [second] });
|
||||||
|
window.location.hash = "#/users";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "用户列表" })).toBeTruthy();
|
||||||
|
expect(await screen.findByText("测试用户")).toBeTruthy();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(adminApi.users).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({ cursor: "user-next", sort: "createdAt", order: "desc" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("第二位用户")).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("用户排序变更会清空旧游标并重新请求", async () => {
|
||||||
|
mockSession("SUPPORT");
|
||||||
|
vi.spyOn(adminApi, "users")
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [user("第一页", userId)],
|
||||||
|
nextCursor: "stale-cursor",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [user("旧游标结果", "22222222-2222-4222-8222-222222222222")],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [user("升序结果", "33333333-3333-4333-8333-333333333333")],
|
||||||
|
});
|
||||||
|
window.location.hash = "#/users";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
expect(await screen.findByText("第一页")).toBeTruthy();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
|
||||||
|
expect(await screen.findByText("旧游标结果")).toBeTruthy();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /注册时间/ }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(adminApi.users).toHaveBeenCalledTimes(3);
|
||||||
|
const refreshedQuery = vi.mocked(adminApi.users).mock.calls[2]?.[0];
|
||||||
|
expect(refreshedQuery).toMatchObject({ order: "asc" });
|
||||||
|
expect(refreshedQuery).not.toHaveProperty("cursor");
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("升序结果")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("旧游标结果")).toBeNull();
|
||||||
|
expect(window.location.hash).toContain("order=asc");
|
||||||
|
expect(window.location.hash).not.toContain("cursor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("积分页自动显示最新不可变流水", async () => {
|
||||||
|
mockSession("SUPPORT");
|
||||||
|
const entry: LedgerEntry = {
|
||||||
|
entryId: "ledger-1",
|
||||||
|
userId,
|
||||||
|
type: "settle",
|
||||||
|
entryType: "USAGE_SETTLE",
|
||||||
|
amount: -18,
|
||||||
|
balanceAfter: 102,
|
||||||
|
reasonCode: "USAGE_SETTLE",
|
||||||
|
usageType: "hotword",
|
||||||
|
createdAt: "2026-08-19T09:00:00Z",
|
||||||
|
};
|
||||||
|
vi.spyOn(adminApi, "latestLedger").mockResolvedValue({ items: [entry] });
|
||||||
|
window.location.hash = "#/credits";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "积分流水" })).toBeTruthy();
|
||||||
|
expect(await screen.findByText("消费结算")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("热词")).toHaveLength(2);
|
||||||
|
expect(screen.getByText("-18")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("积分流水支持金额排序、展开安全详情、关联筛选与用户跳转", async () => {
|
||||||
|
mockSession("SUPPORT");
|
||||||
|
const referenceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const entry: LedgerEntry = {
|
||||||
|
entryId: "ledger-manual-1",
|
||||||
|
userId,
|
||||||
|
type: "grant",
|
||||||
|
entryType: "MANUAL_GRANT",
|
||||||
|
amount: 50,
|
||||||
|
balanceAfter: 170,
|
||||||
|
reasonCode: "MANUAL_CREDIT_GRANT",
|
||||||
|
referenceId,
|
||||||
|
details: {
|
||||||
|
kind: "manualGrant",
|
||||||
|
reason: "客服补偿",
|
||||||
|
operatorName: "support",
|
||||||
|
},
|
||||||
|
createdAt: "2026-08-20T09:00:00Z",
|
||||||
|
};
|
||||||
|
vi.spyOn(adminApi, "latestLedger").mockResolvedValue({ items: [entry] });
|
||||||
|
window.location.hash = "#/credits";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const userLink = await screen.findByRole("link", { name: userId });
|
||||||
|
expect(screen.getAllByText("人工赠送")).toHaveLength(2);
|
||||||
|
expect(screen.queryByText("MANUAL_CREDIT_GRANT")).toBeNull();
|
||||||
|
expect(userLink.getAttribute("href")).toBe(`#/users?userId=${userId}`);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /变动/ }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(adminApi.latestLedger).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ sort: "amount", order: "desc" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(window.location.hash).toContain("sort=amount");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "展开" }));
|
||||||
|
expect(screen.getByText("客服补偿")).toBeTruthy();
|
||||||
|
expect(screen.getByText("support")).toBeTruthy();
|
||||||
|
expect(screen.getByText("MANUAL_CREDIT_GRANT")).toBeTruthy();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "筛选同笔生命周期" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(adminApi.latestLedger).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ referenceId }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(window.location.hash).toContain(`referenceId=${referenceId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("用户 URL 可直接打开详情,ledger 前缀不覆盖列表筛选", async () => {
|
||||||
|
mockSession("SUPPORT");
|
||||||
|
vi.spyOn(adminApi, "user").mockResolvedValue({
|
||||||
|
...user("链接用户", userId),
|
||||||
|
qualifiedUsage: false,
|
||||||
|
});
|
||||||
|
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
|
||||||
|
vi.spyOn(adminApi, "users").mockResolvedValue({ items: [] });
|
||||||
|
window.location.hash =
|
||||||
|
`#/users?q=11111111&status=active&order=asc&userId=${userId}` +
|
||||||
|
"&ledgerType=reserve&ledgerEntryType=USAGE_RESERVE&ledgerUsageType=ai";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "链接用户" })).toBeTruthy();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(adminApi.ledger).toHaveBeenCalledWith(
|
||||||
|
userId,
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "reserve",
|
||||||
|
entryType: "USAGE_RESERVE",
|
||||||
|
usageType: "ai",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(window.location.hash).toContain("q=11111111");
|
||||||
|
expect(window.location.hash).toContain("ledgerType=reserve");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "返回用户列表" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).not.toContain("userId=");
|
||||||
|
expect(window.location.hash).toContain("q=11111111");
|
||||||
|
expect(window.location.hash).toContain("status=active");
|
||||||
|
expect(window.location.hash).toContain("order=asc");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("安全中心保留管理员游标分页", async () => {
|
||||||
|
mockSession("SUPER_ADMIN");
|
||||||
|
const first = operator("operator-1", "owner");
|
||||||
|
const second = operator("operator-2", "support");
|
||||||
|
vi.spyOn(adminApi, "operators")
|
||||||
|
.mockResolvedValueOnce({ items: [first], nextCursor: "operator-next" })
|
||||||
|
.mockResolvedValueOnce({ items: [second] });
|
||||||
|
vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary());
|
||||||
|
window.location.hash = "#/security";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "安全中心" })).toBeTruthy();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(adminApi.operators).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({ cursor: "operator-next", sort: "createdAt", order: "desc" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("support")).toBeTruthy();
|
||||||
|
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("产品分析页展示北极星、留存与付费护栏", async () => {
|
||||||
|
mockSession("SUPPORT");
|
||||||
|
vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(analyticsOverview());
|
||||||
|
window.location.hash = "#/analytics";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "产品增长与留存" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("周 AI 活跃用户")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/较上周 \+12\.5%/)).toBeTruthy();
|
||||||
|
expect(screen.getByText("2026-08-01")).toBeTruthy();
|
||||||
|
expect(screen.getByText("文字润色")).toBeTruthy();
|
||||||
|
expect(screen.getByText("键盘中英文输入")).toBeTruthy();
|
||||||
|
expect(screen.getByText("中文活跃用户")).toBeTruthy();
|
||||||
|
expect(screen.getByText("中英混合")).toBeTruthy();
|
||||||
|
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
|
||||||
|
expect(screen.getByText("购买意向漏斗")).toBeTruthy();
|
||||||
|
expect(screen.getByText("AI 终态延迟分布")).toBeTruthy();
|
||||||
|
expect(screen.getByText("客户端分享信号")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText("实际统计周期").textContent).toContain("包含今日未完整数据");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("产品图表可按执行模式筛选并按用户数稳定排序", async () => {
|
||||||
|
mockSession("SUPPORT");
|
||||||
|
const overview = analyticsOverview();
|
||||||
|
overview.aiFeatures = [
|
||||||
|
{ feature: "POLISH", executionMode: "MANAGED", users: 10, successes: 100 },
|
||||||
|
{ feature: "AI_ASSISTANT", executionMode: "MANAGED", users: 40, successes: 50 },
|
||||||
|
{ feature: "HOTWORD", executionMode: "LOCAL", users: 80, successes: 200 },
|
||||||
|
];
|
||||||
|
vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(overview);
|
||||||
|
window.location.hash = "#/analytics";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
expect(await screen.findByText("快捷指令")).toBeTruthy();
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText("执行模式"), "MANAGED");
|
||||||
|
expect(screen.queryByText("快捷指令")).toBeNull();
|
||||||
|
|
||||||
|
await userEvent.selectOptions(screen.getByDisplayValue("成功次数"), "users");
|
||||||
|
|
||||||
|
const labels = screen
|
||||||
|
.getAllByRole("progressbar")
|
||||||
|
.map((element) => element.getAttribute("aria-label"));
|
||||||
|
expect(labels.indexOf("AI 助手 成功次数:50")).toBeLessThan(
|
||||||
|
labels.indexOf("文字润色 成功次数:100"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
|
||||||
|
vi.spyOn(adminApi, "session").mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
operatorName: "owner",
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyticsOverview(): ProductAnalyticsOverview {
|
||||||
|
const rate = (numerator: number, denominator: number, percent: number) => ({
|
||||||
|
numerator,
|
||||||
|
denominator,
|
||||||
|
percent,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
period: { from: "2026-08-01T00:00:00Z", until: "2026-08-20T00:00:00Z" },
|
||||||
|
northStar: {
|
||||||
|
weeklyAiActiveUsers: 90,
|
||||||
|
previousWeeklyAiActiveUsers: 80,
|
||||||
|
weekOverWeekPercent: 12.5,
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
newInstallations: 100,
|
||||||
|
newAccounts: 80,
|
||||||
|
activation24h: rate(60, 100, 60),
|
||||||
|
medianTimeToValueMinutes: 8,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channel: "APP_STORE_ORGANIC",
|
||||||
|
installations: 100,
|
||||||
|
activated: 60,
|
||||||
|
activationRate: rate(60, 100, 60),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
dau: 20,
|
||||||
|
wau: 90,
|
||||||
|
mau: 150,
|
||||||
|
stickinessPercent: 13.3,
|
||||||
|
successfulAiRequests: 500,
|
||||||
|
successfulRequestsPerActiveUser: 3.3,
|
||||||
|
},
|
||||||
|
consumption: {
|
||||||
|
totalCredits: 1_200,
|
||||||
|
averageDailyCreditsPerActiveUser: 12,
|
||||||
|
medianUserDailyCredits: 8,
|
||||||
|
averageCreditsPerManagedRequest: 2.4,
|
||||||
|
},
|
||||||
|
monetization: {
|
||||||
|
payingUsers: 10,
|
||||||
|
purchases: 12,
|
||||||
|
creditsPurchased: 8_000,
|
||||||
|
conversion7d: rate(8, 70, 11.4),
|
||||||
|
conversion30d: rate(10, 50, 20),
|
||||||
|
repeatPurchaseRate: rate(2, 10, 20),
|
||||||
|
purchaseFunnel: [
|
||||||
|
{ label: "浏览购买页", count: 30 },
|
||||||
|
{ label: "发起购买", count: 15 },
|
||||||
|
{ label: "StoreKit 验证完成", count: 10 },
|
||||||
|
],
|
||||||
|
cancelledUsers: 4,
|
||||||
|
},
|
||||||
|
growthFunnel: [
|
||||||
|
{ label: "首次启动", count: 100 },
|
||||||
|
{ label: "24 小时内首次 AI 成功", count: 60 },
|
||||||
|
],
|
||||||
|
retention: [
|
||||||
|
{
|
||||||
|
cohortDate: "2026-08-01",
|
||||||
|
size: 20,
|
||||||
|
d1: rate(10, 20, 50),
|
||||||
|
d7: rate(6, 20, 30),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aiFeatures: [
|
||||||
|
{ feature: "POLISH", executionMode: "MANAGED", users: 30, successes: 100 },
|
||||||
|
],
|
||||||
|
keyboardUsage: {
|
||||||
|
activeUsers: 40,
|
||||||
|
activationToInput: rate(40, 50, 80),
|
||||||
|
chineseActiveUsers: 30,
|
||||||
|
englishActiveUsers: 20,
|
||||||
|
bilingualActiveUsers: 10,
|
||||||
|
totalCharacters: 12_000,
|
||||||
|
chineseCharacters: 7_000,
|
||||||
|
englishCharacters: 4_000,
|
||||||
|
otherCharacters: 1_000,
|
||||||
|
chineseSharePercent: 63.6,
|
||||||
|
englishSharePercent: 36.4,
|
||||||
|
inputSessions: 200,
|
||||||
|
averageCharactersPerInputSession: 60,
|
||||||
|
chineseOnlySessions: 100,
|
||||||
|
englishOnlySessions: 60,
|
||||||
|
mixedLanguageSessions: 30,
|
||||||
|
otherOnlySessions: 10,
|
||||||
|
},
|
||||||
|
referralSignals: { shared: 20, opened: 15 },
|
||||||
|
referralFunnel: [
|
||||||
|
{ label: "完成绑定", count: 10 },
|
||||||
|
{ label: "绑定后首次 AI 成功", count: 8 },
|
||||||
|
{ label: "完成奖励", count: 5 },
|
||||||
|
],
|
||||||
|
guardrails: {
|
||||||
|
clientAiSuccessRate: rate(90, 100, 90),
|
||||||
|
managedSuccessRate: rate(95, 100, 95),
|
||||||
|
creditBlockedUsers: 3,
|
||||||
|
latencyBuckets: [{ bucket: "S1_TO_3", successful: 80, failed: 5 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function user(displayName: string, id: string): UserSummary {
|
||||||
|
return {
|
||||||
|
userId: id,
|
||||||
|
displayName,
|
||||||
|
status: "active",
|
||||||
|
creditBalance: 120,
|
||||||
|
consumedCredits: 18,
|
||||||
|
createdAt: "2026-08-01T08:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function operator(operatorId: string, username: string): AdminOperator {
|
||||||
|
return {
|
||||||
|
operatorId,
|
||||||
|
username,
|
||||||
|
role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT",
|
||||||
|
enabled: true,
|
||||||
|
failedLoginCount: 0,
|
||||||
|
createdAt: "2026-08-01T08:00:00Z",
|
||||||
|
updatedAt: "2026-08-01T08:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary(): AdminSecuritySummary {
|
||||||
|
return {
|
||||||
|
enabledOperators: 2,
|
||||||
|
lockedOperators: 0,
|
||||||
|
activeSessions: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { adminApi } from "../api/client";
|
||||||
|
import type { ManagedProviderOverview } from "../api/types";
|
||||||
|
import { App } from "../app";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.location.hash = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Provider 配置", () => {
|
||||||
|
it("默认仅展示配置状态,不会回显现有 API Key", async () => {
|
||||||
|
mockSession();
|
||||||
|
mockProviders();
|
||||||
|
window.location.hash = "#/providers";
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "Provider 配置" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("DeepSeek")).toBeTruthy();
|
||||||
|
expect(screen.getByText("火山引擎")).toBeTruthy();
|
||||||
|
expect(screen.getByText("管理后台")).toBeTruthy();
|
||||||
|
expect(screen.getByText("环境变量")).toBeTruthy();
|
||||||
|
expect(document.body.textContent).not.toContain("existing-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("通过动态验证码显示当前 Key,并可再次点击眼睛隐藏", async () => {
|
||||||
|
mockSession();
|
||||||
|
mockProviders();
|
||||||
|
const reveal = vi.spyOn(adminApi, "revealProviderApiKey").mockResolvedValue({
|
||||||
|
apiKey: "existing-secret",
|
||||||
|
});
|
||||||
|
window.location.hash = "#/providers";
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const currentKey = await screen.findByLabelText("DeepSeek 当前 API Key");
|
||||||
|
expect(currentKey).toHaveProperty("value", "••••••••••••••••");
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "显示 DeepSeek 当前 API Key" }),
|
||||||
|
);
|
||||||
|
await userEvent.type(screen.getByLabelText("动态验证码"), "123456");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "验证并显示" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(reveal).toHaveBeenCalledWith("deepseek", { totpCode: "123456" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(currentKey).toHaveProperty("value", "existing-secret"));
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "隐藏 DeepSeek 当前 API Key" }),
|
||||||
|
);
|
||||||
|
expect(currentKey).toHaveProperty("value", "••••••••••••••••");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("可独立替换 DeepSeek API Key,并在成功后清空输入", async () => {
|
||||||
|
mockSession();
|
||||||
|
mockProviders();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
const update = vi.spyOn(adminApi, "updateProviderApiKey").mockResolvedValue({
|
||||||
|
providerId: "deepseek",
|
||||||
|
configured: true,
|
||||||
|
source: "RUNTIME_OVERRIDE",
|
||||||
|
updatedAt: "2026-08-22T08:00:00Z",
|
||||||
|
});
|
||||||
|
window.location.hash = "#/providers";
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const input = await screen.findByLabelText("DeepSeek 新 API Key");
|
||||||
|
await userEvent.type(input, "new-deepseek-key");
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "替换 DeepSeek API Key" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(update).toHaveBeenCalledWith("deepseek", { apiKey: "new-deepseek-key" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(input).toHaveProperty("value", ""));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockSession() {
|
||||||
|
vi.spyOn(adminApi, "session").mockResolvedValue({
|
||||||
|
authenticated: true,
|
||||||
|
operatorName: "owner",
|
||||||
|
role: "SUPER_ADMIN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockProviders() {
|
||||||
|
vi.spyOn(adminApi, "providers").mockResolvedValue(providerOverview());
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerOverview(): ManagedProviderOverview {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
providerId: "deepseek",
|
||||||
|
configured: true,
|
||||||
|
source: "RUNTIME_OVERRIDE",
|
||||||
|
updatedAt: "2026-08-22T07:30:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerId: "volcengine",
|
||||||
|
configured: true,
|
||||||
|
source: "ENVIRONMENT",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { adminApi, ApiError } from "../api/client";
|
||||||
|
import type { UserDetail } from "../api/types";
|
||||||
|
import { CohortHeatmap } from "../components/charts/cohort-heatmap";
|
||||||
|
import { ComparisonBarChart } from "../components/charts/comparison-bar-chart";
|
||||||
|
import { FunnelChart } from "../components/charts/funnel-chart";
|
||||||
|
import { RadialMetric } from "../components/charts/radial-metric";
|
||||||
|
import { TrendChart } from "../components/charts/trend-chart";
|
||||||
|
import { GrantDialog } from "../features/users/users-page";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("高风险交互与 CSP", () => {
|
||||||
|
it("趋势图不生成内联样式并提供完整辅助数据表", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TrendChart
|
||||||
|
points={[
|
||||||
|
{ date: "2026-08-18", registrations: 4, creditsUsed: 20 },
|
||||||
|
{ date: "2026-08-19", registrations: 7, creditsUsed: 35 },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("img", { name: /新增用户与积分消耗趋势/ })).toBeTruthy();
|
||||||
|
expect(container.querySelector("[style]")).toBeNull();
|
||||||
|
expect(screen.getByText("新增用户与积分消耗趋势完整数据")).toBeTruthy();
|
||||||
|
expect(screen.getByText("35")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("比较、漏斗、留存与环形图保持可访问且不生成内联样式", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<>
|
||||||
|
<ComparisonBarChart
|
||||||
|
items={[
|
||||||
|
{ label: "自然量", value: 100, secondaryValue: 60 },
|
||||||
|
{ label: "样本不足", value: null },
|
||||||
|
]}
|
||||||
|
primaryLabel="新增安装"
|
||||||
|
secondaryLabel="24 小时激活"
|
||||||
|
/>
|
||||||
|
<FunnelChart steps={[{ label: "首次启动", count: 100 }]} />
|
||||||
|
<CohortHeatmap
|
||||||
|
cohorts={[
|
||||||
|
{
|
||||||
|
cohortDate: "2026-08-01",
|
||||||
|
size: 20,
|
||||||
|
d1: { numerator: 10, denominator: 20, percent: 50 },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<RadialMetric label="用户活跃率" percent={50} />
|
||||||
|
<RadialMetric label="无样本活跃率" percent={null} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("progressbar", { name: "自然量 新增安装:100" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("progressbar", { name: /首次启动:100/ })).toBeTruthy();
|
||||||
|
expect(screen.getByText("D1、D7、D30 价值留存 cohort 热力图")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "用户活跃率:50.0%" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("progressbar", { name: "样本不足 新增安装:暂无数据" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("img", { name: "无样本活跃率:暂无数据" })).toBeTruthy();
|
||||||
|
expect(container.querySelector("[style]")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("赠送结果未知时保持弹窗并复用同一幂等键重试", async () => {
|
||||||
|
const grant = vi.spyOn(adminApi, "grantCredits").mockRejectedValue(
|
||||||
|
new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0),
|
||||||
|
);
|
||||||
|
const user = userDetail();
|
||||||
|
render(
|
||||||
|
<GrantDialog
|
||||||
|
open
|
||||||
|
user={user}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
onSuccess={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText("赠送积分"), "100");
|
||||||
|
await userEvent.type(screen.getByLabelText("赠送原因"), "运营补偿");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "下一步" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "确认赠送" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("赠送结果尚未确认;重试会复用同一请求,不会重复到账"),
|
||||||
|
).toBeTruthy();
|
||||||
|
const firstKey = grant.mock.calls[0]?.[0].idempotencyKey;
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "确认赠送" }));
|
||||||
|
await waitFor(() => expect(grant).toHaveBeenCalledTimes(2));
|
||||||
|
expect(grant.mock.calls[1]?.[0].idempotencyKey).toBe(firstKey);
|
||||||
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function userDetail(): UserDetail {
|
||||||
|
return {
|
||||||
|
userId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
displayName: "测试用户",
|
||||||
|
status: "active",
|
||||||
|
creditBalance: 120,
|
||||||
|
consumedCredits: 18,
|
||||||
|
createdAt: "2026-08-01T08:00:00Z",
|
||||||
|
qualifiedUsage: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { renderError, renderLoading, setButtonBusy } from "../components/ui";
|
|
||||||
|
|
||||||
describe("Web Awesome UI states", () => {
|
|
||||||
it("renders accessible loading and retry components", () => {
|
|
||||||
const container = document.createElement("div");
|
|
||||||
const retry = vi.fn();
|
|
||||||
|
|
||||||
renderLoading(container, "读取用户");
|
|
||||||
expect(container.querySelector("wa-spinner")).not.toBeNull();
|
|
||||||
expect(container.textContent).toContain("读取用户");
|
|
||||||
|
|
||||||
renderError(container, new Error("failed"), retry);
|
|
||||||
const retryButton = container.querySelector<HTMLElement>("wa-button[data-retry]");
|
|
||||||
expect(container.querySelector('wa-callout[variant="danger"]')).not.toBeNull();
|
|
||||||
retryButton?.click();
|
|
||||||
expect(retry).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the Web Awesome loading state and restores the label", () => {
|
|
||||||
const button = document.createElement("wa-button") as WaButton;
|
|
||||||
button.textContent = "提交";
|
|
||||||
|
|
||||||
setButtonBusy(button, true, "提交中…");
|
|
||||||
expect(button.loading).toBe(true);
|
|
||||||
expect(button.disabled).toBe(true);
|
|
||||||
expect(button.textContent).toBe("提交中…");
|
|
||||||
|
|
||||||
setButtonBusy(button, false);
|
|
||||||
expect(button.loading).toBe(false);
|
|
||||||
expect(button.disabled).toBe(false);
|
|
||||||
expect(button.textContent).toBe("提交");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: "/admin/",
|
base: "/admin/",
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/v1/admin": {
|
"/v1/admin": {
|
||||||
@@ -12,6 +15,21 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: "es2022",
|
target: "es2022",
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (
|
||||||
|
id.includes("/node_modules/react/") ||
|
||||||
|
id.includes("/node_modules/react-dom/") ||
|
||||||
|
id.includes("/node_modules/react-router")
|
||||||
|
) {
|
||||||
|
return "react-vendor";
|
||||||
|
}
|
||||||
|
if (id.includes("/node_modules/@base-ui/")) return "base-ui";
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation("com.auth0:java-jwt:4.6.0")
|
implementation("com.auth0:java-jwt:4.6.0")
|
||||||
implementation("com.nimbusds:nimbus-jose-jwt:10.9.1")
|
implementation("com.nimbusds:nimbus-jose-jwt:10.9.1")
|
||||||
|
implementation("com.apple.itunes.storekit:app-store-server-library:5.2.0")
|
||||||
implementation("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
|
implementation("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
|
||||||
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
|
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
|
||||||
implementation("com.upokecenter:cbor:4.5.6")
|
implementation("com.upokecenter:cbor:4.5.6")
|
||||||
|
|||||||
+15
-2
@@ -26,12 +26,14 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:?set a random JWT secret}
|
JWT_SECRET: ${JWT_SECRET:?set a random JWT secret}
|
||||||
ACCESS_TOKEN_MINUTES: ${ACCESS_TOKEN_MINUTES:-15}
|
ACCESS_TOKEN_MINUTES: ${ACCESS_TOKEN_MINUTES:-15}
|
||||||
REFRESH_TOKEN_DAYS: ${REFRESH_TOKEN_DAYS:-30}
|
REFRESH_TOKEN_DAYS: ${REFRESH_TOKEN_DAYS:-30}
|
||||||
|
LEGACY_REFRESH_REPLAY_SECONDS: ${LEGACY_REFRESH_REPLAY_SECONDS:-30}
|
||||||
GATEWAY_GRANT_DAYS: ${GATEWAY_GRANT_DAYS:-30}
|
GATEWAY_GRANT_DAYS: ${GATEWAY_GRANT_DAYS:-30}
|
||||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY:?set a 32-byte Base64 key}
|
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY:?set a 32-byte Base64 key}
|
||||||
IDENTITY_HMAC_KEY: ${IDENTITY_HMAC_KEY:?set a distinct Base64 key}
|
IDENTITY_HMAC_KEY: ${IDENTITY_HMAC_KEY:?set a distinct Base64 key}
|
||||||
IDENTITY_TOMBSTONE_RETENTION_DAYS: ${IDENTITY_TOMBSTONE_RETENTION_DAYS:-365}
|
IDENTITY_TOMBSTONE_RETENTION_DAYS: ${IDENTITY_TOMBSTONE_RETENTION_DAYS:-365}
|
||||||
|
|
||||||
ADMIN_ENABLED: ${ADMIN_ENABLED:-false}
|
ADMIN_ENABLED: ${ADMIN_ENABLED:-false}
|
||||||
|
ADMIN_MTLS_REQUIRED: ${ADMIN_MTLS_REQUIRED:-true}
|
||||||
ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-false}
|
ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-false}
|
||||||
ADMIN_BOOTSTRAP_OPERATOR_ID: ${ADMIN_BOOTSTRAP_OPERATOR_ID:-}
|
ADMIN_BOOTSTRAP_OPERATOR_ID: ${ADMIN_BOOTSTRAP_OPERATOR_ID:-}
|
||||||
ADMIN_BOOTSTRAP_USERNAME: ${ADMIN_BOOTSTRAP_USERNAME:-}
|
ADMIN_BOOTSTRAP_USERNAME: ${ADMIN_BOOTSTRAP_USERNAME:-}
|
||||||
@@ -40,12 +42,18 @@ services:
|
|||||||
ADMIN_SESSION_HOURS: ${ADMIN_SESSION_HOURS:-8}
|
ADMIN_SESSION_HOURS: ${ADMIN_SESSION_HOURS:-8}
|
||||||
ADMIN_MAXIMUM_MANUAL_GRANT: ${ADMIN_MAXIMUM_MANUAL_GRANT:-100000}
|
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_TEAM_ID: ${APPLE_TEAM_ID:?set Apple team ID}
|
||||||
APPLE_KEY_ID: ${APPLE_KEY_ID:?set Apple key ID}
|
APPLE_KEY_ID: ${APPLE_KEY_ID:?set Apple key ID}
|
||||||
APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-com.osgkeyboard.ios}
|
APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-com.osgkeyboard.ios}
|
||||||
APPLE_PRIVATE_KEY_PEM: ${APPLE_PRIVATE_KEY_PEM:?set Apple private key PEM}
|
APPLE_PRIVATE_KEY_PEM: ${APPLE_PRIVATE_KEY_PEM:?set Apple private key PEM}
|
||||||
APPLE_INTEGRITY_ENVIRONMENT: production
|
APPLE_INTEGRITY_ENVIRONMENT: production
|
||||||
APP_ATTEST_CHALLENGE_TTL_SECONDS: ${APP_ATTEST_CHALLENGE_TTL_SECONDS:-300}
|
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_DEVICE_CHECK: "true"
|
||||||
ENFORCE_APP_ATTEST: "true"
|
ENFORCE_APP_ATTEST: "true"
|
||||||
|
|
||||||
@@ -54,12 +62,17 @@ services:
|
|||||||
VOLCENGINE_ASR_ENDPOINT: ${VOLCENGINE_ASR_ENDPOINT:-wss://openspeech.bytedance.com/api/v3/sauc/bigmodel}
|
VOLCENGINE_ASR_ENDPOINT: ${VOLCENGINE_ASR_ENDPOINT:-wss://openspeech.bytedance.com/api/v3/sauc/bigmodel}
|
||||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?set DeepSeek API key}
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?set DeepSeek API key}
|
||||||
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash}
|
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash}
|
||||||
|
DEEPSEEK_REASONING_MODEL: ${DEEPSEEK_REASONING_MODEL:-}
|
||||||
DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1}
|
DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1}
|
||||||
|
|
||||||
SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000}
|
SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000}
|
||||||
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-3000}
|
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-1000}
|
||||||
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-3000}
|
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-1000}
|
||||||
REFERRAL_BINDING_DAYS: ${REFERRAL_BINDING_DAYS:-7}
|
REFERRAL_BINDING_DAYS: ${REFERRAL_BINDING_DAYS:-7}
|
||||||
|
STOREKIT_ENABLED: ${STOREKIT_ENABLED:-true}
|
||||||
|
STOREKIT_BUNDLE_ID: ${STOREKIT_BUNDLE_ID:-com.osgkeyboard.ios}
|
||||||
|
STOREKIT_APP_APPLE_ID: ${STOREKIT_APP_APPLE_ID:-6781553267}
|
||||||
|
STOREKIT_PRODUCTS: ${STOREKIT_PRODUCTS:-500tks:500,1500tks:1500,3000tks:3000}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080"
|
- "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080"
|
||||||
read_only: true
|
read_only: true
|
||||||
|
|||||||
+24
-6
@@ -2,8 +2,21 @@
|
|||||||
|
|
||||||
`account.osglab.com` 在同一个 TLS `server` 中同时承载移动端 API 和管理端。
|
`account.osglab.com` 在同一个 TLS `server` 中同时承载移动端 API 和管理端。
|
||||||
由于 TLS 握手发生在 HTTP 路径匹配之前,配置必须使用 server 级
|
由于 TLS 握手发生在 HTTP 路径匹配之前,配置必须使用 server 级
|
||||||
`ssl_verify_client optional`:普通客户端不提供证书时仍可正常访问,只有
|
`ssl_verify_client optional`:普通客户端不提供证书时仍可正常访问。OpenResty 会把实际
|
||||||
`/admin`、`/admin/`、`/v1/admin` 和其子路径要求验证成功。
|
证书验证结果传给 Ktor;`ADMIN_MTLS_REQUIRED=true`(默认值)时,`/admin`、`/admin/`、
|
||||||
|
`/v1/admin` 和其子路径要求验证成功。
|
||||||
|
|
||||||
|
## 临时关闭
|
||||||
|
|
||||||
|
在 1Panel/Compose 环境中显式设置并重启应用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ADMIN_MTLS_REQUIRED=false
|
||||||
|
```
|
||||||
|
|
||||||
|
关闭后,管理端无需客户端证书,但登录仍要求用户名、密码和 TOTP,其他会话、CSRF、RBAC、
|
||||||
|
失败锁定、限流与审计规则保持不变。恢复时将该值改回 `true` 并重启应用。不要为了临时关闭
|
||||||
|
而删除客户端 CA、证书或轮换记录。
|
||||||
|
|
||||||
## CA 与证书
|
## CA 与证书
|
||||||
|
|
||||||
@@ -22,14 +35,15 @@
|
|||||||
|
|
||||||
## 上游信任边界
|
## 上游信任边界
|
||||||
|
|
||||||
OpenResty 仅在管理路径且 `$ssl_client_verify = SUCCESS` 时向 Ktor 设置固定头:
|
OpenResty 在管理路径用 `$ssl_client_verify` 覆盖并转发证书验证结果。有效证书对应:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
X-OSG-mTLS-Verified: SUCCESS
|
X-OSG-mTLS-Verified: SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
客户端传入的同名头会被覆盖;其他路径会删除该头。Ktor 只能把这个头作为“边缘已验证”
|
无证书时该值为 `NONE`。客户端传入的同名头会被覆盖;其他路径会删除该头。Ktor 只能把
|
||||||
信号,不能信任客户端提供的证书相关头,也不能用 DN、CN 或证书正文做隐式授权。
|
OpenResty 写入的这个头作为“边缘已验证”信号,不能信任客户端提供的证书相关头,也不能用
|
||||||
|
DN、CN 或证书正文做隐式授权。
|
||||||
后端端口必须继续只监听 `127.0.0.1:18080`,否则攻击者可绕过边缘伪造该头。
|
后端端口必须继续只监听 `127.0.0.1:18080`,否则攻击者可绕过边缘伪造该头。
|
||||||
mTLS 只证明客户端持有受信证书,管理接口仍应执行应用层身份认证、授权和审计。
|
mTLS 只证明客户端持有受信证书,管理接口仍应执行应用层身份认证、授权和审计。
|
||||||
|
|
||||||
@@ -52,7 +66,7 @@ ADMIN_BOOTSTRAP_ENABLED=true
|
|||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
将测试域名解析到目标边缘后执行:
|
`ADMIN_MTLS_REQUIRED=true` 时,将测试域名解析到目标边缘后执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 无证书:管理路径必须是 404。
|
# 无证书:管理路径必须是 404。
|
||||||
@@ -74,6 +88,10 @@ curl -i -H 'X-OSG-mTLS-Verified: SUCCESS' \
|
|||||||
还应使用由非管理 CA 签发或已过期的客户端证书确认返回 404,并在 Ktor 测试端点确认:
|
还应使用由非管理 CA 签发或已过期的客户端证书确认返回 404,并在 Ktor 测试端点确认:
|
||||||
管理请求只收到固定值 `SUCCESS`,普通 API 不收到 `X-OSG-mTLS-Verified`。
|
管理请求只收到固定值 `SUCCESS`,普通 API 不收到 `X-OSG-mTLS-Verified`。
|
||||||
|
|
||||||
|
`ADMIN_MTLS_REQUIRED=false` 时,无证书访问 `/admin/` 应返回管理页面,
|
||||||
|
`/v1/admin/auth/session` 应返回匿名会话状态。伪造 `X-OSG-mTLS-Verified: SUCCESS` 不会
|
||||||
|
改变结果,因为 OpenResty 会将其覆盖为实际验证状态。
|
||||||
|
|
||||||
部署后可在受信设备运行不含登录凭据的自动验收:
|
部署后可在受信设备运行不含登录凭据的自动验收:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -52,13 +52,10 @@ server {
|
|||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Administrative endpoints are indistinguishable from missing routes unless
|
# Always forward administrative paths to Ktor. Ktor decides whether mTLS is
|
||||||
# OpenResty verified a certificate issued by the dedicated admin client CA.
|
# required from ADMIN_MTLS_REQUIRED, while this edge overwrites the trust
|
||||||
|
# signal so clients cannot spoof successful certificate verification.
|
||||||
location ~ ^/(?:admin|v1/admin)(?:/|$) {
|
location ~ ^/(?:admin|v1/admin)(?:/|$) {
|
||||||
if ($ssl_client_verify != SUCCESS) {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
client_max_body_size 32k;
|
client_max_body_size 32k;
|
||||||
limit_req zone=account_api burst=40 nodelay;
|
limit_req zone=account_api burst=40 nodelay;
|
||||||
# Defining a location-level header disables inheritance from the server
|
# 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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
proxy_set_header X-Request-ID $request_id;
|
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 $ssl_client_verify;
|
||||||
proxy_set_header X-OSG-mTLS-Verified "SUCCESS";
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_hide_header Server;
|
proxy_hide_header Server;
|
||||||
|
|||||||
+48
-4
@@ -258,8 +258,15 @@ grant_pattern = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
expected = set()
|
expected = set()
|
||||||
for raw_line in open(sys.argv[1], encoding="utf-8"):
|
with open(sys.argv[1], encoding="utf-8") as grants_file:
|
||||||
match = grant_pattern.match(raw_line.strip())
|
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:
|
if match:
|
||||||
for privilege in match.group(1).split(","):
|
for privilege in match.group(1).split(","):
|
||||||
expected.add((match.group(2).lower(), privilege.strip().upper()))
|
expected.add((match.group(2).lower(), privilege.strip().upper()))
|
||||||
@@ -319,6 +326,12 @@ verify_immutable_history_denials() {
|
|||||||
expect_runtime_denied \
|
expect_runtime_denied \
|
||||||
"admin grant DELETE" \
|
"admin grant DELETE" \
|
||||||
"DELETE FROM admin_credit_grants WHERE 1 = 0"
|
"DELETE FROM admin_credit_grants WHERE 1 = 0"
|
||||||
|
expect_runtime_denied \
|
||||||
|
"StoreKit purchase UPDATE" \
|
||||||
|
"UPDATE storekit_credit_purchases SET credits_granted = credits_granted WHERE 1 = 0"
|
||||||
|
expect_runtime_denied \
|
||||||
|
"StoreKit purchase DELETE" \
|
||||||
|
"DELETE FROM storekit_credit_purchases WHERE 1 = 0"
|
||||||
expect_runtime_denied \
|
expect_runtime_denied \
|
||||||
"Flyway metadata read" \
|
"Flyway metadata read" \
|
||||||
"SELECT version FROM flyway_schema_history LIMIT 1"
|
"SELECT version FROM flyway_schema_history LIMIT 1"
|
||||||
@@ -464,9 +477,40 @@ WHERE version IS NOT NULL
|
|||||||
ORDER BY installed_rank;
|
ORDER BY installed_rank;
|
||||||
SQL
|
SQL
|
||||||
)"
|
)"
|
||||||
EXPECTED_MIGRATIONS=$'1:1\n2:1\n3:1\n4:1\n5:1\n6:1\n7:1\n8:1'
|
EXPECTED_MIGRATIONS="$(seq 1 28 | awk '{ print $1 ":1" }')"
|
||||||
[[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] ||
|
[[ "$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
|
compose --profile setup stop schema-migrator >/dev/null
|
||||||
|
|
||||||
log "installing exact runtime grants and disposable fixture"
|
log "installing exact runtime grants and disposable fixture"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ GRANT SELECT ON osg_account_smoke.credit_reservations TO 'osg_smoke_runtime'@'%'
|
|||||||
GRANT SELECT ON osg_account_smoke.referral_campaigns TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.referral_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_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_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.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_usage_records TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.credit_ledger 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_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_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_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.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_challenges TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT SELECT ON osg_account_smoke.account_profiles TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT SELECT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
GRANT SELECT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT SELECT ON osg_account_smoke.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, DELETE ON osg_account_smoke.accounts TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT, UPDATE ON osg_account_smoke.apple_credentials TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT, UPDATE ON osg_account_smoke.apple_credentials TO 'osg_smoke_runtime'@'%';
|
||||||
@@ -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 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 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_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, 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_usage_records TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT ON osg_account_smoke.credit_ledger 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, 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 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 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.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_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.app_attest_keys TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT, UPDATE ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT, UPDATE ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT, UPDATE ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT, UPDATE ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON osg_account_smoke.account_profiles TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT, UPDATE ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT, UPDATE ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
|
||||||
GRANT INSERT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
GRANT INSERT ON osg_account_smoke.admin_credit_grants TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON osg_account_smoke.gateway_provider_credentials TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT ON osg_account_smoke.storekit_credit_purchases TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.product_analytics_installations
|
||||||
|
TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT ON osg_account_smoke.product_analytics_events TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON osg_account_smoke.product_analytics_daily_counters
|
||||||
|
TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, DELETE ON osg_account_smoke.keyboard_usage_daily_summaries
|
||||||
|
TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT UPDATE ON osg_account_smoke.official_content_catalog TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON osg_account_smoke.official_skills TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON osg_account_smoke.official_skill_localizations TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON osg_account_smoke.official_hint_packs TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT UPDATE ON osg_account_smoke.hint_feed_settings TO 'osg_smoke_runtime'@'%';
|
||||||
|
GRANT UPDATE ON osg_account_smoke.hint_feed_generation_state TO 'osg_smoke_runtime'@'%';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Account data lifecycle
|
||||||
|
|
||||||
|
- Apple subjects, refresh tokens, and account nicknames are encrypted at rest.
|
||||||
|
- Apple email and avatar data are not requested or stored.
|
||||||
|
- Deleting an account removes its session, profile, referral, grant, and mutable
|
||||||
|
account records in the same local transaction before Apple revocation is retried.
|
||||||
|
- Product analytics installations linked to the account and all of their events
|
||||||
|
are deleted by database cascade. The service stores only the digest of a
|
||||||
|
random installation UUID and never stores user content in analytics events.
|
||||||
|
- Keyboard usage contains daily Chinese, English, other-character and
|
||||||
|
input-session counters only. It never contains text or keystrokes and is
|
||||||
|
purged after 90 days regardless of account linkage.
|
||||||
|
- Pseudonymous immutable credit-ledger entries, StoreKit transaction audit data,
|
||||||
|
and time-limited anti-abuse tombstones remain after deletion where required to
|
||||||
|
prevent replay, preserve financial integrity, and stop repeated trial abuse.
|
||||||
|
- Logs must never include Apple subjects, credentials, tokens, or nicknames.
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# OSGKeyboard product analytics metrics dictionary
|
||||||
|
|
||||||
|
This document is the canonical definition of product metrics. All dates and
|
||||||
|
cohorts use UTC calendar boundaries. Counts are based on distinct accounts when
|
||||||
|
an installation is linked, otherwise on the pseudonymous installation.
|
||||||
|
|
||||||
|
Admin presets cover exactly 7, 30, or 90 UTC calendar dates, starting at 00:00
|
||||||
|
on the first date and ending at the current instant. The current UTC date is
|
||||||
|
therefore explicitly partial.
|
||||||
|
|
||||||
|
## North-star metric
|
||||||
|
|
||||||
|
### Weekly AI active users (WAIU)
|
||||||
|
|
||||||
|
The number of distinct users that successfully complete at least one AI feature
|
||||||
|
during a UTC calendar week.
|
||||||
|
|
||||||
|
- Managed AI and ASR use server-settled `credit_usage_records`.
|
||||||
|
- Local and BYOK use accepted `AI_FEATURE_SUCCEEDED` client events.
|
||||||
|
- Managed client success events provide feature breakdowns but are not added to
|
||||||
|
the server-settled total, preventing double counting.
|
||||||
|
- Week-over-week growth is `(current WAIU - previous WAIU) / previous WAIU`.
|
||||||
|
A missing previous population is reported without a percentage.
|
||||||
|
|
||||||
|
## Growth and activation
|
||||||
|
|
||||||
|
### New installations
|
||||||
|
|
||||||
|
Distinct installations whose first accepted `FIRST_OPEN` event occurred in the
|
||||||
|
selected period. Acquisition channel is fixed by the first non-`UNKNOWN`
|
||||||
|
channel observed for the installation.
|
||||||
|
|
||||||
|
Allowed channels:
|
||||||
|
|
||||||
|
- `APP_STORE_ORGANIC`
|
||||||
|
- `REFERRAL`
|
||||||
|
- `SOCIAL_CONTENT`
|
||||||
|
- `UNKNOWN`
|
||||||
|
|
||||||
|
### New accounts
|
||||||
|
|
||||||
|
Accounts whose `accounts.created_at` falls in the selected period.
|
||||||
|
|
||||||
|
### 24-hour AI activation rate
|
||||||
|
|
||||||
|
The percentage of new installations that have completed their full 24-hour
|
||||||
|
observation window and successfully complete any AI feature within 24 hours of
|
||||||
|
their first open. Unmatured installations are excluded from both numerator and
|
||||||
|
denominator. Managed usage before that installation's first open is ignored.
|
||||||
|
|
||||||
|
### Time to first value
|
||||||
|
|
||||||
|
Elapsed time from `FIRST_OPEN` to the first successful AI feature. The dashboard
|
||||||
|
reports the median in minutes. Users without a successful AI feature are not
|
||||||
|
included in the median and remain visible in the activation denominator.
|
||||||
|
|
||||||
|
### 24-hour growth funnel
|
||||||
|
|
||||||
|
A strict cohort of installations with a completed 24-hour observation window:
|
||||||
|
first open, account registration after first open, first AI value event after
|
||||||
|
registration, and first server-verified purchase after that value event. Every
|
||||||
|
downstream step must occur within 24 hours of first open. D7 belongs only to the
|
||||||
|
retention report and is not mixed into this funnel.
|
||||||
|
|
||||||
|
## Activity
|
||||||
|
|
||||||
|
### AI DAU, WAU and MAU
|
||||||
|
|
||||||
|
Distinct value-active users in the last 1, 7 and 30 UTC days ending at the
|
||||||
|
report's `until` timestamp.
|
||||||
|
|
||||||
|
### DAU/MAU stickiness
|
||||||
|
|
||||||
|
`AI DAU / AI MAU`. The value is null when MAU is zero.
|
||||||
|
|
||||||
|
### Successful AI requests
|
||||||
|
|
||||||
|
The sum of settled managed requests and successful local/BYOK client events.
|
||||||
|
Managed client success events are excluded from this total.
|
||||||
|
|
||||||
|
### Successful AI requests per active user
|
||||||
|
|
||||||
|
`successful AI requests / distinct value-active users` for the selected period.
|
||||||
|
|
||||||
|
### Registered product-active users
|
||||||
|
|
||||||
|
The operations overview counts distinct registered accounts with either a
|
||||||
|
successful AI value event (managed, local, or BYOK) or a finalized manual
|
||||||
|
keyboard-input summary in the selected period. The displayed rate divides this
|
||||||
|
population by all registered accounts.
|
||||||
|
|
||||||
|
## Keyboard input usage
|
||||||
|
|
||||||
|
Keyboard input metrics use finalized UTC-day summaries produced on-device.
|
||||||
|
They describe manually committed OSGKeyboard text only and are independent
|
||||||
|
from AI value events, billing and referral qualification.
|
||||||
|
|
||||||
|
### Keyboard input active users
|
||||||
|
|
||||||
|
Distinct account identities, falling back to pseudonymous installations, with
|
||||||
|
at least one accepted keyboard usage summary in the selected UTC-date window.
|
||||||
|
|
||||||
|
### Activation-to-input conversion
|
||||||
|
|
||||||
|
Keyboard input active users divided by distinct identities with either a
|
||||||
|
`KEYBOARD_ACTIVATED` event or an accepted keyboard usage summary in the same
|
||||||
|
UTC-date window. Including summary-only identities prevents missing activation
|
||||||
|
telemetry from producing rates above 100%.
|
||||||
|
|
||||||
|
### Chinese, English and bilingual active users
|
||||||
|
|
||||||
|
- Chinese active: at least one committed Han-script character.
|
||||||
|
- English active: at least one committed Latin letter.
|
||||||
|
- Bilingual active: both Chinese and English counts are non-zero.
|
||||||
|
|
||||||
|
These populations overlap and must not be summed.
|
||||||
|
|
||||||
|
### Character volume and language share
|
||||||
|
|
||||||
|
Character volume is the sum of client-classified Chinese, English and other
|
||||||
|
committed characters. Chinese and English share use only classified language
|
||||||
|
characters as the denominator:
|
||||||
|
|
||||||
|
- Chinese share: `Chinese / (Chinese + English)`.
|
||||||
|
- English share: `English / (Chinese + English)`.
|
||||||
|
|
||||||
|
Both shares are unavailable when the denominator is zero. Other characters
|
||||||
|
remain visible in total volume but do not dilute the language split.
|
||||||
|
|
||||||
|
### Input sessions
|
||||||
|
|
||||||
|
An input session is a keyboard activation containing at least one manually
|
||||||
|
committed character. Chinese-only, English-only, mixed-language and other-only
|
||||||
|
session counts form a complete partition. Average characters per input session
|
||||||
|
is `total committed characters / input sessions`.
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
|
The cohort date is the UTC date of a user's first successful AI feature.
|
||||||
|
Retention is value retention, not application-open retention.
|
||||||
|
|
||||||
|
- `D1`: active on cohort date + 1 day.
|
||||||
|
- `D7`: active on cohort date + 7 days.
|
||||||
|
- `D30`: active on cohort date + 30 days.
|
||||||
|
|
||||||
|
Each retention rate uses the original cohort size as denominator. A day that has
|
||||||
|
not fully elapsed at the report's `until` timestamp is returned as unavailable,
|
||||||
|
not zero. Channel and first-feature breakdowns are optional dimensions and must
|
||||||
|
not alter the base cohort definition.
|
||||||
|
|
||||||
|
## AI feature usage
|
||||||
|
|
||||||
|
Allowed feature types:
|
||||||
|
|
||||||
|
- `TRANSCRIPTION`
|
||||||
|
- `POLISH`
|
||||||
|
- `AI_ASSISTANT`
|
||||||
|
- `AGENT`
|
||||||
|
- `HOTWORD`
|
||||||
|
- `OTHER`
|
||||||
|
|
||||||
|
Allowed execution modes:
|
||||||
|
|
||||||
|
- `MANAGED`
|
||||||
|
- `LOCAL`
|
||||||
|
- `BYOK`
|
||||||
|
|
||||||
|
Feature distributions use accepted client events because server billing only
|
||||||
|
distinguishes `ASR` and `LLM`. Server-settled aggregates remain authoritative
|
||||||
|
for managed totals, credits, token counts and ASR duration.
|
||||||
|
|
||||||
|
## Credit consumption
|
||||||
|
|
||||||
|
### Daily total credit consumption
|
||||||
|
|
||||||
|
The sum of non-negative `credit_usage_records.charged_credits` by UTC date.
|
||||||
|
|
||||||
|
### Average daily credits per AI active user
|
||||||
|
|
||||||
|
For each UTC date, divide total settled credits by distinct managed AI users,
|
||||||
|
then average those daily values across days containing at least one active user.
|
||||||
|
|
||||||
|
### Median user-day credits
|
||||||
|
|
||||||
|
The median of per-account daily settled credits. This is shown beside the mean
|
||||||
|
to prevent a small number of heavy users from distorting typical consumption.
|
||||||
|
|
||||||
|
### Average credits per managed request
|
||||||
|
|
||||||
|
`settled credits / settled managed requests`. Local and BYOK events consume no
|
||||||
|
server credits and are excluded.
|
||||||
|
|
||||||
|
## Monetization
|
||||||
|
|
||||||
|
### 7-day and 30-day free-to-paid conversion
|
||||||
|
|
||||||
|
The percentage of newly registered accounts with a first credited StoreKit
|
||||||
|
purchase no later than 7 or 30 days after registration. The selected report
|
||||||
|
period filters when each observation window matures: a 7-day report cohort uses
|
||||||
|
registrations shifted exactly 7 days earlier, and the 30-day cohort is shifted
|
||||||
|
30 days earlier. This keeps every denominator fully observed and makes the rate
|
||||||
|
available even when the selected preset is no longer than the conversion window.
|
||||||
|
|
||||||
|
### Paying users
|
||||||
|
|
||||||
|
Distinct accounts with at least one credited StoreKit purchase in the period.
|
||||||
|
|
||||||
|
### Repeat purchase rate
|
||||||
|
|
||||||
|
The percentage of paying accounts with at least two credited StoreKit purchases
|
||||||
|
across their lifetime.
|
||||||
|
|
||||||
|
### Purchase intent funnel
|
||||||
|
|
||||||
|
A strict installation cohort: `PURCHASE_VIEWED`, followed by
|
||||||
|
`PURCHASE_STARTED`, followed by a StoreKit purchase verified by the server for
|
||||||
|
the linked account. Each event must occur after the previous step and before the
|
||||||
|
report's `until`. `PURCHASE_CANCELLED` is a separate signal, not a funnel step.
|
||||||
|
|
||||||
|
StoreKit transaction count and granted credits are operational proxies. Net
|
||||||
|
revenue, App Store commission and refunds require App Store financial data and
|
||||||
|
are outside this service's first version.
|
||||||
|
|
||||||
|
## Referral funnel
|
||||||
|
|
||||||
|
The ordered cohort contains bindings created in the selected period:
|
||||||
|
|
||||||
|
1. Referral binding created.
|
||||||
|
2. The same invitee reaches an AI value event after binding.
|
||||||
|
3. The same binding is rewarded before the report's `until`.
|
||||||
|
|
||||||
|
`REFERRAL_SHARED` distinct installations and invitation opens are independent
|
||||||
|
directional signals. Invitation opens combine accepted `INVITE_OPENED` events
|
||||||
|
with anonymous first-party page-view counters, so they are not people and must
|
||||||
|
never be placed in the ordered conversion funnel.
|
||||||
|
|
||||||
|
Pending and ineligible bindings are parallel status counts, not sequential
|
||||||
|
funnel steps.
|
||||||
|
|
||||||
|
## Experience guardrails
|
||||||
|
|
||||||
|
- AI success rate: successful client AI completions divided by starts with a
|
||||||
|
terminal success or failure event.
|
||||||
|
- Managed request failure rate: terminal non-settled `provider_requests` divided
|
||||||
|
by terminal managed requests.
|
||||||
|
- Client latency: successful and failed terminal events grouped by declared
|
||||||
|
duration bucket. Exact P50/P95 values are not inferred from buckets.
|
||||||
|
- Credit-blocked users: distinct installations reporting
|
||||||
|
`INSUFFICIENT_CREDITS` during the period.
|
||||||
|
|
||||||
|
Guardrails are diagnostic and never count as value-active events.
|
||||||
@@ -26,6 +26,10 @@ It does not replace the app's local ASR, BYOK provider access, or iCloud sync.
|
|||||||
- `integrity` evaluates DeviceCheck and App Attest evidence.
|
- `integrity` evaluates DeviceCheck and App Attest evidence.
|
||||||
- `gateway` proxies Volcengine ASR and DeepSeek text requests.
|
- `gateway` proxies Volcengine ASR and DeepSeek text requests.
|
||||||
- `inviteweb` serves the first-party invitation landing page.
|
- `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,
|
Modules communicate through narrow ports. Provider clients, Apple clients,
|
||||||
integrity clients, clocks, token generators, and repositories are replaceable in
|
integrity clients, clocks, token generators, and repositories are replaceable in
|
||||||
@@ -60,7 +64,10 @@ tests.
|
|||||||
claims required for replay protection.
|
claims required for replay protection.
|
||||||
- Usage metadata contains feature, model, metering units, latency, status, and
|
- Usage metadata contains feature, model, metering units, latency, status, and
|
||||||
rate-card version.
|
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
|
- Operational logs use request IDs and internal opaque IDs, never Apple subjects
|
||||||
or bearer credentials.
|
or bearer credentials.
|
||||||
- Account deletion revokes credentials and removes user-linked records. Only
|
- Account deletion revokes credentials and cascades through linked product
|
||||||
non-identifying aggregate service metrics may remain.
|
installations and events. Only non-identifying aggregate service metrics may
|
||||||
|
remain.
|
||||||
|
|||||||
+11
-1
@@ -97,6 +97,10 @@ Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 `.p8
|
|||||||
v3 WSS endpoint、资源 ID 和 API Key;DeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。
|
v3 WSS endpoint、资源 ID 和 API Key;DeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。
|
||||||
三方凭据分别创建、分别轮换,不得复用。
|
三方凭据分别创建、分别轮换,不得复用。
|
||||||
|
|
||||||
|
生产默认仅接受 TestFlight 与 App Store 构建的 production App Attest。确需让已登记真机上的
|
||||||
|
Xcode Development 构建连接生产服务时,可临时设置
|
||||||
|
`ALLOW_DEVELOPMENT_APP_ATTEST=true`;完成测试后应立即恢复为 `false` 并重启服务。
|
||||||
|
|
||||||
## 5. 构建与启动
|
## 5. 构建与启动
|
||||||
|
|
||||||
GitHub CI 在测试通过后发布私有镜像
|
GitHub CI 在测试通过后发布私有镜像
|
||||||
@@ -132,10 +136,16 @@ docker compose logs --since=10m account-server
|
|||||||
2. 将三个 `server` 块作为站点配置;按 1Panel 实际证书路径调整 `ssl_certificate`。
|
2. 将三个 `server` 块作为站点配置;按 1Panel 实际证书路径调整 `ssl_certificate`。
|
||||||
3. 示例 upstream 指向宿主机 `127.0.0.1:18080`。若 OpenResty 自身在容器中,则将其加入
|
3. 示例 upstream 指向宿主机 `127.0.0.1:18080`。若 OpenResty 自身在容器中,则将其加入
|
||||||
`account-backend`,并改为 `account-server:8080`。
|
`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 请求/秒,可基于真实流量谨慎调整。
|
5. API 示例按 IP 限制 20 请求/秒,邀请页限制 5 请求/秒,可基于真实流量谨慎调整。
|
||||||
6. 代理统一支持 HTTP/1.1 Upgrade/Connection,因此当前 HTTP API 与后续 WebSocket 入口都可用。
|
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` 模板输出,不需要复制静态文件。配置检查成功后再通过
|
两个 AASA 地址由 Ktor 根据 `appleAppId` 模板输出,不需要复制静态文件。配置检查成功后再通过
|
||||||
1Panel 重载 OpenResty:
|
1Panel 重载 OpenResty:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# AI Hint Feed migration and coexistence
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
`key.osglab.com` remains the legacy AI Hint Feed for installed clients that still
|
||||||
|
use that origin. This repository must not:
|
||||||
|
|
||||||
|
- change the `key.osglab.com` DNS record;
|
||||||
|
- redirect `key.osglab.com` to `account.osglab.com`;
|
||||||
|
- reuse or delete the legacy container, image, settings file, or data volume;
|
||||||
|
- require the legacy service to call this account service.
|
||||||
|
|
||||||
|
The migrated generator runs independently inside OSGAccountServer and publishes:
|
||||||
|
|
||||||
|
- `https://account.osglab.com/v1/content/hints/manifest`
|
||||||
|
- `https://account.osglab.com/v1/content/hints/{locale}`
|
||||||
|
- `https://account.osglab.com/hints/manifest.json`
|
||||||
|
- `https://account.osglab.com/hints/hints-{locale}.json`
|
||||||
|
|
||||||
|
## Safe rollout
|
||||||
|
|
||||||
|
1. Back up the legacy `settings.json`, `manifest.json`, `hints-zh.json`, and
|
||||||
|
`hints-en.json` from its persistent volume.
|
||||||
|
2. Deploy OSGAccountServer with `HINT_FEED_ENABLED=false`.
|
||||||
|
3. Apply Flyway migration `V24__hint_feed_generation.sql` and the matching
|
||||||
|
runtime grants.
|
||||||
|
4. Use the protected admin console to review generation settings and run one
|
||||||
|
manual generation.
|
||||||
|
5. Verify both v1 and legacy paths on `account.osglab.com`, including ETag/304.
|
||||||
|
6. Set `HINT_FEED_ENABLED=true` only after the generated packs are accepted.
|
||||||
|
7. Point only new client releases at `account.osglab.com`. Existing clients may
|
||||||
|
continue to use `key.osglab.com`.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Disable `HINT_FEED_ENABLED` to stop scheduled generation. Published packs remain
|
||||||
|
available from MySQL and manual editing/saving remains available. No rollback step
|
||||||
|
depends on or modifies `key.osglab.com`.
|
||||||
|
|
||||||
|
Provider credentials such as `TOPHUB_API_KEY` stay in environment-backed secret
|
||||||
|
storage. They are never written to the generation settings table or returned to
|
||||||
|
the admin browser.
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Product analytics
|
||||||
|
|
||||||
|
OSGKeyboard analytics records product behavior metadata only. It is separate
|
||||||
|
from billing, provider execution and immutable credit ledgers.
|
||||||
|
|
||||||
|
## Privacy boundary
|
||||||
|
|
||||||
|
Never send or persist:
|
||||||
|
|
||||||
|
- audio or audio-derived content;
|
||||||
|
- raw keyboard input, keystrokes, prompts, context, transcripts or model output;
|
||||||
|
- Apple subjects, email addresses, names, tokens, API keys or provider
|
||||||
|
credentials;
|
||||||
|
- arbitrary property names or free-form text.
|
||||||
|
|
||||||
|
The service stores a SHA-256 digest of the random installation identifier, not
|
||||||
|
the identifier supplied by the client. Event DTO string representations are
|
||||||
|
redacted. Product analytics rows linked to an account are deleted with that
|
||||||
|
account. Anonymous installations that never link to an account are retained for
|
||||||
|
90 days and are eligible for scheduled deletion in a later operational job.
|
||||||
|
Per-installation keyboard usage summaries are retained for at most 90 days
|
||||||
|
regardless of account linkage.
|
||||||
|
|
||||||
|
## Ingestion API
|
||||||
|
|
||||||
|
`POST /v1/analytics/events` accepts one batch of 1 to 50 events.
|
||||||
|
|
||||||
|
- Authentication is optional so first-open and pre-login events can be
|
||||||
|
measured. Invalid bearer credentials are rejected.
|
||||||
|
- `installationId` must be a client-generated UUID stored in the containing app
|
||||||
|
and shared with the keyboard extension through the App Group. New clients send
|
||||||
|
it once at the batch root. During the migration window, the server also accepts
|
||||||
|
released clients that repeat one identical `installationId` on every event;
|
||||||
|
missing, incomplete, or conflicting identities reject the entire batch.
|
||||||
|
- When a valid account session is present, the installation is linked to that
|
||||||
|
account. An installation cannot later be linked to a different account.
|
||||||
|
- Every `clientEventId` is a client-generated UUID. The pair
|
||||||
|
`(installation, clientEventId)` is unique.
|
||||||
|
- Replaying an identical event is accepted and reported as replayed.
|
||||||
|
- Reusing an event ID with different values returns `409 conflict`.
|
||||||
|
- The whole batch is validated before persistence and committed atomically.
|
||||||
|
- `occurredAt` must be no more than 35 days old and no more than five minutes in
|
||||||
|
the future. UTC ISO-8601 timestamps are required.
|
||||||
|
|
||||||
|
The successful response reports `accepted` and `replayed` event counts. It does
|
||||||
|
not return account or installation identifiers.
|
||||||
|
|
||||||
|
## Keyboard usage summaries
|
||||||
|
|
||||||
|
`POST /v1/analytics/keyboard-usage` accepts 1 to 50 finalized UTC-day
|
||||||
|
summaries. Authentication, installation hashing, account linking, atomic
|
||||||
|
batching and replay semantics match the event ingestion API.
|
||||||
|
|
||||||
|
- Language classification and counting happen on-device.
|
||||||
|
- Only text manually committed by OSGKeyboard is counted. Voice transcripts,
|
||||||
|
pasted text, AI-generated output and text entered with another keyboard are
|
||||||
|
excluded.
|
||||||
|
- Chinese counts Unicode characters classified with the Han script. English
|
||||||
|
counts Latin letters. Digits, punctuation and Emoji are counted as `other`.
|
||||||
|
- Pinyin composition keystrokes are not counted as English; only the final
|
||||||
|
committed text is classified.
|
||||||
|
- An input session is one keyboard activation containing at least one
|
||||||
|
committed character.
|
||||||
|
- Chinese-only sessions contain Chinese but no English; English-only sessions
|
||||||
|
contain English but no Chinese; mixed sessions contain both. Other
|
||||||
|
characters may occur in any of those sessions. Other-only sessions contain
|
||||||
|
neither Chinese nor English.
|
||||||
|
- The four language-session counters must sum to `inputSessionCount`, and the
|
||||||
|
total committed character count must be at least the session count.
|
||||||
|
- Each installation may submit only one immutable summary per UTC date.
|
||||||
|
- Accepted dates are from 35 days ago through yesterday. Current-day partial
|
||||||
|
summaries are rejected.
|
||||||
|
- Raw text, per-keystroke events, surrounding context, host application
|
||||||
|
identifiers and free-form properties are never accepted.
|
||||||
|
|
||||||
|
The client should keep counters and the finalized outbox in the shared App
|
||||||
|
Group so either the containing app or keyboard extension can deliver them.
|
||||||
|
Retries must reuse the stored `clientSummaryId`.
|
||||||
|
|
||||||
|
## Event catalog
|
||||||
|
|
||||||
|
### Lifecycle events
|
||||||
|
|
||||||
|
`FIRST_OPEN`
|
||||||
|
|
||||||
|
- Required: `surface=APP`, `acquisitionChannel`.
|
||||||
|
- Optional: `appVersion`, `osVersion`.
|
||||||
|
- Must be emitted once per installation. Server idempotency protects retries.
|
||||||
|
|
||||||
|
`SESSION_STARTED`
|
||||||
|
|
||||||
|
- Required: `surface` (`APP` or `KEYBOARD`).
|
||||||
|
- Optional: `appVersion`, `osVersion`.
|
||||||
|
|
||||||
|
`KEYBOARD_ACTIVATED`
|
||||||
|
|
||||||
|
- Required: `surface=KEYBOARD`.
|
||||||
|
- Emit when the custom keyboard becomes active, not for every keystroke.
|
||||||
|
|
||||||
|
### AI events
|
||||||
|
|
||||||
|
`AI_FEATURE_STARTED`
|
||||||
|
|
||||||
|
- Required: `feature`, `executionMode`, `surface`.
|
||||||
|
- Optional: `durationBucket` is not allowed.
|
||||||
|
|
||||||
|
`AI_FEATURE_SUCCEEDED`
|
||||||
|
|
||||||
|
- Required: `feature`, `executionMode`, `surface`, `durationBucket`.
|
||||||
|
- This is the client-side core value event.
|
||||||
|
- Managed totals remain server-authoritative; the event provides feature and
|
||||||
|
surface detail.
|
||||||
|
|
||||||
|
`AI_FEATURE_FAILED`
|
||||||
|
|
||||||
|
- Required: `feature`, `executionMode`, `surface`, `failureCategory`.
|
||||||
|
- Optional: `durationBucket`.
|
||||||
|
- Never include provider response bodies or user content.
|
||||||
|
|
||||||
|
### Purchase events
|
||||||
|
|
||||||
|
`PURCHASE_VIEWED`
|
||||||
|
|
||||||
|
- Required: `surface=APP`.
|
||||||
|
|
||||||
|
`PURCHASE_STARTED`
|
||||||
|
|
||||||
|
- Required: `surface=APP`.
|
||||||
|
|
||||||
|
`PURCHASE_CANCELLED`
|
||||||
|
|
||||||
|
- Required: `surface=APP`, `failureCategory=CANCELLED`.
|
||||||
|
|
||||||
|
Successful purchases are derived from verified StoreKit transactions and must
|
||||||
|
not be duplicated as client success events.
|
||||||
|
|
||||||
|
### Referral events
|
||||||
|
|
||||||
|
`REFERRAL_SHARED`
|
||||||
|
|
||||||
|
- Required: `surface=APP`.
|
||||||
|
|
||||||
|
`INVITE_OPENED`
|
||||||
|
|
||||||
|
- Required: `surface=INVITE_WEB`, `acquisitionChannel=REFERRAL`.
|
||||||
|
- The app may emit this event after handling an invitation Universal Link.
|
||||||
|
- The first-party web route separately increments a UTC daily aggregate after
|
||||||
|
validating the referral code. It stores no IP address, user agent, cookie,
|
||||||
|
installation ID, referral code, or other request metadata.
|
||||||
|
- No third-party analytics script is permitted.
|
||||||
|
|
||||||
|
Referral binding, value qualification and reward are derived from server data.
|
||||||
|
|
||||||
|
## Allowed dimensions
|
||||||
|
|
||||||
|
`surface`
|
||||||
|
|
||||||
|
- `APP`
|
||||||
|
- `KEYBOARD`
|
||||||
|
- `INVITE_WEB`
|
||||||
|
|
||||||
|
`acquisitionChannel`
|
||||||
|
|
||||||
|
- `APP_STORE_ORGANIC`
|
||||||
|
- `REFERRAL`
|
||||||
|
- `SOCIAL_CONTENT`
|
||||||
|
- `UNKNOWN`
|
||||||
|
|
||||||
|
`feature`
|
||||||
|
|
||||||
|
- `TRANSCRIPTION`
|
||||||
|
- `POLISH`
|
||||||
|
- `AI_ASSISTANT`
|
||||||
|
- `AGENT`
|
||||||
|
- `HOTWORD`
|
||||||
|
- `OTHER`
|
||||||
|
|
||||||
|
`executionMode`
|
||||||
|
|
||||||
|
- `MANAGED`
|
||||||
|
- `LOCAL`
|
||||||
|
- `BYOK`
|
||||||
|
|
||||||
|
`failureCategory`
|
||||||
|
|
||||||
|
- `NETWORK`
|
||||||
|
- `PROVIDER`
|
||||||
|
- `TIMEOUT`
|
||||||
|
- `CANCELLED`
|
||||||
|
- `INSUFFICIENT_CREDITS`
|
||||||
|
- `VALIDATION`
|
||||||
|
- `UNKNOWN`
|
||||||
|
|
||||||
|
`durationBucket`
|
||||||
|
|
||||||
|
- `LT_1S`
|
||||||
|
- `S1_TO_3`
|
||||||
|
- `S3_TO_10`
|
||||||
|
- `S10_TO_30`
|
||||||
|
- `GTE_30S`
|
||||||
|
|
||||||
|
`appVersion` and `osVersion` are optional ASCII release identifiers of at most
|
||||||
|
32 characters. They may not contain spaces, user-generated values or device
|
||||||
|
names.
|
||||||
|
|
||||||
|
## Client delivery guidance
|
||||||
|
|
||||||
|
- Persist pending events in an append-only local queue.
|
||||||
|
- Retry with exponential backoff after network failures and HTTP 5xx.
|
||||||
|
- Drop events rejected with HTTP 400/422 after recording a local diagnostic
|
||||||
|
counter; do not retry malformed events indefinitely.
|
||||||
|
- Reuse the same `clientEventId` for every retry.
|
||||||
|
- Send at most 50 events per batch and remove events only after a successful
|
||||||
|
response.
|
||||||
|
- Do not block the AI interaction or purchase flow on analytics delivery.
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
The admin analytics endpoints combine:
|
||||||
|
|
||||||
|
- accepted product events for acquisition, local/BYOK usage and client funnel
|
||||||
|
detail;
|
||||||
|
- settled credit usage for managed AI counts and credit consumption;
|
||||||
|
- accounts, StoreKit and referrals for registration, monetization and referral
|
||||||
|
outcomes.
|
||||||
|
- daily keyboard summaries for privacy-minimized Chinese and English input
|
||||||
|
activity, character volume and input sessions.
|
||||||
|
|
||||||
|
Metric formulas are defined in
|
||||||
|
[`ANALYTICS_METRICS_DICTIONARY.md`](ANALYTICS_METRICS_DICTIONARY.md).
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# StoreKit credit product
|
||||||
|
|
||||||
|
The voluntary `ByRockyACoffee` tip remains independent and never grants credits.
|
||||||
|
Credit products are separate consumables:
|
||||||
|
|
||||||
|
- Product ID `500tks`: 500 integer credits at USD 0.99
|
||||||
|
- Product ID `1500tks`: 1,500 integer credits at USD 1.99 / CNY 18
|
||||||
|
- Product ID `3000tks`: 3,000 integer credits at USD 2.99 / CNY 28
|
||||||
|
- Territory prices remain controlled by App Store Connect.
|
||||||
|
- Restore Purchases: not offered for this consumable
|
||||||
|
|
||||||
|
## Cost basis
|
||||||
|
|
||||||
|
Reviewed on 2026-08-18 against the provider pricing pages:
|
||||||
|
|
||||||
|
- DeepSeek V4 Flash peak pricing is CNY 3 per million cache-miss input tokens
|
||||||
|
and CNY 9 per million output tokens. Off-peak pricing is half.
|
||||||
|
<https://api-docs.deepseek.com/zh-cn/quick_start/pricing>
|
||||||
|
- Doubao SeedASR 2.0 streaming recognition is CNY 4.5 per hour.
|
||||||
|
<https://ai.volcengine.com/model>
|
||||||
|
|
||||||
|
The V10 immutable rate card charges:
|
||||||
|
|
||||||
|
- ASR: one credit per started three-second interval. The 3,000-credit pack
|
||||||
|
provides up to 150 minutes and has a worst-case provider cost of CNY 11.25.
|
||||||
|
- DeepSeek: one credit per 1,000 input tokens plus one credit per 400 output
|
||||||
|
tokens, with each dimension rounded upward. At peak pricing, using all 3,000
|
||||||
|
credits exclusively on input or output costs at most about CNY 9.00 or
|
||||||
|
CNY 10.80 respectively.
|
||||||
|
- New signup, inviter and invitee grants are 1,000 credits each. Existing
|
||||||
|
immutable balances are adjusted only through explicit admin grants.
|
||||||
|
- Existing immutable ledger balances are grandfathered and are not rewritten
|
||||||
|
during the denomination change.
|
||||||
|
|
||||||
|
At a CNY 28 sale price, the ASR-heavy worst case leaves CNY 12.55 after a 15%
|
||||||
|
App Store commission, or CNY 8.35 after a 30% commission, before tax and
|
||||||
|
infrastructure costs. USD 2.99 territories are tighter at the worst-case ASR
|
||||||
|
mix and require ongoing margin monitoring.
|
||||||
|
|
||||||
|
## Transaction rules
|
||||||
|
|
||||||
|
- The app supplies the authenticated account UUID as StoreKit `appAccountToken`.
|
||||||
|
- The server verifies Apple's JWS signature, certificate chain, bundle ID,
|
||||||
|
App Apple ID, environment, consumable type, account token, and product ID.
|
||||||
|
- The App Store transaction ID is globally unique and idempotent.
|
||||||
|
- Credit balance and append-only purchase/ledger records commit in one database
|
||||||
|
transaction. A client retry returns the original grant.
|
||||||
|
- The app finishes the StoreKit transaction only after server acknowledgement.
|
||||||
|
- Signed transaction bodies and Apple certificate contents are never logged.
|
||||||
|
|
||||||
|
## Purchase history
|
||||||
|
|
||||||
|
- `GET /v1/storekit/transactions` requires the existing Bearer access token.
|
||||||
|
- The account comes only from the authenticated session; clients never submit it.
|
||||||
|
- History is read from the existing purchase audit and immutable credit ledger,
|
||||||
|
so purchases credited before the endpoint was deployed are included.
|
||||||
|
- Results are ordered by `purchasedAt` and then `transactionId`, both descending,
|
||||||
|
with opaque cursor pagination (`limit` defaults to 50 and is bounded to 1–100).
|
||||||
|
- Responses omit JWS data, Apple identifiers, account IDs, and internal record IDs.
|
||||||
|
- History queries never call the App Store.
|
||||||
@@ -21,6 +21,7 @@ GRANT SELECT ON osg_account.credit_reservations TO 'osg_account_runtime'@'10.20.
|
|||||||
GRANT SELECT ON osg_account.referral_campaigns TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.referral_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_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_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.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_usage_records TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT SELECT ON osg_account.credit_ledger 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_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_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_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.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_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.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT SELECT ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT SELECT ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT SELECT ON osg_account.account_profiles TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT SELECT ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT SELECT ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT SELECT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT SELECT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
GRANT SELECT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT SELECT ON osg_account.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, DELETE ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%';
|
||||||
@@ -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 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 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_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, 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_usage_records TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT ON osg_account.credit_ledger 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, 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 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 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.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_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.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT, UPDATE ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT, UPDATE ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT, UPDATE ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT, UPDATE ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT INSERT, UPDATE ON osg_account.account_profiles TO 'osg_account_runtime'@'10.20.%';
|
||||||
-- Operators and sessions are mutable authentication state. Audit and grant
|
-- Operators and sessions are mutable authentication state. Audit and grant
|
||||||
-- records remain append-only and deliberately receive no UPDATE or DELETE.
|
-- records remain append-only and deliberately receive no UPDATE or DELETE.
|
||||||
GRANT INSERT, UPDATE ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT, UPDATE ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
|
||||||
GRANT INSERT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
GRANT INSERT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
|
||||||
|
GRANT INSERT, 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,
|
-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
|
||||||
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
|
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
|
||||||
|
|||||||
+1643
-26
File diff suppressed because it is too large
Load Diff
@@ -23,18 +23,28 @@ import com.osglab.account.features.admin.services.AdminBootstrapService
|
|||||||
import com.osglab.account.features.admin.services.AdminOperatorService
|
import com.osglab.account.features.admin.services.AdminOperatorService
|
||||||
import com.osglab.account.features.admin.services.AdminSessionService
|
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.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.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.stats.services.AdminStatsService
|
||||||
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
|
||||||
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
|
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
|
||||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||||
import com.osglab.account.features.account.AccountRepository
|
import com.osglab.account.features.account.AccountOperations
|
||||||
import com.osglab.account.features.account.AccountReauthenticator
|
import com.osglab.account.features.account.AccountReauthenticator
|
||||||
|
import com.osglab.account.features.account.AccountRepository
|
||||||
import com.osglab.account.features.account.AccountService
|
import com.osglab.account.features.account.AccountService
|
||||||
import com.osglab.account.features.account.AppleAccountReauthenticator
|
import com.osglab.account.features.account.AppleAccountReauthenticator
|
||||||
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
|
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
|
||||||
import com.osglab.account.features.account.ExposedAccountRepository
|
import com.osglab.account.features.account.ExposedAccountRepository
|
||||||
import com.osglab.account.features.account.accountRoutes
|
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.AppleEventService
|
||||||
import com.osglab.account.features.appleevents.AppleEventRepository
|
import com.osglab.account.features.appleevents.AppleEventRepository
|
||||||
import com.osglab.account.features.appleevents.AppleEventVerifier
|
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.CreditOperations
|
||||||
import com.osglab.account.features.credits.services.CreditService
|
import com.osglab.account.features.credits.services.CreditService
|
||||||
import com.osglab.account.features.credits.services.ReferralRewardConfig
|
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.CreditReservationAdapter
|
||||||
import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
|
import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
|
||||||
import com.osglab.account.features.gateway.GatewaySettings
|
import com.osglab.account.features.gateway.GatewaySettings
|
||||||
import com.osglab.account.features.gateway.asr.AsrStreamingService
|
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.CreditReservationPort
|
||||||
|
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
|
||||||
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
|
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
|
||||||
import com.osglab.account.features.gateway.ports.GatewayGrantPort
|
import com.osglab.account.features.gateway.ports.GatewayGrantPort
|
||||||
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
|
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.GatewayGrantService
|
||||||
import com.osglab.account.features.gateway.services.GatewayReconciliationService
|
import com.osglab.account.features.gateway.services.GatewayReconciliationService
|
||||||
import com.osglab.account.features.gateway.services.GatewayService
|
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.AppAttestCrypto
|
||||||
import com.osglab.account.features.integrity.AppAttestRepository
|
import com.osglab.account.features.integrity.AppAttestRepository
|
||||||
import com.osglab.account.features.integrity.AppAttestService
|
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.createDeviceCheckClient
|
||||||
import com.osglab.account.features.integrity.integrityRoutes
|
import com.osglab.account.features.integrity.integrityRoutes
|
||||||
import com.osglab.account.features.inviteweb.InviteWebConfig
|
import com.osglab.account.features.inviteweb.InviteWebConfig
|
||||||
|
import com.osglab.account.features.inviteweb.InviteOpenRecorder
|
||||||
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
||||||
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
||||||
|
import com.osglab.account.features.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.routes.referralRoutes
|
||||||
import com.osglab.account.features.referrals.services.ReferralOperations
|
import com.osglab.account.features.referrals.services.ReferralOperations
|
||||||
import com.osglab.account.features.referrals.services.ReferralService
|
import com.osglab.account.features.referrals.services.ReferralService
|
||||||
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
|
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
|
||||||
import com.osglab.account.features.referrals.services.ReferralRiskProvider
|
import com.osglab.account.features.referrals.services.ReferralRiskProvider
|
||||||
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
|
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||||
|
import com.osglab.account.features.storekit.routes.storeKitRoutes
|
||||||
|
import com.osglab.account.features.storekit.services.StoreKitService
|
||||||
|
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
|
||||||
|
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
|
||||||
import io.ktor.client.plugins.HttpTimeout
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSockets
|
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.serialization.kotlinx.json.json
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.ApplicationStopped
|
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.plugins.ratelimit.rateLimit
|
||||||
import io.ktor.server.request.httpMethod
|
import io.ktor.server.request.httpMethod
|
||||||
import io.ktor.server.response.respond
|
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.routing.routing
|
||||||
import io.ktor.server.websocket.WebSockets
|
import io.ktor.server.websocket.WebSockets
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -238,13 +276,23 @@ fun Application.module() {
|
|||||||
val providerConfig = appConfig.providers.volcengine.toProviderConfig()
|
val providerConfig = appConfig.providers.volcengine.toProviderConfig()
|
||||||
AsrStreamingService(
|
AsrStreamingService(
|
||||||
gateway = koin.get(),
|
gateway = koin.get(),
|
||||||
upstream = KtorVolcengineAsrTransport(koin.get(), providerConfig),
|
upstream = KtorVolcengineAsrTransport(
|
||||||
|
client = koin.get(),
|
||||||
|
config = providerConfig,
|
||||||
|
credentialResolver = koin.get(),
|
||||||
|
),
|
||||||
scope = this,
|
scope = this,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appConfig.hintFeed.enabled) {
|
||||||
|
launch {
|
||||||
|
koin.get<HintFeedScheduler>().run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
try {
|
try {
|
||||||
@@ -254,6 +302,13 @@ fun Application.module() {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Durable outbox state is retried; never log sensitive token material.
|
// 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 {
|
try {
|
||||||
koin.get<GatewayReconciliationService>().reconcile()
|
koin.get<GatewayReconciliationService>().reconcile()
|
||||||
} catch (exception: CancellationException) {
|
} catch (exception: CancellationException) {
|
||||||
@@ -283,11 +338,13 @@ fun Application.module() {
|
|||||||
healthRoutes(koin.get())
|
healthRoutes(koin.get())
|
||||||
rateLimit(AUTH_RATE_LIMIT) {
|
rateLimit(AUTH_RATE_LIMIT) {
|
||||||
authRoutes(koin.get())
|
authRoutes(koin.get())
|
||||||
|
oobeRoutes(koin.get())
|
||||||
}
|
}
|
||||||
rateLimit(ACCOUNT_RATE_LIMIT) {
|
rateLimit(ACCOUNT_RATE_LIMIT) {
|
||||||
accountRoutes(koin.get())
|
accountRoutes(koin.get())
|
||||||
creditRoutes(koin.get(), 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) {
|
rateLimit(GATEWAY_RATE_LIMIT) {
|
||||||
configureGatewayRoutes(
|
configureGatewayRoutes(
|
||||||
@@ -300,43 +357,33 @@ fun Application.module() {
|
|||||||
}
|
}
|
||||||
rateLimit(PUBLIC_RATE_LIMIT) {
|
rateLimit(PUBLIC_RATE_LIMIT) {
|
||||||
appleEventRoutes(koin.get())
|
appleEventRoutes(koin.get())
|
||||||
configureInviteWebRoutes(koin.get(), koin.get())
|
analyticsRoutes(koin.get())
|
||||||
|
configureInviteWebRoutes(koin.get(), koin.get(), koin.get())
|
||||||
integrityRoutes(koin.get())
|
integrityRoutes(koin.get())
|
||||||
|
contentRoutes(koin.get())
|
||||||
}
|
}
|
||||||
if (appConfig.admin.enabled) {
|
if (appConfig.admin.enabled) {
|
||||||
adminWebRoutes()
|
adminWebRoutes(appConfig)
|
||||||
rateLimit(ADMIN_API_RATE_LIMIT) {
|
rateLimit(ADMIN_API_RATE_LIMIT) {
|
||||||
adminApiRoutes(
|
adminApiRoutes(
|
||||||
config = appConfig,
|
config = appConfig,
|
||||||
authService = koin.get(),
|
authService = koin.get(),
|
||||||
sessionService = koin.get(),
|
sessionService = koin.get(),
|
||||||
statsService = koin.get(),
|
statsService = koin.get(),
|
||||||
|
productAnalyticsService = koin.get(),
|
||||||
usersService = koin.get(),
|
usersService = koin.get(),
|
||||||
grantService = koin.get(),
|
grantService = koin.get(),
|
||||||
operatorService = koin.get(),
|
operatorService = koin.get(),
|
||||||
auditService = 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 {
|
fun accountServerModule(config: AppConfig): Module = module {
|
||||||
single { config }
|
single { config }
|
||||||
single { DatabaseFactory(config.database) }
|
single { DatabaseFactory(config.database) }
|
||||||
@@ -361,6 +408,19 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
single { SessionJwt(config.session) }
|
single { SessionJwt(config.session) }
|
||||||
single { FieldEncryptor(config.encryption.key) }
|
single { FieldEncryptor(config.encryption.key) }
|
||||||
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
|
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<AdminRepository> { ExposedAdminRepository(get()) }
|
||||||
single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() }
|
single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() }
|
||||||
single<AdminTotpVerifier> { HmacTotpVerifier() }
|
single<AdminTotpVerifier> { HmacTotpVerifier() }
|
||||||
@@ -385,9 +445,30 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
single { AdminAuditService(get()) }
|
single { AdminAuditService(get()) }
|
||||||
single<AdminStatsRepository> { ExposedAdminStatsRepository(get()) }
|
single<AdminStatsRepository> { ExposedAdminStatsRepository(get()) }
|
||||||
single { AdminStatsService(get()) }
|
single { AdminStatsService(get()) }
|
||||||
|
single<AdminProductAnalyticsRepository> { ExposedAdminProductAnalyticsRepository(get()) }
|
||||||
|
single { AdminProductAnalyticsService(get()) }
|
||||||
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
|
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
|
||||||
single { AdminUsersService(get()) }
|
single { AdminUsersService(get()) }
|
||||||
single { AdminGrantService(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> {
|
single<AppleJwksProvider> {
|
||||||
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
|
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
|
||||||
}
|
}
|
||||||
@@ -414,6 +495,14 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
single<AuthRepository> { ExposedAuthRepository(get()) }
|
single<AuthRepository> { ExposedAuthRepository(get()) }
|
||||||
single { SessionAccessAuthenticator(get(), get()) }
|
single { SessionAccessAuthenticator(get(), get()) }
|
||||||
single<AccountRepository> { ExposedAccountRepository(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<AppleEventRepository> { ExposedAppleEventRepository(get(), get(), config.antiAbuse) }
|
||||||
single { AppleEventVerifier(config.apple, get()) }
|
single { AppleEventVerifier(config.apple, get()) }
|
||||||
single { AppleEventService(get(), get()) }
|
single { AppleEventService(get(), get()) }
|
||||||
@@ -431,14 +520,37 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
single<CreditOperations> { get<CreditService>() }
|
single<CreditOperations> { get<CreditService>() }
|
||||||
|
single<StoreKitTransactionVerifier> {
|
||||||
|
if (config.storeKit.enabled) {
|
||||||
|
AppleStoreKitTransactionVerifier(
|
||||||
|
bundleId = config.storeKit.bundleId,
|
||||||
|
appAppleId = requireNotNull(config.storeKit.appAppleId),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
StoreKitTransactionVerifier { throw StoreKitUnavailable() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
single {
|
||||||
|
StoreKitService(
|
||||||
|
products = if (config.storeKit.enabled) config.storeKit.products else emptyList(),
|
||||||
|
verifier = get(),
|
||||||
|
transactions = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
single<TrialCreditGranter> {
|
single<TrialCreditGranter> {
|
||||||
TrialCreditGranter { accountId ->
|
val creditService = get<CreditService>()
|
||||||
get<CreditService>().grantSignupTrial(
|
object : TrialCreditGranter {
|
||||||
|
override suspend fun grant(accountId: UUID) {
|
||||||
|
creditService.grantSignupTrial(
|
||||||
userId = accountId,
|
userId = accountId,
|
||||||
credits = config.credits.signupTrial,
|
credits = config.credits.signupTrial,
|
||||||
idempotencyKey = "internal:signup-trial:$accountId",
|
idempotencyKey = signupTrialIdempotencyKey(accountId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun wasGranted(accountId: UUID): Boolean =
|
||||||
|
creditService.hasSignupTrial(accountId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
single {
|
single {
|
||||||
DeviceCheckTrialService(
|
DeviceCheckTrialService(
|
||||||
@@ -453,15 +565,19 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
single<GatewayGrantRepository> { get<ExposedGatewayRepository>() }
|
single<GatewayGrantRepository> { get<ExposedGatewayRepository>() }
|
||||||
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
|
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
|
||||||
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
|
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
|
||||||
|
single<ComplimentaryRequestPort> { get<ExposedGatewayRepository>() }
|
||||||
single<AccountProvisioner> {
|
single<AccountProvisioner> {
|
||||||
AccountProvisioner { accountId, deviceCheckToken ->
|
AccountProvisioner { accountId, deviceCheckToken, displayName ->
|
||||||
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
val trial = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
||||||
if (deviceCheckToken != null && !granted) {
|
if (trial.shouldRestrictAccount) {
|
||||||
get<AuthRepository>().restrictAccountForAntiAbuse(
|
get<AuthRepository>().restrictAccountForAntiAbuse(
|
||||||
accountId,
|
accountId,
|
||||||
java.time.Instant.now(),
|
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 {
|
single {
|
||||||
@@ -494,6 +610,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
reauthenticator = get(),
|
reauthenticator = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
single<AccountOperations> { get<AccountService>() }
|
||||||
|
|
||||||
single<UserRegistrationTimeProvider> {
|
single<UserRegistrationTimeProvider> {
|
||||||
UserRegistrationTimeProvider { accountId ->
|
UserRegistrationTimeProvider { accountId ->
|
||||||
@@ -520,10 +637,9 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
val transactions = get<BillingTransactionRunner>()
|
val transactions = get<BillingTransactionRunner>()
|
||||||
ReferralLookupPort { code ->
|
ReferralLookupPort { code ->
|
||||||
transactions.inTransaction { unit ->
|
transactions.inTransaction { unit ->
|
||||||
val referralCode = unit.referrals.findCode(code)
|
// Invitation codes are permanent account identifiers. Campaign
|
||||||
referralCode?.campaignId
|
// availability is evaluated only when an invitee redeems one.
|
||||||
?.let(unit.referrals::findCampaign)
|
unit.referrals.findCode(code) != null
|
||||||
?.isActive(Instant.now()) == true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,8 +658,27 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
maximumGrantLifetime = Duration.ofDays(config.session.gatewayGrantDays),
|
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 { GatewayGrantService(get(), get()) }
|
||||||
single<GatewayAccessTokenPort> { GatewayBearerIdentity(get()) }
|
single<GatewayAccessTokenPort> {
|
||||||
|
val oobeGrants = get<OobeGrantService>()
|
||||||
|
GatewayBearerIdentity(get(), oobeGrants::authenticate)
|
||||||
|
}
|
||||||
single<CreditReservationPort> {
|
single<CreditReservationPort> {
|
||||||
CreditReservationAdapter(
|
CreditReservationAdapter(
|
||||||
creditService = get(),
|
creditService = get(),
|
||||||
@@ -552,9 +687,9 @@ fun accountServerModule(config: AppConfig): Module = module {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
single {
|
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 { GatewayReconciliationService(get(), get()) }
|
||||||
single {
|
single {
|
||||||
InviteWebConfig(
|
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 {
|
buildList {
|
||||||
config.providers.deepSeek.apiKey?.let { apiKey ->
|
config.providers.deepSeek.apiKey?.let { apiKey ->
|
||||||
add(
|
add(
|
||||||
@@ -575,13 +714,23 @@ private fun configuredProviders(config: AppConfig, client: HttpClient): List<Gat
|
|||||||
endpoint = config.providers.deepSeek.endpoint,
|
endpoint = config.providers.deepSeek.endpoint,
|
||||||
apiKey = apiKey,
|
apiKey = apiKey,
|
||||||
model = config.providers.deepSeek.model,
|
model = config.providers.deepSeek.model,
|
||||||
|
reasoningModel = config.providers.deepSeek.reasoningModel,
|
||||||
),
|
),
|
||||||
|
credentialResolver = credentialResolver,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (config.providers.volcengine.credentialsAvailable) {
|
if (config.providers.volcengine.credentialsAvailable) {
|
||||||
val providerConfig = config.providers.volcengine.toProviderConfig()
|
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
|
package com.osglab.account.config
|
||||||
|
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||||
import io.ktor.server.config.ApplicationConfig
|
import io.ktor.server.config.ApplicationConfig
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.time.ZoneId
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -16,9 +18,11 @@ data class AppConfig(
|
|||||||
val antiAbuse: AntiAbuseConfig,
|
val antiAbuse: AntiAbuseConfig,
|
||||||
val apple: AppleConfig,
|
val apple: AppleConfig,
|
||||||
val credits: CreditsConfig,
|
val credits: CreditsConfig,
|
||||||
|
val storeKit: StoreKitConfig = StoreKitConfig(),
|
||||||
val providers: ProvidersConfig,
|
val providers: ProvidersConfig,
|
||||||
val integrity: IntegrityConfig,
|
val integrity: IntegrityConfig,
|
||||||
val admin: AdminConfig = AdminConfig(),
|
val admin: AdminConfig = AdminConfig(),
|
||||||
|
val hintFeed: HintFeedConfig = HintFeedConfig(),
|
||||||
) {
|
) {
|
||||||
val isProduction: Boolean = environment == Environment.PRODUCTION
|
val isProduction: Boolean = environment == Environment.PRODUCTION
|
||||||
|
|
||||||
@@ -52,6 +56,10 @@ data class AppConfig(
|
|||||||
hmacSecret = config.secret("app.session.secret", production).toByteArray(),
|
hmacSecret = config.secret("app.session.secret", production).toByteArray(),
|
||||||
accessMinutes = config.positiveLong("app.session.accessMinutes"),
|
accessMinutes = config.positiveLong("app.session.accessMinutes"),
|
||||||
refreshDays = config.positiveLong("app.session.refreshDays"),
|
refreshDays = config.positiveLong("app.session.refreshDays"),
|
||||||
|
legacyRefreshReplaySeconds = config.positiveLong(
|
||||||
|
"app.session.legacyRefreshReplaySeconds",
|
||||||
|
30,
|
||||||
|
),
|
||||||
gatewayGrantDays = config.positiveLong("app.session.gatewayGrantDays", 30),
|
gatewayGrantDays = config.positiveLong("app.session.gatewayGrantDays", 30),
|
||||||
)
|
)
|
||||||
val encryption = EncryptionConfig(
|
val encryption = EncryptionConfig(
|
||||||
@@ -76,10 +84,36 @@ data class AppConfig(
|
|||||||
)
|
)
|
||||||
val credits = CreditsConfig(
|
val credits = CreditsConfig(
|
||||||
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
|
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
|
||||||
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000),
|
referralInviter = config.positiveLong("app.credits.referralInviter", 1_000),
|
||||||
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_000),
|
referralInvitee = config.positiveLong("app.credits.referralInvitee", 1_000),
|
||||||
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
|
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
|
||||||
)
|
)
|
||||||
|
val storeKitEnabled = config.booleanOrDefault("app.storeKit.enabled", false)
|
||||||
|
val storeKitAppAppleId = config.optionalValue("app.storeKit.appAppleId")?.let { raw ->
|
||||||
|
raw.toLongOrNull()?.takeIf { it > 0 }
|
||||||
|
?: throw ConfigValidationException("app.storeKit.appAppleId must be positive")
|
||||||
|
}
|
||||||
|
val storeKit = StoreKitConfig(
|
||||||
|
enabled = storeKitEnabled,
|
||||||
|
bundleId = config.valueOrDefault("app.storeKit.bundleId", apple.clientId),
|
||||||
|
appAppleId = storeKitAppAppleId,
|
||||||
|
products = config.storeKitProducts("app.storeKit.products"),
|
||||||
|
)
|
||||||
|
if (storeKitEnabled) {
|
||||||
|
require(storeKit.bundleId == apple.clientId) {
|
||||||
|
"app.storeKit.bundleId must match app.apple.clientId"
|
||||||
|
}
|
||||||
|
require(storeKit.appAppleId != null) {
|
||||||
|
"app.storeKit.appAppleId is required when StoreKit is enabled"
|
||||||
|
}
|
||||||
|
require(storeKit.products.isNotEmpty()) {
|
||||||
|
"app.storeKit.products is required when StoreKit is enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val deepSeekModel = config.valueOrDefault(
|
||||||
|
"app.providers.deepseek.model",
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
)
|
||||||
val providers = ProvidersConfig(
|
val providers = ProvidersConfig(
|
||||||
volcengine = VolcengineConfig(
|
volcengine = VolcengineConfig(
|
||||||
endpoint = config.valueOrDefault(
|
endpoint = config.valueOrDefault(
|
||||||
@@ -100,7 +134,9 @@ data class AppConfig(
|
|||||||
"https://api.deepseek.com/v1",
|
"https://api.deepseek.com/v1",
|
||||||
),
|
),
|
||||||
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
|
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
|
||||||
model = config.valueOrDefault("app.providers.deepseek.model", "deepseek-v4-flash"),
|
model = deepSeekModel,
|
||||||
|
reasoningModel = config.optionalValue("app.providers.deepseek.reasoningModel")
|
||||||
|
?: deepSeekModel,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val integrity = IntegrityConfig(
|
val integrity = IntegrityConfig(
|
||||||
@@ -116,6 +152,10 @@ data class AppConfig(
|
|||||||
if (production) "production" else "development",
|
if (production) "production" else "development",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
allowDevelopmentAppAttest = config.booleanOrDefault(
|
||||||
|
"app.integrity.allowDevelopmentAppAttest",
|
||||||
|
false,
|
||||||
|
),
|
||||||
challengeLifetimeSeconds = config.positiveLong(
|
challengeLifetimeSeconds = config.positiveLong(
|
||||||
"app.integrity.challengeLifetimeSeconds",
|
"app.integrity.challengeLifetimeSeconds",
|
||||||
300,
|
300,
|
||||||
@@ -131,6 +171,7 @@ data class AppConfig(
|
|||||||
}
|
}
|
||||||
val admin = AdminConfig(
|
val admin = AdminConfig(
|
||||||
enabled = adminEnabled,
|
enabled = adminEnabled,
|
||||||
|
mtlsRequired = config.booleanOrDefault("app.admin.mtlsRequired", true),
|
||||||
bootstrapEnabled = adminBootstrapEnabled,
|
bootstrapEnabled = adminBootstrapEnabled,
|
||||||
bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId")
|
bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId")
|
||||||
?.let {
|
?.let {
|
||||||
@@ -156,6 +197,18 @@ data class AppConfig(
|
|||||||
100_000,
|
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) {
|
require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) {
|
||||||
"app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES 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) {
|
require(session.refreshDays in 1..365) {
|
||||||
"app.session.refreshDays must be between 1 and 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) {
|
require(!production || providers.volcengine.credentialsAvailable) {
|
||||||
"Production Volcengine credentials are missing"
|
"Production Volcengine credentials are missing"
|
||||||
}
|
}
|
||||||
@@ -294,9 +350,11 @@ data class AppConfig(
|
|||||||
antiAbuse = antiAbuse,
|
antiAbuse = antiAbuse,
|
||||||
apple = apple,
|
apple = apple,
|
||||||
credits = credits,
|
credits = credits,
|
||||||
|
storeKit = storeKit,
|
||||||
providers = providers,
|
providers = providers,
|
||||||
integrity = integrity,
|
integrity = integrity,
|
||||||
admin = admin,
|
admin = admin,
|
||||||
|
hintFeed = hintFeed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,6 +387,7 @@ data class SessionConfig(
|
|||||||
val hmacSecret: ByteArray,
|
val hmacSecret: ByteArray,
|
||||||
val accessMinutes: Long,
|
val accessMinutes: Long,
|
||||||
val refreshDays: Long,
|
val refreshDays: Long,
|
||||||
|
val legacyRefreshReplaySeconds: Long = 30,
|
||||||
val gatewayGrantDays: Long = 30,
|
val gatewayGrantDays: Long = 30,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -339,6 +398,12 @@ data class AntiAbuseConfig(
|
|||||||
val tombstoneRetentionDays: Long,
|
val tombstoneRetentionDays: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class HintFeedConfig(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val topHubApiKey: String? = null,
|
||||||
|
val zoneId: ZoneId = ZoneId.of("UTC"),
|
||||||
|
)
|
||||||
|
|
||||||
data class AppleConfig(
|
data class AppleConfig(
|
||||||
val teamId: String?,
|
val teamId: String?,
|
||||||
val keyId: String?,
|
val keyId: String?,
|
||||||
@@ -359,6 +424,13 @@ data class CreditsConfig(
|
|||||||
val referralBindingDays: Long,
|
val referralBindingDays: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class StoreKitConfig(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val bundleId: String = "com.osgkeyboard.ios",
|
||||||
|
val appAppleId: Long? = null,
|
||||||
|
val products: List<StoreKitProduct> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
data class ProvidersConfig(
|
data class ProvidersConfig(
|
||||||
val volcengine: VolcengineConfig,
|
val volcengine: VolcengineConfig,
|
||||||
val deepSeek: DeepSeekConfig,
|
val deepSeek: DeepSeekConfig,
|
||||||
@@ -379,6 +451,7 @@ data class DeepSeekConfig(
|
|||||||
val endpoint: String,
|
val endpoint: String,
|
||||||
val apiKey: String?,
|
val apiKey: String?,
|
||||||
val model: String,
|
val model: String,
|
||||||
|
val reasoningModel: String = model,
|
||||||
) {
|
) {
|
||||||
val credentialsAvailable: Boolean
|
val credentialsAvailable: Boolean
|
||||||
get() = !apiKey.isNullOrBlank()
|
get() = !apiKey.isNullOrBlank()
|
||||||
@@ -388,6 +461,7 @@ data class IntegrityConfig(
|
|||||||
val deviceCheckPolicy: IntegrityPolicy,
|
val deviceCheckPolicy: IntegrityPolicy,
|
||||||
val appAttestPolicy: IntegrityPolicy,
|
val appAttestPolicy: IntegrityPolicy,
|
||||||
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
|
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
|
||||||
|
val allowDevelopmentAppAttest: Boolean = false,
|
||||||
val challengeLifetimeSeconds: Long = 300,
|
val challengeLifetimeSeconds: Long = 300,
|
||||||
val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
|
val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
|
||||||
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
|
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
|
||||||
@@ -395,6 +469,7 @@ data class IntegrityConfig(
|
|||||||
|
|
||||||
data class AdminConfig(
|
data class AdminConfig(
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
|
val mtlsRequired: Boolean = true,
|
||||||
val bootstrapEnabled: Boolean = false,
|
val bootstrapEnabled: Boolean = false,
|
||||||
val bootstrapOperatorId: UUID? = null,
|
val bootstrapOperatorId: UUID? = null,
|
||||||
val bootstrapUsername: String? = 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")
|
?: throw ConfigValidationException("$path must be a positive integer")
|
||||||
} ?: default
|
} ?: default
|
||||||
|
|
||||||
|
private fun ApplicationConfig.storeKitProducts(path: String): List<StoreKitProduct> {
|
||||||
|
val raw = optionalValue(path) ?: return emptyList()
|
||||||
|
val products = raw.split(',').map { entry ->
|
||||||
|
val parts = entry.split(':', limit = 2).map(String::trim)
|
||||||
|
if (parts.size != 2) {
|
||||||
|
throw ConfigValidationException("$path must use productId:credits entries")
|
||||||
|
}
|
||||||
|
val credits = parts[1].toLongOrNull()?.takeIf { it > 0 }
|
||||||
|
?: throw ConfigValidationException("$path credits must be positive integers")
|
||||||
|
runCatching { StoreKitProduct(productId = parts[0], credits = credits) }
|
||||||
|
.getOrElse { throw ConfigValidationException("$path contains an invalid product", it) }
|
||||||
|
}
|
||||||
|
if (products.map(StoreKitProduct::productId).distinct().size != products.size) {
|
||||||
|
throw ConfigValidationException("$path contains duplicate product IDs")
|
||||||
|
}
|
||||||
|
return products
|
||||||
|
}
|
||||||
|
|
||||||
private fun ApplicationConfig.boolean(path: String): Boolean =
|
private fun ApplicationConfig.boolean(path: String): Boolean =
|
||||||
required(path).let {
|
required(path).let {
|
||||||
when (it.lowercase()) {
|
when (it.lowercase()) {
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ data class AccountRecord(
|
|||||||
val identityFingerprint: String,
|
val identityFingerprint: String,
|
||||||
val antiAbuseRestricted: Boolean,
|
val antiAbuseRestricted: Boolean,
|
||||||
val encryptedAppleRefreshToken: String?,
|
val encryptedAppleRefreshToken: String?,
|
||||||
|
val encryptedDisplayName: String? = null,
|
||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
) {
|
) {
|
||||||
override fun toString(): String =
|
override fun toString(): String =
|
||||||
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
|
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
|
||||||
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
|
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
|
||||||
|
"encryptedDisplayName=[REDACTED], " +
|
||||||
"createdAt=$createdAt)"
|
"createdAt=$createdAt)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +63,18 @@ internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
|
|||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private object AccountProfilesTable : Table("account_profiles") {
|
||||||
|
val accountId = varchar("account_id", 36)
|
||||||
|
val encryptedDisplayName = text("encrypted_display_name")
|
||||||
|
val createdAt = timestamp("created_at")
|
||||||
|
val updatedAt = timestamp("updated_at")
|
||||||
|
override val primaryKey = PrimaryKey(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
interface AccountRepository {
|
interface AccountRepository {
|
||||||
suspend fun findById(accountId: UUID): AccountRecord?
|
suspend fun findById(accountId: UUID): AccountRecord?
|
||||||
|
suspend fun seedDisplayNameIfAbsent(accountId: UUID, encryptedDisplayName: String, now: Instant)
|
||||||
|
suspend fun updateDisplayName(accountId: UUID, encryptedDisplayName: String, now: Instant)
|
||||||
suspend fun deleteById(
|
suspend fun deleteById(
|
||||||
accountId: UUID,
|
accountId: UUID,
|
||||||
deletedAt: Instant,
|
deletedAt: Instant,
|
||||||
@@ -89,17 +101,58 @@ class ExposedAccountRepository(
|
|||||||
.where { AppleCredentialsTable.accountId eq accountId.toString() }
|
.where { AppleCredentialsTable.accountId eq accountId.toString() }
|
||||||
.singleOrNull()
|
.singleOrNull()
|
||||||
?.get(AppleCredentialsTable.encryptedRefreshToken)
|
?.get(AppleCredentialsTable.encryptedRefreshToken)
|
||||||
|
val encryptedDisplayName = AccountProfilesTable.selectAll()
|
||||||
|
.where { AccountProfilesTable.accountId eq accountId.toString() }
|
||||||
|
.singleOrNull()
|
||||||
|
?.get(AccountProfilesTable.encryptedDisplayName)
|
||||||
row.let {
|
row.let {
|
||||||
AccountRecord(
|
AccountRecord(
|
||||||
id = UUID.fromString(it[AccountsTable.id]),
|
id = UUID.fromString(it[AccountsTable.id]),
|
||||||
identityFingerprint = fingerprint,
|
identityFingerprint = fingerprint,
|
||||||
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
|
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
|
||||||
encryptedAppleRefreshToken = encryptedRefreshToken,
|
encryptedAppleRefreshToken = encryptedRefreshToken,
|
||||||
|
encryptedDisplayName = encryptedDisplayName,
|
||||||
createdAt = it[AccountsTable.createdAt],
|
createdAt = it[AccountsTable.createdAt],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun seedDisplayNameIfAbsent(
|
||||||
|
accountId: UUID,
|
||||||
|
encryptedDisplayName: String,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
databaseFactory.query {
|
||||||
|
AccountProfilesTable.insertIgnore {
|
||||||
|
it[AccountProfilesTable.accountId] = accountId.toString()
|
||||||
|
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||||
|
it[createdAt] = now
|
||||||
|
it[updatedAt] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateDisplayName(
|
||||||
|
accountId: UUID,
|
||||||
|
encryptedDisplayName: String,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
databaseFactory.query {
|
||||||
|
AccountProfilesTable.insertIgnore {
|
||||||
|
it[AccountProfilesTable.accountId] = accountId.toString()
|
||||||
|
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||||
|
it[createdAt] = now
|
||||||
|
it[updatedAt] = now
|
||||||
|
}
|
||||||
|
AccountProfilesTable.update({
|
||||||
|
AccountProfilesTable.accountId eq accountId.toString()
|
||||||
|
}) {
|
||||||
|
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||||
|
it[updatedAt] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteById(
|
override suspend fun deleteById(
|
||||||
accountId: UUID,
|
accountId: UUID,
|
||||||
deletedAt: Instant,
|
deletedAt: Instant,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import io.ktor.server.response.respond
|
|||||||
import io.ktor.server.routing.Route
|
import io.ktor.server.routing.Route
|
||||||
import io.ktor.server.routing.delete
|
import io.ktor.server.routing.delete
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.patch
|
||||||
import io.ktor.server.routing.route
|
import io.ktor.server.routing.route
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -19,8 +20,12 @@ import kotlinx.serialization.Serializable
|
|||||||
data class AccountResponse(
|
data class AccountResponse(
|
||||||
val id: String,
|
val id: String,
|
||||||
val createdAtEpochSeconds: Long,
|
val createdAtEpochSeconds: Long,
|
||||||
|
val displayName: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpdateAccountProfileRequest(val displayName: String)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DeleteAccountRequest(
|
data class DeleteAccountRequest(
|
||||||
val identityToken: String,
|
val identityToken: String,
|
||||||
@@ -39,7 +44,7 @@ data class DeleteAccountRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AccountRoutes(
|
class AccountRoutes(
|
||||||
private val accountService: AccountService,
|
private val accountService: AccountOperations,
|
||||||
) {
|
) {
|
||||||
fun register(parent: Route) {
|
fun register(parent: Route) {
|
||||||
with(parent) {
|
with(parent) {
|
||||||
@@ -49,14 +54,17 @@ class AccountRoutes(
|
|||||||
val principal = call.principal<AccountPrincipal>()
|
val principal = call.principal<AccountPrincipal>()
|
||||||
?: throw UnauthorizedException()
|
?: throw UnauthorizedException()
|
||||||
val account = accountService.get(principal.userId)
|
val account = accountService.get(principal.userId)
|
||||||
call.respond(
|
call.respond(ApiResponse(data = account.toResponse()))
|
||||||
ApiResponse(
|
}
|
||||||
data = AccountResponse(
|
patch {
|
||||||
id = account.id.toString(),
|
val principal = call.principal<AccountPrincipal>()
|
||||||
createdAtEpochSeconds = account.createdAt.epochSecond,
|
?: throw UnauthorizedException()
|
||||||
),
|
val request = call.receive<UpdateAccountProfileRequest>()
|
||||||
),
|
val account = accountService.updateDisplayName(
|
||||||
|
principal.userId,
|
||||||
|
request.displayName,
|
||||||
)
|
)
|
||||||
|
call.respond(ApiResponse(data = account.toResponse()))
|
||||||
}
|
}
|
||||||
delete {
|
delete {
|
||||||
val principal = call.principal<AccountPrincipal>()
|
val principal = call.principal<AccountPrincipal>()
|
||||||
@@ -73,5 +81,11 @@ class AccountRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Route.accountRoutes(accountService: AccountService) =
|
fun Route.accountRoutes(accountService: AccountOperations) =
|
||||||
AccountRoutes(accountService).register(this)
|
AccountRoutes(accountService).register(this)
|
||||||
|
|
||||||
|
private fun AccountView.toResponse() = AccountResponse(
|
||||||
|
id = id.toString(),
|
||||||
|
createdAtEpochSeconds = createdAt.epochSecond,
|
||||||
|
displayName = displayName,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.osglab.account.features.account
|
package com.osglab.account.features.account
|
||||||
|
|
||||||
import com.osglab.account.common.errors.ExternalServiceUnavailableException
|
import com.osglab.account.common.errors.ExternalServiceUnavailableException
|
||||||
|
import com.osglab.account.common.errors.InvalidRequestException
|
||||||
import com.osglab.account.common.errors.UnauthorizedException
|
import com.osglab.account.common.errors.UnauthorizedException
|
||||||
import com.osglab.account.common.security.FieldDecryptionException
|
import com.osglab.account.common.security.FieldDecryptionException
|
||||||
import com.osglab.account.common.security.FieldEncryptor
|
import com.osglab.account.common.security.FieldEncryptor
|
||||||
@@ -16,11 +17,13 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.text.Normalizer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class AccountView(
|
data class AccountView(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
|
val displayName: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AppleReauthenticationProof(
|
data class AppleReauthenticationProof(
|
||||||
@@ -41,6 +44,13 @@ fun interface AccountReauthenticator {
|
|||||||
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
|
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AccountOperations {
|
||||||
|
suspend fun get(accountId: UUID): AccountView
|
||||||
|
suspend fun seedDisplayName(accountId: UUID, candidate: String?)
|
||||||
|
suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView
|
||||||
|
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof)
|
||||||
|
}
|
||||||
|
|
||||||
class AppleAccountReauthenticator(
|
class AppleAccountReauthenticator(
|
||||||
private val identityVerifier: AppleIdentityTokenVerifier,
|
private val identityVerifier: AppleIdentityTokenVerifier,
|
||||||
private val appleTokenClient: AppleTokenClient,
|
private val appleTokenClient: AppleTokenClient,
|
||||||
@@ -94,13 +104,34 @@ class AccountService(
|
|||||||
private val revocationProcessor: AppleRevocationOutboxProcessor,
|
private val revocationProcessor: AppleRevocationOutboxProcessor,
|
||||||
private val reauthenticator: AccountReauthenticator,
|
private val reauthenticator: AccountReauthenticator,
|
||||||
private val clock: Clock = Clock.systemUTC(),
|
private val clock: Clock = Clock.systemUTC(),
|
||||||
) {
|
) : AccountOperations {
|
||||||
suspend fun get(accountId: UUID): AccountView {
|
override suspend fun get(accountId: UUID): AccountView {
|
||||||
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
||||||
return AccountView(account.id, account.createdAt)
|
return account.toView()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
|
override suspend fun seedDisplayName(accountId: UUID, candidate: String?) {
|
||||||
|
val displayName = candidate?.let(::normalizedDisplayNameOrNull) ?: return
|
||||||
|
val account = repository.findById(accountId) ?: return
|
||||||
|
repository.seedDisplayNameIfAbsent(
|
||||||
|
accountId,
|
||||||
|
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
|
||||||
|
clock.instant(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView {
|
||||||
|
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
||||||
|
val displayName = normalizedDisplayName(candidate)
|
||||||
|
repository.updateDisplayName(
|
||||||
|
accountId,
|
||||||
|
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
|
||||||
|
clock.instant(),
|
||||||
|
)
|
||||||
|
return requireNotNull(repository.findById(accountId)).toView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
|
||||||
val account = repository.findById(accountId) ?: return
|
val account = repository.findById(accountId) ?: return
|
||||||
val now = clock.instant()
|
val now = clock.instant()
|
||||||
val currentRefreshToken = reauthenticator.verify(account, proof)
|
val currentRefreshToken = reauthenticator.verify(account, proof)
|
||||||
@@ -126,6 +157,14 @@ class AccountService(
|
|||||||
// Local deletion is final. The durable outbox retry loop handles Apple outages.
|
// Local deletion is final. The durable outbox retry loop handles Apple outages.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun AccountRecord.toView(): AccountView = AccountView(
|
||||||
|
id = id,
|
||||||
|
createdAt = createdAt,
|
||||||
|
displayName = encryptedDisplayName?.let {
|
||||||
|
fieldEncryptor.decrypt(it, accountProfileContext(id))
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppleRevocationOutboxProcessor(
|
class AppleRevocationOutboxProcessor(
|
||||||
@@ -175,3 +214,24 @@ class AppleRevocationOutboxProcessor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
|
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
|
||||||
|
|
||||||
|
private fun accountProfileContext(id: UUID): String = "account-profile:$id"
|
||||||
|
|
||||||
|
private fun normalizedDisplayNameOrNull(candidate: String): String? =
|
||||||
|
runCatching { normalizedDisplayName(candidate) }.getOrNull()
|
||||||
|
|
||||||
|
private fun normalizedDisplayName(candidate: String): String {
|
||||||
|
val normalized = Normalizer.normalize(candidate.trim(), Normalizer.Form.NFC)
|
||||||
|
.replace(WHITESPACE_REGEX, " ")
|
||||||
|
if (
|
||||||
|
normalized.isBlank() ||
|
||||||
|
normalized.codePointCount(0, normalized.length) > MAX_DISPLAY_NAME_CODE_POINTS ||
|
||||||
|
normalized.any { it.isISOControl() }
|
||||||
|
) {
|
||||||
|
throw InvalidRequestException("Display name is invalid")
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private val WHITESPACE_REGEX = Regex("\\s+")
|
||||||
|
private const val MAX_DISPLAY_NAME_CODE_POINTS = 64
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ data class AdminOperatorRecord(
|
|||||||
val updatedAt: Instant,
|
val updatedAt: Instant,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AdminOperatorCursor(
|
|
||||||
val createdAt: Instant,
|
|
||||||
val id: UUID,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NewAdminOperator(
|
data class NewAdminOperator(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
val normalizedUsername: String,
|
val normalizedUsername: String,
|
||||||
@@ -116,6 +111,16 @@ enum class AdminAuditAction {
|
|||||||
OPERATOR_CREDENTIALS_RESET,
|
OPERATOR_CREDENTIALS_RESET,
|
||||||
OPERATOR_SESSIONS_REVOKED,
|
OPERATOR_SESSIONS_REVOKED,
|
||||||
MANUAL_CREDIT_GRANTED,
|
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 {
|
enum class AdminAuditOutcome {
|
||||||
@@ -123,6 +128,12 @@ enum class AdminAuditOutcome {
|
|||||||
DENIED,
|
DENIED,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class AdminStepUpResult {
|
||||||
|
VERIFIED,
|
||||||
|
INVALID_TOTP,
|
||||||
|
LOCKED,
|
||||||
|
}
|
||||||
|
|
||||||
data class NewAdminAuditEvent(
|
data class NewAdminAuditEvent(
|
||||||
val id: UUID = UUID.randomUUID(),
|
val id: UUID = UUID.randomUUID(),
|
||||||
val actorOperatorId: UUID?,
|
val actorOperatorId: UUID?,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.osglab.account.features.admin.models
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
enum class AdminSortOrder {
|
||||||
|
ASC,
|
||||||
|
DESC,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AdminTimeFilter(
|
||||||
|
val from: Instant? = null,
|
||||||
|
val until: Instant? = null,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(from == null || until == null || from < until) {
|
||||||
|
"Admin query time range must be non-empty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AdminAuditQuery(
|
||||||
|
val time: AdminTimeFilter = AdminTimeFilter(),
|
||||||
|
val action: AdminAuditAction? = null,
|
||||||
|
val outcome: AdminAuditOutcome? = null,
|
||||||
|
val order: AdminSortOrder = AdminSortOrder.DESC,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class AdminOperatorSort {
|
||||||
|
CREATED_AT,
|
||||||
|
USERNAME,
|
||||||
|
LAST_LOGIN_AT,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AdminOperatorQuery(
|
||||||
|
val time: AdminTimeFilter = AdminTimeFilter(),
|
||||||
|
val role: AdminRole? = null,
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
val locked: Boolean? = null,
|
||||||
|
val sort: AdminOperatorSort = AdminOperatorSort.CREATED_AT,
|
||||||
|
val order: AdminSortOrder = AdminSortOrder.ASC,
|
||||||
|
val now: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdminOperatorCursor(
|
||||||
|
val value: String?,
|
||||||
|
val id: UUID,
|
||||||
|
)
|
||||||
+142
-15
@@ -1,6 +1,7 @@
|
|||||||
package com.osglab.account.features.admin.repositories
|
package com.osglab.account.features.admin.repositories
|
||||||
|
|
||||||
import com.osglab.account.config.DatabaseFactory
|
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.AdminAuditCursor
|
||||||
import com.osglab.account.features.admin.models.AdminLockState
|
import com.osglab.account.features.admin.models.AdminLockState
|
||||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
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.AdminAuditRecord
|
||||||
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
|
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
|
||||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
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.AdminOperatorMutationResult
|
||||||
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
import com.osglab.account.features.admin.models.AdminOperatorRecord
|
||||||
import com.osglab.account.features.admin.models.AdminRole
|
import com.osglab.account.features.admin.models.AdminRole
|
||||||
import com.osglab.account.features.admin.models.AdminSessionRecord
|
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.NewAdminAuditEvent
|
||||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||||
import com.osglab.account.features.admin.models.NewAdminSession
|
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.and
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.core.greater
|
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.inList
|
||||||
import org.jetbrains.exposed.v1.core.isNotNull
|
import org.jetbrains.exposed.v1.core.isNotNull
|
||||||
import org.jetbrains.exposed.v1.core.isNull
|
import org.jetbrains.exposed.v1.core.isNull
|
||||||
@@ -86,6 +91,7 @@ interface AdminRepository {
|
|||||||
suspend fun listOperatorsPage(
|
suspend fun listOperatorsPage(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
before: AdminOperatorCursor? = null,
|
before: AdminOperatorCursor? = null,
|
||||||
|
query: AdminOperatorQuery,
|
||||||
): List<AdminOperatorRecord>
|
): List<AdminOperatorRecord>
|
||||||
suspend fun countActiveSessions(now: Instant): Long
|
suspend fun countActiveSessions(now: Instant): Long
|
||||||
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
|
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
|
||||||
@@ -148,6 +154,7 @@ interface AdminRepository {
|
|||||||
suspend fun listAudit(
|
suspend fun listAudit(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
before: AdminAuditCursor? = null,
|
before: AdminAuditCursor? = null,
|
||||||
|
query: AdminAuditQuery = AdminAuditQuery(),
|
||||||
): List<AdminAuditRecord>
|
): List<AdminAuditRecord>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,24 +192,119 @@ class ExposedAdminRepository(
|
|||||||
override suspend fun listOperatorsPage(
|
override suspend fun listOperatorsPage(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
before: AdminOperatorCursor?,
|
before: AdminOperatorCursor?,
|
||||||
|
query: AdminOperatorQuery,
|
||||||
): List<AdminOperatorRecord> =
|
): List<AdminOperatorRecord> =
|
||||||
databaseFactory.query {
|
databaseFactory.query {
|
||||||
require(limit in 1..101)
|
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) {
|
if (before != null) {
|
||||||
query.andWhere {
|
statement.andWhere {
|
||||||
(AdminOperatorsTable.createdAt greater before.createdAt) or
|
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())
|
(AdminOperatorsTable.id greater before.id.toString())
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
}
|
(AdminOperatorsTable.createdAt less value) or
|
||||||
query
|
(
|
||||||
.orderBy(
|
(AdminOperatorsTable.createdAt eq value) and
|
||||||
AdminOperatorsTable.createdAt to SortOrder.ASC,
|
(AdminOperatorsTable.id less before.id.toString())
|
||||||
AdminOperatorsTable.id to SortOrder.ASC,
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
.limit(limit)
|
||||||
.map(ResultRow::toOperatorRecord)
|
.map(ResultRow::toOperatorRecord)
|
||||||
}
|
}
|
||||||
@@ -495,12 +597,32 @@ class ExposedAdminRepository(
|
|||||||
override suspend fun listAudit(
|
override suspend fun listAudit(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
before: AdminAuditCursor?,
|
before: AdminAuditCursor?,
|
||||||
|
query: AdminAuditQuery,
|
||||||
): List<AdminAuditRecord> =
|
): List<AdminAuditRecord> =
|
||||||
databaseFactory.query {
|
databaseFactory.query {
|
||||||
require(limit in 1..101)
|
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) {
|
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 less before.occurredAt) or
|
||||||
(
|
(
|
||||||
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
|
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
|
||||||
@@ -508,10 +630,12 @@ class ExposedAdminRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
query
|
}
|
||||||
|
val sortOrder = query.order.toExposedSortOrder()
|
||||||
|
statement
|
||||||
.orderBy(
|
.orderBy(
|
||||||
AdminAuditLogTable.occurredAt to SortOrder.DESC,
|
AdminAuditLogTable.occurredAt to sortOrder,
|
||||||
AdminAuditLogTable.id to SortOrder.DESC,
|
AdminAuditLogTable.id to sortOrder,
|
||||||
)
|
)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.map {
|
.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) {
|
private fun insertAudit(event: NewAdminAuditEvent) {
|
||||||
AdminAuditLogTable.insert {
|
AdminAuditLogTable.insert {
|
||||||
it[id] = event.id.toString()
|
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.config.AppConfig
|
||||||
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
|
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
|
||||||
import com.osglab.account.features.admin.grants.services.AdminGrantService
|
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.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.AdminPrincipal
|
||||||
import com.osglab.account.features.admin.models.AdminRole
|
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.AdminAuditCursorException
|
||||||
import com.osglab.account.features.admin.services.AdminAuditService
|
import com.osglab.account.features.admin.services.AdminAuditService
|
||||||
import com.osglab.account.features.admin.services.AdminAuthService
|
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.services.AdminSessionService
|
||||||
import com.osglab.account.features.admin.stats.models.AdminStatsDto
|
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.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.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.AdminUserDetailDto
|
||||||
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
|
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.AdminUserReferralDto
|
||||||
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
|
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.AdminUserNotFoundException
|
||||||
import com.osglab.account.features.admin.users.services.AdminUsersService
|
import com.osglab.account.features.admin.users.services.AdminUsersService
|
||||||
import com.osglab.account.features.credits.domain.CreditConflict
|
import com.osglab.account.features.credits.domain.CreditConflict
|
||||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
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.Cookie
|
||||||
import io.ktor.http.HttpHeaders
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.ApplicationCall
|
import io.ktor.server.application.ApplicationCall
|
||||||
import io.ktor.server.application.call
|
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.http.content.staticResources
|
||||||
import io.ktor.server.plugins.BadRequestException
|
import io.ktor.server.plugins.BadRequestException
|
||||||
import io.ktor.server.plugins.ratelimit.RateLimitName
|
import io.ktor.server.plugins.ratelimit.RateLimitName
|
||||||
import io.ktor.server.plugins.ratelimit.rateLimit
|
import io.ktor.server.plugins.ratelimit.rateLimit
|
||||||
import io.ktor.server.request.header
|
import io.ktor.server.request.header
|
||||||
import io.ktor.server.request.receive
|
import io.ktor.server.request.receive
|
||||||
|
import io.ktor.server.response.header
|
||||||
import io.ktor.server.response.respond
|
import io.ktor.server.response.respond
|
||||||
import io.ktor.server.routing.Route
|
import io.ktor.server.routing.Route
|
||||||
import io.ktor.server.routing.delete
|
import io.ktor.server.routing.delete
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
import io.ktor.server.routing.post
|
import io.ktor.server.routing.post
|
||||||
|
import io.ktor.server.routing.put
|
||||||
import io.ktor.server.routing.route
|
import io.ktor.server.routing.route
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
fun Route.adminWebRoutes() {
|
fun Route.adminWebRoutes(config: AppConfig) {
|
||||||
staticResources("/admin", "admin", index = "index.html")
|
route("/admin") {
|
||||||
|
install(RequireVerifiedAdminEdge) {
|
||||||
|
appConfig = config
|
||||||
|
}
|
||||||
|
staticResources("/", "admin", index = "index.html")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Route.adminApiRoutes(
|
fun Route.adminApiRoutes(
|
||||||
@@ -59,17 +94,21 @@ fun Route.adminApiRoutes(
|
|||||||
authService: AdminAuthService,
|
authService: AdminAuthService,
|
||||||
sessionService: AdminSessionService,
|
sessionService: AdminSessionService,
|
||||||
statsService: AdminStatsService,
|
statsService: AdminStatsService,
|
||||||
|
productAnalyticsService: AdminProductAnalyticsService,
|
||||||
usersService: AdminUsersService,
|
usersService: AdminUsersService,
|
||||||
grantService: AdminGrantService,
|
grantService: AdminGrantService,
|
||||||
operatorService: AdminOperatorService,
|
operatorService: AdminOperatorService,
|
||||||
auditService: AdminAuditService,
|
auditService: AdminAuditService,
|
||||||
|
credentialService: GatewayCredentialService? = null,
|
||||||
|
contentService: ContentService? = null,
|
||||||
|
hintFeedService: HintFeedService? = null,
|
||||||
clock: Clock = Clock.systemUTC(),
|
clock: Clock = Clock.systemUTC(),
|
||||||
) {
|
) {
|
||||||
route("/v1/admin") {
|
route("/v1/admin") {
|
||||||
rateLimit(ADMIN_AUTH_RATE_LIMIT) {
|
rateLimit(ADMIN_AUTH_RATE_LIMIT) {
|
||||||
route("/auth") {
|
route("/auth") {
|
||||||
get("/session") {
|
get("/session") {
|
||||||
if (!call.requireVerifiedAdminEdge()) return@get
|
if (!call.requireVerifiedAdminEdge(config)) return@get
|
||||||
val principal = call.currentPrincipal(sessionService)
|
val principal = call.currentPrincipal(sessionService)
|
||||||
call.respond(
|
call.respond(
|
||||||
AdminSessionResponse(
|
AdminSessionResponse(
|
||||||
@@ -81,7 +120,7 @@ fun Route.adminApiRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
post("/login") {
|
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 request = call.receive<AdminLoginRequest>()
|
||||||
val password = request.password.toCharArray()
|
val password = request.password.toCharArray()
|
||||||
val result = try {
|
val result = try {
|
||||||
@@ -117,7 +156,7 @@ fun Route.adminApiRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
post("/logout") {
|
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 sessionToken = call.request.cookies[SESSION_COOKIE]
|
||||||
val csrfToken = call.request.header(CSRF_HEADER)
|
val csrfToken = call.request.header(CSRF_HEADER)
|
||||||
if (
|
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") {
|
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)
|
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
|
||||||
?: run {
|
?: run {
|
||||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||||
@@ -149,8 +281,19 @@ fun Route.adminApiRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
get("/referrals") {
|
get("/referrals") {
|
||||||
if (call.requirePrincipal(sessionService) == null) return@get
|
if (call.requirePrincipal(config, sessionService) == null) return@get
|
||||||
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
|
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 {
|
?: run {
|
||||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||||
return@get
|
return@get
|
||||||
@@ -158,13 +301,29 @@ fun Route.adminApiRoutes(
|
|||||||
call.respond(stats.toReferralResponse())
|
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") {
|
get("/users") {
|
||||||
if (
|
if (
|
||||||
call.requireRole(
|
call.requireRole(
|
||||||
|
config,
|
||||||
sessionService,
|
sessionService,
|
||||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||||
) == null
|
) == null
|
||||||
) return@get
|
) 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 {
|
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||||
return@get
|
return@get
|
||||||
@@ -175,9 +334,10 @@ fun Route.adminApiRoutes(
|
|||||||
usersService.list(
|
usersService.list(
|
||||||
limit = limit,
|
limit = limit,
|
||||||
cursor = call.request.queryParameters["cursor"],
|
cursor = call.request.queryParameters["cursor"],
|
||||||
|
query = listQuery,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
usersService.searchByInternalId(query)
|
usersService.searchByInternalId(query, listQuery)
|
||||||
}
|
}
|
||||||
} catch (_: IllegalArgumentException) {
|
} catch (_: IllegalArgumentException) {
|
||||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||||
@@ -194,6 +354,7 @@ fun Route.adminApiRoutes(
|
|||||||
get("/users/{userId}") {
|
get("/users/{userId}") {
|
||||||
if (
|
if (
|
||||||
call.requireRole(
|
call.requireRole(
|
||||||
|
config,
|
||||||
sessionService,
|
sessionService,
|
||||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||||
) == null
|
) == null
|
||||||
@@ -209,11 +370,17 @@ fun Route.adminApiRoutes(
|
|||||||
get("/users/{userId}/ledger") {
|
get("/users/{userId}/ledger") {
|
||||||
if (
|
if (
|
||||||
call.requireRole(
|
call.requireRole(
|
||||||
|
config,
|
||||||
sessionService,
|
sessionService,
|
||||||
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
|
||||||
) == null
|
) == null
|
||||||
) return@get
|
) return@get
|
||||||
val userId = call.uuidPathParameter("userId") ?: 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 {
|
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
|
||||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||||
return@get
|
return@get
|
||||||
@@ -223,6 +390,7 @@ fun Route.adminApiRoutes(
|
|||||||
userId = userId,
|
userId = userId,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
cursor = call.request.queryParameters["cursor"],
|
cursor = call.request.queryParameters["cursor"],
|
||||||
|
query = ledgerQuery,
|
||||||
)
|
)
|
||||||
call.respond(
|
call.respond(
|
||||||
PageResponse(
|
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") {
|
post("/credits/grants") {
|
||||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
|
||||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||||
@@ -286,7 +488,7 @@ fun Route.adminApiRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
get("/operators/summary") {
|
get("/operators/summary") {
|
||||||
val principal = call.requirePrincipal(sessionService) ?: return@get
|
val principal = call.requirePrincipal(config, sessionService) ?: return@get
|
||||||
try {
|
try {
|
||||||
val summary = operatorService.summary(principal)
|
val summary = operatorService.summary(principal)
|
||||||
call.respond(
|
call.respond(
|
||||||
@@ -302,7 +504,12 @@ fun Route.adminApiRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
get("/operators") {
|
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 {
|
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||||
return@get
|
return@get
|
||||||
@@ -312,6 +519,7 @@ fun Route.adminApiRoutes(
|
|||||||
actor = principal,
|
actor = principal,
|
||||||
cursor = call.request.queryParameters["cursor"],
|
cursor = call.request.queryParameters["cursor"],
|
||||||
limit = limit,
|
limit = limit,
|
||||||
|
query = operatorQuery,
|
||||||
)
|
)
|
||||||
call.respond(
|
call.respond(
|
||||||
PageResponse(
|
PageResponse(
|
||||||
@@ -429,7 +637,12 @@ fun Route.adminApiRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
get("/audit") {
|
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 {
|
val limit = call.pageLimit(maximum = 100) ?: run {
|
||||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||||
return@get
|
return@get
|
||||||
@@ -439,6 +652,7 @@ fun Route.adminApiRoutes(
|
|||||||
actor = principal,
|
actor = principal,
|
||||||
cursor = call.request.queryParameters["cursor"],
|
cursor = call.request.queryParameters["cursor"],
|
||||||
limit = limit,
|
limit = limit,
|
||||||
|
query = auditQuery,
|
||||||
)
|
)
|
||||||
} catch (exception: AdminOperatorException) {
|
} catch (exception: AdminOperatorException) {
|
||||||
call.respondOperatorError(exception)
|
call.respondOperatorError(exception)
|
||||||
@@ -472,7 +686,21 @@ fun Route.adminApiRoutes(
|
|||||||
private suspend fun AdminStatsService.getRange(
|
private suspend fun AdminStatsService.getRange(
|
||||||
range: String?,
|
range: String?,
|
||||||
clock: Clock,
|
clock: Clock,
|
||||||
|
referralRankLimit: Int = 20,
|
||||||
|
referralSort: AdminReferralSort? = null,
|
||||||
|
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
|
||||||
): AdminStatsDto? {
|
): 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) {
|
val days = when (range) {
|
||||||
null, "30d" -> 30L
|
null, "30d" -> 30L
|
||||||
"7d" -> 7L
|
"7d" -> 7L
|
||||||
@@ -480,13 +708,211 @@ private suspend fun AdminStatsService.getRange(
|
|||||||
else -> return null
|
else -> return null
|
||||||
}
|
}
|
||||||
val until = clock.instant()
|
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(
|
private suspend fun ApplicationCall.requirePrincipal(
|
||||||
|
config: AppConfig,
|
||||||
sessions: AdminSessionService,
|
sessions: AdminSessionService,
|
||||||
): AdminPrincipal? {
|
): AdminPrincipal? {
|
||||||
if (!requireVerifiedAdminEdge()) return null
|
if (!requireVerifiedAdminEdge(config)) return null
|
||||||
val principal = currentPrincipal(sessions)
|
val principal = currentPrincipal(sessions)
|
||||||
if (principal == null) {
|
if (principal == null) {
|
||||||
respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
|
respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
|
||||||
@@ -494,11 +920,12 @@ private suspend fun ApplicationCall.requirePrincipal(
|
|||||||
return principal
|
return principal
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ApplicationCall.requireRole(
|
internal suspend fun ApplicationCall.requireRole(
|
||||||
|
config: AppConfig,
|
||||||
sessions: AdminSessionService,
|
sessions: AdminSessionService,
|
||||||
allowedRoles: Set<AdminRole>,
|
allowedRoles: Set<AdminRole>,
|
||||||
): AdminPrincipal? {
|
): AdminPrincipal? {
|
||||||
val principal = requirePrincipal(sessions) ?: return null
|
val principal = requirePrincipal(config, sessions) ?: return null
|
||||||
if (principal.role !in allowedRoles) {
|
if (principal.role !in allowedRoles) {
|
||||||
respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
||||||
return null
|
return null
|
||||||
@@ -506,11 +933,11 @@ private suspend fun ApplicationCall.requireRole(
|
|||||||
return principal
|
return principal
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ApplicationCall.requireMutationPrincipal(
|
internal suspend fun ApplicationCall.requireMutationPrincipal(
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
sessions: AdminSessionService,
|
sessions: AdminSessionService,
|
||||||
): AdminPrincipal? {
|
): AdminPrincipal? {
|
||||||
if (!requireVerifiedAdminEdge() || !requireSameOrigin(config)) return null
|
if (!requireVerifiedAdminEdge(config) || !requireSameOrigin(config)) return null
|
||||||
val sessionToken = request.cookies[SESSION_COOKIE]
|
val sessionToken = request.cookies[SESSION_COOKIE]
|
||||||
val csrfToken = request.header(CSRF_HEADER)
|
val csrfToken = request.header(CSRF_HEADER)
|
||||||
val principal = if (sessionToken != null && csrfToken != null) {
|
val principal = if (sessionToken != null && csrfToken != null) {
|
||||||
@@ -524,12 +951,31 @@ private suspend fun ApplicationCall.requireMutationPrincipal(
|
|||||||
return principal
|
return principal
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ApplicationCall.requireVerifiedAdminEdge(): Boolean {
|
private suspend fun ApplicationCall.requireVerifiedAdminEdge(config: AppConfig): Boolean {
|
||||||
if (request.header(MTLS_HEADER) == MTLS_VERIFIED) return true
|
if (isVerifiedAdminEdge(config)) return true
|
||||||
respond(HttpStatusCode.NotFound)
|
respond(HttpStatusCode.NotFound)
|
||||||
return false
|
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 {
|
private suspend fun ApplicationCall.requireSameOrigin(config: AppConfig): Boolean {
|
||||||
if (request.header(HttpHeaders.Origin) == config.publicBaseUrl) return true
|
if (request.header(HttpHeaders.Origin) == config.publicBaseUrl) return true
|
||||||
respond(HttpStatusCode.Forbidden, AdminErrorResponse("ORIGIN_INVALID"))
|
respond(HttpStatusCode.Forbidden, AdminErrorResponse("ORIGIN_INVALID"))
|
||||||
@@ -605,11 +1051,12 @@ private fun adminCookie(
|
|||||||
private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
|
private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
|
||||||
val consumedByDate = creditFlow.associateBy { it.date }
|
val consumedByDate = creditFlow.associateBy { it.date }
|
||||||
return AdminOverviewResponse(
|
return AdminOverviewResponse(
|
||||||
|
period = period,
|
||||||
totalUsers = overview.totalUsers,
|
totalUsers = overview.totalUsers,
|
||||||
activeUsers = overview.activeUsers,
|
activeUsers = overview.activeUsers,
|
||||||
newUsers = overview.registrations,
|
newUsers = overview.registrations,
|
||||||
totalCreditBalance = overview.totalCreditBalance,
|
totalCreditBalance = overview.totalCreditBalance,
|
||||||
creditsGranted = overview.issuedCredits,
|
creditsGranted = overview.grantedCredits,
|
||||||
creditsUsed = overview.consumedCredits,
|
creditsUsed = overview.consumedCredits,
|
||||||
trend = registrationTrend.map {
|
trend = registrationTrend.map {
|
||||||
AdminTrendResponse(
|
AdminTrendResponse(
|
||||||
@@ -624,14 +1071,13 @@ private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
|
|||||||
|
|
||||||
private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
|
private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
|
||||||
AdminReferralResponse(
|
AdminReferralResponse(
|
||||||
|
period = period,
|
||||||
pendingBindings = referralFunnel.pendingBindings,
|
pendingBindings = referralFunnel.pendingBindings,
|
||||||
ineligibleBindings = referralFunnel.ineligibleBindings,
|
ineligibleBindings = referralFunnel.ineligibleBindings,
|
||||||
funnel = listOf(
|
funnel = listOf(
|
||||||
AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated),
|
|
||||||
AdminFunnelResponse("成功绑定", referralFunnel.bindings),
|
AdminFunnelResponse("成功绑定", referralFunnel.bindings),
|
||||||
AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings),
|
AdminFunnelResponse("绑定后首次 AI 成功", referralFunnel.activatedBindings),
|
||||||
AdminFunnelResponse("待资格确认", referralFunnel.pendingBindings),
|
AdminFunnelResponse("完成奖励", referralFunnel.rewardedBindings),
|
||||||
AdminFunnelResponse("不符合奖励条件", referralFunnel.ineligibleBindings),
|
|
||||||
),
|
),
|
||||||
ranking = referralRanking.map {
|
ranking = referralRanking.map {
|
||||||
AdminReferralRankResponse(
|
AdminReferralRankResponse(
|
||||||
@@ -646,9 +1092,10 @@ private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
|
|||||||
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
|
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
|
||||||
AdminUserSummaryResponse(
|
AdminUserSummaryResponse(
|
||||||
userId = id,
|
userId = id,
|
||||||
displayName = "用户 ${id.take(8)}",
|
displayName = "用户 ${id.takeLast(8).uppercase()}",
|
||||||
status = if (antiAbuseRestricted) "suspended" else "active",
|
status = if (antiAbuseRestricted) "suspended" else "active",
|
||||||
creditBalance = creditBalance,
|
creditBalance = creditBalance,
|
||||||
|
consumedCredits = consumedCredits,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -666,20 +1113,35 @@ private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
|
|||||||
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
|
||||||
AdminLedgerResponse(
|
AdminLedgerResponse(
|
||||||
entryId = id,
|
entryId = id,
|
||||||
type = when (type) {
|
userId = userId,
|
||||||
"USAGE_RESERVE" -> "reserve"
|
type = entryType.toAdminLedgerType(),
|
||||||
"USAGE_SETTLE" -> "settle"
|
entryType = entryType.name,
|
||||||
"USAGE_RELEASE", "USAGE_REFUND" -> "refund"
|
|
||||||
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
|
|
||||||
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant"
|
|
||||||
else -> "adjustment"
|
|
||||||
},
|
|
||||||
amount = amountDelta,
|
amount = amountDelta,
|
||||||
balanceAfter = balanceAfter,
|
balanceAfter = balanceAfter,
|
||||||
reasonCode = type,
|
reasonCode = entryType.name,
|
||||||
|
referenceId = referenceId,
|
||||||
|
usageType = usageType,
|
||||||
|
details = details,
|
||||||
createdAt = createdAt,
|
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 =
|
private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse =
|
||||||
AdminOperatorResponse(
|
AdminOperatorResponse(
|
||||||
operatorId = id.toString(),
|
operatorId = id.toString(),
|
||||||
@@ -732,6 +1194,19 @@ private data class AdminGrantRequest(val userId: String, val amount: Long, val r
|
|||||||
@Serializable
|
@Serializable
|
||||||
private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long)
|
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
|
@Serializable
|
||||||
private data class AdminOperatorCreateRequest(
|
private data class AdminOperatorCreateRequest(
|
||||||
val username: String,
|
val username: String,
|
||||||
@@ -775,6 +1250,7 @@ private data class PageResponse<T>(val items: List<T>, val nextCursor: String? =
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class AdminOverviewResponse(
|
private data class AdminOverviewResponse(
|
||||||
|
val period: com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto,
|
||||||
val totalUsers: Long,
|
val totalUsers: Long,
|
||||||
val activeUsers: Long,
|
val activeUsers: Long,
|
||||||
val newUsers: Long,
|
val newUsers: Long,
|
||||||
@@ -794,6 +1270,7 @@ private data class AdminTrendResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class AdminReferralResponse(
|
private data class AdminReferralResponse(
|
||||||
|
val period: com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto,
|
||||||
val pendingBindings: Long,
|
val pendingBindings: Long,
|
||||||
val ineligibleBindings: Long,
|
val ineligibleBindings: Long,
|
||||||
val funnel: List<AdminFunnelResponse>,
|
val funnel: List<AdminFunnelResponse>,
|
||||||
@@ -817,6 +1294,7 @@ private data class AdminUserSummaryResponse(
|
|||||||
val displayName: String,
|
val displayName: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val creditBalance: Long,
|
val creditBalance: Long,
|
||||||
|
val consumedCredits: Long,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -826,6 +1304,7 @@ private data class AdminUserDetailResponse(
|
|||||||
val displayName: String,
|
val displayName: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val creditBalance: Long,
|
val creditBalance: Long,
|
||||||
|
val consumedCredits: Long,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val lastActiveAt: String?,
|
val lastActiveAt: String?,
|
||||||
val qualifiedUsage: Boolean,
|
val qualifiedUsage: Boolean,
|
||||||
@@ -847,6 +1326,7 @@ private data class AdminUserDetailResponse(
|
|||||||
displayName = summary.displayName,
|
displayName = summary.displayName,
|
||||||
status = summary.status,
|
status = summary.status,
|
||||||
creditBalance = summary.creditBalance,
|
creditBalance = summary.creditBalance,
|
||||||
|
consumedCredits = summary.consumedCredits,
|
||||||
createdAt = summary.createdAt,
|
createdAt = summary.createdAt,
|
||||||
lastActiveAt = lastActiveAt,
|
lastActiveAt = lastActiveAt,
|
||||||
qualifiedUsage = qualifiedUsage,
|
qualifiedUsage = qualifiedUsage,
|
||||||
@@ -860,10 +1340,15 @@ private data class AdminUserDetailResponse(
|
|||||||
@Serializable
|
@Serializable
|
||||||
private data class AdminLedgerResponse(
|
private data class AdminLedgerResponse(
|
||||||
val entryId: String,
|
val entryId: String,
|
||||||
|
val userId: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
|
val entryType: String,
|
||||||
val amount: Long,
|
val amount: Long,
|
||||||
val balanceAfter: Long,
|
val balanceAfter: Long,
|
||||||
val reasonCode: String,
|
val reasonCode: String,
|
||||||
|
val referenceId: String?,
|
||||||
|
val usageType: String?,
|
||||||
|
val details: AdminLedgerDetailsDto?,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.osglab.account.features.admin.services
|
package com.osglab.account.features.admin.services
|
||||||
|
|
||||||
import com.osglab.account.features.admin.models.AdminAuditCursor
|
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.AdminAuditRecord
|
||||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||||
import com.osglab.account.features.admin.models.AdminRole
|
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 com.osglab.account.features.admin.repositories.AdminRepository
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -29,13 +31,14 @@ class AdminAuditService(
|
|||||||
actor: AdminPrincipal,
|
actor: AdminPrincipal,
|
||||||
cursor: String?,
|
cursor: String?,
|
||||||
limit: Int = DEFAULT_PAGE_SIZE,
|
limit: Int = DEFAULT_PAGE_SIZE,
|
||||||
|
query: AdminAuditQuery = AdminAuditQuery(),
|
||||||
): AdminAuditPage {
|
): AdminAuditPage {
|
||||||
if (actor.role != AdminRole.SUPER_ADMIN) {
|
if (actor.role != AdminRole.SUPER_ADMIN) {
|
||||||
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
|
||||||
}
|
}
|
||||||
require(limit in 1..MAX_PAGE_SIZE)
|
require(limit in 1..MAX_PAGE_SIZE)
|
||||||
val decodedCursor = cursor?.let(::decodeCursor)
|
val decodedCursor = cursor?.let { decodeCursor(it, query.order) }
|
||||||
val records = repository.listAudit(limit + 1, decodedCursor)
|
val records = repository.listAudit(limit + 1, decodedCursor, query)
|
||||||
val pageRecords = records.take(limit)
|
val pageRecords = records.take(limit)
|
||||||
val operatorNames = repository.listOperators().associate {
|
val operatorNames = repository.listOperators().associate {
|
||||||
it.id to it.normalizedUsername
|
it.id to it.normalizedUsername
|
||||||
@@ -50,36 +53,41 @@ class AdminAuditService(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
nextCursor = if (records.size > limit) {
|
nextCursor = if (records.size > limit) {
|
||||||
pageRecords.lastOrNull()?.let(::encodeCursor)
|
pageRecords.lastOrNull()?.let { encodeCursor(it, query.order) }
|
||||||
} else {
|
} else {
|
||||||
null
|
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()
|
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException()
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val decoded = String(
|
val decoded = String(
|
||||||
Base64.getUrlDecoder().decode(value),
|
Base64.getUrlDecoder().decode(value),
|
||||||
StandardCharsets.UTF_8,
|
StandardCharsets.UTF_8,
|
||||||
)
|
)
|
||||||
val parts = decoded.split(':', limit = 3)
|
val parts = decoded.split(':', limit = 5)
|
||||||
require(parts.size == 3)
|
require(parts.size == 5)
|
||||||
|
require(parts[0] == "v1")
|
||||||
|
require(parts[1] == expectedOrder.name)
|
||||||
AdminAuditCursor(
|
AdminAuditCursor(
|
||||||
occurredAt = Instant.ofEpochSecond(
|
occurredAt = Instant.ofEpochSecond(
|
||||||
parts[0].toLong(),
|
parts[2].toLong(),
|
||||||
parts[1].toLong(),
|
parts[3].toLong(),
|
||||||
),
|
),
|
||||||
id = UUID.fromString(parts[2]),
|
id = UUID.fromString(parts[4]),
|
||||||
)
|
)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
throw AdminAuditCursorException()
|
throw AdminAuditCursorException()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun encodeCursor(record: AdminAuditRecord): String {
|
private fun encodeCursor(record: AdminAuditRecord, order: AdminSortOrder): String {
|
||||||
val payload = buildString {
|
val payload = buildString {
|
||||||
|
append("v1:")
|
||||||
|
append(order.name)
|
||||||
|
append(':')
|
||||||
append(record.occurredAt.epochSecond)
|
append(record.occurredAt.epochSecond)
|
||||||
append(':')
|
append(':')
|
||||||
append(record.occurredAt.nano)
|
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.AdminLoginResult
|
||||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||||
import com.osglab.account.features.admin.models.AdminSessionCredentials
|
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.NewAdminAuditEvent
|
||||||
import com.osglab.account.features.admin.models.NewAdminSession
|
import com.osglab.account.features.admin.models.NewAdminSession
|
||||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
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(
|
private suspend fun failAuthentication(
|
||||||
operatorId: UUID,
|
operatorId: UUID,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
|
|||||||
+39
-12
@@ -5,9 +5,12 @@ import com.osglab.account.features.admin.models.AdminAuditAction
|
|||||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||||
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
|
||||||
import com.osglab.account.features.admin.models.AdminOperatorCursor
|
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.AdminOperatorRecord
|
||||||
|
import com.osglab.account.features.admin.models.AdminOperatorSort
|
||||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||||
import com.osglab.account.features.admin.models.AdminRole
|
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.NewAdminAuditEvent
|
||||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||||
import com.osglab.account.features.admin.repositories.AdminRepository
|
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.AdminTotpProvisioning
|
||||||
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
|
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
|
||||||
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
|
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
|
||||||
import java.time.Clock
|
|
||||||
import java.time.Instant
|
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.time.Clock
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -73,17 +75,33 @@ class AdminOperatorService(
|
|||||||
actor: AdminPrincipal,
|
actor: AdminPrincipal,
|
||||||
cursor: String?,
|
cursor: String?,
|
||||||
limit: Int = DEFAULT_PAGE_SIZE,
|
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 {
|
): AdminOperatorPage {
|
||||||
requireSuperAdministrator(actor)
|
requireSuperAdministrator(actor)
|
||||||
if (limit !in 1..MAX_PAGE_SIZE) {
|
if (limit !in 1..MAX_PAGE_SIZE) {
|
||||||
throw AdminOperatorCursorException()
|
throw AdminOperatorCursorException()
|
||||||
}
|
}
|
||||||
val decodedCursor = cursor?.let(::decodeCursor)
|
val decodedCursor = cursor?.let { decodeCursor(it, query) }
|
||||||
val records = repository.listOperatorsPage(limit + 1, decodedCursor)
|
val records = repository.listOperatorsPage(limit + 1, decodedCursor, query)
|
||||||
val items = records.take(limit)
|
val items = records.take(limit)
|
||||||
return AdminOperatorPage(
|
return AdminOperatorPage(
|
||||||
items = items,
|
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_PASSWORD_CHARS = 1_024
|
||||||
const val MAX_AUDIT_TARGET_CHARS = 128
|
const val MAX_AUDIT_TARGET_CHARS = 128
|
||||||
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
|
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()
|
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException()
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val decoded = String(
|
val decoded = String(
|
||||||
Base64.getUrlDecoder().decode(value),
|
Base64.getUrlDecoder().decode(value),
|
||||||
StandardCharsets.UTF_8,
|
StandardCharsets.UTF_8,
|
||||||
)
|
)
|
||||||
val parts = decoded.split(':', limit = 3)
|
val parts = decoded.split('|')
|
||||||
require(parts.size == 3)
|
require(parts.size == 5)
|
||||||
|
require(parts[0] == "v1")
|
||||||
|
require(parts[1] == query.sort.name)
|
||||||
|
require(parts[2] == query.order.name)
|
||||||
AdminOperatorCursor(
|
AdminOperatorCursor(
|
||||||
createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()),
|
value = parts[3].takeUnless { it == NULL_CURSOR_VALUE },
|
||||||
id = UUID.fromString(parts[2]),
|
id = UUID.fromString(parts[4]),
|
||||||
)
|
)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
throw AdminOperatorCursorException()
|
throw AdminOperatorCursorException()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun encodeCursor(record: AdminOperatorRecord): String {
|
private fun encodeCursor(record: AdminOperatorRecord, query: AdminOperatorQuery): String {
|
||||||
val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}"
|
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(
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||||
payload.toByteArray(StandardCharsets.UTF_8),
|
payload.toByteArray(StandardCharsets.UTF_8),
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user