diff --git a/.env.deploy.example b/.env.deploy.example index 05e7803..0746a78 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -24,6 +24,8 @@ AUTH_SERVICE_MODE=MySql NEO4J_PASSWORD=CHANGE_ME_NEO4J_PASSWORD GALREVIEW_ADMIN_USERNAME=CHANGE_ME_ADMIN_USERNAME GALREVIEW_ADMIN_PASSWORD_HASH=CHANGE_ME_ASPNET_IDENTITY_PASSWORD_HASH +GALREVIEW_ADMIN_PRINCIPAL_ID=00000000-0000-4000-8000-000000000001 +CREDITS_PURCHASE_URL=https://CHANGE_ME.example.com/shop ACCOUNT_FRONTEND_BASE_URL=https://CHANGE_ME.example.com CORS_ORIGINS=https://CHANGE_ME.example.com @@ -43,6 +45,8 @@ PRACTICE_QUESTION_MODEL=deepseek-v4-flash PRACTICE_QUESTION_PARALLELISM=4 MODEL_NLI_MIN_TOP_PROBABILITY=0.75 MODEL_NLI_MIN_MARGIN=0.20 +MODEL_NLI_MAX_BATCHES=2 +MODEL_NLI_FACET_PARALLELISM=4 GALGAME_NARRATIVE_ENABLED=true GALGAME_NARRATIVE_ENDPOINT=https://api.deepseek.com/chat/completions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5f28e7..56aae8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,134 @@ name: CI -# 千知万理 GalReview — 最小可用 CI -# 覆盖:.NET 服务(Auth/User/File/GalGame/Knowledge)构建与测试、gateway 构建与测试 +# 千知万理 GalReview — 核心闭环 CI +# 覆盖: +# - secret 扫描(gitleaks,全历史) +# - 路径过滤(changes job):仅相关目录变更时触发对应服务 job +# - 端口策略门禁 +# - .NET:Auth/User/File/GalGame/Knowledge 构建或测试 + Practice/Credit 测试 + Model 单元测试 +# - Gateway:build + test +# - RenderService:build + test +# - Frontend:typecheck + build +# - Frontend E2E(可选,Playwright 冒烟;稳定绿一周后转必需) # 触发:main 分支推送 + 指向 main 的 PR +# 说明:ModelService Integration 测试依赖真实 ONNX 资产(~500MB,OSCA/下载器), +# CI 只跑不依赖资产的 Domain/Persistence 单元测试;资产门禁由部署脚本负责。 +# 该排除为已评审决策:待 ONNX 资产 hash 门禁就绪后再评估启用。 on: push: branches: [main] pull_request: branches: [main] + workflow_dispatch: + inputs: + integration_smoke: + description: 'Run compose integration smoke (config + required env gate)' + type: boolean + default: true permissions: contents: read -# 同一分支新运行自动取消旧运行,省额度 concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: + secret-scan: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Scan for secrets + # 直接跑 gitleaks CLI:gitleaks-action@v2 对组织仓库强制要求 GITLEAKS_LICENSE, + # 而官方二进制无此限制。版本与 .gitleaksignore 指纹分诊保持一致。 + run: | + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar -xz -C /tmp + sudo install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks + gitleaks detect --source . --redact --no-banner -v + env: + GITLEAKS_VERSION: 8.30.1 + + changes: + name: Detect changed areas + runs-on: ubuntu-latest + outputs: + core: ${{ steps.filter.outputs.core }} + platform: ${{ steps.filter.outputs.platform }} + gateway: ${{ steps.filter.outputs.gateway }} + render: ${{ steps.filter.outputs.render }} + frontend: ${{ steps.filter.outputs.frontend }} + desktop: ${{ steps.filter.outputs.desktop }} + mobile: ${{ steps.filter.outputs.mobile }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check path filters + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + core: + - 'backend/PracticeService/**' + - 'backend/CreditService/**' + - 'backend/ModelService/**' + - 'docs/contract.md' + - '.github/workflows/ci.yml' + platform: + - 'backend/AuthService/**' + - 'backend/UserService/**' + - 'backend/FileService/**' + - 'backend/GalGameService/**' + - 'backend/KnowledgeService/**' + - '.github/workflows/ci.yml' + gateway: + - 'gateway/**' + - '.github/workflows/ci.yml' + render: + - 'backend/RenderService/**' + - '.github/workflows/ci.yml' + frontend: + - 'frontend/**' + - '.github/workflows/ci.yml' + desktop: + - 'desktop/**' + - '.github/workflows/ci.yml' + mobile: + - 'mobile/**' + - '.github/workflows/ci.yml' + + integration-smoke: + name: Compose integration smoke + if: github.event_name == 'workflow_dispatch' && inputs.integration_smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate compose config + run: docker compose -f compose.integration.yaml config --quiet + + - name: Validate production overlay requires secrets + shell: pwsh + run: | + # 故意不提供 .env:production overlay 必须在解析期失败 + docker compose -f compose.integration.yaml -f compose.production.yaml config --quiet + if ($LASTEXITCODE -eq 0) { + Write-Error 'compose.production.yaml parsed without required secrets; gate is broken' + exit 1 + } + Write-Host 'production overlay correctly rejected missing secrets' + + - name: Port policy + shell: pwsh + run: ./scripts/Test-PortPolicy.ps1 + published-port-policy: name: Published host port policy runs-on: ubuntu-latest @@ -30,26 +140,53 @@ jobs: shell: pwsh run: ./scripts/Test-PortPolicy.ps1 - dotnet: - name: .NET services + dotnet-core: + name: .NET core loop (Practice/Credit/Model) + needs: changes + if: needs.changes.outputs.core == 'true' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - # 项目混合 net8.0(Auth/User/File)与 net10.0(GalGame/Knowledge) - - name: Setup .NET SDKs + - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 8.0.x - 10.0.x + dotnet-version: 10.0.x + + - name: Test PracticeService + run: dotnet test backend/PracticeService/PracticeService.Tests/PracticeService.Tests.csproj --nologo - # FileService 无测试项目,单独构建以验证可编译 - - name: Build FileService (no tests) + - name: Test CreditService + run: dotnet test backend/CreditService/CreditService.Tests/CreditService.Tests.csproj --nologo + + # Integration 需要 backend/ModelService/Resources 真实 NLI 资产,CI 不下载 + - name: Test ModelService (unit only) + run: > + dotnet test backend/ModelService/ModelService.Tests/ModelService.Tests.csproj + --nologo + --filter "FullyQualifiedName!~Integration" + + dotnet-platform: + name: .NET platform services + needs: changes + if: needs.changes.outputs.platform == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Build FileService run: dotnet build backend/FileService/GalGame.FileService.csproj --nologo - # 四个测试项目各自 restore+build+test + - name: Test FileService + run: dotnet test backend/FileService/Tests/GalGame.FileService.Tests.csproj --nologo + - name: Test AuthService run: dotnet test backend/AuthService/Tests/GalGame.AuthService.Tests.csproj --nologo @@ -64,6 +201,8 @@ jobs: gateway: name: Gateway (Node) + needs: changes + if: needs.changes.outputs.gateway == 'true' runs-on: ubuntu-latest defaults: run: @@ -82,9 +221,182 @@ jobs: - name: Install dependencies run: npm ci - # tsc 类型检查 + 产物构建 - name: Build run: npm run build - name: Test run: npm test + + render-service: + name: RenderService (Node) + needs: changes + if: needs.changes.outputs.render == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend/RenderService/service + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: backend/RenderService/service/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test + + frontend: + name: Frontend (Vite) + needs: changes + if: needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Unit tests + run: npm test + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + # T11 前端 E2E 冒烟(Playwright)。 + # 稳定性策略:API 一律 page.route mock,不依赖真实后端,因此与 dotnet/gateway + # job 解耦、可并行。当前为可选 job(continue-on-error),不阻塞主链路; + # **CI 里 E2E job 稳定绿一周后转必需**(去掉 continue-on-error 并纳入必需检查)。 + frontend-e2e: + name: Frontend E2E smoke (optional) + needs: changes + if: needs.changes.outputs.frontend == 'true' + continue-on-error: true + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E smoke (desktop + mobile viewports) + run: npm run test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: frontend-e2e-report + path: | + frontend/playwright-report/ + frontend/test-results/ + retention-days: 7 + + desktop: + name: Desktop (Electron) + needs: changes + if: needs.changes.outputs.desktop == 'true' + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: desktop/package-lock.json + + - name: Build frontend assets (extraResources dependency) + run: npm ci && npm run build + working-directory: frontend + + - name: Install desktop dependencies + run: npm ci + working-directory: desktop + + - name: Build unpacked app (smoke, unsigned) + working-directory: desktop + env: + # 未配置签名材料时 sign.js 自行跳过 + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: npx electron-builder --win --dir + + mobile: + name: Mobile (Capacitor Android) + needs: changes + if: needs.changes.outputs.mobile == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + frontend/package-lock.json + mobile/package-lock.json + + # capacitor.config.json 的 webDir 指向 ../frontend/dist,必须先构建前端 + - name: Build frontend assets (Capacitor webDir) + run: npm ci && npm run build + working-directory: frontend + + - name: Install mobile dependencies + run: npm ci + working-directory: mobile + + - name: Sync Capacitor Android project + run: npx cap sync android + working-directory: mobile + + - name: Build debug APK + run: chmod +x gradlew && ./gradlew assembleDebug --no-daemon + working-directory: mobile/android diff --git a/.gitignore b/.gitignore index 18594ab..5a0bed7 100644 --- a/.gitignore +++ b/.gitignore @@ -436,3 +436,16 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# --- GalReview 本地构建产物与签名材料(勿提交) --- +# 发布产物打包目录(含 APK / 安装包 / credit-codes.txt) +/dist/ +/tmp/ +# Electron 桌面端 +/desktop/dist-installer/ +/desktop/build/*.cer +# Android 签名材料与本地配置(密码在 keystore.properties,绝不入库) +/mobile/android/keystore.properties +/mobile/android/*.keystore +/mobile/android/app/*.keystore +/mobile/android/app/*.jks diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..f1d0de4 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,24 @@ +# GalReview gitleaks 历史命中分诊(2026-09-30 全历史扫描,19 条) +# 误报类:字符表/UUID/文件哈希/文档示例/开发默认密钥(Test-ProductionEnv 已拦截默认值进生产) +# 历史残留类:sk- API key 与已删除脚本中的令牌——仅存在于历史提交,当前树已清除;建议轮换相关 API key +# 本文件仅豁免历史指纹;新增泄漏仍会被 CI 拦截 +3c63daf790f8a9afa52ca11863f9775372e5d85b:backend/AuthService/appsettings.Development.json:generic-api-key:4 +3c63daf790f8a9afa52ca11863f9775372e5d85b:backend/ModelService/ModelService.Persistence/ModelAssetCatalog.cs:generic-api-key:12 +5cb9132840021979f369a2ee5b7357d14f9b6549:backend/AuthService/appsettings.Development.json:generic-api-key:4 +5cb9132840021979f369a2ee5b7357d14f9b6549:backend/AuthService/Program.cs:generic-api-key:581 +4de3043229c229fecd0a8ed2d1f030d43aa98101:scripts/download-practice-resources.ps1:generic-api-key:15 +a8e0406088754f2dd077ea982e75b9aacd09b9c8:scripts/import-recitehelper-assets.ps1:generic-api-key:21 +a8e0406088754f2dd077ea982e75b9aacd09b9c8:docs/contract.md:generic-api-key:2432 +f28805d35d3fa2b85badd37c234a148940cb3181:backend/GalGameService/appsettings.json:generic-api-key:9 +f28805d35d3fa2b85badd37c234a148940cb3181:backend/GalGameService/appsettings.json:generic-api-key:22 +ed58a5470f18fe4048304581d1708b3ef54a2dcd:backend/RenderService/service/sessions.test.mjs:generic-api-key:98 +f72cd76804722f53cfc58885d7384a2300bfd8d9:backend/RenderService/service/sessions.test.mjs:generic-api-key:98 +7fcfe6de19d2a0a86bfab2b6afce8f1521bdc5f8:docs/galgame-mock-data.md:curl-auth-header:134 +7fcfe6de19d2a0a86bfab2b6afce8f1521bdc5f8:backend/GalGameService/README.md:curl-auth-header:133 +7fcfe6de19d2a0a86bfab2b6afce8f1521bdc5f8:backend/GalGameService/README.md:curl-auth-header:138 +7fcfe6de19d2a0a86bfab2b6afce8f1521bdc5f8:backend/GalGameService/README.md:curl-auth-header:152 +d442b2ce6745a5684309db672c7037dd78631040:gateway/tests/serviceCompatibility.test.ts:generic-api-key:136 +583254f69b4427a0890ffe21645e134df0a23d5e:docs/contract.md:generic-api-key:1609 +583254f69b4427a0890ffe21645e134df0a23d5e:docs/contract.md:generic-api-key:1831 +966f9e6c9689d5908072fd46e0a37aea30901e15:backend/AuthService/appsettings.Development.json:generic-api-key:18 +d45c206fa8a092c47a892895752219ca4c6052c0:backend/AuthService/AuthRepositories.cs:generic-api-key:131 diff --git a/backend/AuthService/AdminIdentity.cs b/backend/AuthService/AdminIdentity.cs index 0dcfff3..4a7ae5f 100644 --- a/backend/AuthService/AdminIdentity.cs +++ b/backend/AuthService/AdminIdentity.cs @@ -1,4 +1,29 @@ internal static class AdminIdentity { - public const string UserId = "00000000-0000-4000-8000-000000000001"; + /// 联调专用默认管理员;生产环境必须通过 Admin:PrincipalId 显式配置。 + public const string DevDefaultUserId = "00000000-0000-4000-8000-000000000001"; + + public static string UserId { get; private set; } = DevDefaultUserId; + + /// 管理员身份是否来自显式配置(/readyz 上报用)。 + public static bool IsExplicitlyConfigured { get; private set; } + + public static void Configure(string? configuredPrincipalId, bool isProduction) + { + if (Guid.TryParse(configuredPrincipalId, out var parsed) && parsed != Guid.Empty) + { + UserId = parsed.ToString("D"); + IsExplicitlyConfigured = true; + return; + } + + if (isProduction) + { + throw new InvalidOperationException( + "Admin:PrincipalId must be configured in production."); + } + + UserId = DevDefaultUserId; + IsExplicitlyConfigured = false; + } } diff --git a/backend/AuthService/AuthAdminEndpoints.cs b/backend/AuthService/AuthAdminEndpoints.cs new file mode 100644 index 0000000..a3466c2 --- /dev/null +++ b/backend/AuthService/AuthAdminEndpoints.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Identity; + +public static class AuthAdminEndpoints +{ + public static void MapAuthAdminEndpoints(this WebApplication app, string gatewayKey, AuthAdminCredentials adminCredentials) + { + app.MapPost("/api/v1/admin/sessions", (AdminLoginRequest request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => + { + if (!AuthHttp.FixedTimeEquals(request.Username?.Trim() ?? string.Empty, adminCredentials.Username)) + return AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "管理员账号或密码错误"); + + // Prefer hashed password (Admin:PasswordHash). Fall back to legacy plaintext + // (Admin:Password) only if the hash is not configured, using FixedTimeEquals. + var password = request.Password ?? string.Empty; + var passwordValid = false; + if (!string.IsNullOrWhiteSpace(adminCredentials.PasswordHash)) + { + var adminCredential = new Credential(AdminIdentity.UserId, "admin", adminCredentials.PasswordHash); + try + { + passwordValid = hasher.VerifyHashedPassword(adminCredential, adminCredentials.PasswordHash, password) is not PasswordVerificationResult.Failed; + } + catch (FormatException) { passwordValid = false; } + catch (ArgumentException) { passwordValid = false; } + } + else + { + passwordValid = AuthHttp.FixedTimeEquals(password, adminCredentials.PasswordLegacy!); + } + + if (!passwordValid) + return AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "管理员账号或密码错误"); + var session = repository.CreateSession(AdminIdentity.UserId, "MoonStone admin"); + return Results.Created($"/api/v1/auth/sessions/{session.SessionId}", ApiSuccess.Create(session.ToResponse(), c.TraceIdentifier)); + }); + app.MapGet("/api/v1/admin/users", async (HttpContext c, IAdminRepository admin, IHttpClientFactory clients) => + { + if (!AuthHttp.IsAdmin(c, gatewayKey)) return AuthHttp.Failure(c, 403, "FORBIDDEN", "需要管理员权限"); + var accounts = admin.ListUsers(); + var userIds = accounts.Select(account => account.Id).ToArray(); + var displayNamesTask = AuthGatewayClient.LookupProfileDisplayNamesAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, userIds, c.RequestAborted); + var creditBalancesTask = AuthGatewayClient.LookupCreditBalancesAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, userIds, c.RequestAborted); + await Task.WhenAll(displayNamesTask, creditBalancesTask); + var displayNames = await displayNamesTask; + if (displayNames is null) return AuthHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "用户资料服务暂时不可用"); + var creditBalances = await creditBalancesTask; + if (creditBalances is null) return AuthHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "credits 服务暂时不可用"); + var users = accounts.Select(account => new AdminUser(account.Id, account.Email, displayNames.GetValueOrDefault(account.Id, account.Email), true, creditBalances[account.Id])).ToArray(); + return Results.Ok(ApiSuccess.Create(users, c.TraceIdentifier)); + }); + app.MapDelete("/api/v1/admin/users/{userId}", async (string userId, HttpContext c, IAdminRepository admin, IAdminAuditRepository audit, IHttpClientFactory clients) => + { + if (!AuthHttp.IsAdmin(c, gatewayKey)) return AuthHttp.Failure(c, 403, "FORBIDDEN", "需要管理员权限"); + var actorUserId = AuthHttp.GetGatewayUser(c, gatewayKey)!; + if (!admin.UserExists(userId)) + { + audit.Write(AdminAuditRecord.Create(actorUserId, "USER_DELETE", userId, null, "NOT_FOUND", c.TraceIdentifier)); + return AuthHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); + } + if (!await AuthGatewayClient.DeleteUserProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, userId, c.RequestAborted)) + { + audit.Write(AdminAuditRecord.Create(actorUserId, "USER_DELETE", userId, null, "PROFILE_DELETE_FAILED", c.TraceIdentifier)); + return AuthHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "用户资料服务暂时不可用"); + } + var deleted = admin.DeleteAuthUser(userId); + audit.Write(AdminAuditRecord.Create(actorUserId, "USER_DELETE", userId, null, deleted ? "SUCCEEDED" : "NOT_FOUND", c.TraceIdentifier)); + return deleted ? Results.NoContent() : AuthHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); + }); + app.MapPost("/api/v1/admin/users/{userId}/password", (string userId, AdminResetPasswordRequest request, HttpContext c, IAuthRepository repository, IAdminAuditRepository audit, IPasswordHasher hasher) => + { + if (!AuthHttp.IsAdmin(c, gatewayKey)) return AuthHttp.Failure(c, 403, "FORBIDDEN", "需要管理员权限"); + if (!AuthHttp.ValidPassword(request.NewPassword)) return AuthHttp.Failure(c, 400, "VALIDATION_ERROR", "新密码至少需要 8 个字符"); + var credential = repository.FindCredentialById(userId); + if (credential is null) + { + audit.Write(AdminAuditRecord.Create(AuthHttp.GetGatewayUser(c, gatewayKey)!, "USER_PASSWORD_RESET", userId, null, "NOT_FOUND", c.TraceIdentifier)); + return AuthHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); + } + repository.UpdatePassword(credential with { PasswordHash = hasher.HashPassword(credential, request.NewPassword) }); + repository.RevokeAllSessions(userId); + audit.Write(AdminAuditRecord.Create(AuthHttp.GetGatewayUser(c, gatewayKey)!, "USER_PASSWORD_RESET", userId, null, "SUCCEEDED", c.TraceIdentifier)); + return Results.NoContent(); + }); + } +} diff --git a/backend/AuthService/AuthContracts.cs b/backend/AuthService/AuthContracts.cs new file mode 100644 index 0000000..8ddd649 --- /dev/null +++ b/backend/AuthService/AuthContracts.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +public sealed record RegistrationRequest(string Email, string Password, string DisplayName, string? DeviceName); +public sealed record LoginRequest(string Email, string Password, string? DeviceName); +public sealed record AdminLoginRequest(string? Username, string? Password); +public sealed record AdminResetPasswordRequest(string NewPassword); +public sealed record PasswordChangeRequest(string? CurrentPassword, string? NewPassword); +public sealed record AccountDeletionRequest(string CurrentPassword); +public sealed record CreateInvitationRequest(string Type, int? MaxUses, DateTimeOffset? ValidFrom, DateTimeOffset? ValidTo); +public sealed record AdminUser(string Id, string Email, string DisplayName, bool IsActive, decimal Credits); +public sealed record AdminInvitation(string Code, string Type, int MaxUses, int UsedCount, DateTimeOffset? ValidFrom, DateTimeOffset? ValidTo, DateTimeOffset CreatedAt); +public sealed record AdminAccount(string Id, string Email); +public sealed record AdminProfileSummary(string UserId, string DisplayName); +public sealed record ApiEnvelope( + T? Data, + JsonElement Meta, + string? TraceId); +public sealed record RefreshTokenRequest(string? RefreshToken); public sealed record PasswordResetRequest(string Email); public sealed record PasswordResetConfirmation(string? ResetToken, string? NewPassword); public sealed record TokenIntrospectionRequest(string? Token); +public sealed record AuthSession(string SessionId, string UserId, string Status, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt); +public sealed record TokenPair(string AccessToken, string RefreshToken, string TokenType, int ExpiresInSeconds); +public sealed record AuthSessionResponse(AuthSession Session, TokenPair Tokens); +public sealed record TokenIntrospection(bool Active, string? UserId, string? SessionId, string[] Scopes, DateTimeOffset? ExpiresAt); +public sealed record ApiError(string Code, string Message, object Details); public sealed record ApiSuccess(object Data, object Meta, string TraceId) { public static ApiSuccess Create(object data, string id) => new(data, new { }, id); } public sealed record ApiFailure(object? Data, ApiError Error, string TraceId) { public static ApiFailure Create(string code, string message, string id) => new(null, new ApiError(code, message, new { }), id); } +public sealed record AdminAuditRecord(string AuditId, string ActorUserId, string Action, string? TargetUserId, string? TargetInvitationCode, string Outcome, string TraceId, DateTimeOffset CreatedAt) +{ + public static AdminAuditRecord Create(string actorUserId, string action, string? targetUserId, string? targetInvitationCode, string outcome, string traceId) => + new(Guid.NewGuid().ToString(), actorUserId, action, targetUserId, targetInvitationCode, outcome, traceId, DateTimeOffset.UtcNow); +} +public enum RegistrationOutcome { Created, EmailAlreadyRegistered, InvitationUnavailable } +public sealed record Credential(string UserId, string Email, string PasswordHash) { public static Credential New(string email) => new(Guid.NewGuid().ToString(), email, string.Empty); } +public sealed record StoredSession(string SessionId, string UserId, string AccessToken, string RefreshToken, DateTimeOffset CreatedAt, DateTimeOffset AccessExpiresAt, DateTimeOffset RefreshExpiresAt, DateTimeOffset? RevokedAt) +{ + public string Status => RevokedAt is not null ? "REVOKED" : RefreshExpiresAt <= DateTimeOffset.UtcNow ? "EXPIRED" : "ACTIVE"; + public AuthSession ToContract() => new(SessionId, UserId, Status, CreatedAt, RefreshExpiresAt); + public TokenPair ToTokenPair() => new(AccessToken, RefreshToken, "Bearer", Math.Max(0, (int)(AccessExpiresAt - DateTimeOffset.UtcNow).TotalSeconds)); + public AuthSessionResponse ToResponse() => new(ToContract(), ToTokenPair()); +} + +public sealed record AuthAdminCredentials(string Username, string? PasswordHash, string? PasswordLegacy); diff --git a/backend/AuthService/AuthHttp.cs b/backend/AuthService/AuthHttp.cs new file mode 100644 index 0000000..f278099 --- /dev/null +++ b/backend/AuthService/AuthHttp.cs @@ -0,0 +1,177 @@ +using System.Net.Http.Json; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Identity; + +public static class AuthHttp +{ + public static bool IsGateway(HttpContext c, string key) + { + var values = c.Request.Headers["X-Gateway-Key"]; + return values.Count == 1 && FixedTimeEquals(values[0]!, key); + } + + public static string? GetGatewayUser(HttpContext c, string key) => + IsGateway(c, key) && !string.IsNullOrWhiteSpace(c.Request.Headers["X-User-Id"]) + ? c.Request.Headers["X-User-Id"].ToString() + : null; + + public static bool IsAdmin(HttpContext c, string key) => + GetGatewayUser(c, key) == AdminIdentity.UserId; + + public static bool ValidEmail(string? value) => + !string.IsNullOrWhiteSpace(value) && value.Length <= 320 && value.Contains('@'); + + public static bool ValidName(string? value) => + !string.IsNullOrWhiteSpace(value) && value.Trim().Length is >= 1 and <= 64; + + public static bool ValidPassword(string? value) => + !string.IsNullOrWhiteSpace(value) && value.Length >= 8; + + public static bool PasswordMatches(IPasswordHasher hasher, Credential credential, string password) + { + try + { + return hasher.VerifyHashedPassword(credential, credential.PasswordHash, password) + is not PasswordVerificationResult.Failed; + } + catch (FormatException) { return false; } + catch (ArgumentException) { return false; } + } + + public static IResult Failure(HttpContext c, int status, string code, string message) => + Results.Json(ApiFailure.Create(code, message, c.TraceIdentifier), statusCode: status); + + public static bool FixedTimeEquals(string left, string right) + { + var leftBytes = System.Text.Encoding.UTF8.GetBytes(left); + var rightBytes = System.Text.Encoding.UTF8.GetBytes(right); + var length = Math.Max(leftBytes.Length, rightBytes.Length); + var paddedLeft = new byte[length]; + var paddedRight = new byte[length]; + leftBytes.CopyTo(paddedLeft, 0); + rightBytes.CopyTo(paddedRight, 0); + return CryptographicOperations.FixedTimeEquals(paddedLeft, paddedRight) + && leftBytes.Length == rightBytes.Length; + } +} + +public static class AuthGatewayClient +{ + public static async Task CreateProfileAsync( + HttpClient client, string key, string correlationId, string userId, string displayName, + CancellationToken cancellation) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/internal/v1/users") + { + Content = JsonContent.Create(new { userId, displayName, locale = "zh-CN" }) + }; + request.Headers.Add("X-Service-Name", "AuthService"); + request.Headers.Add("X-Service-Key", key); + request.Headers.Add("X-Correlation-Id", correlationId); + try + { + using var response = await client.SendAsync(request, cancellation); + return response.IsSuccessStatusCode + || response.StatusCode == System.Net.HttpStatusCode.Conflict; + } + catch (HttpRequestException) { return false; } + } + + public static async Task CreateCreditAccountAsync( + HttpClient client, string key, string correlationId, string userId, + CancellationToken cancellation) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/internal/v1/credits/accounts") + { + Content = JsonContent.Create(new { userId }) + }; + request.Headers.Add("X-Service-Name", "AuthService"); + request.Headers.Add("X-Service-Key", key); + request.Headers.Add("X-Correlation-Id", correlationId); + try + { + using var response = await client.SendAsync(request, cancellation); + return response.IsSuccessStatusCode + || response.StatusCode == System.Net.HttpStatusCode.Conflict; + } + catch (HttpRequestException) { return false; } + } + + public static async Task DeleteCreditAccountAsync( + HttpClient client, string key, string correlationId, string userId, + CancellationToken cancellation) + { + using var request = new HttpRequestMessage( + HttpMethod.Delete, $"/internal/v1/credits/accounts/{Uri.EscapeDataString(userId)}"); + request.Headers.Add("X-Service-Name", "AuthService"); + request.Headers.Add("X-Service-Key", key); + request.Headers.Add("X-Correlation-Id", correlationId); + try + { + using var response = await client.SendAsync(request, cancellation); + return response.IsSuccessStatusCode + || response.StatusCode == System.Net.HttpStatusCode.NotFound; + } + catch (HttpRequestException) { return false; } + } + + public static async Task?> LookupProfileDisplayNamesAsync( + HttpClient client, string key, string correlationId, string[] userIds, + CancellationToken cancellation) + { + if (userIds.Length == 0) return new Dictionary(StringComparer.Ordinal); + using var request = new HttpRequestMessage(HttpMethod.Post, "/internal/v1/users/profile-lookups") + { + Content = JsonContent.Create(new { userIds }) + }; + request.Headers.Add("X-Service-Name", "AuthService"); + request.Headers.Add("X-Service-Key", key); + request.Headers.Add("X-Correlation-Id", correlationId); + try + { + using var response = await client.SendAsync(request, cancellation); + if (!response.IsSuccessStatusCode) return null; + return await AdminProfileLookupContract.ReadAsync(response.Content, userIds, cancellation); + } + catch (HttpRequestException) { return null; } + } + + public static async Task?> LookupCreditBalancesAsync( + HttpClient client, string key, string correlationId, string[] userIds, + CancellationToken cancellation) + { + if (userIds.Length == 0) return new Dictionary(StringComparer.Ordinal); + using var request = new HttpRequestMessage(HttpMethod.Post, "/internal/v1/credits/balance-lookups") + { + Content = JsonContent.Create(new { userIds }) + }; + request.Headers.Add("X-Service-Name", "AuthService"); + request.Headers.Add("X-Service-Key", key); + request.Headers.Add("X-Correlation-Id", correlationId); + try + { + using var response = await client.SendAsync(request, cancellation); + if (!response.IsSuccessStatusCode) return null; + return await AdminCreditBalanceLookupContract.ReadAsync(response.Content, userIds, cancellation); + } + catch (HttpRequestException) { return null; } + } + + public static async Task DeleteUserProfileAsync( + HttpClient client, string key, string correlationId, string userId, + CancellationToken cancellation) + { + using var request = new HttpRequestMessage( + HttpMethod.Delete, $"/internal/v1/users/{Uri.EscapeDataString(userId)}"); + request.Headers.Add("X-Service-Name", "AuthService"); + request.Headers.Add("X-Service-Key", key); + request.Headers.Add("X-Correlation-Id", correlationId); + try + { + using var response = await client.SendAsync(request, cancellation); + return response.IsSuccessStatusCode + || response.StatusCode == System.Net.HttpStatusCode.NotFound; + } + catch (HttpRequestException) { return false; } + } +} diff --git a/backend/AuthService/AuthIntrospectionEndpoints.cs b/backend/AuthService/AuthIntrospectionEndpoints.cs new file mode 100644 index 0000000..7c1ffd5 --- /dev/null +++ b/backend/AuthService/AuthIntrospectionEndpoints.cs @@ -0,0 +1,17 @@ +public static class AuthIntrospectionEndpoints +{ + public static void MapAuthIntrospectionEndpoints(this WebApplication app, string gatewayKey) + { + app.MapPost("/internal/v1/auth/introspections", (TokenIntrospectionRequest request, HttpContext c, IAuthRepository repository) => + { + if (!AuthHttp.IsGateway(c, gatewayKey)) return AuthHttp.Failure(c, 403, "FORBIDDEN", "仅允许 Gateway 调用令牌内省接口"); + var session = string.IsNullOrWhiteSpace(request.Token) + ? null + : repository.TouchAccessToken(request.Token); + var result = session is not null + ? new TokenIntrospection(true, session.UserId, session.SessionId, ["user"], session.AccessExpiresAt) + : new TokenIntrospection(false, null, null, [], null); + return Results.Ok(ApiSuccess.Create(result, c.TraceIdentifier)); + }); + } +} diff --git a/backend/AuthService/AuthPasswordEndpoints.cs b/backend/AuthService/AuthPasswordEndpoints.cs new file mode 100644 index 0000000..bbf0263 --- /dev/null +++ b/backend/AuthService/AuthPasswordEndpoints.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Identity; + +public static class AuthPasswordEndpoints +{ + public static void MapAuthPasswordEndpoints(this WebApplication app, string gatewayKey) + { + app.MapPost("/api/v1/auth/password-changes", (PasswordChangeRequest request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => + { + var userId = AuthHttp.GetGatewayUser(c, gatewayKey); + if (userId is null) return AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "登录状态已失效"); + if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return AuthHttp.Failure(c, 400, "VALIDATION_ERROR", "请输入当前密码"); + if (!AuthHttp.ValidPassword(request.NewPassword)) return AuthHttp.Failure(c, 400, "VALIDATION_ERROR", "新密码至少需要 8 个字符"); + var credential = repository.FindCredentialById(userId); + if (credential is null) return AuthHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); + if (!AuthHttp.PasswordMatches(hasher, credential, request.CurrentPassword)) return AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "当前密码错误"); + repository.UpdatePassword(credential with { PasswordHash = hasher.HashPassword(credential, request.NewPassword!) }); + repository.RevokeAllSessions(userId); + return Results.NoContent(); + }); + app.MapPost("/api/v1/auth/password-reset-requests", async (PasswordResetRequest request, HttpContext c, IAuthRepository repository, PasswordResetEmailSender emailSender) => + { + if (!AuthHttp.ValidEmail(request.Email)) return AuthHttp.Failure(c, 400, "VALIDATION_ERROR", "请输入有效的邮箱地址"); + var credential = repository.FindCredential(request.Email.Trim().ToLowerInvariant()); + // Always return 202 Accepted to prevent email enumeration. + // If the email is registered, a reset token is sent; otherwise, no action is taken. + if (credential is not null) + { + var resetToken = repository.CreatePasswordReset(credential.UserId); + var delivered = await emailSender.SendAsync(credential.Email, resetToken, c.TraceIdentifier, c.RequestAborted); + if (!delivered) repository.DeletePasswordReset(resetToken); + } + return Results.Accepted(); + }); + app.MapPost("/api/v1/auth/password-resets", (PasswordResetConfirmation request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => + { + if (!AuthHttp.ValidPassword(request.NewPassword)) return AuthHttp.Failure(c, 422, "BUSINESS_RULE_VIOLATION", "新密码不符合安全要求"); + if (string.IsNullOrWhiteSpace(request.ResetToken)) return AuthHttp.Failure(c, 422, "BUSINESS_RULE_VIOLATION", "重置令牌无效或已过期"); + var credential = repository.ConsumePasswordReset(request.ResetToken); + if (credential is null) return AuthHttp.Failure(c, 422, "BUSINESS_RULE_VIOLATION", "重置令牌无效或已过期"); + repository.UpdatePassword(credential with { PasswordHash = hasher.HashPassword(credential, request.NewPassword!) }); + repository.RevokeAllSessions(credential.UserId); + return Results.NoContent(); + }).RequireRateLimiting("password-reset-confirmation"); + } +} diff --git a/backend/AuthService/AuthRegistrationEndpoints.cs b/backend/AuthService/AuthRegistrationEndpoints.cs new file mode 100644 index 0000000..cffef2c --- /dev/null +++ b/backend/AuthService/AuthRegistrationEndpoints.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +public static class AuthRegistrationEndpoints +{ + public static void MapAuthRegistrationEndpoints(this WebApplication app, string gatewayKey) + { + app.MapPost("/api/v1/auth/registrations", async (RegistrationRequest request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher, IHttpClientFactory clients) => + { + if (!AuthHttp.ValidEmail(request.Email) || !AuthHttp.ValidName(request.DisplayName) || !AuthHttp.ValidPassword(request.Password)) return AuthHttp.Failure(c, 400, "VALIDATION_ERROR", "邮箱、显示名或密码格式不正确"); + var credential = Credential.New(request.Email.Trim().ToLowerInvariant()); + credential = credential with { PasswordHash = hasher.HashPassword(credential, request.Password) }; + var registration = repository.TryCreateCredential(credential); + if (registration == RegistrationOutcome.EmailAlreadyRegistered) return AuthHttp.Failure(c, 409, "STATE_CONFLICT", "该邮箱已注册"); + // Registration compensation must finish even when the browser disconnects; + // do not let RequestAborted leave credentials, profile and credits half-created. + var profileCreated = await AuthGatewayClient.CreateProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, request.DisplayName.Trim(), CancellationToken.None); + if (!profileCreated) { repository.DeleteCredential(credential.UserId); return AuthHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "用户资料服务暂时不可用"); } + var creditsCreated = await AuthGatewayClient.CreateCreditAccountAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, CancellationToken.None); + if (!creditsCreated) + { + await AuthGatewayClient.DeleteUserProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, CancellationToken.None); + repository.DeleteCredential(credential.UserId); + return AuthHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "credits 服务暂时不可用"); + } + StoredSession session; + try { session = repository.CreateSession(credential.UserId, request.DeviceName); } + catch + { + await AuthGatewayClient.DeleteUserProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, CancellationToken.None); + await AuthGatewayClient.DeleteCreditAccountAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, CancellationToken.None); + repository.DeleteCredential(credential.UserId); + throw; + } + return Results.Created($"/api/v1/auth/sessions/{session.SessionId}", ApiSuccess.Create(session.ToResponse(), c.TraceIdentifier)); + }); + + app.MapDelete("/api/v1/auth/account", async ([FromBody] AccountDeletionRequest request, HttpContext c, IAuthRepository repository, IAdminAuditRepository audit, IPasswordHasher hasher, IHttpClientFactory clients) => + { + var userId = AuthHttp.GetGatewayUser(c, gatewayKey); + if (userId is null) return AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "登陆状态已失效"); + if (AuthHttp.IsAdmin(c, gatewayKey)) return AuthHttp.Failure(c, 403, "FORBIDDEN", "管理员不能通过此接口注销"); + if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return AuthHttp.Failure(c, 400, "VALIDATION_ERROR", "请输入当前登录密码以确认注销"); + + var credential = repository.FindCredentialById(userId); + if (credential is null) return AuthHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); + if (!AuthHttp.PasswordMatches(hasher, credential, request.CurrentPassword)) return AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "当前密码错误"); + if (!await AuthGatewayClient.DeleteUserProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, userId, c.RequestAborted)) + { + audit.Write(AdminAuditRecord.Create(userId, "ACCOUNT_SELF_DELETE", userId, null, "PROFILE_DELETE_FAILED", c.TraceIdentifier)); + return AuthHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "用户资料服务暂时不可用,注销失败"); + } + + var deleted = repository.DeleteAccount(userId); + audit.Write(AdminAuditRecord.Create(userId, "ACCOUNT_SELF_DELETE", userId, null, deleted ? "SUCCEEDED" : "NOT_FOUND", c.TraceIdentifier)); + return deleted ? Results.NoContent() : AuthHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); + }); + } +} diff --git a/backend/AuthService/AuthRepositories.cs b/backend/AuthService/AuthRepositories.cs new file mode 100644 index 0000000..1cb69c3 --- /dev/null +++ b/backend/AuthService/AuthRepositories.cs @@ -0,0 +1,209 @@ +using System.Security.Cryptography; +using MySqlConnector; + +public sealed class MySqlAuthRepository(AuthDatabase database) : IAuthRepository +{ + public RegistrationOutcome TryCreateCredential(Credential value) + { + try + { + using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO auth_credentials (user_id,email,password_hash) VALUES (@id,@email,@hash);"; + command.Parameters.AddWithValue("@id", value.UserId); command.Parameters.AddWithValue("@email", value.Email); command.Parameters.AddWithValue("@hash", value.PasswordHash); + command.ExecuteNonQuery(); return RegistrationOutcome.Created; + } + catch (MySqlException exception) when (exception.Number == 1062) { return RegistrationOutcome.EmailAlreadyRegistered; } + } + + public RegistrationOutcome TryCreateCredentialWithInvitation(Credential value, string invitationCode) + { + using var connection = database.OpenConnection(); using var transaction = connection.BeginTransaction(); using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "SELECT type,max_uses,used_count,valid_from,valid_to FROM admin_invitations WHERE code=@code LIMIT 1 FOR UPDATE;"; + command.Parameters.AddWithValue("@code", invitationCode); + var foundInvitation = false; + string? type = null; + var maxUses = 0; + var usedCount = 0; + DateTime? validFrom = null; + DateTime? validTo = null; + using (var reader = command.ExecuteReader()) + { + if (reader.Read()) + { + foundInvitation = true; + type = reader.GetString(0); + maxUses = reader.GetInt32(1); + usedCount = reader.GetInt32(2); + validFrom = reader.IsDBNull(3) ? null : reader.GetDateTime(3); + validTo = reader.IsDBNull(4) ? null : reader.GetDateTime(4); + } + } + + if (!foundInvitation) { transaction.Rollback(); return RegistrationOutcome.InvitationUnavailable; } + var now = DateTime.UtcNow; + var timeWindowIsValid = type != "time-window" || (validFrom is not null && validTo is not null && validFrom <= now && validTo >= now); + var usageIsAvailable = type == "time-window" || usedCount < maxUses; + if (!usageIsAvailable || !timeWindowIsValid) { transaction.Rollback(); return RegistrationOutcome.InvitationUnavailable; } + try + { + command.Parameters.Clear(); + command.CommandText = "INSERT INTO auth_credentials (user_id,email,password_hash) VALUES (@id,@email,@hash);"; + command.Parameters.AddWithValue("@id", value.UserId); command.Parameters.AddWithValue("@email", value.Email); command.Parameters.AddWithValue("@hash", value.PasswordHash); + command.ExecuteNonQuery(); + } + catch (MySqlException exception) when (exception.Number == 1062) + { + transaction.Rollback(); + return RegistrationOutcome.EmailAlreadyRegistered; + } + command.Parameters.Clear(); + command.CommandText = "UPDATE admin_invitations SET used_count=used_count+1 WHERE code=@code AND (type='time-window' OR used_count FindSession("session_id",id,false); public StoredSession? FindByAccessToken(string token)=>FindSession("access_hash",Hash(token),true); public StoredSession? TouchAccessToken(string token) { var session=FindByAccessToken(token); var now=DateTimeOffset.UtcNow; return session is null||session.Status!="ACTIVE"||session.AccessExpiresAt<=now?null:session; } + public bool RevokeSession(string sessionId,string userId) { using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="UPDATE auth_sessions SET revoked_at=UTC_TIMESTAMP(6) WHERE session_id=@id AND user_id=@user AND revoked_at IS NULL;";q.Parameters.AddWithValue("@id",sessionId);q.Parameters.AddWithValue("@user",userId);return q.ExecuteNonQuery()==1; } + public StoredSession? Rotate(string refresh) { var previousHash=Hash(refresh); var old=FindSession("refresh_hash",previousHash,true); if(old is null||old.Status!="ACTIVE")return null; var now=DateTimeOffset.UtcNow; var access=Token(); var nextRefresh=Token(); var rotated=old with { AccessToken=access, RefreshToken=nextRefresh, AccessExpiresAt=now.AddMinutes(15), RefreshExpiresAt=now.AddDays(7) }; using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="UPDATE auth_sessions SET access_hash=@access,refresh_hash=@refresh,access_expires_at=@accessExpires,refresh_expires_at=@refreshExpires WHERE session_id=@id AND refresh_hash=@previousRefresh AND revoked_at IS NULL AND refresh_expires_at>UTC_TIMESTAMP(6);";q.Parameters.AddWithValue("@access",Hash(access));q.Parameters.AddWithValue("@refresh",Hash(nextRefresh));q.Parameters.AddWithValue("@accessExpires",rotated.AccessExpiresAt.UtcDateTime);q.Parameters.AddWithValue("@refreshExpires",rotated.RefreshExpiresAt.UtcDateTime);q.Parameters.AddWithValue("@id",old.SessionId);q.Parameters.AddWithValue("@previousRefresh",previousHash);return q.ExecuteNonQuery()==1?rotated:null; } + public void RevokeAllSessions(string userId){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="UPDATE auth_sessions SET revoked_at=UTC_TIMESTAMP(6) WHERE user_id=@id AND revoked_at IS NULL;";q.Parameters.AddWithValue("@id",userId);q.ExecuteNonQuery();} + public void UpdatePassword(Credential credential){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="UPDATE auth_credentials SET password_hash=@hash WHERE user_id=@id;";q.Parameters.AddWithValue("@id",credential.UserId);q.Parameters.AddWithValue("@hash",credential.PasswordHash);q.ExecuteNonQuery();} + public string CreatePasswordReset(string userId) + { + var token = PasswordResetToken(); + var normalizedToken = token.ToUpperInvariant(); + using var c = database.OpenConnection(); + using var tx = c.BeginTransaction(); + using var q = c.CreateCommand(); + q.Transaction = tx; + q.CommandText = "DELETE FROM auth_password_resets WHERE user_id=@user AND used_at IS NULL;"; + q.Parameters.AddWithValue("@user", userId); + q.ExecuteNonQuery(); + q.Parameters.Clear(); + q.CommandText = "INSERT INTO auth_password_resets (token_hash,user_id,expires_at,used_at) VALUES (@hash,@user,@expires,NULL);"; + q.Parameters.AddWithValue("@hash", Hash(normalizedToken)); + q.Parameters.AddWithValue("@user", userId); + q.Parameters.AddWithValue("@expires", DateTime.UtcNow.AddMinutes(10)); + q.ExecuteNonQuery(); + tx.Commit(); + return token; + } + public void DeletePasswordReset(string token){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="DELETE FROM auth_password_resets WHERE token_hash=@hash AND used_at IS NULL;";q.Parameters.AddWithValue("@hash",Hash(token.ToUpperInvariant()));q.ExecuteNonQuery();} + public Credential? ConsumePasswordReset(string token){if(string.IsNullOrWhiteSpace(token)||token.Length<6||token.Length>32)return null;using var c=database.OpenConnection();using var tx=c.BeginTransaction();using var q=c.CreateCommand();q.Transaction=tx;q.CommandText="SELECT CAST(user_id AS CHAR) FROM auth_password_resets WHERE token_hash=@hash AND used_at IS NULL AND expires_at>UTC_TIMESTAMP(6) LIMIT 1 FOR UPDATE;";q.Parameters.AddWithValue("@hash",Hash(token.ToUpperInvariant()));var rawUser=q.ExecuteScalar();var user=rawUser is null?null:DbText(rawUser);if(user is null){tx.Rollback();return null;}q.Parameters.Clear();q.CommandText="UPDATE auth_password_resets SET used_at=UTC_TIMESTAMP(6) WHERE token_hash=@hash;";q.Parameters.AddWithValue("@hash",Hash(token.ToUpperInvariant()));q.ExecuteNonQuery();tx.Commit();using var q2=c.CreateCommand();q2.CommandText="SELECT CAST(user_id AS CHAR),email,password_hash FROM auth_credentials WHERE user_id=@id;";q2.Parameters.AddWithValue("@id",user);using var r=q2.ExecuteReader();return r.Read()?new Credential(DbText(r.GetValue(0)),r.GetString(1),r.GetString(2)):null;} + private void InsertSession(StoredSession s){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="INSERT INTO auth_sessions (session_id,user_id,access_hash,refresh_hash,created_at,access_expires_at,refresh_expires_at,revoked_at) VALUES (@id,@user,@access,@refresh,@created,@accessExpires,@refreshExpires,NULL);";q.Parameters.AddWithValue("@id",s.SessionId);q.Parameters.AddWithValue("@user",s.UserId);q.Parameters.AddWithValue("@access",Hash(s.AccessToken));q.Parameters.AddWithValue("@refresh",Hash(s.RefreshToken));q.Parameters.AddWithValue("@created",s.CreatedAt.UtcDateTime);q.Parameters.AddWithValue("@accessExpires",s.AccessExpiresAt.UtcDateTime);q.Parameters.AddWithValue("@refreshExpires",s.RefreshExpiresAt.UtcDateTime);q.ExecuteNonQuery();} + private StoredSession? FindSession(string column,string value,bool hashed){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText=$"SELECT CAST(session_id AS CHAR),CAST(user_id AS CHAR),created_at,access_expires_at,refresh_expires_at,revoked_at FROM auth_sessions WHERE {column}=@value LIMIT 1;";q.Parameters.AddWithValue("@value",value);using var r=q.ExecuteReader();return r.Read()?new StoredSession(DbText(r.GetValue(0)),DbText(r.GetValue(1)),string.Empty,string.Empty,AsUtc(r.GetDateTime(2)),AsUtc(r.GetDateTime(3)),AsUtc(r.GetDateTime(4)),r.IsDBNull(5)?null:AsUtc(r.GetDateTime(5))):null;} + private static string Token()=>Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)).Replace('+','-').Replace('/','_').TrimEnd('='); private static string PasswordResetToken()=>GenerateResetToken(); private static string Hash(string value)=>Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value)));private static string DbText(object value)=>value is Guid guid?guid.ToString():Convert.ToString(value)!;private static DateTimeOffset AsUtc(DateTime value)=>new(DateTime.SpecifyKind(value,DateTimeKind.Utc)); + private const string ResetTokenAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // gitleaks:allow 验证码字符表(非密钥) + private static string GenerateResetToken() + { + // Reject the top 16 byte values before modulo reduction so every one of + // the 30 symbols is selected with equal probability. + var chars = new char[8]; + Span bytes = stackalloc byte[16]; + var written = 0; + while (written < chars.Length) + { + RandomNumberGenerator.Fill(bytes); + foreach (var value in bytes) + { + if (value >= 240) continue; + chars[written++] = ResetTokenAlphabet[value % ResetTokenAlphabet.Length]; + if (written == chars.Length) break; + } + } + return new string(chars); + } +} + +public sealed class MySqlAdminRepository(AuthDatabase database) : IAdminRepository +{ + public List ListUsers() + { + using var c = database.OpenConnection(); using var q = c.CreateCommand(); + q.CommandText = "SELECT CAST(user_id AS CHAR), email FROM auth_credentials ORDER BY email;"; + using var r = q.ExecuteReader(); var result = new List(); + while (r.Read()) result.Add(new AdminAccount(DbText(r.GetValue(0)), DbText(r.GetValue(1)))); + return result; + } + public bool UserExists(string userId) + { + using var c = database.OpenConnection(); using var q = c.CreateCommand(); + q.CommandText = "SELECT COUNT(*) FROM auth_credentials WHERE user_id=@id;"; + q.Parameters.AddWithValue("@id", userId); + return Convert.ToInt32(q.ExecuteScalar()) == 1; + } + public bool DeleteAuthUser(string userId) + { + using var c=database.OpenConnection();using var tx=c.BeginTransaction();using var q=c.CreateCommand();q.Transaction=tx; + q.CommandText="SELECT COUNT(*) FROM auth_credentials WHERE user_id=@id;";q.Parameters.AddWithValue("@id",userId);if(Convert.ToInt32(q.ExecuteScalar())==0){tx.Rollback();return false;} + q.Parameters.Clear();q.CommandText="DELETE FROM auth_sessions WHERE user_id=@id; DELETE FROM auth_password_resets WHERE user_id=@id; DELETE FROM admin_user_overrides WHERE user_id=@id; DELETE FROM auth_credentials WHERE user_id=@id;";q.Parameters.AddWithValue("@id",userId);q.ExecuteNonQuery();tx.Commit();return true; + } + public List ListInvitations() + { + using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="SELECT code,type,max_uses,used_count,valid_from,valid_to,created_at FROM admin_invitations ORDER BY created_at DESC;";using var r=q.ExecuteReader();var result=new List(); + while(r.Read())result.Add(new AdminInvitation(r.GetString(0),r.GetString(1),r.GetInt32(2),r.GetInt32(3),r.IsDBNull(4)?null:Utc(r.GetDateTime(4)),r.IsDBNull(5)?null:Utc(r.GetDateTime(5)),Utc(r.GetDateTime(6))));return result; + } + public AdminInvitation? CreateInvitation(CreateInvitationRequest request) + { + var type=request.Type?.Trim().ToLowerInvariant(); + if(type is not ("single-use" or "multi-use" or "time-window")||(type=="multi-use"&&!request.MaxUses.HasValue))return null; + var max=type switch{"single-use"=>1,"time-window"=>int.MaxValue,_=>request.MaxUses.GetValueOrDefault()}; + if((type=="multi-use"&&max is <1 or >10000)||(type=="time-window"&&(!request.ValidFrom.HasValue||!request.ValidTo.HasValue||request.ValidTo<=request.ValidFrom)))return null; + for(var i=0;i<3;i++){var value=new AdminInvitation("MS-"+Convert.ToHexString(RandomNumberGenerator.GetBytes(5)),type,max,0,request.ValidFrom,request.ValidTo,DateTimeOffset.UtcNow);try{using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="INSERT INTO admin_invitations (code,type,max_uses,used_count,valid_from,valid_to,created_at) VALUES (@code,@type,@max,0,@from,@to,@created);";q.Parameters.AddWithValue("@code",value.Code);q.Parameters.AddWithValue("@type",value.Type);q.Parameters.AddWithValue("@max",value.MaxUses);q.Parameters.AddWithValue("@from",value.ValidFrom?.UtcDateTime??(object)DBNull.Value);q.Parameters.AddWithValue("@to",value.ValidTo?.UtcDateTime??(object)DBNull.Value);q.Parameters.AddWithValue("@created",value.CreatedAt.UtcDateTime);q.ExecuteNonQuery();return value;}catch(MySqlException ex)when(ex.Number==1062){}}return null; + } + public bool DeleteInvitation(string code){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="DELETE FROM admin_invitations WHERE code=@code;";q.Parameters.AddWithValue("@code",code);return q.ExecuteNonQuery()==1;} + private static string DbText(object value)=>value is Guid guid?guid.ToString():Convert.ToString(value)!;private static DateTimeOffset Utc(DateTime value)=>new(DateTime.SpecifyKind(value,DateTimeKind.Utc)); +} + +public sealed class MySqlAdminAuditRepository(AuthDatabase database) : IAdminAuditRepository +{ + public void Write(AdminAuditRecord record) + { + using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO admin_audit_logs (audit_id,actor_user_id,action,target_user_id,target_invitation_code,outcome,trace_id,created_at) VALUES (@auditId,@actorUserId,@action,@targetUserId,@targetInvitationCode,@outcome,@traceId,@createdAt);"; + command.Parameters.AddWithValue("@auditId", record.AuditId); + command.Parameters.AddWithValue("@actorUserId", record.ActorUserId); + command.Parameters.AddWithValue("@action", record.Action); + command.Parameters.AddWithValue("@targetUserId", record.TargetUserId ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@targetInvitationCode", record.TargetInvitationCode ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@outcome", record.Outcome); + command.Parameters.AddWithValue("@traceId", record.TraceId); + command.Parameters.AddWithValue("@createdAt", record.CreatedAt.UtcDateTime); + command.ExecuteNonQuery(); + } +} diff --git a/backend/AuthService/AuthSessionEndpoints.cs b/backend/AuthService/AuthSessionEndpoints.cs new file mode 100644 index 0000000..9ce5ffe --- /dev/null +++ b/backend/AuthService/AuthSessionEndpoints.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Identity; + +public static class AuthSessionEndpoints +{ + public static void MapAuthSessionEndpoints(this WebApplication app, string gatewayKey) + { + app.MapPost("/api/v1/auth/sessions", (LoginRequest request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => + { + if (!AuthHttp.ValidEmail(request.Email) || string.IsNullOrEmpty(request.Password)) return AuthHttp.Failure(c, 400, "VALIDATION_ERROR", "邮箱和密码不能为空"); + var credential = repository.FindCredential(request.Email.Trim().ToLowerInvariant()); + if (credential is null || !AuthHttp.PasswordMatches(hasher, credential, request.Password)) return AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "邮箱或密码错误"); + var session = repository.CreateSession(credential.UserId, request.DeviceName); + return Results.Created($"/api/v1/auth/sessions/{session.SessionId}", ApiSuccess.Create(session.ToResponse(), c.TraceIdentifier)); + }); + app.MapGet("/api/v1/auth/sessions/{sessionId}", (string sessionId, HttpContext c, IAuthRepository repository) => + { + var caller = AuthHttp.GetGatewayUser(c, gatewayKey); if (caller is null) return AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "登录状态已失效"); + var session = repository.FindSession(sessionId); return session is null || session.UserId != caller ? AuthHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "会话不存在") : Results.Ok(ApiSuccess.Create(session.ToContract(), c.TraceIdentifier)); + }); + app.MapDelete("/api/v1/auth/sessions/{sessionId}", (string sessionId, HttpContext c, IAuthRepository repository) => + { + var caller = AuthHttp.GetGatewayUser(c, gatewayKey); if (caller is null) return AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "登录状态已失效"); + return repository.RevokeSession(sessionId, caller) ? Results.NoContent() : AuthHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "会话不存在"); + }); + app.MapPost("/api/v1/auth/tokens", (RefreshTokenRequest request, HttpContext c, IAuthRepository repository) => + { + var session = string.IsNullOrWhiteSpace(request.RefreshToken) + ? null + : repository.Rotate(request.RefreshToken); + return session is null + ? AuthHttp.Failure(c, 401, "AUTH_REQUIRED", "刷新令牌无效") + : Results.Created("/api/v1/auth/tokens", ApiSuccess.Create(session.ToTokenPair(), c.TraceIdentifier)); + }); + } +} diff --git a/backend/AuthService/Program.cs b/backend/AuthService/Program.cs index d99f04e..9319ec5 100644 --- a/backend/AuthService/Program.cs +++ b/backend/AuthService/Program.cs @@ -1,15 +1,9 @@ -using System.Net.Http.Json; -using System.Security.Cryptography; -using System.Text.Json; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using MySqlConnector; var builder = WebApplication.CreateBuilder(args); -builder.Logging.ClearProviders(); -builder.Logging.AddConsole(); +builder.Logging.AddJsonStructuredLogging(); builder.Services.Configure( options => options.ThrowOnBadRequest = true); var isMockMode = string.Equals(Environment.GetEnvironmentVariable("MOONSTONE_MODE"), "Mock", StringComparison.OrdinalIgnoreCase); @@ -17,6 +11,8 @@ if (!isMockMode && string.IsNullOrWhiteSpace(connectionString)) throw new InvalidOperationException("ConnectionStrings:AuthDatabase must be configured."); var gatewayKey = builder.Configuration["Gateway:ServiceKey"] ?? throw new InvalidOperationException("Gateway:ServiceKey must be configured."); +if (builder.Environment.IsProduction() && string.Equals(gatewayKey, "moonstone-local-gateway-key", StringComparison.Ordinal)) + throw new InvalidOperationException("Gateway:ServiceKey must be changed from the development default in production."); var gatewayBaseUrl = builder.Configuration["Gateway:BaseUrl"] ?? "http://localhost:5000"; var gatewayUri = new Uri(gatewayBaseUrl, UriKind.Absolute); var adminUsername = builder.Configuration["Admin:Username"] ?? throw new InvalidOperationException("Admin:Username must be configured."); @@ -24,6 +20,7 @@ var adminPasswordLegacy = builder.Configuration["Admin:Password"]; if (string.IsNullOrWhiteSpace(adminPasswordHash) && string.IsNullOrWhiteSpace(adminPasswordLegacy)) throw new InvalidOperationException("Admin:PasswordHash (preferred) or Admin:Password (legacy plaintext) must be configured."); +AdminIdentity.Configure(builder.Configuration["Admin:PrincipalId"], builder.Environment.IsProduction()); var isDevelopment = builder.Environment.IsDevelopment(); var storageName = isMockMode ? "memory" : "mysql"; builder.Services.AddSingleton(); @@ -70,13 +67,7 @@ await context.HttpContext.Response.WriteAsJsonAsync( var app = builder.Build(); if (!isMockMode) app.Services.GetRequiredService().EnsureCreated(); -app.Use(async (context, next) => -{ - var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); - context.TraceIdentifier = string.IsNullOrWhiteSpace(correlationId) ? Guid.NewGuid().ToString("N") : correlationId; - context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; - await next(); -}); +app.UseRequestLogging("AuthService"); app.UseExceptionHandler(error => error.Run(context => { var exception = context.Features.Get()?.Error; @@ -109,9 +100,9 @@ await context.HttpContext.Response.WriteAsJsonAsync( return; } - if (!IsGateway(context, gatewayKey)) + if (!AuthHttp.IsGateway(context, gatewayKey)) { - await Failure(context, 403, "FORBIDDEN", "该服务仅接受经 API Gateway 转发的请求").ExecuteAsync(context); + await AuthHttp.Failure(context, 403, "FORBIDDEN", "该服务仅接受经 API Gateway 转发的请求").ExecuteAsync(context); return; } @@ -120,540 +111,11 @@ await context.HttpContext.Response.WriteAsJsonAsync( app.UseRateLimiter(); app.MapGet("/healthz", (HttpContext c) => Results.Ok(ApiSuccess.Create(new { status = "live" }, c.TraceIdentifier))); -app.MapGet("/readyz", (HttpContext c) => Results.Ok(ApiSuccess.Create(new { status = "ready", storage = storageName }, c.TraceIdentifier))); - -app.MapPost("/api/v1/auth/registrations", async (RegistrationRequest request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher, IHttpClientFactory clients) => -{ - if (!ValidEmail(request.Email) || !ValidName(request.DisplayName) || !ValidPassword(request.Password)) return Failure(c, 400, "VALIDATION_ERROR", "邮箱、显示名或密码格式不正确"); - var credential = Credential.New(request.Email.Trim().ToLowerInvariant()); - credential = credential with { PasswordHash = hasher.HashPassword(credential, request.Password) }; - var registration = repository.TryCreateCredential(credential); - if (registration == RegistrationOutcome.EmailAlreadyRegistered) return Failure(c, 409, "STATE_CONFLICT", "该邮箱已注册"); - // Registration compensation must finish even when the browser disconnects; - // do not let RequestAborted leave credentials, profile and credits half-created. - var profileCreated = await CreateProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, request.DisplayName.Trim(), CancellationToken.None); - if (!profileCreated) { repository.DeleteCredential(credential.UserId); return Failure(c, 503, "SERVICE_UNAVAILABLE", "用户资料服务暂时不可用"); } - var creditsCreated = await CreateCreditAccountAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, CancellationToken.None); - if (!creditsCreated) - { - await DeleteUserProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, CancellationToken.None); - repository.DeleteCredential(credential.UserId); - return Failure(c, 503, "SERVICE_UNAVAILABLE", "credits 服务暂时不可用"); - } - StoredSession session; - try { session = repository.CreateSession(credential.UserId, request.DeviceName); } - catch - { - await DeleteUserProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, CancellationToken.None); - await DeleteCreditAccountAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, credential.UserId, CancellationToken.None); - repository.DeleteCredential(credential.UserId); - throw; - } - return Results.Created($"/api/v1/auth/sessions/{session.SessionId}", ApiSuccess.Create(session.ToResponse(), c.TraceIdentifier)); -}); - -app.MapPost("/api/v1/auth/sessions", (LoginRequest request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => -{ - if (!ValidEmail(request.Email) || string.IsNullOrEmpty(request.Password)) return Failure(c, 400, "VALIDATION_ERROR", "邮箱和密码不能为空"); - var credential = repository.FindCredential(request.Email.Trim().ToLowerInvariant()); - if (credential is null || !PasswordMatches(hasher, credential, request.Password)) return Failure(c, 401, "AUTH_REQUIRED", "邮箱或密码错误"); - var session = repository.CreateSession(credential.UserId, request.DeviceName); - return Results.Created($"/api/v1/auth/sessions/{session.SessionId}", ApiSuccess.Create(session.ToResponse(), c.TraceIdentifier)); -}); -app.MapPost("/api/v1/admin/sessions", (AdminLoginRequest request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => -{ - if (!FixedTimeEquals(request.Username?.Trim() ?? string.Empty, adminUsername)) - return Failure(c, 401, "AUTH_REQUIRED", "管理员账号或密码错误"); +app.MapGet("/readyz", (HttpContext c) => Results.Ok(ApiSuccess.Create(new { status = "ready", storage = storageName, adminConfigured = AdminIdentity.IsExplicitlyConfigured }, c.TraceIdentifier))); - // Prefer hashed password (Admin:PasswordHash). Fall back to legacy plaintext - // (Admin:Password) only if the hash is not configured, using FixedTimeEquals. - var password = request.Password ?? string.Empty; - var passwordValid = false; - if (!string.IsNullOrWhiteSpace(adminPasswordHash)) - { - var adminCredential = new Credential(AdminIdentity.UserId, "admin", adminPasswordHash); - try - { - passwordValid = hasher.VerifyHashedPassword(adminCredential, adminPasswordHash, password) is not PasswordVerificationResult.Failed; - } - catch (FormatException) { passwordValid = false; } - catch (ArgumentException) { passwordValid = false; } - } - else - { - passwordValid = FixedTimeEquals(password, adminPasswordLegacy!); - } - - if (!passwordValid) - return Failure(c, 401, "AUTH_REQUIRED", "管理员账号或密码错误"); - var session = repository.CreateSession(AdminIdentity.UserId, "MoonStone admin"); - return Results.Created($"/api/v1/auth/sessions/{session.SessionId}", ApiSuccess.Create(session.ToResponse(), c.TraceIdentifier)); -}); -app.MapGet("/api/v1/admin/users", async (HttpContext c, IAdminRepository admin, IHttpClientFactory clients) => -{ - if (!IsAdmin(c, gatewayKey)) return Failure(c, 403, "FORBIDDEN", "需要管理员权限"); - var accounts = admin.ListUsers(); - var userIds = accounts.Select(account => account.Id).ToArray(); - var displayNamesTask = LookupProfileDisplayNamesAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, userIds, c.RequestAborted); - var creditBalancesTask = LookupCreditBalancesAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, userIds, c.RequestAborted); - await Task.WhenAll(displayNamesTask, creditBalancesTask); - var displayNames = await displayNamesTask; - if (displayNames is null) return Failure(c, 503, "SERVICE_UNAVAILABLE", "用户资料服务暂时不可用"); - var creditBalances = await creditBalancesTask; - if (creditBalances is null) return Failure(c, 503, "SERVICE_UNAVAILABLE", "credits 服务暂时不可用"); - var users = accounts.Select(account => new AdminUser(account.Id, account.Email, displayNames.GetValueOrDefault(account.Id, account.Email), true, creditBalances[account.Id])).ToArray(); - return Results.Ok(ApiSuccess.Create(users, c.TraceIdentifier)); -}); -app.MapDelete("/api/v1/admin/users/{userId}", async (string userId, HttpContext c, IAdminRepository admin, IAdminAuditRepository audit, IHttpClientFactory clients) => -{ - if (!IsAdmin(c, gatewayKey)) return Failure(c, 403, "FORBIDDEN", "需要管理员权限"); - var actorUserId = GetGatewayUser(c, gatewayKey)!; - if (!admin.UserExists(userId)) - { - audit.Write(AdminAuditRecord.Create(actorUserId, "USER_DELETE", userId, null, "NOT_FOUND", c.TraceIdentifier)); - return Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); - } - if (!await DeleteUserProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, userId, c.RequestAborted)) - { - audit.Write(AdminAuditRecord.Create(actorUserId, "USER_DELETE", userId, null, "PROFILE_DELETE_FAILED", c.TraceIdentifier)); - return Failure(c, 503, "SERVICE_UNAVAILABLE", "用户资料服务暂时不可用"); - } - var deleted = admin.DeleteAuthUser(userId); - audit.Write(AdminAuditRecord.Create(actorUserId, "USER_DELETE", userId, null, deleted ? "SUCCEEDED" : "NOT_FOUND", c.TraceIdentifier)); - return deleted ? Results.NoContent() : Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); -}); -app.MapPost("/api/v1/admin/users/{userId}/password", (string userId, AdminResetPasswordRequest request, HttpContext c, IAuthRepository repository, IAdminAuditRepository audit, IPasswordHasher hasher) => -{ - if (!IsAdmin(c, gatewayKey)) return Failure(c, 403, "FORBIDDEN", "需要管理员权限"); - if (!ValidPassword(request.NewPassword)) return Failure(c, 400, "VALIDATION_ERROR", "新密码至少需要 8 个字符"); - var credential = repository.FindCredentialById(userId); - if (credential is null) - { - audit.Write(AdminAuditRecord.Create(GetGatewayUser(c, gatewayKey)!, "USER_PASSWORD_RESET", userId, null, "NOT_FOUND", c.TraceIdentifier)); - return Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); - } - repository.UpdatePassword(credential with { PasswordHash = hasher.HashPassword(credential, request.NewPassword) }); - repository.RevokeAllSessions(userId); - audit.Write(AdminAuditRecord.Create(GetGatewayUser(c, gatewayKey)!, "USER_PASSWORD_RESET", userId, null, "SUCCEEDED", c.TraceIdentifier)); - return Results.NoContent(); -}); -app.MapPost("/api/v1/auth/password-changes", (PasswordChangeRequest request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => -{ - var userId = GetGatewayUser(c, gatewayKey); - if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "登录状态已失效"); - if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return Failure(c, 400, "VALIDATION_ERROR", "请输入当前密码"); - if (!ValidPassword(request.NewPassword)) return Failure(c, 400, "VALIDATION_ERROR", "新密码至少需要 8 个字符"); - var credential = repository.FindCredentialById(userId); - if (credential is null) return Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); - if (!PasswordMatches(hasher, credential, request.CurrentPassword)) return Failure(c, 401, "AUTH_REQUIRED", "当前密码错误"); - repository.UpdatePassword(credential with { PasswordHash = hasher.HashPassword(credential, request.NewPassword!) }); - repository.RevokeAllSessions(userId); - return Results.NoContent(); -}); -app.MapDelete("/api/v1/auth/account", async ([FromBody] AccountDeletionRequest request, HttpContext c, IAuthRepository repository, IAdminAuditRepository audit, IPasswordHasher hasher, IHttpClientFactory clients) => -{ - var userId = GetGatewayUser(c, gatewayKey); - if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "登陆状态已失效"); - if (IsAdmin(c, gatewayKey)) return Failure(c, 403, "FORBIDDEN", "管理员不能通过此接口注销"); - if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return Failure(c, 400, "VALIDATION_ERROR", "请输入当前登录密码以确认注销"); - - var credential = repository.FindCredentialById(userId); - if (credential is null) return Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); - if (!PasswordMatches(hasher, credential, request.CurrentPassword)) return Failure(c, 401, "AUTH_REQUIRED", "当前密码错误"); - if (!await DeleteUserProfileAsync(clients.CreateClient("gateway"), gatewayKey, c.TraceIdentifier, userId, c.RequestAborted)) - { - audit.Write(AdminAuditRecord.Create(userId, "ACCOUNT_SELF_DELETE", userId, null, "PROFILE_DELETE_FAILED", c.TraceIdentifier)); - return Failure(c, 503, "SERVICE_UNAVAILABLE", "用户资料服务暂时不可用,注销失败"); - } - - var deleted = repository.DeleteAccount(userId); - audit.Write(AdminAuditRecord.Create(userId, "ACCOUNT_SELF_DELETE", userId, null, deleted ? "SUCCEEDED" : "NOT_FOUND", c.TraceIdentifier)); - return deleted ? Results.NoContent() : Failure(c, 404, "RESOURCE_NOT_FOUND", "用户不存在"); -}); -app.MapGet("/api/v1/auth/sessions/{sessionId}", (string sessionId, HttpContext c, IAuthRepository repository) => -{ - var caller = GetGatewayUser(c, gatewayKey); if (caller is null) return Failure(c, 401, "AUTH_REQUIRED", "登录状态已失效"); - var session = repository.FindSession(sessionId); return session is null || session.UserId != caller ? Failure(c, 404, "RESOURCE_NOT_FOUND", "会话不存在") : Results.Ok(ApiSuccess.Create(session.ToContract(), c.TraceIdentifier)); -}); -app.MapDelete("/api/v1/auth/sessions/{sessionId}", (string sessionId, HttpContext c, IAuthRepository repository) => -{ - var caller = GetGatewayUser(c, gatewayKey); if (caller is null) return Failure(c, 401, "AUTH_REQUIRED", "登录状态已失效"); - return repository.RevokeSession(sessionId, caller) ? Results.NoContent() : Failure(c, 404, "RESOURCE_NOT_FOUND", "会话不存在"); -}); -app.MapPost("/api/v1/auth/tokens", (RefreshTokenRequest request, HttpContext c, IAuthRepository repository) => -{ - var session = string.IsNullOrWhiteSpace(request.RefreshToken) - ? null - : repository.Rotate(request.RefreshToken); - return session is null - ? Failure(c, 401, "AUTH_REQUIRED", "刷新令牌无效") - : Results.Created("/api/v1/auth/tokens", ApiSuccess.Create(session.ToTokenPair(), c.TraceIdentifier)); -}); -app.MapPost("/api/v1/auth/password-reset-requests", async (PasswordResetRequest request, HttpContext c, IAuthRepository repository, PasswordResetEmailSender emailSender) => -{ - if (!ValidEmail(request.Email)) return Failure(c, 400, "VALIDATION_ERROR", "请输入有效的邮箱地址"); - var credential = repository.FindCredential(request.Email.Trim().ToLowerInvariant()); - // Always return 202 Accepted to prevent email enumeration. - // If the email is registered, a reset token is sent; otherwise, no action is taken. - if (credential is not null) - { - var resetToken = repository.CreatePasswordReset(credential.UserId); - var delivered = await emailSender.SendAsync(credential.Email, resetToken, c.TraceIdentifier, c.RequestAborted); - if (!delivered) repository.DeletePasswordReset(resetToken); - } - return Results.Accepted(); -}); -app.MapPost("/api/v1/auth/password-resets", (PasswordResetConfirmation request, HttpContext c, IAuthRepository repository, IPasswordHasher hasher) => -{ - if (!ValidPassword(request.NewPassword)) return Failure(c, 422, "BUSINESS_RULE_VIOLATION", "新密码不符合安全要求"); - if (string.IsNullOrWhiteSpace(request.ResetToken)) return Failure(c, 422, "BUSINESS_RULE_VIOLATION", "重置令牌无效或已过期"); - var credential = repository.ConsumePasswordReset(request.ResetToken); - if (credential is null) return Failure(c, 422, "BUSINESS_RULE_VIOLATION", "重置令牌无效或已过期"); - repository.UpdatePassword(credential with { PasswordHash = hasher.HashPassword(credential, request.NewPassword!) }); - repository.RevokeAllSessions(credential.UserId); - return Results.NoContent(); -}).RequireRateLimiting("password-reset-confirmation"); -app.MapPost("/internal/v1/auth/introspections", (TokenIntrospectionRequest request, HttpContext c, IAuthRepository repository) => -{ - if (!IsGateway(c, gatewayKey)) return Failure(c, 403, "FORBIDDEN", "仅允许 Gateway 调用令牌内省接口"); - var session = string.IsNullOrWhiteSpace(request.Token) - ? null - : repository.TouchAccessToken(request.Token); - var result = session is not null - ? new TokenIntrospection(true, session.UserId, session.SessionId, ["user"], session.AccessExpiresAt) - : new TokenIntrospection(false, null, null, [], null); - return Results.Ok(ApiSuccess.Create(result, c.TraceIdentifier)); -}); +app.MapAuthRegistrationEndpoints(gatewayKey); +app.MapAuthSessionEndpoints(gatewayKey); +app.MapAuthPasswordEndpoints(gatewayKey); +app.MapAuthAdminEndpoints(gatewayKey, new AuthAdminCredentials(adminUsername, adminPasswordHash, adminPasswordLegacy)); +app.MapAuthIntrospectionEndpoints(gatewayKey); app.Run(); - -static async Task CreateProfileAsync(HttpClient client, string key, string correlationId, string userId, string displayName, CancellationToken cancellation) -{ - using var request = new HttpRequestMessage(HttpMethod.Post, "/internal/v1/users") { Content = JsonContent.Create(new { userId, displayName, locale = "zh-CN" }) }; - request.Headers.Add("X-Service-Name", "AuthService"); request.Headers.Add("X-Service-Key", key); request.Headers.Add("X-Correlation-Id", correlationId); - try { using var response = await client.SendAsync(request, cancellation); return response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.Conflict; } catch (HttpRequestException) { return false; } -} -static async Task CreateCreditAccountAsync(HttpClient client, string key, string correlationId, string userId, CancellationToken cancellation) -{ - using var request = new HttpRequestMessage(HttpMethod.Post, "/internal/v1/credits/accounts") { Content = JsonContent.Create(new { userId }) }; - request.Headers.Add("X-Service-Name", "AuthService"); request.Headers.Add("X-Service-Key", key); request.Headers.Add("X-Correlation-Id", correlationId); - try { using var response = await client.SendAsync(request, cancellation); return response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.Conflict; } - catch (HttpRequestException) { return false; } -} -static async Task DeleteCreditAccountAsync(HttpClient client, string key, string correlationId, string userId, CancellationToken cancellation) -{ - using var request = new HttpRequestMessage(HttpMethod.Delete, $"/internal/v1/credits/accounts/{Uri.EscapeDataString(userId)}"); - request.Headers.Add("X-Service-Name", "AuthService"); request.Headers.Add("X-Service-Key", key); request.Headers.Add("X-Correlation-Id", correlationId); - try { using var response = await client.SendAsync(request, cancellation); return response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NotFound; } - catch (HttpRequestException) { return false; } -} -static async Task?> LookupProfileDisplayNamesAsync(HttpClient client, string key, string correlationId, string[] userIds, CancellationToken cancellation) -{ - if (userIds.Length == 0) return new Dictionary(StringComparer.Ordinal); - using var request = new HttpRequestMessage(HttpMethod.Post, "/internal/v1/users/profile-lookups") { Content = JsonContent.Create(new { userIds }) }; - request.Headers.Add("X-Service-Name", "AuthService"); request.Headers.Add("X-Service-Key", key); request.Headers.Add("X-Correlation-Id", correlationId); - try - { - using var response = await client.SendAsync(request, cancellation); - if (!response.IsSuccessStatusCode) return null; - return await AdminProfileLookupContract.ReadAsync( - response.Content, - userIds, - cancellation); - } - catch (HttpRequestException) { return null; } -} -static async Task?> LookupCreditBalancesAsync(HttpClient client, string key, string correlationId, string[] userIds, CancellationToken cancellation) -{ - if (userIds.Length == 0) return new Dictionary(StringComparer.Ordinal); - using var request = new HttpRequestMessage(HttpMethod.Post, "/internal/v1/credits/balance-lookups") { Content = JsonContent.Create(new { userIds }) }; - request.Headers.Add("X-Service-Name", "AuthService"); request.Headers.Add("X-Service-Key", key); request.Headers.Add("X-Correlation-Id", correlationId); - try - { - using var response = await client.SendAsync(request, cancellation); - if (!response.IsSuccessStatusCode) return null; - return await AdminCreditBalanceLookupContract.ReadAsync(response.Content, userIds, cancellation); - } - catch (HttpRequestException) { return null; } -} -static async Task DeleteUserProfileAsync(HttpClient client, string key, string correlationId, string userId, CancellationToken cancellation) -{ - using var request = new HttpRequestMessage(HttpMethod.Delete, $"/internal/v1/users/{Uri.EscapeDataString(userId)}"); - request.Headers.Add("X-Service-Name", "AuthService"); request.Headers.Add("X-Service-Key", key); request.Headers.Add("X-Correlation-Id", correlationId); - try - { - using var response = await client.SendAsync(request, cancellation); - return response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NotFound; - } - catch (HttpRequestException) { return false; } -} -static bool IsGateway(HttpContext c, string key) -{ - var values = c.Request.Headers["X-Gateway-Key"]; - return values.Count == 1 && FixedTimeEquals(values[0]!, key); -} -static string? GetGatewayUser(HttpContext c, string key) => IsGateway(c, key) && !string.IsNullOrWhiteSpace(c.Request.Headers["X-User-Id"]) ? c.Request.Headers["X-User-Id"].ToString() : null; -static bool ValidEmail(string? value) => !string.IsNullOrWhiteSpace(value) && value.Length <= 320 && value.Contains('@'); -static bool ValidName(string? value) => !string.IsNullOrWhiteSpace(value) && value.Trim().Length is >= 1 and <= 64; -static bool ValidPassword(string? value) => !string.IsNullOrWhiteSpace(value) && value.Length >= 8; -static bool PasswordMatches(IPasswordHasher hasher, Credential credential, string password) -{ - try { return hasher.VerifyHashedPassword(credential, credential.PasswordHash, password) is not PasswordVerificationResult.Failed; } - catch (FormatException) { return false; } - catch (ArgumentException) { return false; } -} -static IResult Failure(HttpContext c, int status, string code, string message) => Results.Json(ApiFailure.Create(code, message, c.TraceIdentifier), statusCode: status); -static bool FixedTimeEquals(string left, string right) -{ - var leftBytes = System.Text.Encoding.UTF8.GetBytes(left); - var rightBytes = System.Text.Encoding.UTF8.GetBytes(right); - var length = Math.Max(leftBytes.Length, rightBytes.Length); - var paddedLeft = new byte[length]; - var paddedRight = new byte[length]; - leftBytes.CopyTo(paddedLeft, 0); - rightBytes.CopyTo(paddedRight, 0); - return CryptographicOperations.FixedTimeEquals(paddedLeft, paddedRight) && leftBytes.Length == rightBytes.Length; -} -static bool IsAdmin(HttpContext c, string key) => GetGatewayUser(c, key) == AdminIdentity.UserId; - -public sealed record RegistrationRequest(string Email, string Password, string DisplayName, string? DeviceName); -public sealed record LoginRequest(string Email, string Password, string? DeviceName); -public sealed record AdminLoginRequest(string? Username, string? Password); -public sealed record AdminResetPasswordRequest(string NewPassword); -public sealed record PasswordChangeRequest(string? CurrentPassword, string? NewPassword); -public sealed record AccountDeletionRequest(string CurrentPassword); -public sealed record CreateInvitationRequest(string Type, int? MaxUses, DateTimeOffset? ValidFrom, DateTimeOffset? ValidTo); -public sealed record AdminUser(string Id, string Email, string DisplayName, bool IsActive, decimal Credits); -public sealed record AdminInvitation(string Code, string Type, int MaxUses, int UsedCount, DateTimeOffset? ValidFrom, DateTimeOffset? ValidTo, DateTimeOffset CreatedAt); -public sealed record AdminAccount(string Id, string Email); -public sealed record AdminProfileSummary(string UserId, string DisplayName); -public sealed record ApiEnvelope( - T? Data, - JsonElement Meta, - string? TraceId); -public sealed record RefreshTokenRequest(string? RefreshToken); public sealed record PasswordResetRequest(string Email); public sealed record PasswordResetConfirmation(string? ResetToken, string? NewPassword); public sealed record TokenIntrospectionRequest(string? Token); -public sealed record AuthSession(string SessionId, string UserId, string Status, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt); -public sealed record TokenPair(string AccessToken, string RefreshToken, string TokenType, int ExpiresInSeconds); -public sealed record AuthSessionResponse(AuthSession Session, TokenPair Tokens); -public sealed record TokenIntrospection(bool Active, string? UserId, string? SessionId, string[] Scopes, DateTimeOffset? ExpiresAt); -public sealed record ApiError(string Code, string Message, object Details); public sealed record ApiSuccess(object Data, object Meta, string TraceId) { public static ApiSuccess Create(object data, string id) => new(data, new { }, id); } public sealed record ApiFailure(object? Data, ApiError Error, string TraceId) { public static ApiFailure Create(string code, string message, string id) => new(null, new ApiError(code, message, new { }), id); } -public sealed record AdminAuditRecord(string AuditId, string ActorUserId, string Action, string? TargetUserId, string? TargetInvitationCode, string Outcome, string TraceId, DateTimeOffset CreatedAt) -{ - public static AdminAuditRecord Create(string actorUserId, string action, string? targetUserId, string? targetInvitationCode, string outcome, string traceId) => - new(Guid.NewGuid().ToString(), actorUserId, action, targetUserId, targetInvitationCode, outcome, traceId, DateTimeOffset.UtcNow); -} -public enum RegistrationOutcome { Created, EmailAlreadyRegistered, InvitationUnavailable } -public sealed record Credential(string UserId, string Email, string PasswordHash) { public static Credential New(string email) => new(Guid.NewGuid().ToString(), email, string.Empty); } -public sealed record StoredSession(string SessionId, string UserId, string AccessToken, string RefreshToken, DateTimeOffset CreatedAt, DateTimeOffset AccessExpiresAt, DateTimeOffset RefreshExpiresAt, DateTimeOffset? RevokedAt) -{ - public string Status => RevokedAt is not null ? "REVOKED" : RefreshExpiresAt <= DateTimeOffset.UtcNow ? "EXPIRED" : "ACTIVE"; - public AuthSession ToContract() => new(SessionId, UserId, Status, CreatedAt, RefreshExpiresAt); - public TokenPair ToTokenPair() => new(AccessToken, RefreshToken, "Bearer", Math.Max(0, (int)(AccessExpiresAt - DateTimeOffset.UtcNow).TotalSeconds)); - public AuthSessionResponse ToResponse() => new(ToContract(), ToTokenPair()); -} - -public sealed class MySqlAuthRepository(AuthDatabase database) : IAuthRepository -{ - public RegistrationOutcome TryCreateCredential(Credential value) - { - try - { - using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "INSERT INTO auth_credentials (user_id,email,password_hash) VALUES (@id,@email,@hash);"; - command.Parameters.AddWithValue("@id", value.UserId); command.Parameters.AddWithValue("@email", value.Email); command.Parameters.AddWithValue("@hash", value.PasswordHash); - command.ExecuteNonQuery(); return RegistrationOutcome.Created; - } - catch (MySqlException exception) when (exception.Number == 1062) { return RegistrationOutcome.EmailAlreadyRegistered; } - } - - public RegistrationOutcome TryCreateCredentialWithInvitation(Credential value, string invitationCode) - { - using var connection = database.OpenConnection(); using var transaction = connection.BeginTransaction(); using var command = connection.CreateCommand(); - command.Transaction = transaction; - command.CommandText = "SELECT type,max_uses,used_count,valid_from,valid_to FROM admin_invitations WHERE code=@code LIMIT 1 FOR UPDATE;"; - command.Parameters.AddWithValue("@code", invitationCode); - var foundInvitation = false; - string? type = null; - var maxUses = 0; - var usedCount = 0; - DateTime? validFrom = null; - DateTime? validTo = null; - using (var reader = command.ExecuteReader()) - { - if (reader.Read()) - { - foundInvitation = true; - type = reader.GetString(0); - maxUses = reader.GetInt32(1); - usedCount = reader.GetInt32(2); - validFrom = reader.IsDBNull(3) ? null : reader.GetDateTime(3); - validTo = reader.IsDBNull(4) ? null : reader.GetDateTime(4); - } - } - - if (!foundInvitation) { transaction.Rollback(); return RegistrationOutcome.InvitationUnavailable; } - var now = DateTime.UtcNow; - var timeWindowIsValid = type != "time-window" || (validFrom is not null && validTo is not null && validFrom <= now && validTo >= now); - var usageIsAvailable = type == "time-window" || usedCount < maxUses; - if (!usageIsAvailable || !timeWindowIsValid) { transaction.Rollback(); return RegistrationOutcome.InvitationUnavailable; } - try - { - command.Parameters.Clear(); - command.CommandText = "INSERT INTO auth_credentials (user_id,email,password_hash) VALUES (@id,@email,@hash);"; - command.Parameters.AddWithValue("@id", value.UserId); command.Parameters.AddWithValue("@email", value.Email); command.Parameters.AddWithValue("@hash", value.PasswordHash); - command.ExecuteNonQuery(); - } - catch (MySqlException exception) when (exception.Number == 1062) - { - transaction.Rollback(); - return RegistrationOutcome.EmailAlreadyRegistered; - } - command.Parameters.Clear(); - command.CommandText = "UPDATE admin_invitations SET used_count=used_count+1 WHERE code=@code AND (type='time-window' OR used_count FindSession("session_id",id,false); public StoredSession? FindByAccessToken(string token)=>FindSession("access_hash",Hash(token),true); public StoredSession? TouchAccessToken(string token) { var session=FindByAccessToken(token); var now=DateTimeOffset.UtcNow; return session is null||session.Status!="ACTIVE"||session.AccessExpiresAt<=now?null:session; } - public bool RevokeSession(string sessionId,string userId) { using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="UPDATE auth_sessions SET revoked_at=UTC_TIMESTAMP(6) WHERE session_id=@id AND user_id=@user AND revoked_at IS NULL;";q.Parameters.AddWithValue("@id",sessionId);q.Parameters.AddWithValue("@user",userId);return q.ExecuteNonQuery()==1; } - public StoredSession? Rotate(string refresh) { var previousHash=Hash(refresh); var old=FindSession("refresh_hash",previousHash,true); if(old is null||old.Status!="ACTIVE")return null; var now=DateTimeOffset.UtcNow; var access=Token(); var nextRefresh=Token(); var rotated=old with { AccessToken=access, RefreshToken=nextRefresh, AccessExpiresAt=now.AddMinutes(15), RefreshExpiresAt=now.AddDays(7) }; using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="UPDATE auth_sessions SET access_hash=@access,refresh_hash=@refresh,access_expires_at=@accessExpires,refresh_expires_at=@refreshExpires WHERE session_id=@id AND refresh_hash=@previousRefresh AND revoked_at IS NULL AND refresh_expires_at>UTC_TIMESTAMP(6);";q.Parameters.AddWithValue("@access",Hash(access));q.Parameters.AddWithValue("@refresh",Hash(nextRefresh));q.Parameters.AddWithValue("@accessExpires",rotated.AccessExpiresAt.UtcDateTime);q.Parameters.AddWithValue("@refreshExpires",rotated.RefreshExpiresAt.UtcDateTime);q.Parameters.AddWithValue("@id",old.SessionId);q.Parameters.AddWithValue("@previousRefresh",previousHash);return q.ExecuteNonQuery()==1?rotated:null; } - public void RevokeAllSessions(string userId){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="UPDATE auth_sessions SET revoked_at=UTC_TIMESTAMP(6) WHERE user_id=@id AND revoked_at IS NULL;";q.Parameters.AddWithValue("@id",userId);q.ExecuteNonQuery();} - public void UpdatePassword(Credential credential){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="UPDATE auth_credentials SET password_hash=@hash WHERE user_id=@id;";q.Parameters.AddWithValue("@id",credential.UserId);q.Parameters.AddWithValue("@hash",credential.PasswordHash);q.ExecuteNonQuery();} - public string CreatePasswordReset(string userId) - { - var token = PasswordResetToken(); - var normalizedToken = token.ToUpperInvariant(); - using var c = database.OpenConnection(); - using var tx = c.BeginTransaction(); - using var q = c.CreateCommand(); - q.Transaction = tx; - q.CommandText = "DELETE FROM auth_password_resets WHERE user_id=@user AND used_at IS NULL;"; - q.Parameters.AddWithValue("@user", userId); - q.ExecuteNonQuery(); - q.Parameters.Clear(); - q.CommandText = "INSERT INTO auth_password_resets (token_hash,user_id,expires_at,used_at) VALUES (@hash,@user,@expires,NULL);"; - q.Parameters.AddWithValue("@hash", Hash(normalizedToken)); - q.Parameters.AddWithValue("@user", userId); - q.Parameters.AddWithValue("@expires", DateTime.UtcNow.AddMinutes(10)); - q.ExecuteNonQuery(); - tx.Commit(); - return token; - } - public void DeletePasswordReset(string token){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="DELETE FROM auth_password_resets WHERE token_hash=@hash AND used_at IS NULL;";q.Parameters.AddWithValue("@hash",Hash(token.ToUpperInvariant()));q.ExecuteNonQuery();} - public Credential? ConsumePasswordReset(string token){if(string.IsNullOrWhiteSpace(token)||token.Length<6||token.Length>32)return null;using var c=database.OpenConnection();using var tx=c.BeginTransaction();using var q=c.CreateCommand();q.Transaction=tx;q.CommandText="SELECT CAST(user_id AS CHAR) FROM auth_password_resets WHERE token_hash=@hash AND used_at IS NULL AND expires_at>UTC_TIMESTAMP(6) LIMIT 1 FOR UPDATE;";q.Parameters.AddWithValue("@hash",Hash(token.ToUpperInvariant()));var rawUser=q.ExecuteScalar();var user=rawUser is null?null:DbText(rawUser);if(user is null){tx.Rollback();return null;}q.Parameters.Clear();q.CommandText="UPDATE auth_password_resets SET used_at=UTC_TIMESTAMP(6) WHERE token_hash=@hash;";q.Parameters.AddWithValue("@hash",Hash(token.ToUpperInvariant()));q.ExecuteNonQuery();tx.Commit();using var q2=c.CreateCommand();q2.CommandText="SELECT CAST(user_id AS CHAR),email,password_hash FROM auth_credentials WHERE user_id=@id;";q2.Parameters.AddWithValue("@id",user);using var r=q2.ExecuteReader();return r.Read()?new Credential(DbText(r.GetValue(0)),r.GetString(1),r.GetString(2)):null;} - private void InsertSession(StoredSession s){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="INSERT INTO auth_sessions (session_id,user_id,access_hash,refresh_hash,created_at,access_expires_at,refresh_expires_at,revoked_at) VALUES (@id,@user,@access,@refresh,@created,@accessExpires,@refreshExpires,NULL);";q.Parameters.AddWithValue("@id",s.SessionId);q.Parameters.AddWithValue("@user",s.UserId);q.Parameters.AddWithValue("@access",Hash(s.AccessToken));q.Parameters.AddWithValue("@refresh",Hash(s.RefreshToken));q.Parameters.AddWithValue("@created",s.CreatedAt.UtcDateTime);q.Parameters.AddWithValue("@accessExpires",s.AccessExpiresAt.UtcDateTime);q.Parameters.AddWithValue("@refreshExpires",s.RefreshExpiresAt.UtcDateTime);q.ExecuteNonQuery();} - private StoredSession? FindSession(string column,string value,bool hashed){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText=$"SELECT CAST(session_id AS CHAR),CAST(user_id AS CHAR),created_at,access_expires_at,refresh_expires_at,revoked_at FROM auth_sessions WHERE {column}=@value LIMIT 1;";q.Parameters.AddWithValue("@value",value);using var r=q.ExecuteReader();return r.Read()?new StoredSession(DbText(r.GetValue(0)),DbText(r.GetValue(1)),string.Empty,string.Empty,AsUtc(r.GetDateTime(2)),AsUtc(r.GetDateTime(3)),AsUtc(r.GetDateTime(4)),r.IsDBNull(5)?null:AsUtc(r.GetDateTime(5))):null;} - private static string Token()=>Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)).Replace('+','-').Replace('/','_').TrimEnd('='); private static string PasswordResetToken()=>GenerateResetToken(); private static string Hash(string value)=>Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value)));private static string DbText(object value)=>value is Guid guid?guid.ToString():Convert.ToString(value)!;private static DateTimeOffset AsUtc(DateTime value)=>new(DateTime.SpecifyKind(value,DateTimeKind.Utc)); - private const string ResetTokenAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - private static string GenerateResetToken() - { - // Reject the top 16 byte values before modulo reduction so every one of - // the 30 symbols is selected with equal probability. - var chars = new char[8]; - Span bytes = stackalloc byte[16]; - var written = 0; - while (written < chars.Length) - { - RandomNumberGenerator.Fill(bytes); - foreach (var value in bytes) - { - if (value >= 240) continue; - chars[written++] = ResetTokenAlphabet[value % ResetTokenAlphabet.Length]; - if (written == chars.Length) break; - } - } - return new string(chars); - } -} - -public sealed class MySqlAdminRepository(AuthDatabase database) : IAdminRepository -{ - public List ListUsers() - { - using var c = database.OpenConnection(); using var q = c.CreateCommand(); - q.CommandText = "SELECT CAST(user_id AS CHAR), email FROM auth_credentials ORDER BY email;"; - using var r = q.ExecuteReader(); var result = new List(); - while (r.Read()) result.Add(new AdminAccount(DbText(r.GetValue(0)), DbText(r.GetValue(1)))); - return result; - } - public bool UserExists(string userId) - { - using var c = database.OpenConnection(); using var q = c.CreateCommand(); - q.CommandText = "SELECT COUNT(*) FROM auth_credentials WHERE user_id=@id;"; - q.Parameters.AddWithValue("@id", userId); - return Convert.ToInt32(q.ExecuteScalar()) == 1; - } - public bool DeleteAuthUser(string userId) - { - using var c=database.OpenConnection();using var tx=c.BeginTransaction();using var q=c.CreateCommand();q.Transaction=tx; - q.CommandText="SELECT COUNT(*) FROM auth_credentials WHERE user_id=@id;";q.Parameters.AddWithValue("@id",userId);if(Convert.ToInt32(q.ExecuteScalar())==0){tx.Rollback();return false;} - q.Parameters.Clear();q.CommandText="DELETE FROM auth_sessions WHERE user_id=@id; DELETE FROM auth_password_resets WHERE user_id=@id; DELETE FROM admin_user_overrides WHERE user_id=@id; DELETE FROM auth_credentials WHERE user_id=@id;";q.Parameters.AddWithValue("@id",userId);q.ExecuteNonQuery();tx.Commit();return true; - } - public List ListInvitations() - { - using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="SELECT code,type,max_uses,used_count,valid_from,valid_to,created_at FROM admin_invitations ORDER BY created_at DESC;";using var r=q.ExecuteReader();var result=new List(); - while(r.Read())result.Add(new AdminInvitation(r.GetString(0),r.GetString(1),r.GetInt32(2),r.GetInt32(3),r.IsDBNull(4)?null:Utc(r.GetDateTime(4)),r.IsDBNull(5)?null:Utc(r.GetDateTime(5)),Utc(r.GetDateTime(6))));return result; - } - public AdminInvitation? CreateInvitation(CreateInvitationRequest request) - { - var type=request.Type?.Trim().ToLowerInvariant(); - if(type is not ("single-use" or "multi-use" or "time-window")||(type=="multi-use"&&!request.MaxUses.HasValue))return null; - var max=type switch{"single-use"=>1,"time-window"=>int.MaxValue,_=>request.MaxUses.GetValueOrDefault()}; - if((type=="multi-use"&&max is <1 or >10000)||(type=="time-window"&&(!request.ValidFrom.HasValue||!request.ValidTo.HasValue||request.ValidTo<=request.ValidFrom)))return null; - for(var i=0;i<3;i++){var value=new AdminInvitation("MS-"+Convert.ToHexString(RandomNumberGenerator.GetBytes(5)),type,max,0,request.ValidFrom,request.ValidTo,DateTimeOffset.UtcNow);try{using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="INSERT INTO admin_invitations (code,type,max_uses,used_count,valid_from,valid_to,created_at) VALUES (@code,@type,@max,0,@from,@to,@created);";q.Parameters.AddWithValue("@code",value.Code);q.Parameters.AddWithValue("@type",value.Type);q.Parameters.AddWithValue("@max",value.MaxUses);q.Parameters.AddWithValue("@from",value.ValidFrom?.UtcDateTime??(object)DBNull.Value);q.Parameters.AddWithValue("@to",value.ValidTo?.UtcDateTime??(object)DBNull.Value);q.Parameters.AddWithValue("@created",value.CreatedAt.UtcDateTime);q.ExecuteNonQuery();return value;}catch(MySqlException ex)when(ex.Number==1062){}}return null; - } - public bool DeleteInvitation(string code){using var c=database.OpenConnection();using var q=c.CreateCommand();q.CommandText="DELETE FROM admin_invitations WHERE code=@code;";q.Parameters.AddWithValue("@code",code);return q.ExecuteNonQuery()==1;} - private static string DbText(object value)=>value is Guid guid?guid.ToString():Convert.ToString(value)!;private static DateTimeOffset Utc(DateTime value)=>new(DateTime.SpecifyKind(value,DateTimeKind.Utc)); -} - -public sealed class MySqlAdminAuditRepository(AuthDatabase database) : IAdminAuditRepository -{ - public void Write(AdminAuditRecord record) - { - using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "INSERT INTO admin_audit_logs (audit_id,actor_user_id,action,target_user_id,target_invitation_code,outcome,trace_id,created_at) VALUES (@auditId,@actorUserId,@action,@targetUserId,@targetInvitationCode,@outcome,@traceId,@createdAt);"; - command.Parameters.AddWithValue("@auditId", record.AuditId); - command.Parameters.AddWithValue("@actorUserId", record.ActorUserId); - command.Parameters.AddWithValue("@action", record.Action); - command.Parameters.AddWithValue("@targetUserId", record.TargetUserId ?? (object)DBNull.Value); - command.Parameters.AddWithValue("@targetInvitationCode", record.TargetInvitationCode ?? (object)DBNull.Value); - command.Parameters.AddWithValue("@outcome", record.Outcome); - command.Parameters.AddWithValue("@traceId", record.TraceId); - command.Parameters.AddWithValue("@createdAt", record.CreatedAt.UtcDateTime); - command.ExecuteNonQuery(); - } -} diff --git a/backend/AuthService/RequestLogging.cs b/backend/AuthService/RequestLogging.cs new file mode 100644 index 0000000..dae90c5 --- /dev/null +++ b/backend/AuthService/RequestLogging.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +/// +/// X-Correlation-Id 归一 + JSON 结构化日志 + 请求级 scope/耗时(T10 可观测性)。 +/// traceId 写入 context.TraceIdentifier,错误信封与响应头回传同一值。 +/// +public static class RequestLogging +{ + /// + /// 可选:服务侧注册请求级 ambient trace(如 PracticeService.Persistence.TraceFlow.Begin), + /// 供出站 HTTP 客户端透传 X-Correlation-Id。未注册则不影响。 + /// + public static Func? BeginAmbientTrace { get; set; } + + public static ILoggingBuilder AddJsonStructuredLogging(this ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddJsonConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + options.UseUtcTimestamp = true; + options.IncludeScopes = true; + options.JsonWriterOptions = new System.Text.Json.JsonWriterOptions { Indented = false }; + }); + return logging; + } + + /// + /// false:跳过 X-Correlation-Id 归一,沿用上游中间件已写入的 TraceIdentifier + /// (如 GalGameService 自带的严格字符集校验)。 + /// + public static IApplicationBuilder UseRequestLogging( + this IApplicationBuilder app, string service, bool normalizeCorrelationId = true) => + app.Use(async (context, next) => + { + if (normalizeCorrelationId) + { + var incoming = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); + context.TraceIdentifier = string.IsNullOrWhiteSpace(incoming) + ? Guid.NewGuid().ToString("N") + : incoming.Length <= 128 ? incoming : Guid.NewGuid().ToString("N"); + context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; + } + + var logger = context.RequestServices.GetRequiredService().CreateLogger(service); + var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); + var method = context.Request.Method; + var path = context.Request.Path.Value ?? "/"; + var start = Stopwatch.GetTimestamp(); + using (logger.BeginScope(new Dictionary + { + ["traceId"] = context.TraceIdentifier, + ["userId"] = userId, + ["path"] = path, + ["method"] = method, + })) + using (BeginAmbientTrace?.Invoke(context.TraceIdentifier)) + { + try + { + await next(); + } + finally + { + var durationMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + logger.LogInformation( + "HTTP {Method} {Path} completed with {Status} in {DurationMs:F1}ms traceId={TraceId} userId={UserId}", + method, path, context.Response.StatusCode, durationMs, context.TraceIdentifier, userId); + } + } + }); +} diff --git a/backend/CreditService/CreditService.API/Program.cs b/backend/CreditService/CreditService.API/Program.cs index 53ef2cf..0c4f6e4 100644 --- a/backend/CreditService/CreditService.API/Program.cs +++ b/backend/CreditService/CreditService.API/Program.cs @@ -4,27 +4,36 @@ using MediatR; using Microsoft.AspNetCore.Diagnostics; -var builder=WebApplication.CreateBuilder(args);builder.Logging.ClearProviders();builder.Logging.AddConsole();builder.Services.Configure(x=>x.ThrowOnBadRequest=true);builder.Services.AddMediatR(x=>x.RegisterServicesFromAssembly(typeof(CreditHandlers).Assembly));builder.Services.AddCreditPersistence(builder.Configuration); -var gatewayKey=builder.Configuration["Gateway:ServiceKey"]??throw new InvalidOperationException("Gateway:ServiceKey must be configured.");var provider=builder.Configuration["CreditStore:Provider"]??"MySQL"; +var builder=WebApplication.CreateBuilder(args);builder.Logging.AddJsonStructuredLogging();builder.Services.Configure(x=>x.ThrowOnBadRequest=true);builder.Services.AddMediatR(x=>x.RegisterServicesFromAssembly(typeof(CreditHandlers).Assembly));builder.Services.AddCreditPersistence(builder.Configuration); +var gatewayKey=builder.Configuration["Gateway:ServiceKey"]??throw new InvalidOperationException("Gateway:ServiceKey must be configured.");if(builder.Environment.IsProduction()&&string.Equals(gatewayKey,"moonstone-local-gateway-key",StringComparison.Ordinal))throw new InvalidOperationException("Gateway:ServiceKey must be changed from the development default in production.");var provider=builder.Configuration["CreditStore:Provider"]??"MySQL"; +bool adminPrincipalIdIsConfigured;Guid adminPrincipalId; +if(Guid.TryParse(builder.Configuration["Admin:PrincipalId"],out var configuredAdmin)&&configuredAdmin!=Guid.Empty){adminPrincipalId=configuredAdmin;adminPrincipalIdIsConfigured=true;} +else if(builder.Environment.IsProduction()){throw new InvalidOperationException("Admin:PrincipalId must be configured in production.");} +else{adminPrincipalId=Guid.Parse("00000000-0000-4000-8000-000000000001");adminPrincipalIdIsConfigured=false;} +var purchaseUrl = builder.Configuration["Credits:PurchaseUrl"]; +if (!string.IsNullOrWhiteSpace(purchaseUrl)) + CreditPolicy.PurchaseUrl = purchaseUrl.Trim(); var app=builder.Build();if(!string.Equals(provider,"Memory",StringComparison.OrdinalIgnoreCase))app.Services.GetRequiredService().EnsureCreated(); app.Use(async(c,next)=>{var id=c.Request.Headers["X-Correlation-Id"].FirstOrDefault();c.TraceIdentifier=string.IsNullOrWhiteSpace(id)?Guid.NewGuid().ToString("N"):id;c.Response.Headers["X-Correlation-Id"]=c.TraceIdentifier;await next();}); +app.UseRequestLogging("CreditService"); app.UseExceptionHandler(h=>h.Run(async c=>{var ex=c.Features.Get()?.Error;if(ex is CreditDomainException d){c.Response.StatusCode=d.StatusCode;await c.Response.WriteAsJsonAsync(ApiFailure.Create(d.Code,d.Message,c.TraceIdentifier,d.Details));return;}if(ex is BadHttpRequestException or System.Text.Json.JsonException){c.Response.StatusCode=400;await c.Response.WriteAsJsonAsync(ApiFailure.Create("VALIDATION_ERROR","请求格式不正确。",c.TraceIdentifier));return;}app.Logger.LogError(ex,"Unhandled CreditService error; traceId={TraceId}",c.TraceIdentifier);c.Response.StatusCode=500;await c.Response.WriteAsJsonAsync(ApiFailure.Create("INTERNAL_ERROR","credits 服务暂时不可用。",c.TraceIdentifier));})); app.Use(async(c,next)=>{if(c.Request.Path==new PathString("/healthz")||c.Request.Path==new PathString("/readyz")){await next();return;}if(c.Request.Headers["X-Gateway-Key"].Count!=1||!System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(System.Text.Encoding.UTF8.GetBytes(c.Request.Headers["X-Gateway-Key"]!),System.Text.Encoding.UTF8.GetBytes(gatewayKey))){c.Response.StatusCode=403;await c.Response.WriteAsJsonAsync(ApiFailure.Create("FORBIDDEN","该服务仅接受 Gateway 请求。",c.TraceIdentifier));return;}await next();}); -app.MapGet("/healthz",()=>Results.Ok(new{status="live"}));app.MapGet("/readyz",()=>Results.Ok(new{status="ready",storage=provider})); +app.MapGet("/healthz",()=>Results.Ok(new{status="live"}));app.MapGet("/readyz",()=>Results.Ok(new{status="ready",storage=provider,adminConfigured=adminPrincipalIdIsConfigured})); +app.MapGet("/metrics",()=>Results.Text(CreditServiceMetrics.RenderPrometheus(),"text/plain; version=0.0.4; charset=utf-8")); app.MapGet("/api/v1/credits/balance",async(HttpContext c,ISender sender,CancellationToken ct)=>Results.Ok(ApiSuccess.Create(await sender.Send(new GetBalanceQuery(User(c)),ct),c.TraceIdentifier))); app.MapPost("/api/v1/credits/redemptions",async(RedeemRequest request,HttpContext c,ISender sender,CancellationToken ct)=>Results.Ok(ApiSuccess.Create(await sender.Send(new RedeemCodeCommand(User(c),request.Code??""),ct),c.TraceIdentifier))); app.MapGet("/api/v1/admin/credit-codes",async(HttpContext c,ISender sender,CancellationToken ct)=>{Admin(c);return Results.Ok(ApiSuccess.Create(new{items=await sender.Send(new ListCodesQuery(),ct)},c.TraceIdentifier));}); app.MapPost("/api/v1/admin/credit-codes/batches",async(CreateBatchRequest request,HttpContext c,ISender sender,CancellationToken ct)=>{var admin=Admin(c);var items=await sender.Send(new CreateCodeBatchCommand(admin,request.Count??0,request.CreditsPerCode??0,request.ExpiresAt),ct);return Results.Json(ApiSuccess.Create(new{items},c.TraceIdentifier),statusCode:201);}); app.MapDelete("/api/v1/admin/credit-codes/{codeId:guid}",async(Guid codeId,HttpContext c,ISender sender,CancellationToken ct)=>{Admin(c);await sender.Send(new RevokeCodeCommand(codeId),ct);return Results.NoContent();}); app.MapPost("/internal/v1/credits/accounts",async(ProvisionRequest request,HttpContext c,ISender sender,CancellationToken ct)=>{Service(c,"AuthService");if(!request.UserId.HasValue)throw new CreditDomainException(400,"VALIDATION_ERROR","userId 必填。");return Results.Json(ApiSuccess.Create(await sender.Send(new ProvisionAccountCommand(request.UserId.Value),ct),c.TraceIdentifier),statusCode:201);}); -app.MapPost("/internal/v1/credits/balance-lookups",async(BalanceLookupRequest request,HttpContext c,ISender sender,CancellationToken ct)=>{Service(c,"AuthService");if(request.UserIds is null||request.UserIds.Length>1000)throw new CreditDomainException(400,"VALIDATION_ERROR","userIds 数量必须在 0 到 1000 之间。");var balances=new List();foreach(var userId in request.UserIds.Distinct())balances.Add(await sender.Send(new GetBalanceQuery(userId),ct));return Results.Ok(ApiSuccess.Create(balances,c.TraceIdentifier));}); +app.MapPost("/internal/v1/credits/balance-lookups",async(BalanceLookupRequest request,HttpContext c,ISender sender,CancellationToken ct)=>{Service(c,"AuthService");if(request.UserIds is null||request.UserIds.Length>1000)throw new CreditDomainException(400,"VALIDATION_ERROR","userIds 数量必须在 0 到 1000 之间。");var balances=await sender.Send(new GetBalancesQuery(request.UserIds),ct);return Results.Ok(ApiSuccess.Create(balances,c.TraceIdentifier));}); app.MapDelete("/internal/v1/credits/accounts/{userId:guid}",async(Guid userId,HttpContext c,ISender sender,CancellationToken ct)=>{Service(c,"AuthService");await sender.Send(new DeleteAccountCommand(userId),ct);return Results.NoContent();}); app.MapPost("/internal/v1/credits/reservations",async(ReserveRequest request,HttpContext c,ISender sender,CancellationToken ct)=>{Service(c,"PracticeService","GalGameService");if(!request.UserId.HasValue||!request.OperationId.HasValue||!request.EstimatedTokenUnits.HasValue)throw new CreditDomainException(400,"VALIDATION_ERROR","预授权字段均为必填。");return Results.Json(ApiSuccess.Create(await sender.Send(new ReserveCreditsCommand(request.UserId.Value,request.OperationId.Value,request.OperationType??"",request.EstimatedTokenUnits.Value),ct),c.TraceIdentifier),statusCode:201);}); app.MapPost("/internal/v1/credits/reservations/{operationId:guid}/settlement",async(Guid operationId,SettleRequest request,HttpContext c,ISender sender,CancellationToken ct)=>{Service(c,"PracticeService","GalGameService");if(!request.ActualTokenUnits.HasValue)throw new CreditDomainException(400,"VALIDATION_ERROR","actualTokenUnits 必填。");return Results.Ok(ApiSuccess.Create(await sender.Send(new SettleCreditsCommand(operationId,request.ActualTokenUnits.Value),ct),c.TraceIdentifier));}); app.MapPost("/internal/v1/credits/reservations/{operationId:guid}/release",async(Guid operationId,HttpContext c,ISender sender,CancellationToken ct)=>{Service(c,"PracticeService","GalGameService");return Results.Ok(ApiSuccess.Create(await sender.Send(new ReleaseCreditsCommand(operationId),ct),c.TraceIdentifier));}); app.Run(); static Guid User(HttpContext c)=>Guid.TryParse(c.Request.Headers["X-User-Id"],out var id)?id:throw new CreditDomainException(401,"AUTH_REQUIRED","需要登录。"); -static Guid Admin(HttpContext c){var id=User(c);return id==Guid.Parse("00000000-0000-4000-8000-000000000001")?id:throw new CreditDomainException(403,"FORBIDDEN","需要管理员权限。");} +Guid Admin(HttpContext c){var id=User(c);return id==adminPrincipalId?id:throw new CreditDomainException(403,"FORBIDDEN","需要管理员权限。");} static void Service(HttpContext c,params string[] allowed){var name=c.Request.Headers["X-Service-Name"].ToString();if(!allowed.Contains(name,StringComparer.Ordinal))throw new CreditDomainException(403,"FORBIDDEN","服务身份无权调用该接口。");} public sealed record RedeemRequest(string? Code);public sealed record CreateBatchRequest(int? Count,decimal? CreditsPerCode,DateTimeOffset? ExpiresAt);public sealed record ProvisionRequest(Guid? UserId);public sealed record BalanceLookupRequest(Guid[]? UserIds);public sealed record ReserveRequest(Guid? UserId,Guid? OperationId,string? OperationType,long? EstimatedTokenUnits);public sealed record SettleRequest(long? ActualTokenUnits); public sealed record ApiError(string Code,string Message,object Details);public sealed record ApiSuccess(object Data,object Meta,string TraceId){public static ApiSuccess Create(object data,string trace)=>new(data,new{},trace);}public sealed record ApiFailure(object? Data,ApiError Error,string TraceId){public static ApiFailure Create(string code,string message,string trace,object? details=null)=>new(null,new(code,message,details??new{}),trace);} diff --git a/backend/CreditService/CreditService.API/RequestLogging.cs b/backend/CreditService/CreditService.API/RequestLogging.cs new file mode 100644 index 0000000..dae90c5 --- /dev/null +++ b/backend/CreditService/CreditService.API/RequestLogging.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +/// +/// X-Correlation-Id 归一 + JSON 结构化日志 + 请求级 scope/耗时(T10 可观测性)。 +/// traceId 写入 context.TraceIdentifier,错误信封与响应头回传同一值。 +/// +public static class RequestLogging +{ + /// + /// 可选:服务侧注册请求级 ambient trace(如 PracticeService.Persistence.TraceFlow.Begin), + /// 供出站 HTTP 客户端透传 X-Correlation-Id。未注册则不影响。 + /// + public static Func? BeginAmbientTrace { get; set; } + + public static ILoggingBuilder AddJsonStructuredLogging(this ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddJsonConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + options.UseUtcTimestamp = true; + options.IncludeScopes = true; + options.JsonWriterOptions = new System.Text.Json.JsonWriterOptions { Indented = false }; + }); + return logging; + } + + /// + /// false:跳过 X-Correlation-Id 归一,沿用上游中间件已写入的 TraceIdentifier + /// (如 GalGameService 自带的严格字符集校验)。 + /// + public static IApplicationBuilder UseRequestLogging( + this IApplicationBuilder app, string service, bool normalizeCorrelationId = true) => + app.Use(async (context, next) => + { + if (normalizeCorrelationId) + { + var incoming = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); + context.TraceIdentifier = string.IsNullOrWhiteSpace(incoming) + ? Guid.NewGuid().ToString("N") + : incoming.Length <= 128 ? incoming : Guid.NewGuid().ToString("N"); + context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; + } + + var logger = context.RequestServices.GetRequiredService().CreateLogger(service); + var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); + var method = context.Request.Method; + var path = context.Request.Path.Value ?? "/"; + var start = Stopwatch.GetTimestamp(); + using (logger.BeginScope(new Dictionary + { + ["traceId"] = context.TraceIdentifier, + ["userId"] = userId, + ["path"] = path, + ["method"] = method, + })) + using (BeginAmbientTrace?.Invoke(context.TraceIdentifier)) + { + try + { + await next(); + } + finally + { + var durationMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + logger.LogInformation( + "HTTP {Method} {Path} completed with {Status} in {DurationMs:F1}ms traceId={TraceId} userId={UserId}", + method, path, context.Response.StatusCode, durationMs, context.TraceIdentifier, userId); + } + } + }); +} diff --git a/backend/CreditService/CreditService.Application/CreditServiceMetrics.cs b/backend/CreditService/CreditService.Application/CreditServiceMetrics.cs new file mode 100644 index 0000000..bab7cb5 --- /dev/null +++ b/backend/CreditService/CreditService.Application/CreditServiceMetrics.cs @@ -0,0 +1,14 @@ +namespace CreditService.Application; + +/// 进程内轻量计数器(T10 告警信号源):Credit 预授权释放失败次数。 +public static class CreditServiceMetrics +{ + private static long _releaseFailures; + public static long ReleaseFailures => Interlocked.Read(ref _releaseFailures); + public static void RecordReleaseFailure() => Interlocked.Increment(ref _releaseFailures); + + public static string RenderPrometheus() => + "# HELP credit_release_failures_total Credit reservation release attempts that failed\n" + + "# TYPE credit_release_failures_total counter\n" + + $"credit_release_failures_total {ReleaseFailures}\n"; +} diff --git a/backend/CreditService/CreditService.Application/CreditUseCases.cs b/backend/CreditService/CreditService.Application/CreditUseCases.cs index e2fd32b..82832d5 100644 --- a/backend/CreditService/CreditService.Application/CreditUseCases.cs +++ b/backend/CreditService/CreditService.Application/CreditUseCases.cs @@ -6,6 +6,8 @@ namespace CreditService.Application; public interface ICreditRepository { CreditAccount Provision(Guid userId); + /// 批量惰性开通并读取账户;缺失账户按初始额度创建。顺序与 userIds 一致。 + IReadOnlyList ProvisionMany(IReadOnlyList userIds); CreditAccount? GetAccount(Guid userId); void DeleteAccount(Guid userId); (CreditAccount Account, RedemptionCode Code) Redeem(Guid userId, string code); @@ -28,6 +30,7 @@ public sealed record ReservationView(Guid OperationId, string OperationType, dec public sealed record ProvisionAccountCommand(Guid UserId) : IRequest; public sealed record DeleteAccountCommand(Guid UserId) : IRequest; public sealed record GetBalanceQuery(Guid UserId) : IRequest; +public sealed record GetBalancesQuery(IReadOnlyList UserIds) : IRequest>; public sealed record RedeemCodeCommand(Guid UserId, string Code) : IRequest; public sealed record ListCodesQuery : IRequest>; public sealed record CreateCodeBatchCommand(Guid AdminUserId, int Count, decimal CreditsPerCode, DateTimeOffset? ExpiresAt) : IRequest>; @@ -38,7 +41,8 @@ public sealed record ReleaseCreditsCommand(Guid OperationId) : IRequest, IRequestHandler, - IRequestHandler, IRequestHandler, + IRequestHandler, IRequestHandler>, + IRequestHandler, IRequestHandler>, IRequestHandler>, IRequestHandler, IRequestHandler, IRequestHandler, @@ -49,6 +53,13 @@ public sealed class CreditHandlers(ICreditRepository repository) : // Provision is idempotent. Lazily provisioning here migrates users created // before CreditService was deployed without requiring a cross-database scan. public Task Handle(GetBalanceQuery request, CancellationToken ct) => Task.FromResult(CreditBalance.From(repository.Provision(request.UserId))); + public Task> Handle(GetBalancesQuery request, CancellationToken ct) + { + var distinct = request.UserIds.Distinct().ToArray(); + if (distinct.Length == 0) return Task.FromResult>([]); + var accounts = repository.ProvisionMany(distinct); + return Task.FromResult>(accounts.Select(CreditBalance.From).ToArray()); + } public Task Handle(RedeemCodeCommand request, CancellationToken ct) { repository.Provision(request.UserId); @@ -78,7 +89,18 @@ public Task Handle(SettleCreditsCommand request, CancellationTo if (request.ActualTokenUnits < 0 || request.ActualTokenUnits > 10_000_000_000) throw new CreditDomainException(400, "VALIDATION_ERROR", "actualTokenUnits 超出允许范围。"); return Task.FromResult(View(repository.Settle(request.OperationId, request.ActualTokenUnits))); } - public Task Handle(ReleaseCreditsCommand request, CancellationToken ct) => Task.FromResult(View(repository.Release(request.OperationId))); + public Task Handle(ReleaseCreditsCommand request, CancellationToken ct) + { + try + { + return Task.FromResult(View(repository.Release(request.OperationId))); + } + catch + { + CreditServiceMetrics.RecordReleaseFailure(); + throw; + } + } private static string NormalizeCode(string value) => string.IsNullOrWhiteSpace(value) || value.Trim().Length > 48 ? throw new CreditDomainException(400, "VALIDATION_ERROR", "兑换码格式不正确。") : value.Trim().ToUpperInvariant(); private static CreditDomainException NotFound() => new(404, "RESOURCE_NOT_FOUND", "资源不存在。"); private static RedemptionCodeView View(RedemptionCode x) => new(x.CodeId, x.Code, CreditPolicy.ToCredits(x.CreditUnits), x.Status.ToString().ToUpperInvariant(), x.RedeemedBy, x.RedeemedAt, x.ExpiresAt, x.CreatedAt); diff --git a/backend/CreditService/CreditService.Domain/CreditModel.cs b/backend/CreditService/CreditService.Domain/CreditModel.cs index bae3b25..ff96d9d 100644 --- a/backend/CreditService/CreditService.Domain/CreditModel.cs +++ b/backend/CreditService/CreditService.Domain/CreditModel.cs @@ -4,6 +4,8 @@ public static class CreditPolicy { public const long UnitsPerCredit = 100_000; public const long InitialUnits = UnitsPerCredit; + /// 不足时返回的购买入口;生产可通过 Credits:PurchaseUrl 覆盖。 + public static string PurchaseUrl { get; set; } = "https://pay.ldxp.cn/shop/7CX09W5E"; public static decimal ToCredits(long units) => decimal.Round(units / (decimal)UnitsPerCredit, 5, MidpointRounding.AwayFromZero); public static long ToUnits(decimal credits) { diff --git a/backend/CreditService/CreditService.Persistence/CreditPersistence.cs b/backend/CreditService/CreditService.Persistence/CreditPersistence.cs index 1ad4f81..ab94e27 100644 --- a/backend/CreditService/CreditService.Persistence/CreditPersistence.cs +++ b/backend/CreditService/CreditService.Persistence/CreditPersistence.cs @@ -44,6 +44,94 @@ public CreditAccount Provision(Guid userId) if(inserted==1){q.Parameters.Clear();q.CommandText="INSERT INTO credit_ledger(entry_id,user_id,operation_id,entry_type,units,balance_after_units,created_at) VALUES(@id,@user,NULL,'INITIAL_GRANT',@units,@units,UTC_TIMESTAMP(6))";q.Parameters.AddWithValue("@id",Guid.NewGuid());q.Parameters.AddWithValue("@user",userId);q.Parameters.AddWithValue("@units",CreditPolicy.InitialUnits);q.ExecuteNonQuery();} tx.Commit(); return GetAccount(userId)!; } + public IReadOnlyList ProvisionMany(IReadOnlyList userIds) + { + if (userIds.Count == 0) return []; + var ordered = userIds.Distinct().ToArray(); + using var c = database.Open(); + using var tx = c.BeginTransaction(); + // 1) 已有账户 + var existing = new Dictionary(ordered.Length); + foreach (var chunk in Chunk(ordered, 200)) + { + using var select = c.CreateCommand(); + select.Transaction = tx; + var names = new List(chunk.Length); + for (var i = 0; i < chunk.Length; i++) + { + var p = $"@u{i}"; + names.Add(p); + select.Parameters.AddWithValue(p, chunk[i]); + } + select.CommandText = $"SELECT user_id,balance_units,held_units,created_at,updated_at FROM credit_accounts WHERE user_id IN ({string.Join(',', names)})"; + using var reader = select.ExecuteReader(); + while (reader.Read()) + { + var id = ReadGuid(reader, 0); + existing[id] = new(id, reader.GetInt64(1), reader.GetInt64(2), Utc(reader.GetDateTime(3)), Utc(reader.GetDateTime(4))); + } + } + // 2) 逐个开通缺失账户:仅在 INSERT 真正新建行时写 INITIAL_GRANT, + // 避免并发 ProvisionMany 对同一用户双写初始额度。 + var missing = ordered.Where(id => !existing.ContainsKey(id)).ToArray(); + foreach (var id in missing) + { + using var insert = c.CreateCommand(); + insert.Transaction = tx; + insert.CommandText = "INSERT IGNORE INTO credit_accounts(user_id,balance_units,held_units,created_at,updated_at) VALUES(@user,@initial,0,UTC_TIMESTAMP(6),UTC_TIMESTAMP(6))"; + insert.Parameters.AddWithValue("@user", id); + insert.Parameters.AddWithValue("@initial", CreditPolicy.InitialUnits); + var inserted = insert.ExecuteNonQuery(); + if (inserted != 1) continue; + using var ledger = c.CreateCommand(); + ledger.Transaction = tx; + ledger.CommandText = "INSERT INTO credit_ledger(entry_id,user_id,operation_id,entry_type,units,balance_after_units,created_at) VALUES(@id,@user,NULL,'INITIAL_GRANT',@units,@units,UTC_TIMESTAMP(6))"; + ledger.Parameters.AddWithValue("@id", Guid.NewGuid()); + ledger.Parameters.AddWithValue("@user", id); + ledger.Parameters.AddWithValue("@units", CreditPolicy.InitialUnits); + ledger.ExecuteNonQuery(); + } + tx.Commit(); + // 3) 回读并按入参顺序返回;缺失(极罕见:提交后被并发删除)则再走单户 Provision + var byId = new Dictionary(ordered.Length); + foreach (var chunk in Chunk(ordered, 200)) + { + using var select = c.CreateCommand(); + var names = new List(chunk.Length); + for (var i = 0; i < chunk.Length; i++) + { + var p = $"@r{i}"; + names.Add(p); + select.Parameters.AddWithValue(p, chunk[i]); + } + select.CommandText = $"SELECT user_id,balance_units,held_units,created_at,updated_at FROM credit_accounts WHERE user_id IN ({string.Join(',', names)})"; + using var reader = select.ExecuteReader(); + while (reader.Read()) + { + var id = ReadGuid(reader, 0); + byId[id] = new(id, reader.GetInt64(1), reader.GetInt64(2), Utc(reader.GetDateTime(3)), Utc(reader.GetDateTime(4))); + } + } + var result = new List(ordered.Length); + foreach (var id in ordered) + { + if (byId.TryGetValue(id, out var account)) + result.Add(account); + else + result.Add(Provision(id)); + } + return result; + } + private static IEnumerable Chunk(T[] source, int size) + { + for (var i = 0; i < source.Length; i += size) + { + var take = Math.Min(size, source.Length - i); + var part = new T[take]; + Array.Copy(source, i, part, 0, take); + yield return part; + } + } public CreditAccount? GetAccount(Guid userId){using var c=database.Open();using var q=c.CreateCommand();q.CommandText="SELECT balance_units,held_units,created_at,updated_at FROM credit_accounts WHERE user_id=@user";q.Parameters.AddWithValue("@user",userId);using var r=q.ExecuteReader();return r.Read()?new(userId,r.GetInt64(0),r.GetInt64(1),Utc(r.GetDateTime(2)),Utc(r.GetDateTime(3))):null;} public void DeleteAccount(Guid userId){using var c=database.Open();using var tx=c.BeginTransaction();using var q=c.CreateCommand();q.Transaction=tx;q.CommandText="DELETE FROM credit_ledger WHERE user_id=@user;DELETE FROM credit_reservations WHERE user_id=@user;DELETE FROM credit_accounts WHERE user_id=@user";q.Parameters.AddWithValue("@user",userId);q.ExecuteNonQuery();tx.Commit();} public (CreditAccount Account, RedemptionCode Code) Redeem(Guid userId,string code) @@ -61,7 +149,7 @@ public CreditAccount Provision(Guid userId) public CreditReservation Reserve(Guid user,Guid operation,string type,long estimate) { using var c=database.Open();using var tx=c.BeginTransaction();using var q=c.CreateCommand();q.Transaction=tx;q.CommandText="SELECT user_id,operation_type,estimated_units,actual_units,status,created_at,updated_at FROM credit_reservations WHERE operation_id=@op FOR UPDATE";q.Parameters.AddWithValue("@op",operation);using(var r=q.ExecuteReader()){if(r.Read()){var existing=ReadReservation(operation,r);if(existing.UserId!=user||existing.OperationType!=type||existing.EstimatedUnits!=estimate)throw new CreditDomainException(409,"IDEMPOTENCY_KEY_REUSED","operationId 已用于不同的 credits 预授权。");tx.Commit();return existing;}} - q.Parameters.Clear();q.CommandText="SELECT balance_units,held_units FROM credit_accounts WHERE user_id=@user FOR UPDATE";q.Parameters.AddWithValue("@user",user);long balance,held;using(var r=q.ExecuteReader()){if(!r.Read())throw new CreditDomainException(404,"RESOURCE_NOT_FOUND","credits 账户不存在。");balance=r.GetInt64(0);held=r.GetInt64(1);}if(balance-held accounts=[];private readonly Dictionary codes=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary reservations=[]; public CreditAccount Provision(Guid user){lock(sync){if(!accounts.TryGetValue(user,out var a)){var now=DateTimeOffset.UtcNow;a=new(user,CreditPolicy.InitialUnits,0,now,now);accounts[user]=a;}return a;}} + public IReadOnlyList ProvisionMany(IReadOnlyList userIds) + { + lock(sync) + { + var result = new List(userIds.Count); + foreach (var user in userIds.Distinct()) + { + if (!accounts.TryGetValue(user, out var a)) + { + var now = DateTimeOffset.UtcNow; + a = new(user, CreditPolicy.InitialUnits, 0, now, now); + accounts[user] = a; + } + result.Add(a); + } + return result; + } + } public CreditAccount? GetAccount(Guid user){lock(sync)return accounts.GetValueOrDefault(user);} public void DeleteAccount(Guid user){lock(sync){accounts.Remove(user);foreach(var id in reservations.Where(x=>x.Value.UserId==user).Select(x=>x.Key).ToArray())reservations.Remove(id);}} public (CreditAccount,RedemptionCode) Redeem(Guid user,string code){lock(sync){if(!codes.TryGetValue(code,out var value)||value.Status!=RedemptionCodeStatus.Active||(value.ExpiresAt is not null&&value.ExpiresAt<=DateTimeOffset.UtcNow))throw new CreditDomainException(422,"REDEMPTION_CODE_UNAVAILABLE","兑换码无效、已使用、已撤销或已过期。");var a=accounts.GetValueOrDefault(user)??throw new CreditDomainException(404,"RESOURCE_NOT_FOUND","credits 账户不存在。");var now=DateTimeOffset.UtcNow;value=value with{Status=RedemptionCodeStatus.Redeemed,RedeemedBy=user,RedeemedAt=now};codes[code]=value;a=a with{BalanceUnits=a.BalanceUnits+value.CreditUnits,UpdatedAt=now};accounts[user]=a;return(a,value);}} public IReadOnlyList ListCodes(){lock(sync)return codes.Values.OrderByDescending(x=>x.CreatedAt).ToArray();} public IReadOnlyList CreateCodes(Guid admin,int count,long units,DateTimeOffset? expires){lock(sync){var result=new List();while(result.Countx.Value.CodeId==codeId);if(pair.Value is null||pair.Value.Status!=RedemptionCodeStatus.Active)return false;codes[pair.Key]=pair.Value with{Status=RedemptionCodeStatus.Revoked};return true;}} - public CreditReservation Reserve(Guid user,Guid op,string type,long estimate){lock(sync){if(reservations.TryGetValue(op,out var old)){if(old.UserId!=user||old.OperationType!=type||old.EstimatedUnits!=estimate)throw new CreditDomainException(409,"IDEMPOTENCY_KEY_REUSED","operationId 已用于不同预授权。");return old;}var a=accounts.GetValueOrDefault(user)??throw new CreditDomainException(404,"RESOURCE_NOT_FOUND","credits 账户不存在。");if(a.AvailableUnits +/// 并发扣减语义:多任务同时 Reserve/Settle/Release 时余额不透支、 +/// 预授权不重复记账(MemoryCreditRepository + CreditHandlers)。 +/// +public sealed class CreditConcurrencyTests +{ + [Fact] + public async Task Concurrent_reservations_never_overdraw_the_balance() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + + // 初始 1.0 credit = 100_000 units;32 个并发抢 40_000 预授权,最多只能成功 2 个。 + const long estimate = 40_000; + var attempts = 32; + var successes = 0; + var failures = new ConcurrentBag(); + + Parallel.For(0, attempts, new ParallelOptions { MaxDegreeOfParallelism = 16 }, i => + { + try + { + handlers.Handle(new ReserveCreditsCommand(user, Guid.NewGuid(), "GAME_GENERATION", estimate), default) + .GetAwaiter().GetResult(); + Interlocked.Increment(ref successes); + } + catch (CreditDomainException ex) + { + failures.Add(ex); + } + }); + + Assert.Equal(2, successes); + Assert.Equal(attempts - 2, failures.Count); + Assert.All(failures, ex => Assert.Equal("CREDITS_INSUFFICIENT", ex.Code)); + + var balance = await handlers.Handle(new GetBalanceQuery(user), default); + Assert.Equal(1m, balance.Balance); + Assert.Equal(.2m, balance.Available); + Assert.Equal(.8m, balance.Held); + } + + [Fact] + public async Task Concurrent_settle_and_release_of_distinct_operations_keep_ledger_consistent() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + + // 10 个独立预授权,各 10_000 units;一半结算一半释放。 + // 初始 100_000 units 全部进入 held,可用为 0。 + var operations = Enumerable.Range(0, 10).Select(_ => Guid.NewGuid()).ToArray(); + await Task.WhenAll(operations.Select(op => + handlers.Handle(new ReserveCreditsCommand(user, op, "GAME_GENERATION", 10_000), default))); + + var held = await handlers.Handle(new GetBalanceQuery(user), default); + Assert.Equal(0m, held.Available); + Assert.Equal(1m, held.Held); + + var settleOps = operations.Take(5).ToArray(); + var releaseOps = operations.Skip(5).ToArray(); + await Task.WhenAll( + settleOps.Select(op => handlers.Handle(new SettleCreditsCommand(op, 4_000), default)) + .Concat(releaseOps.Select(op => handlers.Handle(new ReleaseCreditsCommand(op), default)))); + + var final = await handlers.Handle(new GetBalanceQuery(user), default); + // 结算 5 × 0.04 = 0.2;释放 5 × 0.1 回到可用;held 归零。 + Assert.Equal(.8m, final.Balance); + Assert.Equal(.8m, final.Available); + Assert.Equal(0m, final.Held); + } + + [Fact] + public async Task Concurrent_settle_of_the_same_operation_charges_exactly_once() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + var op = Guid.NewGuid(); + await handlers.Handle(new ReserveCreditsCommand(user, op, "GAME_GENERATION", 50_000), default); + + await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => + handlers.Handle(new SettleCreditsCommand(op, 20_000), default))); + + var balance = await handlers.Handle(new GetBalanceQuery(user), default); + Assert.Equal(.8m, balance.Balance); + Assert.Equal(0m, balance.Held); + } + + [Fact] + public async Task Concurrent_release_of_the_same_operation_returns_hold_exactly_once() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + var op = Guid.NewGuid(); + await handlers.Handle(new ReserveCreditsCommand(user, op, "GAME_GENERATION", 30_000), default); + + await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => + handlers.Handle(new ReleaseCreditsCommand(op), default))); + + var balance = await handlers.Handle(new GetBalanceQuery(user), default); + Assert.Equal(1m, balance.Available); + Assert.Equal(0m, balance.Held); + } + + [Fact] + public async Task Concurrent_identical_reservations_are_idempotent_not_double_holds() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + var op = Guid.NewGuid(); + + // 同一 operationId 并发重试:必须只产生一笔 HELD 预授权。 + var views = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => + handlers.Handle(new ReserveCreditsCommand(user, op, "GAME_GENERATION", 60_000), default))); + + Assert.All(views, v => Assert.Equal(.6m, v.EstimatedCredits)); + var balance = await handlers.Handle(new GetBalanceQuery(user), default); + Assert.Equal(.4m, balance.Available); + Assert.Equal(.6m, balance.Held); + } + + [Fact] + public async Task Settle_above_estimate_with_sufficient_balance_is_rejected() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + var op = Guid.NewGuid(); + await handlers.Handle(new ReserveCreditsCommand(user, op, "GAME_GENERATION", 20_000), default); + + // 实际消耗 0.5 > 预授权 0.2,且剩余可用 0.8 不足以覆盖 0.5 超出部分的语义边界。 + // MemoryCreditRepository:balance(1.0) - otherHeld(0) < actual(50_000+1) 时不成立, + // 用超过 total balance 的实际值触发 CREDIT_ESTIMATE_EXCEEDED。 + var ex = await Assert.ThrowsAsync(() => + handlers.Handle(new SettleCreditsCommand(op, 150_000), default)); + Assert.Equal("CREDIT_ESTIMATE_EXCEEDED", ex.Code); + } +} diff --git a/backend/CreditService/CreditService.Tests/CreditFlowTests.cs b/backend/CreditService/CreditService.Tests/CreditFlowTests.cs index f508f04..5384fb1 100644 --- a/backend/CreditService/CreditService.Tests/CreditFlowTests.cs +++ b/backend/CreditService/CreditService.Tests/CreditFlowTests.cs @@ -12,4 +12,17 @@ public sealed class CreditFlowTests [Fact] public async Task Reservation_holds_and_settles_actual(){var repo=new MemoryCreditRepository();var h=new CreditHandlers(repo);var user=Guid.NewGuid();await h.Handle(new ProvisionAccountCommand(user),default);var op=Guid.NewGuid();await h.Handle(new ReserveCreditsCommand(user,op,"GAME_GENERATION",60_000),default);var held=await h.Handle(new GetBalanceQuery(user),default);Assert.Equal(.4m,held.Available);await h.Handle(new SettleCreditsCommand(op,25_000),default);var settled=await h.Handle(new GetBalanceQuery(user),default);Assert.Equal(.75m,settled.Balance);Assert.Equal(.75m,settled.Available);} [Fact] public async Task Insufficient_balance_returns_purchase_details(){var repo=new MemoryCreditRepository();var h=new CreditHandlers(repo);var user=Guid.NewGuid();await h.Handle(new ProvisionAccountCommand(user),default);var ex=await Assert.ThrowsAsync(()=>h.Handle(new ReserveCreditsCommand(user,Guid.NewGuid(),"GAME_GENERATION",100_001),default));Assert.Equal("CREDITS_INSUFFICIENT",ex.Code);Assert.Equal(402,ex.StatusCode);} [Fact] public async Task Released_reservation_restores_available_balance(){var repo=new MemoryCreditRepository();var h=new CreditHandlers(repo);var user=Guid.NewGuid();await h.Handle(new ProvisionAccountCommand(user),default);var op=Guid.NewGuid();await h.Handle(new ReserveCreditsCommand(user,op,"PRACTICE_GENERATION",30_000),default);await h.Handle(new ReleaseCreditsCommand(op),default);Assert.Equal(1m,(await h.Handle(new GetBalanceQuery(user),default)).Available);} + [Fact] public async Task Batch_balance_lookup_provisions_and_dedupes() + { + var repo = new MemoryCreditRepository(); + var h = new CreditHandlers(repo); + var a = Guid.NewGuid(); + var b = Guid.NewGuid(); + var balances = await h.Handle(new GetBalancesQuery([a, b, a]), default); + Assert.Equal(2, balances.Count); + Assert.All(balances, x => Assert.Equal(1m, x.Balance)); + Assert.Contains(balances, x => x.UserId == a); + Assert.Contains(balances, x => x.UserId == b); + Assert.Empty(await h.Handle(new GetBalancesQuery([]), default)); + } } diff --git a/backend/CreditService/CreditService.Tests/CreditRedeemReplayTests.cs b/backend/CreditService/CreditService.Tests/CreditRedeemReplayTests.cs new file mode 100644 index 0000000..746a504 --- /dev/null +++ b/backend/CreditService/CreditService.Tests/CreditRedeemReplayTests.cs @@ -0,0 +1,191 @@ +using System.Collections.Concurrent; +using CreditService.Application; +using CreditService.Domain; +using CreditService.Persistence; +using Xunit; + +namespace CreditService.Tests; + +/// +/// 兑换码防重放:同一码在串行/并发下只能兑换一次;撤销与过期码不可兑; +/// 并发兑换多个不同码时余额精确累加。 +/// +public sealed class CreditRedeemReplayTests +{ + [Fact] + public async Task Same_code_redeemed_twice_sequentially_fails_on_second_try() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + var codes = await handlers.Handle(new CreateCodeBatchCommand(Guid.NewGuid(), 1, 2.5m, null), default); + + var first = await handlers.Handle(new RedeemCodeCommand(user, codes[0].Code), default); + Assert.Equal(3.5m, first.Balance); + + var ex = await Assert.ThrowsAsync(() => + handlers.Handle(new RedeemCodeCommand(user, codes[0].Code), default)); + Assert.Equal("REDEMPTION_CODE_UNAVAILABLE", ex.Code); + Assert.Equal(422, ex.StatusCode); + + var after = await handlers.Handle(new GetBalanceQuery(user), default); + Assert.Equal(3.5m, after.Balance); + } + + [Fact] + public async Task Concurrent_redeems_of_the_same_code_grant_credits_exactly_once() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var users = Enumerable.Range(0, 8).Select(_ => Guid.NewGuid()).ToArray(); + foreach (var user in users) + await handlers.Handle(new ProvisionAccountCommand(user), default); + + var codes = await handlers.Handle(new CreateCodeBatchCommand(Guid.NewGuid(), 1, 3m, null), default); + var code = codes[0].Code; + + var results = new ConcurrentBag<(Guid User, bool Success)>(); + Parallel.ForEach(users, user => + { + try + { + handlers.Handle(new RedeemCodeCommand(user, code), default).GetAwaiter().GetResult(); + results.Add((user, true)); + } + catch (CreditDomainException ex) + { + Assert.Equal("REDEMPTION_CODE_UNAVAILABLE", ex.Code); + results.Add((user, false)); + } + }); + + Assert.Equal(1, results.Count(x => x.Success)); + Assert.Equal(users.Length - 1, results.Count(x => !x.Success)); + + foreach (var (user, success) in results) + { + var balance = await handlers.Handle(new GetBalanceQuery(user), default); + Assert.Equal(success ? 4m : 1m, balance.Balance); + } + } + + [Fact] + public async Task Concurrent_redeems_across_users_and_codes_accumulate_exactly() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + + var codes = await handlers.Handle(new CreateCodeBatchCommand(Guid.NewGuid(), 5, 1.5m, null), default); + + // 每个码被 4 个并发任务抢兑:每个码恰好成功一次,余额 = 1 + 5 × 1.5 = 8.5 + await Task.WhenAll(codes.SelectMany(code => + Enumerable.Range(0, 4).Select(_ => Task.Run(async () => + { + try { await handlers.Handle(new RedeemCodeCommand(user, code.Code), default); } + catch (CreditDomainException ex) { Assert.Equal("REDEMPTION_CODE_UNAVAILABLE", ex.Code); } + })))); + + var balance = await handlers.Handle(new GetBalanceQuery(user), default); + Assert.Equal(8.5m, balance.Balance); + } + + [Fact] + public async Task Redeemed_code_view_is_masked_and_marked_redeemed() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + var created = await handlers.Handle(new CreateCodeBatchCommand(Guid.NewGuid(), 1, 1m, null), default); + await handlers.Handle(new RedeemCodeCommand(user, created[0].Code), default); + + var listed = await handlers.Handle(new ListCodesQuery(), default); + var view = Assert.Single(listed); + Assert.Equal("REDEEMED", view.Status); + Assert.Equal(user, view.RedeemedBy); + Assert.NotNull(view.RedeemedAt); + Assert.False(string.IsNullOrWhiteSpace(view.Code)); + // 说明:MySQL 仓储在列表视图中对 code 做 "****"+suffix 掩码; + // MemoryCreditRepository 保留完整 code,掩码语义由集成层验证。 + } + + [Fact] + public async Task Revoked_code_cannot_be_redeemed() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + var codes = await handlers.Handle(new CreateCodeBatchCommand(Guid.NewGuid(), 1, 2m, null), default); + + await handlers.Handle(new RevokeCodeCommand(codes[0].CodeId), default); + + var ex = await Assert.ThrowsAsync(() => + handlers.Handle(new RedeemCodeCommand(user, codes[0].Code), default)); + Assert.Equal("REDEMPTION_CODE_UNAVAILABLE", ex.Code); + + var balance = await handlers.Handle(new GetBalanceQuery(user), default); + Assert.Equal(1m, balance.Balance); + } + + [Fact] + public async Task Expired_code_cannot_be_redeemed() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + + // 直接向仓库注入一个已过期码(CreateCodeBatchCommand 拒绝过去的过期时间)。 + var code = repo.CreateCodes(Guid.NewGuid(), 1, 200_000, DateTimeOffset.UtcNow.AddMinutes(-1))[0]; + + var ex = await Assert.ThrowsAsync(() => + handlers.Handle(new RedeemCodeCommand(user, code.Code), default)); + Assert.Equal("REDEMPTION_CODE_UNAVAILABLE", ex.Code); + } + + [Fact] + public async Task Unknown_code_is_rejected() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + + var ex = await Assert.ThrowsAsync(() => + handlers.Handle(new RedeemCodeCommand(user, "QZ-DEADBEEFDEADBEEFDEAD"), default)); + Assert.Equal("REDEMPTION_CODE_UNAVAILABLE", ex.Code); + } + + [Fact] + public async Task Redeem_normalizes_case_and_whitespace() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + await handlers.Handle(new ProvisionAccountCommand(user), default); + var codes = await handlers.Handle(new CreateCodeBatchCommand(Guid.NewGuid(), 1, 1m, null), default); + + var balance = await handlers.Handle(new RedeemCodeCommand(user, $" {codes[0].Code.ToLowerInvariant()} "), default); + Assert.Equal(2m, balance.Balance); + } + + [Fact] + public async Task Malformed_codes_are_validation_errors() + { + var repo = new MemoryCreditRepository(); + var handlers = new CreditHandlers(repo); + var user = Guid.NewGuid(); + + var blank = await Assert.ThrowsAsync(() => + handlers.Handle(new RedeemCodeCommand(user, " "), default)); + Assert.Equal("VALIDATION_ERROR", blank.Code); + + var tooLong = await Assert.ThrowsAsync(() => + handlers.Handle(new RedeemCodeCommand(user, new string('A', 49)), default)); + Assert.Equal("VALIDATION_ERROR", tooLong.Code); + } +} diff --git a/backend/FileService/Contracts.cs b/backend/FileService/Contracts.cs index 26369d2..cec428c 100644 --- a/backend/FileService/Contracts.cs +++ b/backend/FileService/Contracts.cs @@ -5,7 +5,7 @@ public sealed record Material(string MaterialId, string OwnerUserId, string Disp public sealed record MaterialPage(IReadOnlyList Items, string? NextCursor); public sealed record CreateIngestionJobRequest(string? ParserVersion, bool Force = false, bool EnableOcr = false, string? OcrMode = null); [BsonIgnoreExtraElements] -public sealed record IngestionJob(string JobId, string MaterialId, string Status, int Progress, string ParserVersion, ApiError? Error, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, bool EnableOcr = false, string OcrMode = "standard", bool OcrUsed = false); +public sealed record IngestionJob(string JobId, string MaterialId, string Status, int Progress, string ParserVersion, ApiError? Error, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, bool EnableOcr = false, string OcrMode = "standard", bool OcrUsed = false, int AttemptCount = 0); public sealed record CreateAccessGrantRequest(string Purpose); public sealed record AccessGrant(string Url, DateTimeOffset ExpiresAt); public sealed record TextSourceSpan(long StartOffset, long EndOffset, int? PageNumber, int? ParagraphIndex, string? SourceLabel); diff --git a/backend/FileService/FileStore.cs b/backend/FileService/FileStore.cs index 301a71b..cc0d1fd 100644 --- a/backend/FileService/FileStore.cs +++ b/backend/FileService/FileStore.cs @@ -12,6 +12,12 @@ public interface IFileStore IngestionJob? GetLatestJob(string materialId); IngestionJob? CreateJob(string materialId, string parserVersion, bool enableOcr, string ocrMode); Task ProcessJobAsync(string jobId, CancellationToken cancellationToken); + + /// + /// 将 FAILED 任务重置为 QUEUED 并递增 AttemptCount;AttemptCount 已达 maxAttempts 时 + /// 不再重排(死信:任务保持 FAILED 且带最后一次 Error,可经任务查询接口检索)。 + /// + bool TryRequeueForRetry(string jobId, int maxAttempts); Stream? OpenContent(string materialId); ExtractedTextDocument? GetExtractedText(string materialId); } @@ -96,6 +102,12 @@ public async Task ProcessJobAsync(string jobId, CancellationToken cancellationTo _logger.LogWarning(exception, "Extraction failed for material {MaterialId}", job.MaterialId); } } + public bool TryRequeueForRetry(string jobId, int maxAttempts) + { + if (!_jobs.TryGetValue(jobId, out var job) || job.Status != "FAILED" || job.AttemptCount >= maxAttempts) return false; + _jobs[jobId] = job with { Status = "QUEUED", Progress = 0, Error = null, AttemptCount = job.AttemptCount + 1, UpdatedAt = DateTimeOffset.UtcNow }; + return true; + } public Stream? OpenContent(string materialId) => _materials.TryGetValue(materialId, out var stored) && stored.Material.Status != "DELETED" && File.Exists(stored.ContentPath) ? File.OpenRead(stored.ContentPath) : null; public ExtractedTextDocument? GetExtractedText(string materialId) => _materials.TryGetValue(materialId, out var stored) ? stored.ExtractedText : null; } diff --git a/backend/FileService/GalGame.FileService.csproj b/backend/FileService/GalGame.FileService.csproj index 522a59b..873bb9f 100644 --- a/backend/FileService/GalGame.FileService.csproj +++ b/backend/FileService/GalGame.FileService.csproj @@ -4,6 +4,11 @@ enable enable + + + + + diff --git a/backend/FileService/IngestionQueue.cs b/backend/FileService/IngestionQueue.cs new file mode 100644 index 0000000..62a0063 --- /dev/null +++ b/backend/FileService/IngestionQueue.cs @@ -0,0 +1,29 @@ +using System.Threading.Channels; + +/// +/// 进程内解析任务队列。比 Task.Run fire-and-forget 更可靠: +/// 可随 Host 生命周期取消,并在优雅关闭时排空已入队任务。 +/// +public sealed class IngestionQueue +{ + private readonly Channel _channel = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + public void Enqueue(string jobId) + { + if (!_channel.Writer.TryWrite(jobId)) + throw new InvalidOperationException("Ingestion queue is completed."); + } + + public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) => + _channel.Reader.ReadAllAsync(cancellationToken); + + /// 底层读取端:Worker 用它做 TryRead / WaitToReadAsync 的重试调度。 + public System.Threading.Channels.ChannelReader Reader => _channel.Reader; + + public void Complete() => _channel.Writer.TryComplete(); +} diff --git a/backend/FileService/IngestionWorker.cs b/backend/FileService/IngestionWorker.cs new file mode 100644 index 0000000..dedad78 --- /dev/null +++ b/backend/FileService/IngestionWorker.cs @@ -0,0 +1,128 @@ +using Microsoft.Extensions.Configuration; + +/// +/// 消费 :串行执行解析任务,避免无界 Task.Run。 +/// 失败任务按指数退避自动重试(Ingestion:RetryBaseDelaySeconds × 2^n), +/// 超过 Ingestion:MaxAttempts 后保持 FAILED(死信,带最后一次 Error,可经任务查询接口检索)。 +/// 启动恢复在消费前执行:崩溃时卡在 QUEUED/RUNNING 的任务被显式置 FAILED。 +/// +public sealed class IngestionWorker( + IngestionQueue queue, + IServiceProvider services, + IConfiguration configuration, + ILogger logger) : BackgroundService +{ + private readonly int _maxAttempts = Math.Clamp(configuration.GetValue("Ingestion:MaxAttempts", 3), 1, 10); + private readonly int _retryBaseDelaySeconds = Math.Clamp(configuration.GetValue("Ingestion:RetryBaseDelaySeconds", 5), 1, 300); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // jobId → 到期时间;退避等待不占用队列,避免阻塞新任务消费 + var pendingRetries = new List<(string JobId, DateTimeOffset ReadyAt)>(); + try + { + try + { + await services.GetRequiredService() + .RecoverIncompleteJobsAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception recoveryError) + { + logger.LogError(recoveryError, "Startup ingestion recovery failed; continuing to consume queue."); + } + + while (!stoppingToken.IsCancellationRequested) + { + DrainDueRetries(pendingRetries); + + if (queue.Reader.TryRead(out var jobId)) + { + await ProcessAsync(jobId, pendingRetries, stoppingToken); + continue; + } + + if (pendingRetries.Count == 0) + { + if (!await queue.Reader.WaitToReadAsync(stoppingToken)) break; + continue; + } + + var nextReadyIn = pendingRetries.Min(retry => retry.ReadyAt) - DateTimeOffset.UtcNow; + var wait = nextReadyIn > TimeSpan.Zero ? nextReadyIn : TimeSpan.FromMilliseconds(50); + await Task.WhenAny( + queue.Reader.WaitToReadAsync(stoppingToken).AsTask(), + Task.Delay(wait, stoppingToken)); + } + } + catch (OperationCanceledException) + { + // Host shutdown. + } + } + + private void DrainDueRetries(List<(string JobId, DateTimeOffset ReadyAt)> pendingRetries) + { + var now = DateTimeOffset.UtcNow; + for (var index = pendingRetries.Count - 1; index >= 0; index--) + { + if (pendingRetries[index].ReadyAt > now) continue; + logger.LogInformation( + "Re-enqueuing ingestion job {JobId} for retry.", pendingRetries[index].JobId); + queue.Enqueue(pendingRetries[index].JobId); + pendingRetries.RemoveAt(index); + } + } + + private async Task ProcessAsync( + string jobId, + List<(string JobId, DateTimeOffset ReadyAt)> pendingRetries, + CancellationToken stoppingToken) + { + try + { + var store = services.GetRequiredService(); + await store.ProcessJobAsync(jobId, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + logger.LogError(error, "Ingestion job {JobId} failed in background worker.", jobId); + } + + // 终态检查:FAILED 的任务要么按退避重排(AttemptCount < MaxAttempts),要么保持 FAILED 死信。 + var store2 = services.GetRequiredService(); + var job = store2.GetJob(jobId); + if (job is not { Status: "FAILED" }) return; + + if (!store2.TryRequeueForRetry(jobId, _maxAttempts)) + { + logger.LogWarning( + "Ingestion job {JobId} exhausted its retry budget ({Attempts} attempt(s)); kept as FAILED for inspection.", + jobId, + Math.Max(job.AttemptCount, 1)); + return; + } + + var nextAttempt = job.AttemptCount + 1; + var delay = TimeSpan.FromSeconds(_retryBaseDelaySeconds * Math.Pow(2, Math.Max(0, nextAttempt - 2))); + pendingRetries.Add((jobId, DateTimeOffset.UtcNow + delay)); + logger.LogInformation( + "Ingestion job {JobId} scheduled for retry #{Attempt} in {Seconds:0}s.", + jobId, + nextAttempt, + delay.TotalSeconds); + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + queue.Complete(); + return base.StopAsync(cancellationToken); + } +} diff --git a/backend/FileService/MongoFileStore.cs b/backend/FileService/MongoFileStore.cs index 52309b0..89e342a 100644 --- a/backend/FileService/MongoFileStore.cs +++ b/backend/FileService/MongoFileStore.cs @@ -214,6 +214,22 @@ public bool TryDelete(string materialId, string ownerUserId, out Material? mater } public IngestionJob? GetJob(string jobId) => _jobs.Find(x => x.JobId == jobId).FirstOrDefault(); public IngestionJob? GetLatestJob(string materialId) => _jobs.Find(x => x.MaterialId == materialId).SortByDescending(x => x.CreatedAt).FirstOrDefault(); + + public bool TryRequeueForRetry(string jobId, int maxAttempts) + { + var updated = _jobs.FindOneAndUpdate( + candidate => candidate.JobId == jobId + && candidate.Status == "FAILED" + && candidate.AttemptCount < maxAttempts, + Builders.Update + .Set(candidate => candidate.Status, "QUEUED") + .Set(candidate => candidate.Progress, 0) + .Set(candidate => candidate.Error, null) + .Inc(candidate => candidate.AttemptCount, 1) + .Set(candidate => candidate.UpdatedAt, DateTimeOffset.UtcNow), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + return updated is not null; + } public IngestionJob? CreateJob(string materialId, string parserVersion, bool enableOcr, string ocrMode) { var stored = FindMaterial(materialId); diff --git a/backend/FileService/Program.cs b/backend/FileService/Program.cs index 1d05045..c0421ca 100644 --- a/backend/FileService/Program.cs +++ b/backend/FileService/Program.cs @@ -1,12 +1,15 @@ -using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; using System.Security.Cryptography; var builder = WebApplication.CreateBuilder(args); +builder.Logging.AddJsonStructuredLogging(); builder.Services.Configure( options => options.ThrowOnBadRequest = true); var gatewayKey = builder.Configuration["Gateway:ServiceKey"] ?? throw new InvalidOperationException("Gateway:ServiceKey must be configured."); +if (builder.Environment.IsProduction() && string.Equals(gatewayKey, "moonstone-local-gateway-key", StringComparison.Ordinal)) + throw new InvalidOperationException("Gateway:ServiceKey must be changed from the development default in production."); const long MaxFileSizeBytes = 10 * 1024 * 1024; const long MultipartOverheadBytes = 1024 * 1024; builder.Services.Configure(options => options.MultipartBodyLengthLimit = MaxFileSizeBytes + MultipartOverheadBytes); @@ -26,26 +29,10 @@ }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { UseProxy = false }); builder.Services.AddSingleton(); builder.Services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); var app = builder.Build(); -app.Lifetime.ApplicationStarted.Register(() => -{ - _ = Task.Run(async () => - { - try - { - await app.Services.GetRequiredService().RecoverIncompleteJobsAsync(CancellationToken.None); - } - catch (Exception exception) - { - app.Logger.LogError(exception, "Failed to recover incomplete ingestion jobs."); - } - }); -}); -app.Use(async (context, next) => -{ - context.TraceIdentifier = context.Request.Headers["X-Correlation-Id"].FirstOrDefault() is { Length: > 0 } id ? id : Guid.NewGuid().ToString("N"); - context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; await next(); -}); +app.UseRequestLogging("FileService"); app.UseExceptionHandler(error => error.Run(context => { var exception = context.Features.Get()?.Error; @@ -59,8 +46,8 @@ return Failure(context, 500, "INTERNAL_ERROR", "File service is temporarily unavailable.").ExecuteAsync(context); })); app.MapGet("/healthz", (HttpContext c) => Results.Ok(ApiSuccess.Create(new { status = "live" }, c.TraceIdentifier))); -app.MapGet("/readyz", (HttpContext c, MongoFileStore store) => store.IsReady() - ? Results.Ok(ApiSuccess.Create(new { status = "ready", storage = "mongodb-gridfs" }, c.TraceIdentifier)) +app.MapGet("/readyz", (HttpContext c, MongoFileStore store, IngestionQueue queue) => store.IsReady() + ? Results.Ok(ApiSuccess.Create(new { status = "ready", storage = "mongodb-gridfs", ingestionQueueDepth = queue.Reader.Count }, c.TraceIdentifier)) : Failure(c, 503, "SERVICE_UNAVAILABLE", "MongoDB is unavailable.")); app.MapPost("/api/v1/materials", async (HttpContext c, [FromForm] IFormFile? file, [FromForm] string? displayName, [FromForm] string? subjectCode, IFileStore store) => @@ -101,7 +88,7 @@ var document = store.GetExtractedText(materialId); return document is null ? Failure(c, 409, "MATERIAL_TEXT_NOT_READY", "Material text is not ready.") : Results.Ok(ApiSuccess.Create(document, c.TraceIdentifier)); }); -app.MapDelete("/api/v1/materials/{materialId}", (string materialId, HttpContext c, IFileStore store) => +app.MapDelete("/api/v1/materials/{materialId}", async (string materialId, HttpContext c, IFileStore store) => { var userId = GatewayUser(c, gatewayKey); if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "A gateway-authenticated user is required."); @@ -112,17 +99,15 @@ if (activeJob?.EnableOcr == true && activeJob.Status is ("QUEUED" or "RUNNING")) { var ocrClient = c.RequestServices.GetRequiredService().CreateClient("ocr"); - _ = Task.Run(async () => + try { - try - { - await ocrClient.PostAsync($"v1/ocr/jobs/{activeJob.JobId}/cancel", content: null, CancellationToken.None); - } - catch - { - // Deletion remains successful even if OCRService is already offline. - } - }); + using var cancelTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + await ocrClient.PostAsync($"v1/ocr/jobs/{activeJob.JobId}/cancel", content: null, cancelTimeout.Token); + } + catch + { + // Deletion remains successful even if OCRService is offline or slow. + } } if (store.TryDelete(materialId, userId, out _)) return Results.NoContent(); @@ -143,7 +128,7 @@ if (ocrMode is not ("quick" or "standard")) return Failure(c, 400, "VALIDATION_ERROR", "OCR mode must be quick or standard."); var job = store.CreateJob(materialId, string.IsNullOrWhiteSpace(request.ParserVersion) ? "files-text-v1" : request.ParserVersion, request.EnableOcr, ocrMode); if (job is null) return Failure(c, 409, "STATE_CONFLICT", "The material state changed before the ingestion job was created."); - _ = Task.Run(() => store.ProcessJobAsync(job.JobId, CancellationToken.None)); + c.RequestServices.GetRequiredService().Enqueue(job.JobId); return Results.Accepted($"/api/v1/ingestion-jobs/{job.JobId}", ApiSuccess.Create(job, c.TraceIdentifier)); }); app.MapGet("/api/v1/ingestion-jobs/{jobId}", async (string jobId, HttpContext c, IFileStore store, IHttpClientFactory httpClientFactory) => diff --git a/backend/FileService/RequestLogging.cs b/backend/FileService/RequestLogging.cs new file mode 100644 index 0000000..dae90c5 --- /dev/null +++ b/backend/FileService/RequestLogging.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +/// +/// X-Correlation-Id 归一 + JSON 结构化日志 + 请求级 scope/耗时(T10 可观测性)。 +/// traceId 写入 context.TraceIdentifier,错误信封与响应头回传同一值。 +/// +public static class RequestLogging +{ + /// + /// 可选:服务侧注册请求级 ambient trace(如 PracticeService.Persistence.TraceFlow.Begin), + /// 供出站 HTTP 客户端透传 X-Correlation-Id。未注册则不影响。 + /// + public static Func? BeginAmbientTrace { get; set; } + + public static ILoggingBuilder AddJsonStructuredLogging(this ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddJsonConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + options.UseUtcTimestamp = true; + options.IncludeScopes = true; + options.JsonWriterOptions = new System.Text.Json.JsonWriterOptions { Indented = false }; + }); + return logging; + } + + /// + /// false:跳过 X-Correlation-Id 归一,沿用上游中间件已写入的 TraceIdentifier + /// (如 GalGameService 自带的严格字符集校验)。 + /// + public static IApplicationBuilder UseRequestLogging( + this IApplicationBuilder app, string service, bool normalizeCorrelationId = true) => + app.Use(async (context, next) => + { + if (normalizeCorrelationId) + { + var incoming = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); + context.TraceIdentifier = string.IsNullOrWhiteSpace(incoming) + ? Guid.NewGuid().ToString("N") + : incoming.Length <= 128 ? incoming : Guid.NewGuid().ToString("N"); + context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; + } + + var logger = context.RequestServices.GetRequiredService().CreateLogger(service); + var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); + var method = context.Request.Method; + var path = context.Request.Path.Value ?? "/"; + var start = Stopwatch.GetTimestamp(); + using (logger.BeginScope(new Dictionary + { + ["traceId"] = context.TraceIdentifier, + ["userId"] = userId, + ["path"] = path, + ["method"] = method, + })) + using (BeginAmbientTrace?.Invoke(context.TraceIdentifier)) + { + try + { + await next(); + } + finally + { + var durationMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + logger.LogInformation( + "HTTP {Method} {Path} completed with {Status} in {DurationMs:F1}ms traceId={TraceId} userId={UserId}", + method, path, context.Response.StatusCode, durationMs, context.TraceIdentifier, userId); + } + } + }); +} diff --git a/backend/FileService/Tests/ApiEnvelopeTests.cs b/backend/FileService/Tests/ApiEnvelopeTests.cs new file mode 100644 index 0000000..47a03a9 --- /dev/null +++ b/backend/FileService/Tests/ApiEnvelopeTests.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Xunit; + +namespace FileService.Tests; + +/// +/// 统一错误/成功信封契约(docs/contract.md §2.3–2.4): +/// ApiFailure.data 恒为 null,error 为 ApiError(code/message/details),traceId 回传。 +/// +public sealed class ApiEnvelopeTests +{ + private static readonly JsonSerializerOptions WebJson = new(JsonSerializerDefaults.Web); + + [Fact] + public void ApiFailure_Create_builds_documented_error_envelope() + { + var failure = ApiFailure.Create("FILE_TOO_LARGE", "The file exceeds the 10 MB limit.", "trace-1"); + + Assert.Null(failure.Data); + Assert.Equal("FILE_TOO_LARGE", failure.Error.Code); + Assert.Equal("The file exceeds the 10 MB limit.", failure.Error.Message); + Assert.NotNull(failure.Error.Details); + Assert.Equal("trace-1", failure.TraceId); + } + + [Fact] + public void ApiSuccess_Create_fills_empty_meta_and_keeps_trace() + { + var success = ApiSuccess.Create(new { status = "live" }, "trace-2"); + + Assert.NotNull(success.Data); + Assert.NotNull(success.Meta); + Assert.Equal("trace-2", success.TraceId); + } + + [Fact] + public void ApiFailure_serializes_with_null_data_and_error_object() + { + var failure = ApiFailure.Create("MATERIAL_TEXT_EXTRACTION_FAILED", "Text extraction failed.", "trace-3"); + var json = JsonSerializer.Serialize(failure, WebJson); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + Assert.Equal(JsonValueKind.Null, root.GetProperty("data").ValueKind); + var error = root.GetProperty("error"); + Assert.Equal("MATERIAL_TEXT_EXTRACTION_FAILED", error.GetProperty("code").GetString()); + Assert.Equal("Text extraction failed.", error.GetProperty("message").GetString()); + Assert.Equal(JsonValueKind.Object, error.GetProperty("details").ValueKind); + Assert.Equal("trace-3", root.GetProperty("traceId").GetString()); + } + + [Theory] + [InlineData("VALIDATION_ERROR")] + [InlineData("AUTH_REQUIRED")] + [InlineData("FORBIDDEN")] + [InlineData("RESOURCE_NOT_FOUND")] + [InlineData("STATE_CONFLICT")] + [InlineData("FILE_TOO_LARGE")] + [InlineData("MEDIA_TYPE_UNSUPPORTED")] + [InlineData("MATERIAL_TEXT_NOT_READY")] + [InlineData("MATERIAL_TEXT_EXTRACTION_FAILED")] + [InlineData("SERVICE_UNAVAILABLE")] + [InlineData("INTERNAL_ERROR")] + public void Contract_error_codes_are_string_stable(string code) + { + var failure = ApiFailure.Create(code, "message", "t"); + Assert.Equal(code, failure.Error.Code); + } + + [Fact] + public void ApiError_carries_custom_details_payload() + { + var error = new ApiError("CREDITS_INSUFFICIENT", "credits 不足", new { required = 1.5m }); + Assert.Equal("CREDITS_INSUFFICIENT", error.Code); + Assert.NotNull(error.Details); + } +} diff --git a/backend/FileService/Tests/FileTextContractValidatorTests.cs b/backend/FileService/Tests/FileTextContractValidatorTests.cs new file mode 100644 index 0000000..04a6e13 --- /dev/null +++ b/backend/FileService/Tests/FileTextContractValidatorTests.cs @@ -0,0 +1,123 @@ +using Xunit; + +namespace FileService.Tests; + +/// 纯文本交付基线校验器(docs/contract.md §5.2.1)。 +public sealed class FileTextContractValidatorTests +{ + private static ExtractionResult Build( + string text, + IReadOnlyList? sourceMap = null, + IReadOnlyList? blocks = null) + { + var map = sourceMap ?? (text.Length == 0 + ? Array.Empty() + : new TextSourceSpan[] { new(0, text.Length, null, null, null) }); + var body = blocks ?? (text.Length == 0 + ? Array.Empty() + : new TextDocumentBlock[] { new("PARAGRAPH", null, text, map[0]) }); + return new ExtractionResult(text, map, body); + } + + [Fact] + public void Valid_single_block_document_passes() + { + FileTextContractValidator.Validate(Build("hello world")); + } + + [Fact] + public void Valid_multi_block_document_with_ordered_spans_passes() + { + const string text = "para one\npara two"; + var first = new TextSourceSpan(0, 8, 1, 0, "Paragraph"); + var second = new TextSourceSpan(9, 17, 2, 1, "Paragraph"); + var document = Build(text, + new[] { first, second }, + new[] + { + new TextDocumentBlock("PARAGRAPH", null, "para one", first), + new TextDocumentBlock("PARAGRAPH", null, "para two", second), + }); + FileTextContractValidator.Validate(document); + } + + [Fact] + public void Empty_text_is_rejected() + { + Assert.Throws(() => + FileTextContractValidator.Validate(Build(""))); + } + + [Fact] + public void Whitespace_only_text_is_rejected() + { + Assert.Throws(() => + FileTextContractValidator.Validate(Build(" \n\t"))); + } + + [Fact] + public void Empty_source_map_is_rejected() + { + Assert.Throws(() => + FileTextContractValidator.Validate(new ExtractionResult("text", [], Build("text").Blocks))); + } + + [Fact] + public void Empty_blocks_are_rejected() + { + Assert.Throws(() => + FileTextContractValidator.Validate(new ExtractionResult("text", Build("text").SourceMap, []))); + } + + [Fact] + public void Overlapping_source_map_spans_are_rejected() + { + const string text = "abcdef"; + var a = new TextSourceSpan(0, 4, null, null, null); + var b = new TextSourceSpan(3, 6, null, null, null); + Assert.Throws(() => + FileTextContractValidator.Validate(Build(text, new[] { a, b }, + new[] + { + new TextDocumentBlock("PARAGRAPH", null, "abcd", a), + new TextDocumentBlock("PARAGRAPH", null, "def", b), + }))); + } + + [Fact] + public void Source_span_end_beyond_text_is_rejected() + { + const string text = "abc"; + var span = new TextSourceSpan(0, 10, null, null, null); + Assert.Throws(() => + FileTextContractValidator.Validate(Build(text, new[] { span }, + new[] { new TextDocumentBlock("PARAGRAPH", null, "abc", span) }))); + } + + [Fact] + public void Zero_length_span_is_rejected() + { + const string text = "abc"; + var span = new TextSourceSpan(1, 1, null, null, null); + Assert.Throws(() => + FileTextContractValidator.Validate(Build(text, new[] { span }, + new[] { new TextDocumentBlock("PARAGRAPH", null, "abc", span) }))); + } + + [Fact] + public void Block_text_must_match_source_slice_exactly() + { + const string text = "hello world"; + var span = new TextSourceSpan(0, 5, null, null, null); + Assert.Throws(() => + FileTextContractValidator.Validate(Build(text, new[] { span }, + new[] { new TextDocumentBlock("PARAGRAPH", null, "HELLO", span) }))); + } + + [Fact] + public void Null_extraction_is_rejected() + { + Assert.Throws(() => + FileTextContractValidator.Validate(null!)); + } +} diff --git a/backend/FileService/Tests/GalGame.FileService.Tests.csproj b/backend/FileService/Tests/GalGame.FileService.Tests.csproj new file mode 100644 index 0000000..797eedc --- /dev/null +++ b/backend/FileService/Tests/GalGame.FileService.Tests.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + diff --git a/backend/FileService/Tests/IngestionJobLifecycleTests.cs b/backend/FileService/Tests/IngestionJobLifecycleTests.cs new file mode 100644 index 0000000..b74a11e --- /dev/null +++ b/backend/FileService/Tests/IngestionJobLifecycleTests.cs @@ -0,0 +1,150 @@ +using Xunit; + +namespace FileService.Tests; + +/// +/// 解析任务生命周期与 Worker 重试/死信语义(IFileStore.TryRequeueForRetry 契约)。 +/// 覆盖:QUEUED→PROCESSING 置位、成功推进 READY、失败错误信封、FAILED→QUEUED 重排与 AttemptCount 死信。 +/// +public sealed class IngestionJobLifecycleTests +{ + [Fact] + public async Task CreateJob_claims_material_as_processing_with_latest_job_id() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = await store.CreateAsync(owner, TestSupport.File("a.txt", "text/plain", "hello"), "A", null, CancellationToken.None); + var id = stored.Material.MaterialId; + + var job = store.CreateJob(id, "files-text-v1", enableOcr: false, "standard"); + Assert.NotNull(job); + Assert.Equal("QUEUED", job!.Status); + Assert.Equal(0, job.Progress); + Assert.Equal("standard", job.OcrMode); + + var material = store.GetMaterial(id); + Assert.Equal("PROCESSING", material!.Status); + Assert.Equal(job.JobId, material.LatestIngestionJobId); + } + + [Fact] + public async Task ProcessJob_text_input_publishes_ready_text_before_terminal_state() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = await store.CreateAsync(owner, TestSupport.File("a.txt", "text/plain", "line1\r\nline2"), "A", null, CancellationToken.None); + var id = stored.Material.MaterialId; + var job = store.CreateJob(id, "files-text-v1", false, "standard")!; + + await store.ProcessJobAsync(job.JobId, CancellationToken.None); + + var finished = store.GetJob(job.JobId)!; + Assert.Equal("SUCCEEDED", finished.Status); + Assert.Equal(100, finished.Progress); + Assert.Null(finished.Error); + + var material = store.GetMaterial(id)!; + Assert.Equal("READY", material.Status); + + // 契约 §5.2.1:CRLF 规范为 LF,NFC,textChecksum 为规范化文本的 SHA-256 + var document = store.GetExtractedText(id); + Assert.NotNull(document); + Assert.Equal("line1\nline2", document!.Text); + Assert.Equal("utf-8", document.Encoding); + Assert.Equal("NFC", document.Normalization); + Assert.Equal("LF", document.LineEnding); + Assert.Equal("1", document.SourceMapVersion); + Assert.Equal(document.Text.Length, document.TextLength); + Assert.Single(document.SourceMap); + Assert.Single(document.Blocks); + Assert.Equal("PARAGRAPH", document.Blocks[0].Kind); + } + + [Fact] + public async Task ProcessJob_non_text_input_fails_with_extraction_error_envelope() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = await store.CreateAsync(owner, TestSupport.File("a.pdf", "application/pdf", "x"), "A", null, CancellationToken.None); + var id = stored.Material.MaterialId; + var job = store.CreateJob(id, "files-text-v1", false, "standard")!; + + await store.ProcessJobAsync(job.JobId, CancellationToken.None); + + var failed = store.GetJob(job.JobId)!; + Assert.Equal("FAILED", failed.Status); + Assert.Equal("MATERIAL_TEXT_EXTRACTION_FAILED", failed.Error!.Code); + Assert.Equal("FAILED", store.GetMaterial(id)!.Status); + Assert.Null(store.GetExtractedText(id)); + } + + [Fact] + public async Task TryRequeueForRetry_requeues_failed_job_and_increments_attempt_count() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = await store.CreateAsync(owner, TestSupport.File("a.pdf", "application/pdf", "x"), "A", null, CancellationToken.None); + var job = store.CreateJob(stored.Material.MaterialId, "files-text-v1", false, "standard")!; + await store.ProcessJobAsync(job.JobId, CancellationToken.None); + Assert.Equal("FAILED", store.GetJob(job.JobId)!.Status); + + Assert.True(store.TryRequeueForRetry(job.JobId, maxAttempts: 3)); + var requeued = store.GetJob(job.JobId)!; + Assert.Equal("QUEUED", requeued.Status); + Assert.Equal(0, requeued.Progress); + Assert.Null(requeued.Error); + Assert.Equal(1, requeued.AttemptCount); + } + + [Fact] + public async Task TryRequeueForRetry_dead_letters_after_max_attempts() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = await store.CreateAsync(owner, TestSupport.File("a.pdf", "application/pdf", "x"), "A", null, CancellationToken.None); + var job = store.CreateJob(stored.Material.MaterialId, "files-text-v1", false, "standard")!; + + // AttemptCount 记录的是“重排次数”;首次失败后 AttemptCount=0。 + // maxAttempts=3:允许 3 次重排(共 4 次执行),第 4 次失败后死信。 + for (var round = 0; round < 4; round++) + { + await store.ProcessJobAsync(job.JobId, CancellationToken.None); + Assert.Equal("FAILED", store.GetJob(job.JobId)!.Status); + var canRetry = store.TryRequeueForRetry(job.JobId, maxAttempts: 3); + Assert.Equal(round < 3, canRetry); + } + + var dead = store.GetJob(job.JobId)!; + Assert.Equal("FAILED", dead.Status); + Assert.Equal(3, dead.AttemptCount); + Assert.Equal("MATERIAL_TEXT_EXTRACTION_FAILED", dead.Error!.Code); + } + + [Fact] + public void TryRequeueForRetry_rejects_non_failed_or_unknown_jobs() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = store.CreateAsync(owner, TestSupport.File("a.txt", "text/plain", "x"), "A", null, CancellationToken.None).GetAwaiter().GetResult(); + var job = store.CreateJob(stored.Material.MaterialId, "files-text-v1", false, "standard")!; + + Assert.False(store.TryRequeueForRetry(job.JobId, maxAttempts: 3)); // QUEUED 不可重排 + Assert.False(store.TryRequeueForRetry("missing-job", maxAttempts: 3)); + } + + [Fact] + public async Task GetLatestJob_returns_most_recent_job_for_material() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = await store.CreateAsync(owner, TestSupport.File("a.txt", "text/plain", "hello"), "A", null, CancellationToken.None); + var id = stored.Material.MaterialId; + + var first = store.CreateJob(id, "files-text-v1", false, "standard")!; + await store.ProcessJobAsync(first.JobId, CancellationToken.None); + var second = store.CreateJob(id, "files-text-v2", false, "standard")!; + + Assert.Equal(second.JobId, store.GetLatestJob(id)!.JobId); + Assert.Equal(first.JobId, store.GetJob(first.JobId)!.JobId); + } +} diff --git a/backend/FileService/Tests/IngestionQueueTests.cs b/backend/FileService/Tests/IngestionQueueTests.cs new file mode 100644 index 0000000..4d28034 --- /dev/null +++ b/backend/FileService/Tests/IngestionQueueTests.cs @@ -0,0 +1,78 @@ +using System.Threading.Channels; +using Xunit; + +namespace FileService.Tests; + +/// 进程内解析队列语义:入队/读取/排空/Complete 后拒收。 +public sealed class IngestionQueueTests +{ + [Fact] + public async Task Enqueued_job_ids_come_out_in_fifo_order() + { + var queue = new IngestionQueue(); + queue.Enqueue("job-a"); + queue.Enqueue("job-b"); + queue.Enqueue("job-c"); + + Assert.Equal("job-a", await queue.Reader.ReadAsync()); + Assert.Equal("job-b", await queue.Reader.ReadAsync()); + Assert.Equal("job-c", await queue.Reader.ReadAsync()); + } + + [Fact] + public void TryRead_returns_false_when_empty() + { + var queue = new IngestionQueue(); + Assert.False(queue.Reader.TryRead(out _)); + queue.Enqueue("job-1"); + Assert.True(queue.Reader.TryRead(out var jobId)); + Assert.Equal("job-1", jobId); + Assert.False(queue.Reader.TryRead(out _)); + } + + [Fact] + public async Task ReadAllAsync_yields_items_until_completed() + { + var queue = new IngestionQueue(); + queue.Enqueue("a"); + queue.Enqueue("b"); + + var seen = new List(); + queue.Complete(); + await foreach (var jobId in queue.ReadAllAsync(CancellationToken.None)) + seen.Add(jobId); + + Assert.Equal(new[] { "a", "b" }, seen); + } + + [Fact] + public void Enqueue_after_Complete_throws() + { + var queue = new IngestionQueue(); + queue.Complete(); + Assert.Throws(() => queue.Enqueue("late")); + } + + [Fact] + public void Complete_is_idempotent() + { + var queue = new IngestionQueue(); + queue.Complete(); + queue.Complete(); + } + + [Fact] + public async Task Concurrent_writers_all_land_in_the_queue() + { + var queue = new IngestionQueue(); + var ids = Enumerable.Range(0, 50).Select(i => $"job-{i}").ToArray(); + Parallel.ForEach(ids, id => queue.Enqueue(id)); + + var seen = new HashSet(); + while (queue.Reader.TryRead(out var jobId)) + Assert.True(seen.Add(jobId), $"duplicate job {jobId}"); + + Assert.Equal(ids.Length, seen.Count); + await Task.CompletedTask; + } +} diff --git a/backend/FileService/Tests/InternalServiceAccessPolicyTests.cs b/backend/FileService/Tests/InternalServiceAccessPolicyTests.cs new file mode 100644 index 0000000..11dc7ad --- /dev/null +++ b/backend/FileService/Tests/InternalServiceAccessPolicyTests.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace FileService.Tests; + +public sealed class InternalServiceAccessPolicyTests +{ + [Fact] + public void IsTrusted_accepts_matching_key_and_allowlisted_service() + { + var headers = new HeaderDictionary + { + ["X-Gateway-Key"] = "secret", + ["X-Service-Name"] = "KnowledgeService", + }; + var allow = new HashSet(StringComparer.OrdinalIgnoreCase) { "KnowledgeService", "PracticeService" }; + Assert.True(InternalServiceAccessPolicy.IsTrusted(headers, "secret", allow)); + } + + [Fact] + public void IsTrusted_rejects_wrong_key_or_unknown_service() + { + var headers = new HeaderDictionary + { + ["X-Gateway-Key"] = "wrong", + ["X-Service-Name"] = "KnowledgeService", + }; + var allow = new HashSet(StringComparer.OrdinalIgnoreCase) { "KnowledgeService" }; + Assert.False(InternalServiceAccessPolicy.IsTrusted(headers, "secret", allow)); + + headers["X-Gateway-Key"] = "secret"; + headers["X-Service-Name"] = "EvilService"; + Assert.False(InternalServiceAccessPolicy.IsTrusted(headers, "secret", allow)); + } + + [Fact] + public void IsTrusted_rejects_duplicate_gateway_key_headers() + { + var headers = new HeaderDictionary(); + headers.Append("X-Gateway-Key", "secret"); + headers.Append("X-Gateway-Key", "secret"); + headers["X-Service-Name"] = "KnowledgeService"; + var allow = new HashSet(StringComparer.OrdinalIgnoreCase) { "KnowledgeService" }; + Assert.False(InternalServiceAccessPolicy.IsTrusted(headers, "secret", allow)); + } + + [Fact] + public void CreateAllowlist_uses_section_when_present() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Access:0"] = "PracticeService", + ["Access:1"] = "KnowledgeService", + }) + .Build(); + var allow = InternalServiceAccessPolicy.CreateAllowlist(config.GetSection("Access"), "Fallback"); + Assert.Contains("PracticeService", allow); + Assert.Contains("KnowledgeService", allow); + Assert.DoesNotContain("Fallback", allow); + } +} diff --git a/backend/FileService/Tests/LocalFileStoreTests.cs b/backend/FileService/Tests/LocalFileStoreTests.cs new file mode 100644 index 0000000..1518dea --- /dev/null +++ b/backend/FileService/Tests/LocalFileStoreTests.cs @@ -0,0 +1,167 @@ +using System.Security.Cryptography; +using System.Text; +using Xunit; + +namespace FileService.Tests; + +/// +/// LocalFileStore 的文件路径映射、checksum 与资料生命周期(内存实现,不起 Mongo)。 +/// Mongo 路径将 ContentPath 映射为 GridFS id,本地实现映射为 contentRoot/{materialId}.bin。 +/// +public sealed class LocalFileStoreTests +{ + private static string Sha256Hex(string text) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); + + [Fact] + public async Task Create_maps_content_path_under_configured_root() + { + var store = TestSupport.NewStore(); + var stored = await store.CreateAsync( + Guid.NewGuid().ToString(), + TestSupport.File("notes.txt", "text/plain", "hello"), + "Notes", + "AGRONOMY", + CancellationToken.None); + + var contentRoot = TestSupport.ContentRootOf(store); + var expected = Path.Combine(contentRoot, stored.Material.MaterialId + ".bin"); + Assert.Equal(expected, stored.ContentPath); + Assert.True(File.Exists(stored.ContentPath)); + Assert.Equal("AGRONOMY", stored.SubjectCode); + Assert.Equal("notes.txt", stored.Material.OriginalFileName); + Assert.Equal("UPLOADED", stored.Material.Status); + } + + [Fact] + public async Task Create_computes_lowercase_sha256_checksum() + { + var store = TestSupport.NewStore(); + var stored = await store.CreateAsync( + Guid.NewGuid().ToString(), + TestSupport.File("a.txt", "text/plain", "hello"), + "A", + null, + CancellationToken.None); + + Assert.Equal(Sha256Hex("hello"), stored.Material.Checksum); + Assert.Equal(5, stored.Material.SizeBytes); + } + + [Fact] + public async Task Create_strips_directory_from_original_file_name() + { + var store = TestSupport.NewStore(); + var stored = await store.CreateAsync( + Guid.NewGuid().ToString(), + TestSupport.File("folder/sub/notes.txt", "text/plain", "x"), + "N", + null, + CancellationToken.None); + + Assert.Equal("notes.txt", stored.Material.OriginalFileName); + } + + [Fact] + public async Task GetMaterial_round_trips_by_id_and_open_content_streams_bytes() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = await store.CreateAsync(owner, TestSupport.File("a.txt", "text/plain", "payload"), "A", null, CancellationToken.None); + + var material = store.GetMaterial(stored.Material.MaterialId); + Assert.NotNull(material); + Assert.Equal(stored.Material.MaterialId, material!.MaterialId); + + using var stream = store.OpenContent(stored.Material.MaterialId); + Assert.NotNull(stream); + using var reader = new StreamReader(stream!); + Assert.Equal("payload", await reader.ReadToEndAsync()); + } + + [Fact] + public async Task OpenContent_is_null_for_unknown_or_deleted_material() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = await store.CreateAsync(owner, TestSupport.File("a.txt", "text/plain", "x"), "A", null, CancellationToken.None); + + Assert.Null(store.OpenContent("no-such-id")); + Assert.True(store.TryDelete(stored.Material.MaterialId, owner, out _)); + Assert.Null(store.OpenContent(stored.Material.MaterialId)); + } + + [Fact] + public async Task List_pages_with_cursor_and_filters() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var other = Guid.NewGuid().ToString(); + for (var i = 0; i < 5; i++) + await store.CreateAsync(owner, TestSupport.File($"m{i}.txt", "text/plain", "x"), $"M{i}", i % 2 == 0 ? "AGRONOMY" : null, CancellationToken.None); + await store.CreateAsync(other, TestSupport.File("other.txt", "text/plain", "x"), "Other", null, CancellationToken.None); + + var page1 = store.List(owner, null, 2, null, null, out var next); + Assert.Equal(2, page1.Count); + Assert.NotNull(next); + + var page2 = store.List(owner, next, 2, null, null, out var next2); + Assert.Equal(2, page2.Count); + Assert.NotNull(next2); + + var page3 = store.List(owner, next2, 2, null, null, out var next3); + Assert.Single(page3); + Assert.Null(next3); + + // 其他用户的资料不出现在列表中 + Assert.DoesNotContain(page1, x => x.DisplayName == "Other"); + Assert.DoesNotContain(page2, x => x.DisplayName == "Other"); + + var filtered = store.List(owner, null, 10, null, "AGRONOMY", out _); + Assert.Equal(3, filtered.Count); + Assert.All(filtered, x => Assert.Equal("UPLOADED", x.Status)); + } + + [Fact] + public async Task List_hides_deleted_and_status_filter_applies() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var kept = await store.CreateAsync(owner, TestSupport.File("k.txt", "text/plain", "x"), "K", null, CancellationToken.None); + var dropped = await store.CreateAsync(owner, TestSupport.File("d.txt", "text/plain", "x"), "D", null, CancellationToken.None); + Assert.True(store.TryDelete(dropped.Material.MaterialId, owner, out _)); + + var all = store.List(owner, null, 10, null, null, out _); + Assert.Single(all); + Assert.Equal(kept.Material.MaterialId, all[0].MaterialId); + + Assert.Empty(store.List(owner, null, 10, "DELETED", null, out _)); + } + + [Fact] + public async Task TryDelete_enforces_ownership_and_single_soft_delete() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = await store.CreateAsync(owner, TestSupport.File("a.txt", "text/plain", "x"), "A", null, CancellationToken.None); + var id = stored.Material.MaterialId; + + Assert.False(store.TryDelete(id, Guid.NewGuid().ToString(), out _)); + Assert.True(store.TryDelete(id, owner, out var deleted)); + Assert.Equal("DELETED", deleted!.Status); + Assert.False(store.TryDelete(id, owner, out _)); + Assert.Equal("DELETED", store.GetMaterial(id)!.Status); + } + + [Fact] + public async Task Unknown_cursor_yields_empty_page_without_throwing() + { + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + await store.CreateAsync(owner, TestSupport.File("a.txt", "text/plain", "x"), "A", null, CancellationToken.None); + + var page = store.List(owner, "not-a-real-id", 10, null, null, out var next); + Assert.Empty(page); + Assert.Null(next); + } +} diff --git a/backend/FileService/Tests/MediaPathMappingTests.cs b/backend/FileService/Tests/MediaPathMappingTests.cs new file mode 100644 index 0000000..089ca2c --- /dev/null +++ b/backend/FileService/Tests/MediaPathMappingTests.cs @@ -0,0 +1,105 @@ +using System.Reflection; +using MongoDB.Bson; +using Xunit; + +namespace FileService.Tests; + +/// +/// GridFS/媒体类型路径映射:上传时按扩展名推断 MIME(MongoFileStore.DetectMediaType), +/// 内容句柄为 GridFS ObjectId 字符串(ContentPath / MaterialDocument.GridFsId)。 +/// +public sealed class MediaPathMappingTests +{ + private static string DetectMediaType(string fileName, string? submittedType) + { + var method = typeof(MongoFileStore).GetMethod( + "DetectMediaType", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("MongoFileStore.DetectMediaType not found."); + return (string)method.Invoke(null, new object?[] { fileName, submittedType })!; + } + + [Theory] + [InlineData("notes.txt", null, "text/plain")] + [InlineData("notes.TXT", "application/octet-stream", "text/plain")] + [InlineData("readme.md", "", "text/markdown")] + [InlineData("page.html", null, "text/html")] + [InlineData("page.htm", "application/octet-stream", "text/html")] + [InlineData("mail.mhtml", null, "multipart/related")] + [InlineData("mail.mht", "application/octet-stream", "multipart/related")] + [InlineData("deck.pptx", null, "application/vnd.openxmlformats-officedocument.presentationml.presentation")] + [InlineData("photo.jpg", null, "image/jpeg")] + [InlineData("photo.jpeg", "application/octet-stream", "image/jpeg")] + [InlineData("scan.png", null, "image/png")] + public void Extension_maps_to_media_type_when_submitted_type_is_missing_or_generic( + string fileName, + string? submitted, + string expected) + { + Assert.Equal(expected, DetectMediaType(fileName, submitted)); + } + + [Theory] + [InlineData("notes.txt", "text/markdown", "text/markdown")] + [InlineData("scan.png", "image/jpeg", "image/jpeg")] + [InlineData("mystery.xyz", "application/x-custom", "application/x-custom")] + public void Explicit_submitted_content_type_wins_over_extension( + string fileName, + string submitted, + string expected) + { + Assert.Equal(expected, DetectMediaType(fileName, submitted)); + } + + [Fact] + public void Unknown_extension_without_content_type_falls_back_to_octet_stream() + { + Assert.Equal("application/octet-stream", DetectMediaType("blob.xyz", null)); + Assert.Equal("application/octet-stream", DetectMediaType("blob.xyz", " ")); + } + + [Fact] + public void Grid_fs_object_id_round_trips_through_content_path_string() + { + // MongoFileStore 以 gridFsId.ToString() 作为 StoredMaterial.ContentPath, + // 读取时 ObjectId.TryParse(StoredMaterial.ContentPath) 必须能还原。 + var gridFsId = ObjectId.GenerateNewId(); + var contentPath = gridFsId.ToString(); + + Assert.True(ObjectId.TryParse(contentPath, out var parsed)); + Assert.Equal(gridFsId, parsed); + Assert.False(ObjectId.TryParse("not-an-object-id", out _)); + Assert.False(ObjectId.TryParse("", out _)); + } + + [Fact] + public void Material_document_maps_material_id_to_grid_fs_id() + { + var gridFsId = ObjectId.GenerateNewId(); + var now = DateTimeOffset.UtcNow; + var material = new Material( + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString(), + "Display", + "notes.txt", + "text/plain", + 5, + "abc", + "UPLOADED", + null, + now, + now); + var document = new MaterialDocument + { + Material = material, + GridFsId = gridFsId.ToString(), + SubjectCode = "AGRONOMY", + ExtractedText = null, + }; + + Assert.Equal(material.MaterialId, document.Material.MaterialId); + Assert.Equal(gridFsId.ToString(), document.GridFsId); + Assert.True(ObjectId.TryParse(document.GridFsId, out _)); + Assert.Equal("AGRONOMY", document.SubjectCode); + } +} diff --git a/backend/FileService/Tests/OcrResponseContractTests.cs b/backend/FileService/Tests/OcrResponseContractTests.cs new file mode 100644 index 0000000..2b11cff --- /dev/null +++ b/backend/FileService/Tests/OcrResponseContractTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using Xunit; + +namespace FileService.Tests; + +/// +/// FileService ↔ OCRService 响应契约:OcrResponse/OcrPage 形状必须与 +/// OCRService `POST /v1/ocr` 返回的 `{pages:[{pageNumber,lines,...}]}` 兼容。 +/// +public sealed class OcrResponseContractTests +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + [Fact] + public void Ocr_response_deserializes_pages_with_page_number_and_lines() + { + const string body = """ + { + "pages": [ + { "pageNumber": 1, "lines": ["Hello", "World"], "formulas": [] }, + { "pageNumber": 2, "lines": ["第二行"], "formulas": ["$$E=mc^2$$"] } + ] + } + """; + + var result = JsonSerializer.Deserialize(body, Options); + Assert.NotNull(result); + Assert.Equal(2, result!.Pages.Count); + Assert.Equal(1, result.Pages[0].PageNumber); + Assert.Equal(new[] { "Hello", "World" }, result.Pages[0].Lines); + Assert.Equal(2, result.Pages[1].PageNumber); + Assert.Equal(new[] { "第二行" }, result.Pages[1].Lines); + } + + [Fact] + public void Ocr_response_accepts_case_insensitive_property_names() + { + // FileService 使用 PropertyNameCaseInsensitive = true 反序列化 OCR 响应。 + const string body = """{ "pages": [ { "pagenumber": 3, "lines": ["x"] } ] }"""; + var result = JsonSerializer.Deserialize( + body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(result); + Assert.Equal(3, result!.Pages[0].PageNumber); + Assert.Equal(new[] { "x" }, result.Pages[0].Lines); + } + + [Fact] + public void Empty_pages_array_is_a_valid_response_shape() + { + var result = JsonSerializer.Deserialize("""{ "pages": [] }""", Options); + Assert.NotNull(result); + Assert.Empty(result!.Pages); + } + + [Fact] + public void Ocr_progress_shape_matches_polling_contract() + { + const string body = """{ "status": "RUNNING", "currentPage": 2, "totalPages": 5, "phase": "RECOGNIZING" }"""; + var progress = JsonSerializer.Deserialize(body, Options); + + Assert.Equal("RUNNING", progress!.Status); + Assert.Equal(2, progress.CurrentPage); + Assert.Equal(5, progress.TotalPages); + Assert.Equal("RECOGNIZING", progress.Phase); + } + + [Fact] + public void Extraction_result_tracks_ocr_used_flag() + { + var plain = new ExtractionResult("text", [], []); + Assert.False(plain.OcrUsed); + + var fromOcr = new ExtractionResult("text", [], [], OcrUsed: true); + Assert.True(fromOcr.OcrUsed); + } +} diff --git a/backend/FileService/Tests/ParserInputPolicyTests.cs b/backend/FileService/Tests/ParserInputPolicyTests.cs new file mode 100644 index 0000000..056b185 --- /dev/null +++ b/backend/FileService/Tests/ParserInputPolicyTests.cs @@ -0,0 +1,47 @@ +using Xunit; + +namespace FileService.Tests; + +public sealed class ParserInputPolicyTests +{ + [Theory] + [InlineData("notes.txt", "text/plain")] + [InlineData("slides.pdf", "application/pdf")] + [InlineData("handout.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")] + [InlineData("deck.pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation")] + [InlineData("page.html", "text/html")] + [InlineData("mail.mhtml", "multipart/related")] + [InlineData("scan.jpg", "image/jpeg")] + [InlineData("scan.png", "image/png")] + [InlineData("blob.bin", "application/octet-stream")] + public void Supported_inputs_are_accepted(string fileName, string mediaType) + { + Assert.True(ParserInputPolicy.IsSupported(fileName, mediaType)); + } + + [Theory] + [InlineData("malware.exe", "application/x-msdownload")] + [InlineData("archive.zip", "application/zip")] + [InlineData("audio.mp3", "audio/mpeg")] + public void Unsupported_inputs_are_rejected(string fileName, string mediaType) + { + Assert.False(ParserInputPolicy.IsSupported(fileName, mediaType)); + } + + [Fact] + public void ResolveParserKind_maps_common_formats() + { + Assert.Equal(ParserInputKind.Pdf, ParserInputPolicy.ResolveParserKind("a.pdf", "application/pdf")); + Assert.Equal(ParserInputKind.Docx, ParserInputPolicy.ResolveParserKind("a.docx", "application/octet-stream")); + Assert.Equal(ParserInputKind.Markdown, ParserInputPolicy.ResolveParserKind("a.md", "text/markdown")); + Assert.Equal(ParserInputKind.PlainText, ParserInputPolicy.ResolveParserKind("a.txt", "text/plain")); + Assert.Equal(ParserInputKind.Image, ParserInputPolicy.ResolveParserKind("a.png", "image/png")); + } + + [Fact] + public void ResolveParserKind_throws_for_unsupported() + { + Assert.Throws(() => + ParserInputPolicy.ResolveParserKind("a.exe", "application/x-msdownload")); + } +} diff --git a/backend/FileService/Tests/TestSupport.cs b/backend/FileService/Tests/TestSupport.cs new file mode 100644 index 0000000..b9d4c02 --- /dev/null +++ b/backend/FileService/Tests/TestSupport.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging.Abstractions; + +namespace FileService.Tests; + +/// Shared in-memory fakes so store tests never touch a real MongoDB or the real disk layout. +internal static class TestSupport +{ + public static IConfiguration Config(params (string Key, string? Value)[] values) => + new ConfigurationBuilder() + .AddInMemoryCollection(values.ToDictionary(x => x.Key, x => x.Value)) + .Build(); + + public static LocalFileStore NewStore(string rootPath = "data") + { + var contentRoot = Path.Combine(Path.GetTempPath(), "galreview-file-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(contentRoot); + var environment = new TestHostEnvironment { ContentRootPath = contentRoot }; + var store = new LocalFileStore( + Config(("FileStorage:RootPath", rootPath)), + environment, + NullLogger.Instance); + return store; + } + + public static string ContentRootOf(LocalFileStore store, string rootPath = "data") + { + // LocalFileStore derives its content root from the host ContentRootPath at construction; + // recover it from the first stored file path so assertions do not duplicate that logic. + var field = typeof(LocalFileStore).GetField("_contentRoot", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return (string)field!.GetValue(store)!; + } + + public static TestFormFile File(string fileName, string contentType, string content) => + new(fileName, contentType, System.Text.Encoding.UTF8.GetBytes(content)); + + public static TestFormFile File(string fileName, string contentType, byte[] content) => + new(fileName, contentType, content); +} + +internal sealed class TestFormFile(string fileName, string contentType, byte[] content) : IFormFile +{ + public string ContentType { get; } = contentType; + public string ContentDisposition => "form-data"; + public IHeaderDictionary Headers { get; } = new HeaderDictionary(); + public long Length => content.Length; + public string Name => "file"; + public string FileName { get; } = fileName; + + public void CopyTo(Stream target) => target.Write(content); + public Task CopyToAsync(Stream target, CancellationToken cancellationToken = default) + { + target.Write(content); + return Task.CompletedTask; + } + + public Stream OpenReadStream() => new MemoryStream(content, writable: false); +} + +internal sealed class TestHostEnvironment : IWebHostEnvironment +{ + public string EnvironmentName { get; set; } = "Development"; + public string ApplicationName { get; set; } = "GalGame.FileService.Tests"; + public string WebRootPath { get; set; } = ""; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + public string ContentRootPath { get; set; } = ""; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); +} diff --git a/backend/FileService/Tests/UploadLimitAndIdPathTests.cs b/backend/FileService/Tests/UploadLimitAndIdPathTests.cs new file mode 100644 index 0000000..31596c9 --- /dev/null +++ b/backend/FileService/Tests/UploadLimitAndIdPathTests.cs @@ -0,0 +1,95 @@ +using Xunit; + +namespace FileService.Tests; + +/// +/// 上传限制与 ID/路径解析边界:10 MiB 上限契约值、displayName/subjectCode 格式、 +/// 含 charset 的 Content-Type 解析、路径段隔离。 +/// +public sealed class UploadLimitAndIdPathTests +{ + // 与 Program.cs 的 MaxFileSizeBytes / 契约 §5.4「单文件上传上限为 10 MiB」保持一致。 + private const long MaxFileSizeBytes = 10 * 1024 * 1024; + + [Fact] + public void Documented_max_upload_is_ten_mebibytes() + { + Assert.Equal(10_485_760, MaxFileSizeBytes); + } + + [Theory] + [InlineData("notes.txt", "text/plain; charset=utf-8", ParserInputKind.PlainText)] + [InlineData("readme.md", "text/markdown; charset=utf-8", ParserInputKind.Markdown)] + [InlineData("page.html", "text/html; charset=gbk", ParserInputKind.Html)] + [InlineData("report.pdf", "application/pdf", ParserInputKind.Pdf)] + [InlineData("report.pdf", "application/pdf; charset=binary", ParserInputKind.Pdf)] + [InlineData("scan.jpg", "image/jpeg", ParserInputKind.Image)] + [InlineData("slides.pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation", ParserInputKind.Pptx)] + [InlineData("mail.mhtml", "multipart/related", ParserInputKind.Mhtml)] + public void Content_type_with_parameters_still_resolves_parser_kind( + string fileName, + string mediaType, + ParserInputKind expected) + { + Assert.Equal(expected, ParserInputPolicy.ResolveParserKind(fileName, mediaType)); + } + + [Fact] + public void Mimetype_wins_over_extension_for_binary_document_types() + { + // 契约 §5:application/pdf 即使扩展名为 .bin 也必须走 PDF 解析。 + Assert.Equal(ParserInputKind.Pdf, ParserInputPolicy.ResolveParserKind("blob.bin", "application/pdf")); + Assert.Equal(ParserInputKind.Docx, ParserInputPolicy.ResolveParserKind("blob.bin", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document")); + } + + [Theory] + [InlineData("notes.txt", "text/plain")] + [InlineData("NOTES.TXT", "TEXT/PLAIN")] + [InlineData("Readme.Md", "Application/Octet-Stream")] + public void Support_checks_are_case_insensitive(string fileName, string mediaType) + { + Assert.True(ParserInputPolicy.IsSupported(fileName, mediaType)); + } + + [Fact] + public void Null_or_blank_inputs_throw_argument_exceptions() + { + Assert.Throws(() => ParserInputPolicy.IsSupported(null!, "text/plain")); + Assert.Throws(() => ParserInputPolicy.IsSupported(" ", "text/plain")); + Assert.Throws(() => ParserInputPolicy.IsSupported("a.txt", " ")); + } + + [Fact] + public void Material_id_is_a_guid_string_not_a_filesystem_path() + { + // 路径映射契约:materialId 是标识符,真正的存储路径/内容句柄由 store 决定 + // (LocalFileStore → contentRoot/{id}.bin;MongoFileStore → GridFS ObjectId)。 + var store = TestSupport.NewStore(); + var owner = Guid.NewGuid().ToString(); + var stored = store.CreateAsync(owner, TestSupport.File("../../evil.txt", "text/plain", "x"), "E", null, CancellationToken.None) + .GetAwaiter().GetResult(); + + Assert.True(Guid.TryParse(stored.Material.MaterialId, out _)); + // 原始文件名只保留文件名段,路径穿越段被剥离 + Assert.Equal("evil.txt", stored.Material.OriginalFileName); + // 内容落盘路径固定在 content root 之下,不会随原始文件名逃逸 + var contentRoot = TestSupport.ContentRootOf(store); + Assert.StartsWith(contentRoot, stored.ContentPath); + Assert.Equal("evil.txt", Path.GetFileName(stored.Material.OriginalFileName)); + } + + [Fact] + public void Subject_code_contract_shape_is_documented_for_gateway_validation() + { + // Program.cs NormalizeSubjectCode:^[A-Z][A-Z0-9_]{0,31}$(大写化后) + var pattern = "^[A-Z][A-Z0-9_]{0,31}$"; + Assert.Matches(pattern, "AGRONOMY"); + Assert.Matches(pattern, "A"); + Assert.Matches(pattern, "BIO_CHEM2"); + Assert.DoesNotMatch(pattern, "agronomy"); // 未大写化 + Assert.DoesNotMatch(pattern, "1ABC"); + Assert.DoesNotMatch(pattern, "BIO-CHEM"); + Assert.DoesNotMatch(pattern, new string('A', 33)); + } +} diff --git a/backend/GalGameService/Contracts.cs b/backend/GalGameService/Contracts.cs index 2f18ee6..c7042aa 100644 --- a/backend/GalGameService/Contracts.cs +++ b/backend/GalGameService/Contracts.cs @@ -49,7 +49,8 @@ public sealed record GameGenerationJob( string GeneratorVersion, ApiError? Error, DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt); + DateTimeOffset UpdatedAt, + int AttemptCount = 0); /// 游戏包清单(GET /api/v1/game-packages/{packageId} 响应) public sealed record GamePackageManifest( @@ -275,3 +276,6 @@ private static bool HasSingleExactValue( ?? Array.Empty(); } } + +/// 启动恢复结果:FAILED 数量与对应作业 ID(用于释放 HELD 预授权)。 +public sealed record GameStaleJobRecovery(int FailedCount, IReadOnlyList FailedJobIds); diff --git a/backend/GalGameService/CreditBillingClient.cs b/backend/GalGameService/CreditBillingClient.cs index a8513df..a6784a4 100644 --- a/backend/GalGameService/CreditBillingClient.cs +++ b/backend/GalGameService/CreditBillingClient.cs @@ -20,6 +20,11 @@ public sealed class CreditBillingClient(IHttpClientFactory clients,IConfiguratio private async Task SendAsync(HttpMethod method,string path,object? body,CancellationToken ct) { using var request=new HttpRequestMessage(method,path);if(body is not null)request.Content=JsonContent.Create(body);request.Headers.TryAddWithoutValidation("X-Service-Name","GalGameService");request.Headers.TryAddWithoutValidation("X-Service-Key",ServiceKey); + var ambient = GalGameTraceFlow.Current; + if (!string.IsNullOrEmpty(ambient)) + request.Headers.TryAddWithoutValidation("X-Correlation-Id", ambient); + else + request.Headers.TryAddWithoutValidation("X-Correlation-Id", Guid.NewGuid().ToString("N")); using var response=await clients.CreateClient("gateway").SendAsync(request,ct);var text=await response.Content.ReadAsStringAsync(ct);if(response.IsSuccessStatusCode)return; logger.LogWarning("CreditService call failed: {Status} {Body}",(int)response.StatusCode,text.Length>2000?text[..2000]:text); try{using var doc=JsonDocument.Parse(text);var error=doc.RootElement.GetProperty("error");throw new CreditBillingException((int)response.StatusCode,error.GetProperty("code").GetString()??"UPSTREAM_ERROR",error.GetProperty("message").GetString()??"credits 服务调用失败。",error.TryGetProperty("details",out var details)?details.Clone():new{});}catch(CreditBillingException){throw;}catch{throw new CreditBillingException((int)response.StatusCode,"UPSTREAM_ERROR","credits 服务调用失败。",new{});} diff --git a/backend/GalGameService/GalGameHttp.cs b/backend/GalGameService/GalGameHttp.cs new file mode 100644 index 0000000..d5165a9 --- /dev/null +++ b/backend/GalGameService/GalGameHttp.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Primitives; + +public static class GalGameHttp +{ + public static bool IsGateway(HttpContext context, string key) + => context.Request.Headers.TryGetValue("X-Gateway-Key", out var values) + && values.Count == 1 + && string.Equals(values[0], key, StringComparison.Ordinal); + + public static string? GatewayUser(HttpContext context, string key) + { + if (!context.Request.Headers.TryGetValue("X-Gateway-Key", out var gwValues) + || gwValues.Count != 1 + || !string.Equals(gwValues[0], key, StringComparison.Ordinal)) + return null; + + if (!context.Request.Headers.TryGetValue("X-User-Id", out var userIdValues) + || userIdValues.Count != 1 + || !TryParseUuidV4(userIdValues[0], out _)) + return null; + + return userIdValues[0]; + } + + public static IResult Failure(HttpContext context, int status, string code, string message, object? details = null) + => Results.Json(new ApiFailure(null, new ApiError(code, message, details ?? new { }), context.TraceIdentifier), statusCode: status); + + public static bool TryParseUuidV4(string? value, out Guid id) + => Guid.TryParse(value, out id) && IsUuidV4(id); + + public static bool IsUuidV4(Guid id) + { + if (id == Guid.Empty) return false; + var value = id.ToString("D"); + return value[14] == '4' && value[19] is '8' or '9' or 'a' or 'b'; + } + + public static bool IfNoneMatchMatches(StringValues values, string currentEtag) + { + foreach (var rawValue in values) + { + if (rawValue is null) continue; + foreach (var rawTag in rawValue.Split(',')) + { + var tag = rawTag.Trim(); + if (tag == "*") return true; + if (tag.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) + tag = tag[2..].TrimStart(); + if (string.Equals(tag, currentEtag, StringComparison.Ordinal)) + return true; + } + } + return false; + } +} diff --git a/backend/GalGameService/GalGameMiddleware.cs b/backend/GalGameService/GalGameMiddleware.cs new file mode 100644 index 0000000..2e11cec --- /dev/null +++ b/backend/GalGameService/GalGameMiddleware.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Diagnostics; + +public static class GalGameMiddleware +{ + // X-Correlation-Id:校验格式并截断长度,防止头注入和日志注入 + public static void UseGalGameCorrelationId(this WebApplication app) + { + app.Use(async (context, next) => + { + const int MaxCorrelationIdLength = 64; + var rawCorrelationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); + + string correlationId; + if (!string.IsNullOrWhiteSpace(rawCorrelationId) + && rawCorrelationId.Length <= MaxCorrelationIdLength + && rawCorrelationId.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')) + { + correlationId = rawCorrelationId; + } + else + { + correlationId = Guid.NewGuid().ToString("N"); + } + + context.TraceIdentifier = correlationId; + context.Response.Headers["X-Correlation-Id"] = correlationId; + await next(); + }); + } + + // 异常处理 + public static void UseGalGameExceptionHandler(this WebApplication app) + { + app.UseExceptionHandler(error => error.Run(context => + { + var exception = context.Features.Get()?.Error; + var logger = context.RequestServices.GetRequiredService().CreateLogger("GalGameService"); + + if (exception is UpstreamContractException) + { + logger.LogError(exception, "KnowledgeService returned an invalid contract. CorrelationId: {CorrelationId}; Path: {Path}", + context.TraceIdentifier, context.Request.Path); + return Results.Json( + ApiFailure.Create("UPSTREAM_CONTRACT_INVALID", "知识图谱服务响应不符合契约", context.TraceIdentifier), + statusCode: StatusCodes.Status502BadGateway).ExecuteAsync(context); + } + if (exception is BadHttpRequestException or System.Text.Json.JsonException) + { + logger.LogWarning(exception, "Invalid GalGameService request. CorrelationId: {CorrelationId}; Path: {Path}", + context.TraceIdentifier, context.Request.Path); + return Results.Json( + ApiFailure.Create("VALIDATION_ERROR", "请求 JSON、参数或字段格式错误", context.TraceIdentifier), + statusCode: StatusCodes.Status400BadRequest).ExecuteAsync(context); + } + logger.LogError(exception, "Unhandled GalGameService error. CorrelationId: {CorrelationId}; Path: {Path}", + context.TraceIdentifier, context.Request.Path); + // 未预期异常不向调用方泄露内部实现细节;对外统一为可重试的上游不可用。 + return Results.Json( + ApiFailure.Create("SERVICE_UNAVAILABLE", "游戏生成服务暂时不可用", context.TraceIdentifier), + statusCode: StatusCodes.Status503ServiceUnavailable).ExecuteAsync(context); + })); + } + + // Gateway 密钥验证(/healthz、/readyz 豁免) + public static void UseGalGameGatewayKey(this WebApplication app, string gatewayKey) + { + app.Use(async (context, next) => + { + if (context.Request.Path == "/healthz" || context.Request.Path == "/readyz") + { + await next(); + return; + } + if (!GalGameHttp.IsGateway(context, gatewayKey)) + { + await GalGameHttp.Failure(context, 403, "FORBIDDEN", "该服务仅接受经 API Gateway 转发的请求").ExecuteAsync(context); + return; + } + await next(); + }); + } +} diff --git a/backend/GalGameService/GalGameStartupRecovery.cs b/backend/GalGameService/GalGameStartupRecovery.cs new file mode 100644 index 0000000..d1b3560 --- /dev/null +++ b/backend/GalGameService/GalGameStartupRecovery.cs @@ -0,0 +1,35 @@ +// ============================================================================ +// 启动恢复:将因服务重启而卡在 RUNNING/QUEUED 的生成任务标记为 FAILED +// ============================================================================ + +public static class GalGameStartupRecovery +{ + public static async Task RecoverStaleJobsAsync(WebApplication app, bool useMongoStore) + { + if (!useMongoStore) return; + try + { + var store = app.Services.GetRequiredService(); + var recovery = store.RecoverStaleJobs(); + if (recovery.FailedCount > 0) + { + var startupLogger = app.Services.GetRequiredService().CreateLogger("GalGameService"); + startupLogger.LogWarning("Startup recovery: {Count} stale job(s) marked as FAILED", recovery.FailedCount); + // 恢复置 FAILED 的作业仍持有 HELD 预授权;逐一释放,避免 credits 悬挂 + foreach (var failedJobId in recovery.FailedJobIds) + { + try { await app.Services.GetRequiredService().ReleaseAsync(failedJobId, CancellationToken.None); } + catch (Exception releaseError) + { + startupLogger.LogError(releaseError, "Unable to release credits for recovered job {GenerationId}", failedJobId); + } + } + } + } + catch (Exception ex) + { + var startupLogger = app.Services.GetRequiredService().CreateLogger("GalGameService"); + startupLogger.LogWarning(ex, "Startup recovery failed; stale jobs may remain in RUNNING/QUEUED state"); + } + } +} diff --git a/backend/GalGameService/GalGameTraceFlow.cs b/backend/GalGameService/GalGameTraceFlow.cs new file mode 100644 index 0000000..3bd6f47 --- /dev/null +++ b/backend/GalGameService/GalGameTraceFlow.cs @@ -0,0 +1,23 @@ +/// +/// 请求级链路上下文(AsyncLocal)。由 RequestLogging 中间件写入, +/// 供 CreditBillingClient / PlanGraphClient 等出站调用透传 X-Correlation-Id。 +/// 后台任务(GameGenerationWorker)无 HttpContext 时可显式 Begin。 +/// +public static class GalGameTraceFlow +{ + private static readonly AsyncLocal CurrentId = new(); + + public static string? Current => CurrentId.Value; + + public static IDisposable? Begin(string traceId) + { + var previous = CurrentId.Value; + CurrentId.Value = traceId; + return new Releaser(previous); + } + + private sealed class Releaser(string? previous) : IDisposable + { + public void Dispose() => CurrentId.Value = previous; + } +} diff --git a/backend/GalGameService/GameGenerationEndpoints.cs b/backend/GalGameService/GameGenerationEndpoints.cs new file mode 100644 index 0000000..80ebc17 --- /dev/null +++ b/backend/GalGameService/GameGenerationEndpoints.cs @@ -0,0 +1,272 @@ +// ============================================================================ +// 端点 1:POST /api/v1/game-generations — 创建游戏包生成任务 +// 契约 §7.3.1 URGENT:同步经 Gateway 读取 PlanGraph 并校验 snapshotVersion。 +// - snapshot 不匹配 → 422 REVIEW_PLAN_SNAPSHOT_MISMATCH +// - reviewPlan 不存在 → 422 REVIEW_PLAN_NOT_FOUND +// - 上游不可用 → 503 SERVICE_UNAVAILABLE +// - 校验通过 → 202 Accepted,后台异步生成 +// ============================================================================ +// 端点 2:GET /api/v1/game-generations/{generationId} — 查询生成任务 +// ============================================================================ + +public static class GameGenerationEndpoints +{ + public static void MapGameGenerationEndpoints( + this WebApplication app, + string gatewayKey, + bool isMockMode, + bool narrativeEnabled, + NarrativeGenerationOptions narrativeOptions) + { + app.MapPost("/api/v1/game-generations", async (GameGenerationRequest request, HttpContext c, IGameStore store, PlanGraphClient planClient, NarrativeGenerationService narrativeService, PackageVoiceService voiceService, IGameCreditBilling billing) => + { + var userId = GalGameHttp.GatewayUser(c, gatewayKey); + if (userId is null) return GalGameHttp.Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); + + // 请求验证 + if (!GalGameHttp.IsUuidV4(request.ReviewPlanId)) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", "reviewPlanId 必须为 UUID v4。"); + if (string.IsNullOrWhiteSpace(request.SnapshotVersion)) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", "snapshotVersion 不能为空。"); + if (string.IsNullOrWhiteSpace(request.Locale)) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", "locale 不能为空。"); + + var traceId = c.TraceIdentifier; + var logger = c.RequestServices.GetRequiredService().CreateLogger("GalGameService.Generation"); + if (!Guid.TryParse(userId, out var ownerUserId)) + return GalGameHttp.Failure(c, 401, "AUTH_REQUIRED", "用户身份格式无效。"); + + // §7.3.1 URGENT:同步经 Gateway 读取不可变 PlanGraph,校验 snapshotVersion + PlanGraphFetchResult fetchResult; + try + { + fetchResult = await planClient.GetGraphAsync( + request.ReviewPlanId, request.SnapshotVersion, traceId, c.RequestAborted, mockOwnerUserId: ownerUserId); + } + + catch (OperationCanceledException) when (c.RequestAborted.IsCancellationRequested) + { + return GalGameHttp.Failure(c, 499, "CLIENT_CLOSED_REQUEST", "客户端断开连接"); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to read PlanGraph. CorrelationId: {CorrelationId}; ReviewPlanId: {ReviewPlanId}", + traceId, request.ReviewPlanId); + return GalGameHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "知识图谱服务暂时不可用"); + } + + if (fetchResult.Status == PlanGraphFetchStatus.SnapshotMismatch) + return GalGameHttp.Failure(c, 422, "REVIEW_PLAN_SNAPSHOT_MISMATCH", fetchResult.Detail ?? "snapshot 不匹配"); + if (fetchResult.Status == PlanGraphFetchStatus.NotFound) + return GalGameHttp.Failure(c, 422, "REVIEW_PLAN_NOT_FOUND", fetchResult.Detail ?? "复习计划不存在"); + if (fetchResult.Status == PlanGraphFetchStatus.InvalidRequest) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", fetchResult.Detail ?? "复习计划图请求不符合契约"); + if (fetchResult.Status == PlanGraphFetchStatus.UpstreamContractInvalid) + throw new UpstreamContractException(fetchResult.Detail ?? "KnowledgeService 响应不符合契约"); + if (fetchResult.Status == PlanGraphFetchStatus.Unavailable) + return GalGameHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", fetchResult.Detail ?? "知识图谱服务不可用"); + if (fetchResult.Status != PlanGraphFetchStatus.Success || fetchResult.Graph is null) + throw new UpstreamContractException("PlanGraph 读取结果状态不完整"); + + var graph = fetchResult.Graph; + if (graph.OwnerUserId != ownerUserId) + return GalGameHttp.Failure(c, 422, "REVIEW_PLAN_NOT_FOUND", "复习计划不存在或不可用于当前用户。"); + + // PlanGraph 已校验且属于当前用户,创建生成任务 + GameGenerationJob job; + try + { + job = store.CreateJob(userId, request); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to create generation job. CorrelationId: {CorrelationId}; ReviewPlanId: {ReviewPlanId}", + traceId, request.ReviewPlanId); + return GalGameHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "游戏生成任务暂时不可用"); + } + + var estimatedUnits = narrativeEnabled + ? Math.Max(1L, + ((graph.Nodes ?? []).Sum(node => (long)((node.Title?.Length ?? 0) + (node.Summary?.Length ?? 0))) / 2L + + 2_048L + + Math.Clamp(narrativeOptions.MaxOutputTokens, 1_000, 32_000)) + * Math.Clamp(narrativeOptions.MaxDraftAttempts, 1, 3) + * Math.Clamp(narrativeOptions.MaxProviderAttempts, 1, 4)) + : 1L; + try + { + await billing.ReserveAsync(ownerUserId, job.GenerationId, estimatedUnits, c.RequestAborted); + } + catch (CreditBillingException credit) + { + // 上游 details 由 System.Text.Json 解析为 JsonElement;MongoDB 的安全 ObjectSerializer + // 不接受该运行时类型。任务仅持久化稳定的错误码与消息,即时响应仍完整返回购买信息。 + store.TryTransitionJob(job.GenerationId, JobStatus.QUEUED, j => j with { Status = JobStatus.FAILED, Error = new ApiError(credit.Code, credit.Message, new Dictionary()) }); + return GalGameHttp.Failure(c, credit.StatusCode, credit.Code, credit.Message, credit.Details); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to reserve credits. CorrelationId: {CorrelationId}", traceId); + store.TryTransitionJob(job.GenerationId, JobStatus.QUEUED, j => j with { Status = JobStatus.FAILED, Error = new ApiError("SERVICE_UNAVAILABLE", "credits 服务暂时不可用", new Dictionary()) }); + return GalGameHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "credits 服务暂时不可用"); + } + + // 入队到 GameGenerationQueue,由 GameGenerationWorker 随 Host 生命周期消费 + // 从根 Provider 取单例,避免请求结束销毁 scoped 服务 + // 闭包提为方法级局部变量:入队失败时可在 catch 中按退避重试同一工作项 + Func? generationWork = null; + try + { + var generationQueue = app.Services.GetRequiredService(); + generationWork = async workToken => + { + // 后台执行时已离开 HTTP 上下文,显式挂回原请求 traceId,保证 credits 调用可串联 + using var workTrace = GalGameTraceFlow.Begin(traceId); + var reservationSettled = false; + try + { + // 原子状态转换:QUEUED → RUNNING + if (store.TryTransitionJob(job.GenerationId, JobStatus.QUEUED, + j => j with { Status = JobStatus.RUNNING, Progress = 5, AttemptCount = j.AttemptCount + 1 }) is null) + { + logger.LogWarning("Job {GenerationId} was not in QUEUED state, skipping generation", job.GenerationId); + return; + } + + void ReportProgress(int progress) + { + try + { + store.TryTransitionJob(job.GenerationId, JobStatus.RUNNING, current => + current.Progress >= progress ? current : current with { Progress = progress }); + } + catch (Exception progressError) + { + // 进度写入是观测信息,不应破坏已经在进行的剧情生成。 + logger.LogWarning(progressError, + "Unable to persist progress {Progress} for job {GenerationId}", + progress, + job.GenerationId); + } + } + + logger.LogInformation("Job {GenerationId} started generating. Style={Style}, Difficulty={Difficulty}", + job.GenerationId, request.Style, request.Difficulty); + + // Mock 固定返回同一套原创演示剧情;仍保留请求的计划与快照字段, + // 使包的溯源、任务查询和权限边界继续符合 §7.1 / §7.3.1。 + // 非 Mock 才执行真实的骨架生成与可选叙事模型重写。 + GamePackage package; + long actualTokenUnits; + if (isMockMode) + { + package = MockStoryPackageFactory.Create(request); + actualTokenUnits = 0; + ReportProgress(85); + } + else + { + var generation = await narrativeService.GenerateAsync( + graph, + request, + userId, + ReportProgress); + package = generation.Package; + actualTokenUnits = generation.TotalTokens; + } + + ReportProgress(86); + var voiceResult = await voiceService.SynthesizeAsync( + package, + ReportProgress); + package = voiceResult.Package; + ReportProgress(94); + var checksum = GamePackageValidator.ComputeChecksum(package); + ReportProgress(95); + var manifest = new GamePackageManifest( + package.PackageId, package.SchemaVersion, package.GeneratorVersion, + package.ReviewPlanId, package.SnapshotVersion, package.EntrySceneId, + package.Scenes.Length, checksum, + $"/api/v1/game-packages/{package.PackageId}/content", + userId, DateTimeOffset.UtcNow); + + foreach (var audio in voiceResult.AudioAssets) + store.SaveAudio(audio); + store.SavePackage(package, manifest, userId); + + // A failed persistence step must release the reservation instead of + // charging for a package that the user cannot retrieve. + await billing.SettleAsync(job.GenerationId, actualTokenUnits, CancellationToken.None); + reservationSettled = true; + ReportProgress(97); + + // 原子状态转换:RUNNING → SUCCEEDED + store.TryTransitionJob(job.GenerationId, JobStatus.RUNNING, + j => j with { Status = JobStatus.SUCCEEDED, Progress = 100, PackageId = package.PackageId }); + + logger.LogInformation("Job {GenerationId} succeeded. PackageId={PackageId}, Scenes={SceneCount}", + job.GenerationId, package.PackageId, package.Scenes.Length); + } + catch (Exception ex) + { + logger.LogError(ex, "Job {GenerationId} failed during generation", job.GenerationId); + if (!reservationSettled) + { + try { await billing.ReleaseAsync(job.GenerationId, CancellationToken.None); } + catch (Exception releaseError) { logger.LogError(releaseError, "Unable to release credits for failed job {GenerationId}", job.GenerationId); } + } + store.TryTransitionJob(job.GenerationId, JobStatus.RUNNING, + j => j with { Status = JobStatus.FAILED, Error = new ApiError("INTERNAL_ERROR", "生成任务处理失败,请稍后重试或联系支持团队", new Dictionary()) }); + } + }; + generationQueue.Enqueue(generationWork); + } + catch (Exception enqueueError) + { + // 入队失败(如队列瞬时不可用):预授权仍持有,短退避内联重试;全部失败才释放并置 FAILED + logger.LogError(enqueueError, "Unable to enqueue generation job {GenerationId}; retrying with backoff", job.GenerationId); + var enqueued = false; + for (var retryAttempt = 1; retryAttempt <= 3 && !enqueued && generationWork is not null; retryAttempt++) + { + if (retryAttempt > 1) + { + try { await Task.Delay(TimeSpan.FromSeconds(2 * (retryAttempt - 1)), c.RequestAborted); } + catch (OperationCanceledException) { break; } + } + try + { + app.Services.GetRequiredService().Enqueue(generationWork!); + enqueued = true; + } + catch (Exception retryError) + { + logger.LogWarning(retryError, "Enqueue retry #{Attempt} failed for {GenerationId}", retryAttempt, job.GenerationId); + } + } + if (enqueued) + return Results.Accepted($"/api/v1/game-generations/{job.GenerationId}", ApiSuccess.Create(job, c.TraceIdentifier)); + + logger.LogError("Enqueue retries exhausted for generation job {GenerationId}", job.GenerationId); + try { await billing.ReleaseAsync(job.GenerationId, CancellationToken.None); } + catch (Exception releaseError) { logger.LogError(releaseError, "Unable to release credits after enqueue failure for {GenerationId}", job.GenerationId); } + store.TryTransitionJob(job.GenerationId, JobStatus.QUEUED, + j => j with { Status = JobStatus.FAILED, Error = new ApiError("SERVICE_UNAVAILABLE", "生成队列暂不可用,请稍后重试", new Dictionary()) }); + return GalGameHttp.Failure(c, 503, "SERVICE_UNAVAILABLE", "生成队列暂不可用,请稍后重试"); + } + + return Results.Accepted($"/api/v1/game-generations/{job.GenerationId}", ApiSuccess.Create(job, c.TraceIdentifier)); + }); + + app.MapGet("/api/v1/game-generations/{generationId}", (string generationId, HttpContext c, IGameStore store) => + { + var userId = GalGameHttp.GatewayUser(c, gatewayKey); + if (userId is null) return GalGameHttp.Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); + if (!GalGameHttp.TryParseUuidV4(generationId, out var id)) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", "generationId 必须为 UUID v4。"); + var job = store.GetJob(id); + if (job is null || job.OwnerUserId != userId) + return GalGameHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "生成任务不存在。"); + return Results.Ok(ApiSuccess.Create(job, c.TraceIdentifier)); + }); + } +} diff --git a/backend/GalGameService/GameGenerationQueue.cs b/backend/GalGameService/GameGenerationQueue.cs new file mode 100644 index 0000000..7df5db4 --- /dev/null +++ b/backend/GalGameService/GameGenerationQueue.cs @@ -0,0 +1,27 @@ +using System.Threading.Channels; + +/// +/// 剧情生成工作队列:替代 Task.Run fire-and-forget,随 Host 生命周期取消并可排空。 +/// +public sealed class GameGenerationQueue +{ + private readonly Channel> _channel = + Channel.CreateUnbounded>( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + public void Enqueue(Func work) + { + ArgumentNullException.ThrowIfNull(work); + if (!_channel.Writer.TryWrite(work)) + throw new InvalidOperationException("Game generation queue is completed."); + } + + public IAsyncEnumerable> ReadAllAsync(CancellationToken cancellationToken) => + _channel.Reader.ReadAllAsync(cancellationToken); + + public void Complete() => _channel.Writer.TryComplete(); +} diff --git a/backend/GalGameService/GameGenerationWorker.cs b/backend/GalGameService/GameGenerationWorker.cs new file mode 100644 index 0000000..f212221 --- /dev/null +++ b/backend/GalGameService/GameGenerationWorker.cs @@ -0,0 +1,41 @@ +/// +/// 串行消费生成队列。每个任务内部已有完整状态机与 credits 结算;此处只负责 +/// 生命周期与异常兜底,避免无界 Task.Run。 +/// +public sealed class GameGenerationWorker( + GameGenerationQueue queue, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await foreach (var work in queue.ReadAllAsync(stoppingToken)) + { + if (stoppingToken.IsCancellationRequested) break; + try + { + await work(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception error) + { + logger.LogError(error, "Unhandled error in game generation work item."); + } + } + } + catch (OperationCanceledException) + { + // Host shutdown. + } + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + queue.Complete(); + return base.StopAsync(cancellationToken); + } +} diff --git a/backend/GalGameService/GamePackageEndpoints.cs b/backend/GalGameService/GamePackageEndpoints.cs new file mode 100644 index 0000000..9c4b0b1 --- /dev/null +++ b/backend/GalGameService/GamePackageEndpoints.cs @@ -0,0 +1,121 @@ +public static class GamePackageEndpoints +{ + public static void MapGamePackageEndpoints( + this WebApplication app, + string gatewayKey, + IReadOnlySet packageReaderAllowedServices, + IReadOnlySet validationAllowedServices) + { + app.MapGet("/api/v1/game-packages/{packageId}", (string packageId, HttpContext c, IGameStore store) => + { + var userId = GalGameHttp.GatewayUser(c, gatewayKey); + if (userId is null) return GalGameHttp.Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); + if (!GalGameHttp.TryParseUuidV4(packageId, out var id)) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", "packageId 必须为 UUID v4。"); + var manifest = store.GetManifest(id); + if (manifest is null || manifest.OwnerUserId != userId) + return GalGameHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "游戏包不存在。"); + return Results.Ok(ApiSuccess.Create(manifest, c.TraceIdentifier)); + }); + + app.MapGet("/api/v1/game-packages/{packageId}/content", (string packageId, HttpContext c, IGameStore store) => + { + var userId = GalGameHttp.GatewayUser(c, gatewayKey); + if (userId is null) return GalGameHttp.Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); + if (!GalGameHttp.TryParseUuidV4(packageId, out var id)) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", "packageId 必须为 UUID v4。"); + + var owner = store.GetPackageOwner(id); + if (owner is null || owner != userId) + return GalGameHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "游戏包不存在。"); + + var manifest = store.GetManifest(id); + var package = store.GetPackage(id); + if (manifest is null || package is null) + return GalGameHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "游戏包不存在。"); + + var etag = $"\"{manifest.Checksum}\""; + c.Response.Headers.ETag = etag; + c.Response.Headers.CacheControl = "private, no-cache"; + c.Response.Headers.XContentTypeOptions = "nosniff"; + if (GalGameHttp.IfNoneMatchMatches(c.Request.Headers.IfNoneMatch, etag)) + return Results.StatusCode(304); + + return Results.Text( + GamePackageValidator.SerializeCanonical(package), + "application/json; charset=utf-8"); + }); + + app.MapGet("/api/v1/game-packages/{packageId}/audio/{assetId}", ( + string packageId, + string assetId, + HttpContext c, + IGameStore store) => + { + var userId = GalGameHttp.GatewayUser(c, gatewayKey); + if (userId is null) + return GalGameHttp.Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); + if (!GalGameHttp.TryParseUuidV4(packageId, out var id) + || string.IsNullOrWhiteSpace(assetId) + || assetId.Length > 128) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", "packageId 或 assetId 格式无效。"); + + var owner = store.GetPackageOwner(id); + var package = store.GetPackage(id); + if (owner is null || package is null || owner != userId) + return GalGameHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "语音资源不存在。"); + + var expectedUri = $"/api/v1/game-packages/{id}/audio/{assetId}"; + var referenced = (package.Assets ?? Array.Empty()).Any(asset => + asset is not null + && asset.Type == AssetType.AUDIO + && string.Equals(asset.AssetId, assetId, StringComparison.Ordinal) + && string.Equals(asset.Uri, expectedUri, StringComparison.Ordinal)); + var audio = referenced ? store.GetAudio(id, assetId) : null; + if (audio is null) + return GalGameHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "语音资源不存在。"); + + c.Response.Headers.CacheControl = "private, max-age=31536000, immutable"; + c.Response.Headers.XContentTypeOptions = "nosniff"; + return Results.File(audio.Data, audio.ContentType, enableRangeProcessing: true); + }); + + app.MapGet("/internal/v1/game-packages/{packageId}", ( + string packageId, + string? ownerUserId, + HttpContext c, + IGameStore store) => + { + if (!InternalServiceAccessPolicy.IsTrusted( + c.Request.Headers, gatewayKey, packageReaderAllowedServices)) + return GalGameHttp.Failure(c, 403, "FORBIDDEN", "需要经 Gateway 转发的可信 RenderService 身份。"); + + if (!GalGameHttp.TryParseUuidV4(packageId, out var id) + || !GalGameHttp.TryParseUuidV4(ownerUserId, out _)) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", "packageId 与 ownerUserId 必须为 UUID v4。"); + + var owner = store.GetPackageOwner(id); + var package = store.GetPackage(id); + if (owner is null || package is null + || !string.Equals(owner, ownerUserId, StringComparison.OrdinalIgnoreCase)) + return GalGameHttp.Failure(c, 404, "RESOURCE_NOT_FOUND", "游戏包不存在。"); + + return Results.Ok(ApiSuccess.Create(package, c.TraceIdentifier)); + }); + + app.MapPost("/internal/v1/game-package-validations", (GamePackageValidationRequest request, HttpContext c, GamePackageValidator validator) => + { + if (!InternalServiceAccessPolicy.IsTrusted( + c.Request.Headers, gatewayKey, validationAllowedServices)) + return GalGameHttp.Failure(c, 403, "FORBIDDEN", "需要经 Gateway 转发的可信服务身份。"); + + if (request.Package is null) + return GalGameHttp.Failure(c, 400, "VALIDATION_ERROR", "package 不能为空。"); + + var result = validator.Validate(request.Package); + return Results.Json( + ApiSuccess.Create(result, c.TraceIdentifier), + statusCode: result.Valid ? StatusCodes.Status200OK : StatusCodes.Status422UnprocessableEntity); + }); + } +} diff --git a/backend/GalGameService/InMemoryGameStore.cs b/backend/GalGameService/InMemoryGameStore.cs index f8ae1f3..139b076 100644 --- a/backend/GalGameService/InMemoryGameStore.cs +++ b/backend/GalGameService/InMemoryGameStore.cs @@ -34,10 +34,10 @@ public interface IGameStore GameAudioAsset? GetAudio(Guid packageId, string assetId); /// - /// 启动恢复:将卡在 RUNNING/QUEUED 的任务标记为 FAILED。 - /// InMemoryGameStore 无持久化,直接返回 0。 + /// 启动恢复:将卡在 RUNNING/QUEUED 的任务标记为 FAILED,并返回失败作业 ID + /// (调用方负责释放其 HELD 预授权)。InMemoryGameStore 无持久化,无事可恢复。 /// - int RecoverStaleJobs() => 0; + GameStaleJobRecovery RecoverStaleJobs() => new(0, []); } public sealed class InMemoryGameStore : IGameStore diff --git a/backend/GalGameService/MongoGameStore.cs b/backend/GalGameService/MongoGameStore.cs index a0416aa..0e81523 100644 --- a/backend/GalGameService/MongoGameStore.cs +++ b/backend/GalGameService/MongoGameStore.cs @@ -172,7 +172,7 @@ public MongoGameStore( /// 因为当前实例无法继续执行前一个实例的生成流程。 /// 应在应用启动阶段调用。 /// - public int RecoverStaleJobs() + public GameStaleJobRecovery RecoverStaleJobs() { try { @@ -180,9 +180,13 @@ public int RecoverStaleJobs() Builders.Filter.Eq(j => j.Status, JobStatus.RUNNING), Builders.Filter.Eq(j => j.Status, JobStatus.QUEUED)); + // 先读后写:启动恢复发生在对外服务之前,读写间隙没有并发转换 + var staleJobs = _jobs.Find(staleFilter).ToList(); + if (staleJobs.Count == 0) return new GameStaleJobRecovery(0, []); + var recoveryError = new ApiError( Code: "JOB_RECOVERED_AFTER_RESTART", - Message: "Service restarted while job was running; job has been marked as failed", + Message: "Service restarted while job was pending or running; job has been marked as failed", Details: new Dictionary()); var update = Builders.Update @@ -192,23 +196,20 @@ public int RecoverStaleJobs() .Set(j => j.UpdatedAt, DateTimeOffset.UtcNow); var result = _jobs.UpdateMany(staleFilter, update); - var recovered = (int)result.ModifiedCount; + var failedIds = staleJobs.Select(j => j.GenerationId).ToList(); - if (recovered > 0) - { - _logger?.LogWarning( - "Recovered {Count} stale job(s) (RUNNING/QUEUED -> FAILED) after service restart", - recovered); - } + _logger?.LogWarning( + "Recovered {Count} stale job(s) (RUNNING/QUEUED -> FAILED) after service restart", + result.ModifiedCount); - return recovered; + return new GameStaleJobRecovery((int)result.ModifiedCount, failedIds); } // 同上:不可达时是 TimeoutException,只捕 MongoException 会让"失败即维持原状" // 的自述路径实际走不到,异常会一路冒到启动逻辑 catch (Exception ex) when (ex is MongoException or TimeoutException) { _logger?.LogWarning(ex, "Failed to recover stale jobs; they will remain in RUNNING/QUEUED state"); - return 0; + return new GameStaleJobRecovery(0, []); } } diff --git a/backend/GalGameService/Program.cs b/backend/GalGameService/Program.cs index 5cbace0..eb49411 100644 --- a/backend/GalGameService/Program.cs +++ b/backend/GalGameService/Program.cs @@ -1,6 +1,4 @@ using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Mvc; // ============================================================================ // GalGameService — 游戏生成任务、游戏包与 schema(F15EX 负责) @@ -14,8 +12,7 @@ // ============================================================================ var builder = WebApplication.CreateBuilder(args); -builder.Logging.ClearProviders(); -builder.Logging.AddConsole(); +builder.Logging.AddJsonStructuredLogging(); builder.Services.Configure( options => options.ThrowOnBadRequest = true); @@ -29,6 +26,8 @@ var gatewayKey = builder.Configuration["Gateway:ServiceKey"] ?? throw new InvalidOperationException("Gateway:ServiceKey must be configured."); +if (builder.Environment.IsProduction() && string.Equals(gatewayKey, "moonstone-local-gateway-key", StringComparison.Ordinal)) + throw new InvalidOperationException("Gateway:ServiceKey must be changed from the development default in production."); var isMockMode = string.Equals( builder.Configuration["MOONSTONE_MODE"] ?? Environment.GetEnvironmentVariable("MOONSTONE_MODE"), "Mock", StringComparison.OrdinalIgnoreCase); @@ -134,6 +133,8 @@ sp.GetRequiredService(), isMockMode)); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); var app = builder.Build(); @@ -165,100 +166,14 @@ Math.Clamp(voiceOptions.MaxConcurrency, 1, 6)); } -// ============================================================================ -// 启动恢复:将因服务重启而卡在 RUNNING/QUEUED 的生成任务标记为 FAILED -// ============================================================================ -if (useMongoStore) -{ - try - { - var store = app.Services.GetRequiredService(); - var recovered = store.RecoverStaleJobs(); - if (recovered > 0) - { - var startupLogger = app.Services.GetRequiredService().CreateLogger("GalGameService"); - startupLogger.LogWarning("Startup recovery: {Count} stale job(s) marked as FAILED", recovered); - } - } - catch (Exception ex) - { - var startupLogger = app.Services.GetRequiredService().CreateLogger("GalGameService"); - startupLogger.LogWarning(ex, "Startup recovery failed; stale jobs may remain in RUNNING/QUEUED state"); - } -} +await GalGameStartupRecovery.RecoverStaleJobsAsync(app, useMongoStore); -// ============================================================================ -// 中间件 -// ============================================================================ - -// X-Correlation-Id:校验格式并截断长度,防止头注入和日志注入 -app.Use(async (context, next) => -{ - const int MaxCorrelationIdLength = 64; - var rawCorrelationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); - - string correlationId; - if (!string.IsNullOrWhiteSpace(rawCorrelationId) - && rawCorrelationId.Length <= MaxCorrelationIdLength - && rawCorrelationId.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')) - { - correlationId = rawCorrelationId; - } - else - { - correlationId = Guid.NewGuid().ToString("N"); - } - - context.TraceIdentifier = correlationId; - context.Response.Headers["X-Correlation-Id"] = correlationId; - await next(); -}); - -// 异常处理 -app.UseExceptionHandler(error => error.Run(context => -{ - var exception = context.Features.Get()?.Error; - var logger = context.RequestServices.GetRequiredService().CreateLogger("GalGameService"); - - if (exception is UpstreamContractException) - { - logger.LogError(exception, "KnowledgeService returned an invalid contract. CorrelationId: {CorrelationId}; Path: {Path}", - context.TraceIdentifier, context.Request.Path); - return Results.Json( - ApiFailure.Create("UPSTREAM_CONTRACT_INVALID", "知识图谱服务响应不符合契约", context.TraceIdentifier), - statusCode: StatusCodes.Status502BadGateway).ExecuteAsync(context); - } - if (exception is BadHttpRequestException or System.Text.Json.JsonException) - { - logger.LogWarning(exception, "Invalid GalGameService request. CorrelationId: {CorrelationId}; Path: {Path}", - context.TraceIdentifier, context.Request.Path); - return Results.Json( - ApiFailure.Create("VALIDATION_ERROR", "请求 JSON、参数或字段格式错误", context.TraceIdentifier), - statusCode: StatusCodes.Status400BadRequest).ExecuteAsync(context); - } - logger.LogError(exception, "Unhandled GalGameService error. CorrelationId: {CorrelationId}; Path: {Path}", - context.TraceIdentifier, context.Request.Path); - // 未预期异常不向调用方泄露内部实现细节;对外统一为可重试的上游不可用。 - return Results.Json( - ApiFailure.Create("SERVICE_UNAVAILABLE", "游戏生成服务暂时不可用", context.TraceIdentifier), - statusCode: StatusCodes.Status503ServiceUnavailable).ExecuteAsync(context); -})); - -// Gateway 密钥验证(/healthz、/readyz 豁免) -app.Use(async (context, next) => -{ - if (context.Request.Path == "/healthz" || context.Request.Path == "/readyz") - { - await next(); - return; - } - if (!IsGateway(context, gatewayKey)) - { - await Failure(context, 403, "FORBIDDEN", "该服务仅接受经 API Gateway 转发的请求").ExecuteAsync(context); - return; - } - await next(); -}); +app.UseGalGameCorrelationId(); +app.UseRequestLogging("GalGameService", normalizeCorrelationId: false); +// 出站透传:CreditBillingClient 等读 GalGameTraceFlow.Current +RequestLogging.BeginAmbientTrace = GalGameTraceFlow.Begin; +app.UseGalGameExceptionHandler(); +app.UseGalGameGatewayKey(gatewayKey); // ============================================================================ // 健康检查 @@ -284,423 +199,13 @@ }, c.TraceIdentifier)); }); -// ============================================================================ -// 端点 1:POST /api/v1/game-generations — 创建游戏包生成任务 -// 契约 §7.3.1 URGENT:同步经 Gateway 读取 PlanGraph 并校验 snapshotVersion。 -// - snapshot 不匹配 → 422 REVIEW_PLAN_SNAPSHOT_MISMATCH -// - reviewPlan 不存在 → 422 REVIEW_PLAN_NOT_FOUND -// - 上游不可用 → 503 SERVICE_UNAVAILABLE -// - 校验通过 → 202 Accepted,后台异步生成 -// ============================================================================ - -app.MapPost("/api/v1/game-generations", async (GameGenerationRequest request, HttpContext c, IGameStore store, PlanGraphClient planClient, NarrativeGenerationService narrativeService, PackageVoiceService voiceService, IGameCreditBilling billing) => -{ - var userId = GatewayUser(c, gatewayKey); - if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); - - // 请求验证 - if (!IsUuidV4(request.ReviewPlanId)) - return Failure(c, 400, "VALIDATION_ERROR", "reviewPlanId 必须为 UUID v4。"); - if (string.IsNullOrWhiteSpace(request.SnapshotVersion)) - return Failure(c, 400, "VALIDATION_ERROR", "snapshotVersion 不能为空。"); - if (string.IsNullOrWhiteSpace(request.Locale)) - return Failure(c, 400, "VALIDATION_ERROR", "locale 不能为空。"); - - var traceId = c.TraceIdentifier; - var logger = c.RequestServices.GetRequiredService().CreateLogger("GalGameService.Generation"); - if (!Guid.TryParse(userId, out var ownerUserId)) - return Failure(c, 401, "AUTH_REQUIRED", "用户身份格式无效。"); - - // §7.3.1 URGENT:同步经 Gateway 读取不可变 PlanGraph,校验 snapshotVersion - PlanGraphFetchResult fetchResult; - try - { - fetchResult = await planClient.GetGraphAsync( - request.ReviewPlanId, request.SnapshotVersion, traceId, c.RequestAborted, mockOwnerUserId: ownerUserId); - } - - catch (OperationCanceledException) when (c.RequestAborted.IsCancellationRequested) - { - return Failure(c, 499, "CLIENT_CLOSED_REQUEST", "客户端断开连接"); - } - catch (Exception ex) - { - logger.LogError(ex, "Unable to read PlanGraph. CorrelationId: {CorrelationId}; ReviewPlanId: {ReviewPlanId}", - traceId, request.ReviewPlanId); - return Failure(c, 503, "SERVICE_UNAVAILABLE", "知识图谱服务暂时不可用"); - } - - if (fetchResult.Status == PlanGraphFetchStatus.SnapshotMismatch) - return Failure(c, 422, "REVIEW_PLAN_SNAPSHOT_MISMATCH", fetchResult.Detail ?? "snapshot 不匹配"); - if (fetchResult.Status == PlanGraphFetchStatus.NotFound) - return Failure(c, 422, "REVIEW_PLAN_NOT_FOUND", fetchResult.Detail ?? "复习计划不存在"); - if (fetchResult.Status == PlanGraphFetchStatus.InvalidRequest) - return Failure(c, 400, "VALIDATION_ERROR", fetchResult.Detail ?? "复习计划图请求不符合契约"); - if (fetchResult.Status == PlanGraphFetchStatus.UpstreamContractInvalid) - throw new UpstreamContractException(fetchResult.Detail ?? "KnowledgeService 响应不符合契约"); - if (fetchResult.Status == PlanGraphFetchStatus.Unavailable) - return Failure(c, 503, "SERVICE_UNAVAILABLE", fetchResult.Detail ?? "知识图谱服务不可用"); - if (fetchResult.Status != PlanGraphFetchStatus.Success || fetchResult.Graph is null) - throw new UpstreamContractException("PlanGraph 读取结果状态不完整"); - - var graph = fetchResult.Graph; - if (graph.OwnerUserId != ownerUserId) - return Failure(c, 422, "REVIEW_PLAN_NOT_FOUND", "复习计划不存在或不可用于当前用户。"); - - // PlanGraph 已校验且属于当前用户,创建生成任务 - GameGenerationJob job; - try - { - job = store.CreateJob(userId, request); - } - catch (Exception ex) - { - logger.LogError(ex, "Unable to create generation job. CorrelationId: {CorrelationId}; ReviewPlanId: {ReviewPlanId}", - traceId, request.ReviewPlanId); - return Failure(c, 503, "SERVICE_UNAVAILABLE", "游戏生成任务暂时不可用"); - } - - var estimatedUnits = narrativeEnabled - ? Math.Max(1L, - ((graph.Nodes ?? []).Sum(node => (long)((node.Title?.Length ?? 0) + (node.Summary?.Length ?? 0))) / 2L - + 2_048L - + Math.Clamp(narrativeOptions.MaxOutputTokens, 1_000, 32_000)) - * Math.Clamp(narrativeOptions.MaxDraftAttempts, 1, 3) - * Math.Clamp(narrativeOptions.MaxProviderAttempts, 1, 4)) - : 1L; - try - { - await billing.ReserveAsync(ownerUserId, job.GenerationId, estimatedUnits, c.RequestAborted); - } - catch (CreditBillingException credit) - { - // 上游 details 由 System.Text.Json 解析为 JsonElement;MongoDB 的安全 ObjectSerializer - // 不接受该运行时类型。任务仅持久化稳定的错误码与消息,即时响应仍完整返回购买信息。 - store.TryTransitionJob(job.GenerationId, JobStatus.QUEUED, j => j with { Status = JobStatus.FAILED, Error = new ApiError(credit.Code, credit.Message, new Dictionary()) }); - return Failure(c, credit.StatusCode, credit.Code, credit.Message, credit.Details); - } - catch (Exception ex) - { - logger.LogError(ex, "Unable to reserve credits. CorrelationId: {CorrelationId}", traceId); - store.TryTransitionJob(job.GenerationId, JobStatus.QUEUED, j => j with { Status = JobStatus.FAILED, Error = new ApiError("SERVICE_UNAVAILABLE", "credits 服务暂时不可用", new Dictionary()) }); - return Failure(c, 503, "SERVICE_UNAVAILABLE", "credits 服务暂时不可用"); - } - - // 后台异步生成(PlanGraph 已确认可用,不再重复获取) - // 使用 _ = 丢弃 Task 但内部有完整异常处理,不会静默吞异常 - _ = Task.Run(async () => - { - var reservationSettled = false; - try - { - // 原子状态转换:QUEUED → RUNNING - if (store.TryTransitionJob(job.GenerationId, JobStatus.QUEUED, - j => j with { Status = JobStatus.RUNNING, Progress = 5 }) is null) - { - logger.LogWarning("Job {GenerationId} was not in QUEUED state, skipping generation", job.GenerationId); - return; - } - - void ReportProgress(int progress) - { - try - { - store.TryTransitionJob(job.GenerationId, JobStatus.RUNNING, current => - current.Progress >= progress ? current : current with { Progress = progress }); - } - catch (Exception progressError) - { - // 进度写入是观测信息,不应破坏已经在进行的剧情生成。 - logger.LogWarning(progressError, - "Unable to persist progress {Progress} for job {GenerationId}", - progress, - job.GenerationId); - } - } - - logger.LogInformation("Job {GenerationId} started generating. Style={Style}, Difficulty={Difficulty}", - job.GenerationId, request.Style, request.Difficulty); - - // Mock 固定返回同一套原创演示剧情;仍保留请求的计划与快照字段, - // 使包的溯源、任务查询和权限边界继续符合 §7.1 / §7.3.1。 - // 非 Mock 才执行真实的骨架生成与可选叙事模型重写。 - GamePackage package; - long actualTokenUnits; - if (isMockMode) - { - package = MockStoryPackageFactory.Create(request); - actualTokenUnits = 0; - ReportProgress(85); - } - else - { - var generation = await narrativeService.GenerateAsync( - graph, - request, - userId, - ReportProgress); - package = generation.Package; - actualTokenUnits = generation.TotalTokens; - } - - ReportProgress(86); - var voiceResult = await voiceService.SynthesizeAsync( - package, - ReportProgress); - package = voiceResult.Package; - ReportProgress(94); - var checksum = GamePackageValidator.ComputeChecksum(package); - ReportProgress(95); - var manifest = new GamePackageManifest( - package.PackageId, package.SchemaVersion, package.GeneratorVersion, - package.ReviewPlanId, package.SnapshotVersion, package.EntrySceneId, - package.Scenes.Length, checksum, - $"/api/v1/game-packages/{package.PackageId}/content", - userId, DateTimeOffset.UtcNow); - - foreach (var audio in voiceResult.AudioAssets) - store.SaveAudio(audio); - store.SavePackage(package, manifest, userId); +// 端点 1-2:生成任务创建 / 查询(GameGenerationEndpoints) +app.MapGameGenerationEndpoints(gatewayKey, isMockMode, narrativeEnabled, narrativeOptions); - // A failed persistence step must release the reservation instead of - // charging for a package that the user cannot retrieve. - await billing.SettleAsync(job.GenerationId, actualTokenUnits, CancellationToken.None); - reservationSettled = true; - ReportProgress(97); - - // 原子状态转换:RUNNING → SUCCEEDED - store.TryTransitionJob(job.GenerationId, JobStatus.RUNNING, - j => j with { Status = JobStatus.SUCCEEDED, Progress = 100, PackageId = package.PackageId }); - - logger.LogInformation("Job {GenerationId} succeeded. PackageId={PackageId}, Scenes={SceneCount}", - job.GenerationId, package.PackageId, package.Scenes.Length); - } - catch (Exception ex) - { - logger.LogError(ex, "Job {GenerationId} failed during generation", job.GenerationId); - if (!reservationSettled) - { - try { await billing.ReleaseAsync(job.GenerationId, CancellationToken.None); } - catch (Exception releaseError) { logger.LogError(releaseError, "Unable to release credits for failed job {GenerationId}", job.GenerationId); } - } - store.TryTransitionJob(job.GenerationId, JobStatus.RUNNING, - j => j with { Status = JobStatus.FAILED, Error = new ApiError("INTERNAL_ERROR", "生成任务处理失败,请稍后重试或联系支持团队", new Dictionary()) }); - } - }); - - return Results.Accepted($"/api/v1/game-generations/{job.GenerationId}", ApiSuccess.Create(job, c.TraceIdentifier)); -}); - -// ============================================================================ -// 端点 2:GET /api/v1/game-generations/{generationId} — 查询生成任务 -// ============================================================================ - -app.MapGet("/api/v1/game-generations/{generationId}", (string generationId, HttpContext c, IGameStore store) => -{ - var userId = GatewayUser(c, gatewayKey); - if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); - if (!TryParseUuidV4(generationId, out var id)) - return Failure(c, 400, "VALIDATION_ERROR", "generationId 必须为 UUID v4。"); - var job = store.GetJob(id); - if (job is null || job.OwnerUserId != userId) - return Failure(c, 404, "RESOURCE_NOT_FOUND", "生成任务不存在。"); - return Results.Ok(ApiSuccess.Create(job, c.TraceIdentifier)); -}); - -// ============================================================================ -// 端点 3:GET /api/v1/game-packages/{packageId} — 读取游戏包清单 -// ============================================================================ - -app.MapGet("/api/v1/game-packages/{packageId}", (string packageId, HttpContext c, IGameStore store) => -{ - var userId = GatewayUser(c, gatewayKey); - if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); - if (!TryParseUuidV4(packageId, out var id)) - return Failure(c, 400, "VALIDATION_ERROR", "packageId 必须为 UUID v4。"); - var manifest = store.GetManifest(id); - if (manifest is null || manifest.OwnerUserId != userId) - return Failure(c, 404, "RESOURCE_NOT_FOUND", "游戏包不存在。"); - return Results.Ok(ApiSuccess.Create(manifest, c.TraceIdentifier)); -}); - -// ============================================================================ -// 端点 4:GET /api/v1/game-packages/{packageId}/content — 下载完整 JSON 游戏包 -// 支持 ETag / 304 协商缓存 + Cache-Control 防止中间代理缓存私有内容 -// ============================================================================ - -app.MapGet("/api/v1/game-packages/{packageId}/content", (string packageId, HttpContext c, IGameStore store) => -{ - var userId = GatewayUser(c, gatewayKey); - if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); - if (!TryParseUuidV4(packageId, out var id)) - return Failure(c, 400, "VALIDATION_ERROR", "packageId 必须为 UUID v4。"); - - var owner = store.GetPackageOwner(id); - if (owner is null || owner != userId) - return Failure(c, 404, "RESOURCE_NOT_FOUND", "游戏包不存在。"); - - var manifest = store.GetManifest(id); - var package = store.GetPackage(id); - if (manifest is null || package is null) - return Failure(c, 404, "RESOURCE_NOT_FOUND", "游戏包不存在。"); - - // ETag / 304 协商缓存 - var etag = $"\"{manifest.Checksum}\""; - c.Response.Headers.ETag = etag; - // 游戏包是用户私有的,禁止共享缓存 - c.Response.Headers.CacheControl = "private, no-cache"; - c.Response.Headers.XContentTypeOptions = "nosniff"; - if (IfNoneMatchMatches(c.Request.Headers.IfNoneMatch, etag)) - return Results.StatusCode(304); - - return Results.Text( - GamePackageValidator.SerializeCanonical(package), - "application/json; charset=utf-8"); -}); - -// Package-scoped MiMo dialogue audio. Authorization is identical to package content; -// the browser downloads bytes with its bearer token and plays a local Blob URL. -app.MapGet("/api/v1/game-packages/{packageId}/audio/{assetId}", ( - string packageId, - string assetId, - HttpContext c, - IGameStore store) => -{ - var userId = GatewayUser(c, gatewayKey); - if (userId is null) - return Failure(c, 401, "AUTH_REQUIRED", "需要网关认证的用户身份。"); - if (!TryParseUuidV4(packageId, out var id) - || string.IsNullOrWhiteSpace(assetId) - || assetId.Length > 128) - return Failure(c, 400, "VALIDATION_ERROR", "packageId 或 assetId 格式无效。"); - - var owner = store.GetPackageOwner(id); - var package = store.GetPackage(id); - if (owner is null || package is null || owner != userId) - return Failure(c, 404, "RESOURCE_NOT_FOUND", "语音资源不存在。"); - - var expectedUri = $"/api/v1/game-packages/{id}/audio/{assetId}"; - var referenced = (package.Assets ?? Array.Empty()).Any(asset => - asset is not null - && asset.Type == AssetType.AUDIO - && string.Equals(asset.AssetId, assetId, StringComparison.Ordinal) - && string.Equals(asset.Uri, expectedUri, StringComparison.Ordinal)); - var audio = referenced ? store.GetAudio(id, assetId) : null; - if (audio is null) - return Failure(c, 404, "RESOURCE_NOT_FOUND", "语音资源不存在。"); - - c.Response.Headers.CacheControl = "private, max-age=31536000, immutable"; - c.Response.Headers.XContentTypeOptions = "nosniff"; - return Results.File( - audio.Data, - audio.ContentType, - enableRangeProcessing: true); -}); - -// ============================================================================ -// INTERNAL:RenderService 按当前会话用户读取权威游戏包。 -// ownerUserId 必须来自 RenderService 收到的 Gateway 可信 X-User-Id,不能取浏览器自报值。 -// ============================================================================ - -app.MapGet("/internal/v1/game-packages/{packageId}", ( - string packageId, - string? ownerUserId, - HttpContext c, - IGameStore store) => -{ - if (!InternalServiceAccessPolicy.IsTrusted( - c.Request.Headers, gatewayKey, packageReaderAllowedServices)) - return Failure(c, 403, "FORBIDDEN", "需要经 Gateway 转发的可信 RenderService 身份。"); - - if (!TryParseUuidV4(packageId, out var id) - || !TryParseUuidV4(ownerUserId, out _)) - return Failure(c, 400, "VALIDATION_ERROR", "packageId 与 ownerUserId 必须为 UUID v4。"); - - var owner = store.GetPackageOwner(id); - var package = store.GetPackage(id); - if (owner is null || package is null - || !string.Equals(owner, ownerUserId, StringComparison.OrdinalIgnoreCase)) - return Failure(c, 404, "RESOURCE_NOT_FOUND", "游戏包不存在。"); - - return Results.Ok(ApiSuccess.Create(package, c.TraceIdentifier)); -}); - -// ============================================================================ -// 端点 5:POST /internal/v1/game-package-validations — 校验游戏包(服务间) -// ============================================================================ - -app.MapPost("/internal/v1/game-package-validations", (GamePackageValidationRequest request, HttpContext c, GamePackageValidator validator) => -{ - // 服务身份验证:X-Gateway-Key + X-Service-Name - if (!InternalServiceAccessPolicy.IsTrusted( - c.Request.Headers, gatewayKey, validationAllowedServices)) - return Failure(c, 403, "FORBIDDEN", "需要经 Gateway 转发的可信服务身份。"); - - if (request.Package is null) - return Failure(c, 400, "VALIDATION_ERROR", "package 不能为空。"); - - var result = validator.Validate(request.Package); - return Results.Json( - ApiSuccess.Create(result, c.TraceIdentifier), - statusCode: result.Valid ? StatusCodes.Status200OK : StatusCodes.Status422UnprocessableEntity); -}); +// 端点 3-5:游戏包读取 / 音频 / INTERNAL 校验(已拆至 GamePackageEndpoints) +app.MapGamePackageEndpoints(gatewayKey, packageReaderAllowedServices, validationAllowedServices); app.Run(); -// ============================================================================ -// 辅助函数 -// ============================================================================ - -static bool IsGateway(HttpContext context, string key) - => context.Request.Headers.TryGetValue("X-Gateway-Key", out var values) - && values.Count == 1 - && string.Equals(values[0], key, StringComparison.Ordinal); - -static string? GatewayUser(HttpContext context, string key) -{ - if (!context.Request.Headers.TryGetValue("X-Gateway-Key", out var gwValues) - || gwValues.Count != 1 - || !string.Equals(gwValues[0], key, StringComparison.Ordinal)) - return null; - - if (!context.Request.Headers.TryGetValue("X-User-Id", out var userIdValues) - || userIdValues.Count != 1 - || !TryParseUuidV4(userIdValues[0], out _)) - return null; - - return userIdValues[0]; -} - -static IResult Failure(HttpContext context, int status, string code, string message, object? details = null) - => Results.Json(new ApiFailure(null, new ApiError(code, message, details ?? new { }), context.TraceIdentifier), statusCode: status); - -static bool TryParseUuidV4(string? value, out Guid id) - => Guid.TryParse(value, out id) && IsUuidV4(id); - -static bool IsUuidV4(Guid id) -{ - if (id == Guid.Empty) return false; - var value = id.ToString("D"); - return value[14] == '4' && value[19] is '8' or '9' or 'a' or 'b'; -} - -static bool IfNoneMatchMatches(Microsoft.Extensions.Primitives.StringValues values, string currentEtag) -{ - foreach (var rawValue in values) - { - if (rawValue is null) continue; - foreach (var rawTag in rawValue.Split(',')) - { - var tag = rawTag.Trim(); - if (tag == "*") return true; - if (tag.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) - tag = tag[2..].TrimStart(); - if (string.Equals(tag, currentEtag, StringComparison.Ordinal)) - return true; - } - } - return false; -} - // 暴露 partial class 供 WebApplicationFactory 集成测试使用 public partial class Program { } diff --git a/backend/GalGameService/RequestLogging.cs b/backend/GalGameService/RequestLogging.cs new file mode 100644 index 0000000..dae90c5 --- /dev/null +++ b/backend/GalGameService/RequestLogging.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +/// +/// X-Correlation-Id 归一 + JSON 结构化日志 + 请求级 scope/耗时(T10 可观测性)。 +/// traceId 写入 context.TraceIdentifier,错误信封与响应头回传同一值。 +/// +public static class RequestLogging +{ + /// + /// 可选:服务侧注册请求级 ambient trace(如 PracticeService.Persistence.TraceFlow.Begin), + /// 供出站 HTTP 客户端透传 X-Correlation-Id。未注册则不影响。 + /// + public static Func? BeginAmbientTrace { get; set; } + + public static ILoggingBuilder AddJsonStructuredLogging(this ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddJsonConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + options.UseUtcTimestamp = true; + options.IncludeScopes = true; + options.JsonWriterOptions = new System.Text.Json.JsonWriterOptions { Indented = false }; + }); + return logging; + } + + /// + /// false:跳过 X-Correlation-Id 归一,沿用上游中间件已写入的 TraceIdentifier + /// (如 GalGameService 自带的严格字符集校验)。 + /// + public static IApplicationBuilder UseRequestLogging( + this IApplicationBuilder app, string service, bool normalizeCorrelationId = true) => + app.Use(async (context, next) => + { + if (normalizeCorrelationId) + { + var incoming = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); + context.TraceIdentifier = string.IsNullOrWhiteSpace(incoming) + ? Guid.NewGuid().ToString("N") + : incoming.Length <= 128 ? incoming : Guid.NewGuid().ToString("N"); + context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; + } + + var logger = context.RequestServices.GetRequiredService().CreateLogger(service); + var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); + var method = context.Request.Method; + var path = context.Request.Path.Value ?? "/"; + var start = Stopwatch.GetTimestamp(); + using (logger.BeginScope(new Dictionary + { + ["traceId"] = context.TraceIdentifier, + ["userId"] = userId, + ["path"] = path, + ["method"] = method, + })) + using (BeginAmbientTrace?.Invoke(context.TraceIdentifier)) + { + try + { + await next(); + } + finally + { + var durationMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + logger.LogInformation( + "HTTP {Method} {Path} completed with {Status} in {DurationMs:F1}ms traceId={TraceId} userId={UserId}", + method, path, context.Response.StatusCode, durationMs, context.TraceIdentifier, userId); + } + } + }); +} diff --git a/backend/GalGameService/Tests/MongoGameStoreTests.cs b/backend/GalGameService/Tests/MongoGameStoreTests.cs index 36c5822..77d8b02 100644 --- a/backend/GalGameService/Tests/MongoGameStoreTests.cs +++ b/backend/GalGameService/Tests/MongoGameStoreTests.cs @@ -326,7 +326,7 @@ public void RecoverStaleJobs_MarksRunningJobsAsFailed() }); var recovered = _store.RecoverStaleJobs(); - Assert.True(recovered >= 1); + Assert.True(recovered.FailedCount >= 1); var recoveredJob = _store.GetJob(job.GenerationId); Assert.NotNull(recoveredJob); @@ -343,7 +343,7 @@ public void RecoverStaleJobs_MarksQueuedJobsAsFailed() var job = _store.CreateJob("user-recover-2", CreateRequest()); var recovered = _store.RecoverStaleJobs(); - Assert.True(recovered >= 1); + Assert.True(recovered.FailedCount >= 1); var recoveredJob = _store.GetJob(job.GenerationId); Assert.NotNull(recoveredJob); @@ -386,7 +386,7 @@ public void RecoverStaleJobs_ReturnsZero_WhenNoStaleJobs() _store.RecoverStaleJobs(); var recovered = _store.RecoverStaleJobs(); - Assert.Equal(0, recovered); + Assert.Equal(0, recovered.FailedCount); } [Fact] @@ -403,9 +403,9 @@ public void RecoverStaleJobs_IsIdempotent() }); var firstRecovery = _store.RecoverStaleJobs(); - Assert.True(firstRecovery >= 1); + Assert.True(firstRecovery.FailedCount >= 1); var secondRecovery = _store.RecoverStaleJobs(); - Assert.Equal(0, secondRecovery); + Assert.Equal(0, secondRecovery.FailedCount); } } diff --git a/backend/KnowledgeService/KnowledgeService.API/Infrastructure/RequestLogging.cs b/backend/KnowledgeService/KnowledgeService.API/Infrastructure/RequestLogging.cs new file mode 100644 index 0000000..34d9f6e --- /dev/null +++ b/backend/KnowledgeService/KnowledgeService.API/Infrastructure/RequestLogging.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; + +namespace KnowledgeService.API.Infrastructure; + +/// +/// JSON 结构化日志 + 请求级 scope/耗时(T10 可观测性)。 +/// X-Correlation-Id 归一由既有 TraceContextMiddleware 完成,此处只消费 TraceIdentifier。 +/// +public static class RequestLogging +{ + public static ILoggingBuilder AddJsonStructuredLogging(this ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddJsonConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + options.UseUtcTimestamp = true; + options.IncludeScopes = true; + options.JsonWriterOptions = new System.Text.Json.JsonWriterOptions { Indented = false }; + }); + return logging; + } + + public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app, string service) => + app.Use(async (context, next) => + { + var logger = context.RequestServices.GetRequiredService().CreateLogger(service); + var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); + var method = context.Request.Method; + var path = context.Request.Path.Value ?? "/"; + var start = Stopwatch.GetTimestamp(); + using (logger.BeginScope(new Dictionary + { + ["traceId"] = context.TraceIdentifier, + ["userId"] = userId, + ["path"] = path, + ["method"] = method, + })) + { + try + { + await next(); + } + finally + { + var durationMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + logger.LogInformation( + "HTTP {Method} {Path} completed with {Status} in {DurationMs:F1}ms traceId={TraceId} userId={UserId}", + method, path, context.Response.StatusCode, durationMs, context.TraceIdentifier, userId); + } + } + }); +} diff --git a/backend/KnowledgeService/KnowledgeService.API/Program.cs b/backend/KnowledgeService/KnowledgeService.API/Program.cs index 58dcf0f..91bde76 100644 --- a/backend/KnowledgeService/KnowledgeService.API/Program.cs +++ b/backend/KnowledgeService/KnowledgeService.API/Program.cs @@ -18,6 +18,7 @@ using Neo4j.Driver; var builder = WebApplication.CreateBuilder(args); +builder.Logging.AddJsonStructuredLogging(); var neo4jOptions = builder.Configuration .GetSection(Neo4jOptions.SectionName) @@ -31,6 +32,12 @@ neo4jOptions.Validate(); materialTextOptions.Validate(); gatewayTrustOptions.Validate(); +if (builder.Environment.IsProduction() && + string.Equals(gatewayTrustOptions.ServiceKey, "moonstone-local-gateway-key", StringComparison.Ordinal)) +{ + throw new InvalidOperationException( + "Gateway:ServiceKey must be changed from the development default in production."); +} builder.Services.ConfigureHttpJsonOptions(options => { @@ -67,6 +74,7 @@ var app = builder.Build(); app.UseMiddleware(); +app.UseRequestLogging("KnowledgeService"); app.UseMiddleware(); app.UseMiddleware(); diff --git a/backend/ModelService/ModelService.API/Program.cs b/backend/ModelService/ModelService.API/Program.cs index 43bfdb4..bc8198b 100644 --- a/backend/ModelService/ModelService.API/Program.cs +++ b/backend/ModelService/ModelService.API/Program.cs @@ -7,8 +7,7 @@ using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); -builder.Logging.ClearProviders(); -builder.Logging.AddConsole(); +builder.Logging.AddJsonStructuredLogging(); builder.Services.Configure(options => options.ThrowOnBadRequest = true); builder.Services.ConfigureHttpJsonOptions(options => @@ -22,8 +21,11 @@ builder.Services.AddModelPersistence(builder.Configuration, builder.Environment.ContentRootPath); var gatewayKey = builder.Configuration["Gateway:ServiceKey"] ?? throw new InvalidOperationException("Gateway:ServiceKey must be configured."); +if (builder.Environment.IsProduction() && string.Equals(gatewayKey, "moonstone-local-gateway-key", StringComparison.Ordinal)) + throw new InvalidOperationException("Gateway:ServiceKey must be changed from the development default in production."); var app = builder.Build(); +app.UseRequestLogging("ModelService"); app.UseExceptionHandler(handler => handler.Run(async context => { var exception = context.Features.Get()?.Error; @@ -35,6 +37,15 @@ await context.Response.WriteAsJsonAsync(ApiFailure.Create( domain.Code, domain.Message, trace, domain.Details)); return; } + if (exception is InferenceOverloadedException overloaded) + { + // 背压:队列满时明确拒绝并给出退避时间,Practice 侧已有超时/失败→ABSTAINED 降级路径 + context.Response.StatusCode = 503; + context.Response.Headers.RetryAfter = overloaded.RetryAfterSeconds.ToString(); + await context.Response.WriteAsJsonAsync(ApiFailure.Create( + "MODEL_SERVICE_BUSY", "模型推理队列已满,请稍后重试。", trace)); + return; + } if (exception is BadHttpRequestException bad) { context.Response.StatusCode = 400; diff --git a/backend/ModelService/ModelService.API/RequestLogging.cs b/backend/ModelService/ModelService.API/RequestLogging.cs new file mode 100644 index 0000000..61a47f7 --- /dev/null +++ b/backend/ModelService/ModelService.API/RequestLogging.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; + +namespace ModelService.API; + +/// +/// X-Correlation-Id 归一 + JSON 结构化日志 + 请求级 scope/耗时(T10 可观测性)。 +/// traceId 写入 context.TraceIdentifier,错误信封与响应头回传同一值。 +/// +public static class RequestLogging +{ + public static ILoggingBuilder AddJsonStructuredLogging(this ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddJsonConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + options.UseUtcTimestamp = true; + options.IncludeScopes = true; + options.JsonWriterOptions = new System.Text.Json.JsonWriterOptions { Indented = false }; + }); + return logging; + } + + public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app, string service) => + app.Use(async (context, next) => + { + var incoming = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); + context.TraceIdentifier = string.IsNullOrWhiteSpace(incoming) + ? Guid.NewGuid().ToString("N") + : incoming.Length <= 128 ? incoming : Guid.NewGuid().ToString("N"); + context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; + + var logger = context.RequestServices.GetRequiredService().CreateLogger(service); + var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); + var method = context.Request.Method; + var path = context.Request.Path.Value ?? "/"; + var start = Stopwatch.GetTimestamp(); + using (logger.BeginScope(new Dictionary + { + ["traceId"] = context.TraceIdentifier, + ["userId"] = userId, + ["path"] = path, + ["method"] = method, + })) + { + try + { + await next(); + } + finally + { + var durationMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + logger.LogInformation( + "HTTP {Method} {Path} completed with {Status} in {DurationMs:F1}ms traceId={TraceId} userId={UserId}", + method, path, context.Response.StatusCode, durationMs, context.TraceIdentifier, userId); + } + } + }); +} diff --git a/backend/ModelService/ModelService.Application/Abstractions.cs b/backend/ModelService/ModelService.Application/Abstractions.cs index d172b45..6d93477 100644 --- a/backend/ModelService/ModelService.Application/Abstractions.cs +++ b/backend/ModelService/ModelService.Application/Abstractions.cs @@ -10,6 +10,16 @@ Task InferAsync( CancellationToken cancellationToken); } +/// 推理负载指标,供 /readyz 与运维观测。 +public interface IInferenceLoadStats +{ + int InflightBatches { get; } + long CompletedBatches { get; } + + /// 正在等待批次门(排队中)的批次数。 + int QueuedBatches { get; } +} + public interface IModelAssetStatusReader { IReadOnlyList Inspect(); diff --git a/backend/ModelService/ModelService.Application/InferenceUseCases.cs b/backend/ModelService/ModelService.Application/InferenceUseCases.cs index d184b3e..26cc42f 100644 --- a/backend/ModelService/ModelService.Application/InferenceUseCases.cs +++ b/backend/ModelService/ModelService.Application/InferenceUseCases.cs @@ -22,9 +22,21 @@ public Task Handle( public sealed record GetModelReadinessQuery : IRequest; public sealed record ModelReadiness( string Status, - IReadOnlyList Models); + IReadOnlyList Models, + InferenceLoadSnapshot? Load = null); -public sealed class GetModelReadinessHandler(IModelAssetStatusReader assets) : +public sealed record InferenceLoadSnapshot(int InflightBatches, long CompletedBatches, int QueuedBatches); + +/// 推理队列已满:调用方应按 Retry-After 退避后重试。 +public sealed class InferenceOverloadedException(int queuedLimit, int retryAfterSeconds) + : Exception($"Inference queue is full; at most {queuedLimit} batches may be pending.") +{ + public int RetryAfterSeconds { get; } = retryAfterSeconds; +} + +public sealed class GetModelReadinessHandler( + IModelAssetStatusReader assets, + IInferenceLoadStats? loadStats = null) : IRequestHandler { public Task Handle( @@ -35,6 +47,9 @@ public Task Handle( var states = assets.Inspect(); var ready = states.Where(state => state.Required) .All(state => state.Status == "READY"); - return Task.FromResult(new ModelReadiness(ready ? "ready" : "not-ready", states)); + InferenceLoadSnapshot? load = loadStats is null + ? null + : new(loadStats.InflightBatches, loadStats.CompletedBatches, loadStats.QueuedBatches); + return Task.FromResult(new ModelReadiness(ready ? "ready" : "not-ready", states, load)); } } diff --git a/backend/ModelService/ModelService.Persistence/DependencyInjection.cs b/backend/ModelService/ModelService.Persistence/DependencyInjection.cs index 67997f8..5d68e41 100644 --- a/backend/ModelService/ModelService.Persistence/DependencyInjection.cs +++ b/backend/ModelService/ModelService.Persistence/DependencyInjection.cs @@ -18,7 +18,11 @@ public static IServiceCollection AddModelPersistence( services.AddSingleton(new ModelAssetCatalog(resourceRoot)); services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(provider => + provider.GetRequiredService()); return services; } } diff --git a/backend/ModelService/ModelService.Persistence/MultilingualNliInferenceEngine.cs b/backend/ModelService/ModelService.Persistence/MultilingualNliInferenceEngine.cs index 2c7357d..0015203 100644 --- a/backend/ModelService/ModelService.Persistence/MultilingualNliInferenceEngine.cs +++ b/backend/ModelService/ModelService.Persistence/MultilingualNliInferenceEngine.cs @@ -8,7 +8,7 @@ namespace ModelService.Persistence; -public sealed class MultilingualNliInferenceEngine : IFacetInferenceEngine, IDisposable +public sealed class MultilingualNliInferenceEngine : IFacetInferenceEngine, IInferenceLoadStats, IDisposable { public const string Version = "multilingual-minilmv2-l6-mnli-xnli@0a71e92a985b6e1ad1828cf67ce9c459639c1dca+strict-synonym-v1"; private const int MaximumTokens = 512; @@ -21,6 +21,15 @@ public sealed class MultilingualNliInferenceEngine : IFacetInferenceEngine, IDis private readonly double _minimumTopProbability; private readonly double _minimumMargin; private readonly Lazy _runtime; + /// 同时进行的推理批次数上限,避免 ONNX CPU 被过多并发打满。 + private readonly SemaphoreSlim _batchGate; + /// 等待批次门的批次数上限;超出立即 503 + Retry-After,防止无限排队。 + private readonly int _maxPendingBatches; + private readonly int _retryAfterSeconds; + private readonly int _facetParallelism; + private int _inflight; + private int _pending; + private long _completedBatches; public MultilingualNliInferenceEngine( ModelAssetCatalog assets, @@ -33,10 +42,28 @@ public MultilingualNliInferenceEngine( configuration.GetValue("Nli:MinimumTopProbability", 0.75d), 0d, 1d); _minimumMargin = Math.Clamp( configuration.GetValue("Nli:MinimumMargin", 0.20d), 0d, 1d); + var cores = Math.Max(1, Environment.ProcessorCount); + var maxBatches = Math.Clamp( + configuration.GetValue("Nli:MaxConcurrentBatches", Math.Max(1, cores / 2)), + 1, cores); + _batchGate = new SemaphoreSlim(maxBatches, maxBatches); + _maxPendingBatches = Math.Clamp( + configuration.GetValue("Nli:MaxPendingBatches", 64), 1, 4096); + _retryAfterSeconds = Math.Clamp( + configuration.GetValue("Nli:RetryAfterSeconds", 2), 1, 60); + _facetParallelism = Math.Clamp( + configuration.GetValue("Nli:FacetParallelism", Math.Min(4, cores)), + 1, 8); _runtime = new(CreateRuntime, LazyThreadSafetyMode.ExecutionAndPublication); } - public Task InferAsync( + public int InflightBatches => Volatile.Read(ref _inflight); + public long CompletedBatches => Interlocked.Read(ref _completedBatches); + + /// 已进入 InferAsync 但尚未获得批次门的批次数(排队深度)。 + public int QueuedBatches => Math.Max(0, Volatile.Read(ref _pending) - Volatile.Read(ref _inflight)); + + public async Task InferAsync( string answer, IReadOnlyList claims, CancellationToken cancellationToken) @@ -44,25 +71,83 @@ public Task InferAsync( cancellationToken.ThrowIfCancellationRequested(); var runtime = _runtime.Value; if (runtime is null) - return Task.FromResult(new FacetInferenceBatch(false, Version, [], "NLI_MODEL_UNAVAILABLE")); + return new FacetInferenceBatch(false, Version, [], "NLI_MODEL_UNAVAILABLE"); + + // 背压:排队深度超过上限时立即拒绝(503 + Retry-After),避免无限排队堆积 + var pending = Interlocked.Increment(ref _pending); + if (pending > _maxPendingBatches) + { + Interlocked.Decrement(ref _pending); + throw new InferenceOverloadedException(_maxPendingBatches, _retryAfterSeconds); + } + Interlocked.Increment(ref _inflight); try { - var results = new InferenceFacet[claims.Count]; - for (var index = 0; index < claims.Count; index++) + await _batchGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - cancellationToken.ThrowIfCancellationRequested(); - results[index] = Adjudicate(runtime, answer, claims[index]); + // ONNX CPU 推理是同步阻塞:offload 到线程池,避免占用 ASP.NET 请求线程 + var batch = await Task.Run( + () => RunFacets(runtime, answer, claims, cancellationToken), + cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref _completedBatches); + return batch; + } + finally + { + _batchGate.Release(); } - return Task.FromResult(new FacetInferenceBatch(true, Version, results, null)); } - catch (Exception error) when (error is not OperationCanceledException) + catch (OperationCanceledException) + { + throw; + } + catch (Exception error) { _logger.LogError(error, "NLI facet inference failed."); - return Task.FromResult(new FacetInferenceBatch(false, Version, [], "NLI_INFERENCE_FAILED")); + return new FacetInferenceBatch(false, Version, [], "NLI_INFERENCE_FAILED"); + } + finally + { + Interlocked.Decrement(ref _inflight); + Interlocked.Decrement(ref _pending); } } + private FacetInferenceBatch RunFacets( + ModelRuntime runtime, + string answer, + IReadOnlyList claims, + CancellationToken cancellationToken) + { + var results = new InferenceFacet[claims.Count]; + if (claims.Count == 1 || _facetParallelism == 1) + { + for (var index = 0; index < claims.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + results[index] = Adjudicate(runtime, answer, claims[index]); + } + return new FacetInferenceBatch(true, Version, results, null); + } + + // InferenceSession.Run 线程安全;facet 级有界并行降低单题延迟 + Parallel.For( + 0, + claims.Count, + new ParallelOptions + { + MaxDegreeOfParallelism = _facetParallelism, + CancellationToken = cancellationToken, + }, + index => + { + results[index] = Adjudicate(runtime, answer, claims[index]); + }); + return new FacetInferenceBatch(true, Version, results, null); + } + private InferenceFacet Adjudicate(ModelRuntime runtime, string answer, string claim) { var raw = Infer(runtime, answer, claim); @@ -172,7 +257,21 @@ private static double[] Softmax(IReadOnlyList logits) public void Dispose() { + // 先占满批次门,尽量等待进行中的 Parallel.For/Session.Run 结束,再释放 session + try + { + var max = _batchGate.CurrentCount + _inflight; + for (var i = 0; i < Math.Max(1, max); i++) + { + if (!_batchGate.Wait(TimeSpan.FromMilliseconds(200))) break; + } + } + catch (ObjectDisposedException) + { + // ignore + } if (_runtime.IsValueCreated) _runtime.Value?.Session.Dispose(); + _batchGate.Dispose(); } private sealed record ModelRuntime( diff --git a/backend/ModelService/ModelService.Tests/Persistence/DependencyInjectionTests.cs b/backend/ModelService/ModelService.Tests/Persistence/DependencyInjectionTests.cs index 1ba3f87..6693c89 100644 --- a/backend/ModelService/ModelService.Tests/Persistence/DependencyInjectionTests.cs +++ b/backend/ModelService/ModelService.Tests/Persistence/DependencyInjectionTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using ModelService.Application; using ModelService.Persistence; using Xunit; @@ -27,5 +28,12 @@ public void Configured_resource_root_overrides_release_content_root() Assert.Equal( Path.Combine(sharedRoot, "Models", "multilingual-minilm-nli", "model.onnx"), catalog.NliModelPath); + // 引擎与负载指标必须是同一单例,但此处不实例化(构造需要 ILogger) + var descriptors = services.Where(d => + d.ServiceType == typeof(IFacetInferenceEngine) + || d.ServiceType == typeof(IInferenceLoadStats) + || d.ServiceType == typeof(MultilingualNliInferenceEngine)) + .ToArray(); + Assert.Equal(3, descriptors.Length); } } diff --git a/backend/OCRService/tests/test_contract.py b/backend/OCRService/tests/test_contract.py new file mode 100644 index 0000000..9f63742 --- /dev/null +++ b/backend/OCRService/tests/test_contract.py @@ -0,0 +1,277 @@ +"""OCRService 最小契约测试(P2/T12)。 + +覆盖:healthz、网关密钥中间件、文件类型/模式/大小校验、响应形状、 +任务进度/取消,以及固定小样例图 + mock OCR 引擎的期望文本契约。 +不测真实 OCR 精度;不加载 PaddleOCR/PaddleX 模型。 + +运行: + python -m pytest backend/OCRService/tests/test_contract.py -q +或 python -m unittest backend.OCRService.tests.test_contract +(若环境无 pytest,unittest 同样可跑。) +""" + +from __future__ import annotations + +import io +import sys +import types +import unittest +from pathlib import Path + +# app.py 在 import 时会拉起 fitz / paddleocr。测试环境不装这些重依赖, +# 先注入桩模块,保证契约测试只验证 API 形状与错误码。 +if "fitz" not in sys.modules: + _fitz = types.ModuleType("fitz") + + class _FakePage: + def get_pixmap(self, matrix=None, alpha=False): # noqa: ANN001, ARG002 + raise RuntimeError("PDF rendering is not available in contract tests") + + class _FakeDoc: + page_count = 0 + + def __iter__(self): + return iter(()) + + def close(self): # noqa: ANN201 + return None + + _fitz.open = lambda *_args, **_kwargs: _FakeDoc() # noqa: E731 + _fitz.Matrix = lambda *_args, **_kwargs: None # noqa: E731 + sys.modules["fitz"] = _fitz + +if "paddleocr" not in sys.modules: + _paddleocr = types.ModuleType("paddleocr") + + class _FakeEngine: + def __init__(self, **_kwargs): + pass + + def predict(self, *_args, **_kwargs): + return [] + + _paddleocr.PaddleOCR = _FakeEngine + _paddleocr.FormulaRecognitionPipeline = _FakeEngine + sys.modules["paddleocr"] = _paddleocr + +# 密钥必须在 import app 之前就位。 +import os + +os.environ.setdefault("GATEWAY_KEY", "test-gateway-key") +os.environ.setdefault("PADDLE_PDX_CACHE_HOME", str(Path(__file__).resolve().parent / "models")) + +_OCR_DIR = Path(__file__).resolve().parents[1] +if str(_OCR_DIR) not in sys.path: + sys.path.insert(0, str(_OCR_DIR)) + +from fastapi.testclient import TestClient # noqa: E402 + +import app as ocr_app # noqa: E402 + +GATEWAY_KEY = os.environ["GATEWAY_KEY"] +AUTH_HEADERS = {"X-Gateway-Key": GATEWAY_KEY} + +# 固定样例图:纯色小 PNG(合法 PNG 头),配合 mock 引擎断言期望文本。 +SAMPLE_PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d494844520000000100000001080200000090" + "7753de0000000c4944415408d763f8cfc000000301010018dd8db000000000" + "49454e44ae426082" +) +EXPECTED_LINES = ["Hello", "GalReview"] + + +def _reset_state() -> None: + ocr_app.JOB_PROGRESS.clear() + ocr_app.CANCELLED_JOBS.clear() + + +class _FakeTextRegion: + def __init__(self, text: str, left: float, top: float, right: float, bottom: float): + self.text = text + self.left = left + self.top = top + self.right = right + self.bottom = bottom + + +def _fake_recognize_text_regions(image_path, mode): # noqa: ANN001, ARG001 + # 固定样例图 → 期望文本的契约桩。两行垂直间距 >18,避免被 merge 成同一视觉行。 + return [ + _FakeTextRegion("Hello", 0, 0, 50, 10), + _FakeTextRegion("GalReview", 0, 40, 80, 50), + ] + + +def _fake_recognize_formula_regions(image_path): # noqa: ANN001, ARG001 + return [] + + +class OcrContractTests(unittest.TestCase): + def setUp(self): + _reset_state() + self.client = TestClient(ocr_app.app, raise_server_exceptions=False) + self._orig_text = ocr_app.recognize_text_regions + self._orig_formula = ocr_app.recognize_formula_regions + ocr_app.recognize_text_regions = _fake_recognize_text_regions + ocr_app.recognize_formula_regions = _fake_recognize_formula_regions + + def tearDown(self): + ocr_app.recognize_text_regions = self._orig_text + ocr_app.recognize_formula_regions = self._orig_formula + _reset_state() + + # ---------- healthz ---------- + + def test_healthz_is_public_and_returns_live(self): + response = self.client.get("/healthz") + self.assertEqual(200, response.status_code) + self.assertEqual({"status": "live"}, response.json()) + + # ---------- 网关密钥中间件 ---------- + + def test_missing_gateway_key_is_rejected_with_detail_envelope(self): + response = self.client.get("/v1/ocr/jobs/any") + self.assertEqual(401, response.status_code) + body = response.json() + self.assertIn("detail", body) + self.assertIn("X-Gateway-Key", body["detail"]) + + def test_wrong_gateway_key_is_rejected(self): + response = self.client.get( + "/v1/ocr/jobs/any", headers={"X-Gateway-Key": "not-the-key"} + ) + self.assertEqual(401, response.status_code) + + # ---------- 文件类型 / 模式 / 大小校验 ---------- + + def test_unsupported_media_type_returns_415(self): + response = self.client.post( + "/v1/ocr", + headers=AUTH_HEADERS, + files={"file": ("notes.txt", io.BytesIO(b"plain text"), "text/plain")}, + ) + self.assertEqual(415, response.status_code) + self.assertIn("PDF, JPG and PNG", response.json()["detail"]) + + def test_invalid_ocr_mode_returns_400(self): + response = self.client.post( + "/v1/ocr", + headers={**AUTH_HEADERS, "X-Ocr-Mode": "turbo"}, + files={"file": ("a.png", io.BytesIO(SAMPLE_PNG), "image/png")}, + ) + self.assertEqual(400, response.status_code) + self.assertIn("quick or standard", response.json()["detail"]) + + def test_oversized_upload_returns_413(self): + payload = b"\x89PNG\r\n\x1a\n" + b"0" * (ocr_app.MAX_UPLOAD_BYTES + 1) + response = self.client.post( + "/v1/ocr", + headers=AUTH_HEADERS, + files={"file": ("big.png", io.BytesIO(payload), "image/png")}, + ) + self.assertEqual(413, response.status_code) + self.assertIn("10 MB", response.json()["detail"]) + + # ---------- 响应形状:固定小图 + mock 引擎 → 期望文本 ---------- + + def test_png_with_mocked_engine_returns_expected_lines(self): + response = self.client.post( + "/v1/ocr", + headers={**AUTH_HEADERS, "X-Ocr-Mode": "quick", "X-Ocr-Job-Id": "job-png-1"}, + files={"file": ("sample.png", io.BytesIO(SAMPLE_PNG), "image/png")}, + ) + self.assertEqual(200, response.status_code) + body = response.json() + self.assertIn("pages", body) + self.assertEqual(1, len(body["pages"])) + page = body["pages"][0] + self.assertEqual(1, page["pageNumber"]) + self.assertEqual(EXPECTED_LINES, page["lines"]) + self.assertIn("formulas", page) + + def test_jpeg_magic_bytes_are_accepted_even_with_generic_content_type(self): + jpeg = b"\xff\xd8\xff\xe0" + b"\x00" * 32 + response = self.client.post( + "/v1/ocr", + headers={**AUTH_HEADERS, "X-Ocr-Mode": "quick"}, + files={"file": ("scan.bin", io.BytesIO(jpeg), "application/octet-stream")}, + ) + self.assertEqual(200, response.status_code) + self.assertEqual(EXPECTED_LINES, response.json()["pages"][0]["lines"]) + + def test_quick_mode_skips_formula_phase(self): + phases: list[str] = [] + ocr_app.recognize_formula_regions = lambda path: phases.append("formula") or [] + + response = self.client.post( + "/v1/ocr", + headers={**AUTH_HEADERS, "X-Ocr-Mode": "quick", "X-Ocr-Job-Id": "job-quick"}, + files={"file": ("a.png", io.BytesIO(SAMPLE_PNG), "image/png")}, + ) + self.assertEqual(200, response.status_code) + self.assertEqual([], phases) + + # ---------- 进度 / 取消 ---------- + + def test_unknown_job_progress_shape(self): + response = self.client.get("/v1/ocr/jobs/never-seen", headers=AUTH_HEADERS) + self.assertEqual(200, response.status_code) + self.assertEqual( + { + "status": "UNKNOWN", + "currentPage": 0, + "totalPages": 0, + "phase": "WAITING", + }, + response.json(), + ) + + def test_cancel_marks_job_cancelled(self): + response = self.client.post( + "/v1/ocr/jobs/job-x/cancel", headers=AUTH_HEADERS + ) + self.assertEqual(202, response.status_code) + self.assertEqual( + {"jobId": "job-x", "status": "CANCELLED"}, response.json() + ) + progress = self.client.get("/v1/ocr/jobs/job-x", headers=AUTH_HEADERS).json() + self.assertEqual("CANCELLED", progress["status"]) + self.assertEqual("CANCELLED", progress["phase"]) + + def test_cancelled_job_cannot_continue(self): + ocr_app.CANCELLED_JOBS.add("job-stop") + with self.assertRaises(ocr_app.HTTPException) as ctx: + ocr_app.ensure_not_cancelled("job-stop") + self.assertEqual(409, ctx.exception.status_code) + + def test_successful_run_marks_progress_succeeded(self): + response = self.client.post( + "/v1/ocr", + headers={**AUTH_HEADERS, "X-Ocr-Mode": "quick", "X-Ocr-Job-Id": "job-ok"}, + files={"file": ("a.png", io.BytesIO(SAMPLE_PNG), "image/png")}, + ) + self.assertEqual(200, response.status_code) + progress = ocr_app.JOB_PROGRESS["job-ok"] + self.assertEqual("SUCCEEDED", progress["status"]) + self.assertEqual("COMPLETED", progress["phase"]) + self.assertEqual(1, progress["totalPages"]) + + # ---------- 纯函数契约 ---------- + + def test_is_choice_label_extracts_parentheses_markers(self): + self.assertEqual("(A)", ocr_app.is_choice_label("(A) 正确答案")) + self.assertEqual("(c)", ocr_app.is_choice_label(" (c)")) + self.assertIsNone(ocr_app.is_choice_label("A) 不是选项标记")) + + def test_rect_normalizes_and_rejects_bad_boxes(self): + self.assertEqual((0.0, 0.0, 10.0, 5.0), ocr_app.rect([10, 5, 0, 0])) + self.assertIsNone(ocr_app.rect([1, 2])) + self.assertIsNone(ocr_app.rect(None)) + + def test_result_to_payload_unwraps_res_key(self): + self.assertEqual({"rec_texts": ["x"]}, ocr_app.result_to_payload({"res": {"rec_texts": ["x"]}})) + self.assertEqual({}, ocr_app.result_to_payload(123)) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/PracticeService/PracticeService.API/MetricsEndpoints.cs b/backend/PracticeService/PracticeService.API/MetricsEndpoints.cs new file mode 100644 index 0000000..68549db --- /dev/null +++ b/backend/PracticeService/PracticeService.API/MetricsEndpoints.cs @@ -0,0 +1,12 @@ +namespace PracticeService.API; + +/// 轻量 Prometheus 文本指标端点(T10 告警信号)。 +public static class MetricsEndpoints +{ + public static void MapServiceMetrics(this IEndpointRouteBuilder app) + { + app.MapGet("/metrics", () => Results.Text( + PracticeService.Application.PracticeServiceMetrics.RenderPrometheus(), + "text/plain; version=0.0.4; charset=utf-8")); + } +} diff --git a/backend/PracticeService/PracticeService.API/Program.cs b/backend/PracticeService/PracticeService.API/Program.cs index 9dd6be7..7fcb749 100644 --- a/backend/PracticeService/PracticeService.API/Program.cs +++ b/backend/PracticeService/PracticeService.API/Program.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Http.Features; var builder = WebApplication.CreateBuilder(args); -builder.Logging.ClearProviders(); builder.Logging.AddConsole(); +builder.Logging.AddJsonStructuredLogging(); builder.Services.Configure(x => x.ThrowOnBadRequest = true); builder.Services.ConfigureHttpJsonOptions(options => { @@ -19,9 +19,14 @@ builder.Services.AddPracticePersistence(builder.Configuration, builder.Environment.ContentRootPath); builder.Services.Configure(options => options.MultipartBodyLengthLimit = 50L * 1024 * 1024); var gatewayKey = builder.Configuration["Gateway:ServiceKey"] ?? throw new InvalidOperationException("Gateway:ServiceKey must be configured."); +if (builder.Environment.IsProduction() && string.Equals(gatewayKey, "moonstone-local-gateway-key", StringComparison.Ordinal)) + throw new InvalidOperationException("Gateway:ServiceKey must be changed from the development default in production."); var storage = string.Equals(builder.Configuration["PracticeStore:Provider"], "Memory", StringComparison.OrdinalIgnoreCase) ? "ephemeral-memory" : "mongodb"; var app = builder.Build(); +app.UseRequestLogging("PracticeService"); +// 出站透传:GatewayClient/GatewayModelFacetAdjudicator 读 TraceFlow.Current +RequestLogging.BeginAmbientTrace = PracticeService.Persistence.TraceFlow.Begin; app.UseExceptionHandler(handler => handler.Run(async context => { var exception = context.Features.Get()?.Error; var trace = context.TraceIdentifier; @@ -32,6 +37,7 @@ })); app.MapGet("/healthz", () => Results.Ok(new { status = "ok", service = "PracticeService" })); app.MapGet("/readyz", async (ISender sender, CancellationToken ct) => Results.Ok(await sender.Send(new GetReadinessQuery(storage), ct))); +app.MapServiceMetrics(); app.MapPost("/api/v1/practice-projects", async (CreateProjectRequest request, HttpContext c, ISender sender, CancellationToken ct) => { diff --git a/backend/PracticeService/PracticeService.API/RequestLogging.cs b/backend/PracticeService/PracticeService.API/RequestLogging.cs new file mode 100644 index 0000000..dae90c5 --- /dev/null +++ b/backend/PracticeService/PracticeService.API/RequestLogging.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +/// +/// X-Correlation-Id 归一 + JSON 结构化日志 + 请求级 scope/耗时(T10 可观测性)。 +/// traceId 写入 context.TraceIdentifier,错误信封与响应头回传同一值。 +/// +public static class RequestLogging +{ + /// + /// 可选:服务侧注册请求级 ambient trace(如 PracticeService.Persistence.TraceFlow.Begin), + /// 供出站 HTTP 客户端透传 X-Correlation-Id。未注册则不影响。 + /// + public static Func? BeginAmbientTrace { get; set; } + + public static ILoggingBuilder AddJsonStructuredLogging(this ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddJsonConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + options.UseUtcTimestamp = true; + options.IncludeScopes = true; + options.JsonWriterOptions = new System.Text.Json.JsonWriterOptions { Indented = false }; + }); + return logging; + } + + /// + /// false:跳过 X-Correlation-Id 归一,沿用上游中间件已写入的 TraceIdentifier + /// (如 GalGameService 自带的严格字符集校验)。 + /// + public static IApplicationBuilder UseRequestLogging( + this IApplicationBuilder app, string service, bool normalizeCorrelationId = true) => + app.Use(async (context, next) => + { + if (normalizeCorrelationId) + { + var incoming = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); + context.TraceIdentifier = string.IsNullOrWhiteSpace(incoming) + ? Guid.NewGuid().ToString("N") + : incoming.Length <= 128 ? incoming : Guid.NewGuid().ToString("N"); + context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; + } + + var logger = context.RequestServices.GetRequiredService().CreateLogger(service); + var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); + var method = context.Request.Method; + var path = context.Request.Path.Value ?? "/"; + var start = Stopwatch.GetTimestamp(); + using (logger.BeginScope(new Dictionary + { + ["traceId"] = context.TraceIdentifier, + ["userId"] = userId, + ["path"] = path, + ["method"] = method, + })) + using (BeginAmbientTrace?.Invoke(context.TraceIdentifier)) + { + try + { + await next(); + } + finally + { + var durationMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + logger.LogInformation( + "HTTP {Method} {Path} completed with {Status} in {DurationMs:F1}ms traceId={TraceId} userId={UserId}", + method, path, context.Response.StatusCode, durationMs, context.TraceIdentifier, userId); + } + } + }); +} diff --git a/backend/PracticeService/PracticeService.Application/AutomaticAnswerScoring.cs b/backend/PracticeService/PracticeService.Application/AutomaticAnswerScoring.cs index f28c691..8bd3546 100644 --- a/backend/PracticeService/PracticeService.Application/AutomaticAnswerScoring.cs +++ b/backend/PracticeService/PracticeService.Application/AutomaticAnswerScoring.cs @@ -101,8 +101,10 @@ private static ScoreResult Result( double? similarity, string judgeVersion, IReadOnlyList facets, - bool degraded) => - new( + bool degraded) + { + PracticeServiceMetrics.RecordGrading(decision.Status == GradingStatus.Abstained, degraded); + return new( decision.Status, decision.Outcome, decision.Correct, @@ -113,6 +115,7 @@ private static ScoreResult Result( decision.AbstainReason, facets, degraded); + } } public static partial class ReferenceFacetExtractor diff --git a/backend/PracticeService/PracticeService.Application/PracticeServiceMetrics.cs b/backend/PracticeService/PracticeService.Application/PracticeServiceMetrics.cs new file mode 100644 index 0000000..a019d22 --- /dev/null +++ b/backend/PracticeService/PracticeService.Application/PracticeServiceMetrics.cs @@ -0,0 +1,37 @@ +namespace PracticeService.Application; + +/// +/// 进程内轻量计数器(T10 告警信号源)。 +/// 单实例自增,供 /metrics 输出 Prometheus 文本;无外部依赖。 +/// +public static class PracticeServiceMetrics +{ + private static long _gradingsTotal; + private static long _abstainedTotal; + private static long _degradedTotal; + + public static long GradingsTotal => Interlocked.Read(ref _gradingsTotal); + public static long AbstainedTotal => Interlocked.Read(ref _abstainedTotal); + public static long DegradedTotal => Interlocked.Read(ref _degradedTotal); + + public static void RecordGrading(bool abstained, bool degraded) + { + Interlocked.Increment(ref _gradingsTotal); + if (abstained) Interlocked.Increment(ref _abstainedTotal); + if (degraded) Interlocked.Increment(ref _degradedTotal); + } + + public static string RenderPrometheus() + { + return + "# HELP practice_gradings_total Practice answer gradings by outcome\n" + + "# TYPE practice_gradings_total counter\n" + + $"practice_gradings_total {GradingsTotal}\n" + + "# HELP practice_abstained_total Grading results that abstained (model unavailable/degraded)\n" + + "# TYPE practice_abstained_total counter\n" + + $"practice_abstained_total {AbstainedTotal}\n" + + "# HELP practice_degraded_total Grading results marked degraded\n" + + "# TYPE practice_degraded_total counter\n" + + $"practice_degraded_total {DegradedTotal}\n"; + } +} diff --git a/backend/PracticeService/PracticeService.Application/PracticeUseCases.cs b/backend/PracticeService/PracticeService.Application/PracticeUseCases.cs index 2dcb1eb..d4d1165 100644 --- a/backend/PracticeService/PracticeService.Application/PracticeUseCases.cs +++ b/backend/PracticeService/PracticeService.Application/PracticeUseCases.cs @@ -40,10 +40,12 @@ public async Task Handle(GenerateQuestionsCommand request, Cancella PracticePlanRules.Validate(project, request.OwnerUserId, plan); if (plan.Points.Count == 0) throw new PracticeDomainException(422, "PLAN_TARGETS_EMPTY", "复习计划没有可用于生成题目的目标知识点。"); - var materials = new List(); - foreach (var materialId in project.MaterialIds) + // 资料最多 20 份,并行拉取以避免串行 N+1;顺序与 MaterialIds 一致 + var fetchedMaterials = await Task.WhenAll( + project.MaterialIds.Select(id => gateway.GetMaterialTextAsync(id, ct))); + var materials = new List(fetchedMaterials.Length); + foreach (var material in fetchedMaterials) { - var material = await gateway.GetMaterialTextAsync(materialId, ct); if (material.OwnerUserId != request.OwnerUserId) throw PracticeOwnership.NotFound(); materials.Add(material); } @@ -132,10 +134,15 @@ public Task Handle(GetPracticeJobQuery request, CancellationToken c public async Task Handle(GetQuestionHelpQuery request, CancellationToken ct) { var question = repository.GetQuestion(request.QuestionId) ?? throw PracticeOwnership.NotFound(); - _ = PracticeOwnership.Project(question.ProjectId, request.OwnerUserId, repository); var matches = new List(); - foreach (var source in question.SourceReferences.Take(3)) + _ = PracticeOwnership.Project(question.ProjectId, request.OwnerUserId, repository); + var sources = question.SourceReferences.Take(3).ToArray(); + var fetched = await Task.WhenAll(sources.Select(s => gateway.GetMaterialTextAsync(s.MaterialId, ct))); + var matches = new List(); + for (var i = 0; i < sources.Length; i++) { - var material = await gateway.GetMaterialTextAsync(source.MaterialId, ct); if (material.OwnerUserId != request.OwnerUserId) continue; + var source = sources[i]; + var material = fetched[i]; + if (material.OwnerUserId != request.OwnerUserId) continue; var start = (int)Math.Clamp(source.StartOffset, 0, material.Text.Length); var end = (int)Math.Clamp(source.EndOffset, start, material.Text.Length); var excerpt = material.Text[start..end]; matches.Add(new(question.KnowledgePointId, question.Prompt, excerpt, source, PracticeRules.LevenshteinSimilarity(question.Prompt, excerpt))); } @@ -237,7 +244,12 @@ public Task Handle(ArchiveProjectCommand request, CancellationToken ct) private static string ValidateName(string value) { var name = value.Trim(); if (name.Length is < 1 or > 120) throw new PracticeDomainException(400, "VALIDATION_ERROR", "name 必须包含 1-120 个字符。"); return name; } private static IReadOnlyList ValidateMaterials(IEnumerable value) { var ids = value.Distinct().ToArray(); if (ids.Length is < 1 or > 20 || ids.Any(x => x == Guid.Empty)) throw new PracticeDomainException(400, "PROJECT_MATERIALS_REQUIRED", "项目必须引用 1-20 份有效资料。"); return ids; } private async Task ValidateMaterialOwnership(IReadOnlyList ids, Guid owner, CancellationToken ct) - { foreach (var id in ids) { var material = await gateway.GetMaterialTextAsync(id, ct); if (material.OwnerUserId != owner) throw PracticeOwnership.NotFound(); } } + { + // 最多 20 份资料:并行校验所有权,避免串行 N+1 + var materials = await Task.WhenAll(ids.Select(id => gateway.GetMaterialTextAsync(id, ct))); + if (materials.Any(material => material.OwnerUserId != owner)) + throw PracticeOwnership.NotFound(); + } private static string? NormalizeSubject(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var result = value.Trim().ToUpperInvariant(); if (!System.Text.RegularExpressions.Regex.IsMatch(result, "^[A-Z][A-Z0-9_]{0,31}$")) throw new PracticeDomainException(400, "VALIDATION_ERROR", "subjectCode 格式无效。"); return result; } } diff --git a/backend/PracticeService/PracticeService.Persistence/GatewayClient.cs b/backend/PracticeService/PracticeService.Persistence/GatewayClient.cs index 76787aa..e866d54 100644 --- a/backend/PracticeService/PracticeService.Persistence/GatewayClient.cs +++ b/backend/PracticeService/PracticeService.Persistence/GatewayClient.cs @@ -122,7 +122,10 @@ private async Task SendCreditAsync(HttpRequestMessage request, CancellationToken } private HttpRequestMessage Create(HttpMethod method, string path) { - var request = new HttpRequestMessage(method, path); request.Headers.TryAddWithoutValidation("X-Service-Name", ServiceName); request.Headers.TryAddWithoutValidation("X-Service-Key", ServiceKey); return request; + var request = new HttpRequestMessage(method, path); request.Headers.TryAddWithoutValidation("X-Service-Name", ServiceName); request.Headers.TryAddWithoutValidation("X-Service-Key", ServiceKey); + // 跨服务透传:缺省取请求级 TraceFlow,后台任务未设置时生成新 id + request.Headers.TryAddWithoutValidation("X-Correlation-Id", TraceFlow.Current ?? Guid.NewGuid().ToString("N")); + return request; } private PracticeDomainException Failure(System.Net.HttpStatusCode status, string body, string fallback) { diff --git a/backend/PracticeService/PracticeService.Persistence/GatewayModelFacetAdjudicator.cs b/backend/PracticeService/PracticeService.Persistence/GatewayModelFacetAdjudicator.cs index 0f450ff..b26b549 100644 --- a/backend/PracticeService/PracticeService.Persistence/GatewayModelFacetAdjudicator.cs +++ b/backend/PracticeService/PracticeService.Persistence/GatewayModelFacetAdjudicator.cs @@ -28,6 +28,7 @@ public async Task AdjudicateAsync( "/internal/v1/model-inference/facet-adjudications"); request.Headers.TryAddWithoutValidation("X-Service-Name", ServiceName); request.Headers.TryAddWithoutValidation("X-Service-Key", ServiceKey); + request.Headers.TryAddWithoutValidation("X-Correlation-Id", TraceFlow.Current ?? Guid.NewGuid().ToString("N")); request.Content = JsonContent.Create(new { answer, diff --git a/backend/PracticeService/PracticeService.Persistence/GroundedQuestionExtractor.cs b/backend/PracticeService/PracticeService.Persistence/GroundedQuestionExtractor.cs new file mode 100644 index 0000000..89fef3c --- /dev/null +++ b/backend/PracticeService/PracticeService.Persistence/GroundedQuestionExtractor.cs @@ -0,0 +1,228 @@ +using PracticeService.Application; +using PracticeService.Domain; +using System.Text.RegularExpressions; + +namespace PracticeService.Persistence; + +internal static class GroundedQuestionExtractor +{ + private static readonly Regex ReferenceAnswerMarker = new(@"【参考答案】", RegexOptions.Compiled); + private static readonly Regex NumberedItemMarker = new( + @"(?\d{1,3})[\.、.]\s*", + RegexOptions.Compiled); + private static readonly Regex SectionHeading = new( + @"(?名词解释|填空题|选择题|单项选择题|简答题|论述题|综合题|大题|重要知识点|还有重要的知识点|结课思考题)", + RegexOptions.Compiled); + private static readonly Regex StructuralBoundary = new( + @"(?:第[一二三四五六七八九十百\d]+章\s*[^\r\n]{0,80}|[一二三四五六七八九十]+、\s*(?:名词解释|填空题|选择题|单项选择题|简答题|论述题|综合题|大题|重要知识点|还有重要的知识点))", + RegexOptions.Compiled); + + internal static ExtractionBatch ExtractGroundedQuestions(QuestionGenerationInput input) + { + var kinds = (input.Kinds.Count == 0 ? ReciteQuestionGenerator.ClassicKinds : input.Kinds).ToHashSet(); + var drafts = new List<QuestionDraft>(); + var diagnostics = new List<PracticeJobDiagnostic>(); + var coveredRanges = new List<GroundedRange>(); + var explicitItems = 0; + foreach (var material in input.Materials) + { + var occupied = new List<(int Start, int End)>(); + var explicitSources = ExtractExplicitSourceItems(material.Text, diagnostics, material.MaterialId); + explicitItems += explicitSources.Count; + foreach (var sourceItem in explicitSources) + { + var parsed = ParseSourceItem(material, sourceItem, true, input.Points, diagnostics); + occupied.Add((sourceItem.StartOffset, sourceItem.EndOffset)); + coveredRanges.Add(new(material.MaterialId, sourceItem.StartOffset, sourceItem.EndOffset)); + if (parsed is not null && kinds.Contains(parsed.Kind)) drafts.Add(parsed); + } + foreach (var sourceItem in ExtractSemiStructuredSourceItems(material.Text, occupied)) + { + var parsed = ParseSourceItem(material, sourceItem, false, input.Points, diagnostics); + coveredRanges.Add(new(material.MaterialId, sourceItem.StartOffset, sourceItem.EndOffset)); + if (parsed is not null && kinds.Contains(parsed.Kind)) drafts.Add(parsed); + } + } + var unique = drafts + .GroupBy(ReciteQuestionGenerator.QuestionDedupKey, StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(draft => draft.SourceReferences[0].MaterialId) + .ThenBy(draft => draft.SourceReferences[0].StartOffset) + .ToArray(); + return new(unique, diagnostics, explicitItems >= 3, coveredRanges); + } + + private static IReadOnlyList<SourceItem> ExtractExplicitSourceItems( + string text, + List<PracticeJobDiagnostic> diagnostics, + Guid materialId) + { + var answerMarkers = ReferenceAnswerMarker.Matches(text).Cast<Match>().ToArray(); + if (answerMarkers.Length == 0) return []; + var promptMarkers = new SourceMarker?[answerMarkers.Length]; + var previousAnswerMarkerEnd = 0; + for (var index = 0; index < answerMarkers.Length; index++) + { + var answerMarker = answerMarkers[index]; + var candidates = NumberedItemMarker.Matches(text[previousAnswerMarkerEnd..answerMarker.Index]); + if (candidates.Count > 0) + { + var candidate = candidates[^1]; + promptMarkers[index] = new(previousAnswerMarkerEnd + candidate.Index, candidate.Length); + } + else diagnostics.Add(new(materialId, "SOURCE_QUESTION_BOUNDARY_NOT_FOUND", + $"资料中的第 {index + 1} 个参考答案标记之前没有可核对题号,已拒绝自动成题。", false)); + previousAnswerMarkerEnd = answerMarker.Index + answerMarker.Length; + } + + var items = new List<SourceItem>(); + for (var index = 0; index < answerMarkers.Length; index++) + { + var promptMarker = promptMarkers[index]; + if (promptMarker is null) continue; + var marker = answerMarkers[index]; + var structuralEnd = FindNextStructuralBoundary(text, marker.Index + marker.Length); + var nextPromptEnd = index + 1 < promptMarkers.Length && promptMarkers[index + 1] is SourceMarker nextPrompt + ? nextPrompt.Index + : text.Length; + var end = Math.Min(structuralEnd, nextPromptEnd); + end = Math.Clamp(end, marker.Index + marker.Length, text.Length); + var section = FindSection(text, promptMarker.Index); + items.Add(new(promptMarker.Index, end, + text[(promptMarker.Index + promptMarker.Length)..marker.Index], + text[(marker.Index + marker.Length)..end], section?.Title, section?.Meta ?? string.Empty)); + } + return items; + } + + private static IReadOnlyList<SourceItem> ExtractSemiStructuredSourceItems( + string text, + IReadOnlyList<(int Start, int End)> occupied) + { + var sections = SectionHeading.Matches(text).Cast<Match>().ToArray(); + var items = new List<SourceItem>(); + foreach (var section in sections) + { + var title = section.Groups["title"].Value; + var sectionStart = section.Index + section.Length; + var nextSection = sections.FirstOrDefault(candidate => candidate.Index >= sectionStart); + var boundary = StructuralBoundary.Match(text, sectionStart); + var sectionEnd = new[] + { + nextSection?.Index ?? text.Length, + boundary.Success ? boundary.Index : text.Length, + text.Length + } + .Min(); + if (sectionEnd <= sectionStart) continue; + + if (title == "结课思考题") + { + var raw = text[sectionStart..sectionEnd].TrimStart(':', ':', ' ', '\t'); + var answerStart = Regex.Match(raw, @"。(?=\s*\d{1,2}(?![\d\.]))"); + if (answerStart.Success) + { + var absoluteStart = sectionStart + text[sectionStart..sectionEnd].IndexOf(raw, StringComparison.Ordinal); + var source = new SourceItem(absoluteStart, sectionEnd, raw[..(answerStart.Index + 1)], + raw[(answerStart.Index + 1)..], title, string.Empty); + if (!Overlaps(source.StartOffset, source.EndOffset, occupied)) items.Add(source); + } + continue; + } + + var sectionText = text[sectionStart..sectionEnd]; + var markers = NumberedItemMarker.Matches(sectionText).Cast<Match>().ToArray(); + for (var index = 0; index < markers.Length; index++) + { + var marker = markers[index]; + var itemStart = sectionStart + marker.Index; + var itemEnd = index + 1 < markers.Length ? sectionStart + markers[index + 1].Index : sectionEnd; + if (Overlaps(itemStart, itemEnd, occupied)) continue; + var body = text[(itemStart + marker.Length)..itemEnd]; + var split = FindAnswerSeparator(body, title); + if (split < 1 || split >= body.Length - 1) continue; + items.Add(new(itemStart, itemEnd, body[..split], body[(split + 1)..], title, string.Empty)); + } + } + return items; + } + + private static int FindAnswerSeparator(string body, string section) + { + var colon = body.IndexOfAny([':', ':']); + return colon < 1 ? -1 : colon; + } + + private static bool Overlaps(int start, int end, IReadOnlyList<(int Start, int End)> occupied) => + occupied.Any(range => start < range.End && end > range.Start); + + private static int FindNextStructuralBoundary(string text, int start) + { + var section = SectionHeading.Match(text, start); + var structural = StructuralBoundary.Match(text, start); + return new[] + { + section.Success ? section.Index : text.Length, + structural.Success ? structural.Index : text.Length, + text.Length + }.Min(); + } + + private static (string Title, string Meta)? FindSection(string text, int offset) + { + var sections = SectionHeading.Matches(text[..Math.Clamp(offset, 0, text.Length)]); + if (sections.Count == 0) return null; + var match = sections[^1]; + return (match.Groups["title"].Value.Trim(), string.Empty); + } + + private static QuestionDraft? ParseSourceItem(MaterialText material, SourceItem item, bool explicitAnswer, + IReadOnlyList<PlanGraphPoint> points, List<PracticeJobDiagnostic> diagnostics) + { + var body = QuestionTextParser.CleanText(item.Prompt); + var answer = QuestionTextParser.CleanAnswer(item.Answer); + var options = QuestionTextParser.ParseOptions(body, out var prompt); + prompt = Regex.Replace(prompt, @"^\s*\(?\d+\s*分\)?\s*", string.Empty).Trim(); + if (prompt.Length < 2 || answer.Length < 1) return null; + + var kind = QuestionTextParser.InferKind(item.SectionTitle, prompt, options.Length); + IReadOnlyList<string> correctAnswers; + if (kind == PracticeQuestionKind.SingleChoice) + { + var optionId = Regex.Match(answer, @"(?i)(?<![A-Z])[A-H](?![A-Z])").Value.ToUpperInvariant(); + if (options.Length < 2 || optionId.Length == 0 || !options.Any(option => option.Id == optionId)) + { + diagnostics.Add(new(material.MaterialId, "SOURCE_CHOICE_ANSWER_AMBIGUOUS", $"原题“{QuestionTextParser.TrimForDiagnostic(prompt)}”的选项或答案无法唯一核对,保留为草稿。", false)); + return null; + } + else correctAnswers = [optionId]; + } + else correctAnswers = [answer]; + + var excerpt = material.Text[item.StartOffset..item.EndOffset]; + var source = new SourceReference(material.MaterialId, item.StartOffset, item.EndOffset, + material.SourceMapVersion, PracticeRules.Sha256(excerpt)); + var binding = KnowledgePointBindingRules.Bind(kind, prompt, correctAnswers, excerpt, points, [source]); + var answerSupported = QuestionTextParser.EvidenceSupportsAnswer(excerpt, answer); + var reliableChoice = kind != PracticeQuestionKind.SingleChoice || options.Length == 4; + var ready = binding.PointId.HasValue && answerSupported && reliableChoice; + if (!binding.PointId.HasValue) + diagnostics.Add(new(material.MaterialId, binding.Ambiguous ? "KNOWLEDGE_POINT_BINDING_AMBIGUOUS" : "KNOWLEDGE_POINT_SOURCE_NOT_FOUND", + $"原题“{QuestionTextParser.TrimForDiagnostic(prompt)}”未能唯一绑定本册知识点,已保留为待核对草稿。", false)); + if (!answerSupported) + diagnostics.Add(new(material.MaterialId, "SOURCE_ANSWER_NOT_SUPPORTED", + $"原题“{QuestionTextParser.TrimForDiagnostic(prompt)}”的答案不能由同一原文区间完整支持,已保留为待核对草稿。", false)); + if (!reliableChoice) + diagnostics.Add(new(material.MaterialId, "SOURCE_CHOICE_OPTIONS_UNRELIABLE", + $"原题“{QuestionTextParser.TrimForDiagnostic(prompt)}”不是恰好四个选项,未自动收入 READY 题库。", false)); + return new(kind, QuestionTextParser.EnsureQuestionPrompt(prompt, kind), options, correctAnswers, + explicitAnswer ? "题目与答案按资料原文提取。" : "题目与答案按资料中的可核对问答结构提取。", + QuestionTextParser.ParseScore(item.SectionMeta, excerpt, kind), QuestionTextParser.InferDifficulty(kind), binding.PointId, [source], ready ? QuestionStatus.Ready : QuestionStatus.Draft); + } +} + +internal sealed record ExtractionBatch(IReadOnlyList<QuestionDraft> Drafts, IReadOnlyList<PracticeJobDiagnostic> Diagnostics, + bool IsExplicitQuestionBank, IReadOnlyList<GroundedRange> CoveredRanges); +internal sealed record GroundedRange(Guid MaterialId, int StartOffset, int EndOffset); +internal sealed record SourceMarker(int Index, int Length); +internal sealed record SourceItem(int StartOffset, int EndOffset, string Prompt, string Answer, string? SectionTitle, string SectionMeta); diff --git a/backend/PracticeService/PracticeService.Persistence/ModelQuestionClient.cs b/backend/PracticeService/PracticeService.Persistence/ModelQuestionClient.cs new file mode 100644 index 0000000..c5253bc --- /dev/null +++ b/backend/PracticeService/PracticeService.Persistence/ModelQuestionClient.cs @@ -0,0 +1,238 @@ +using Microsoft.Extensions.Configuration; +using PracticeService.Application; +using PracticeService.Domain; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace PracticeService.Persistence; + +internal sealed class ModelQuestionClient(IHttpClientFactory clients, IConfiguration configuration) +{ + private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web); + + private string? ApiKey => configuration["QuestionGeneration:ApiKey"]; + private string Endpoint => configuration["QuestionGeneration:Endpoint"] ?? "https://api.deepseek.com/chat/completions"; + private string Model => configuration["QuestionGeneration:Model"] ?? "deepseek-v4-flash"; + + internal bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey); + + internal async Task<GeneratedChunk> GenerateChunkAsync(SourceChunk chunk, QuestionGenerationInput input, CancellationToken cancellationToken) + { + var allowedKinds = (input.Kinds.Count == 0 ? ReciteQuestionGenerator.ClassicKinds : input.Kinds).Select(kind => kind.ToString()).ToArray(); + var points = chunk.Points.Select(point => new { pointId = point.KnowledgePointId, point.Title, point.Summary, point.Tags }).ToArray(); + var system = "你是农学、社会科学与人文类复习资料的题库编辑。只处理事实、概念、关系、比较、步骤和论述,不生成计算题、公式推导或复杂理工题。先从原文识别可独立作答的答案原子,再据此写题。不得补充原文没有的信息。输出严格 JSON。"; + var user = JsonSerializer.Serialize(new + { + task = "从 evidence 中生成至多 8 道互不重复的复习题", + rules = new[] + { + "每题必须绑定 suppliedPoints 中唯一 pointId", + "sourceQuote 必须逐字复制 evidence 中能完整支持标准答案的最短连续原文", + "correctAnswers 必须逐字出现在 sourceQuote 中;简答题可把多个原文要点作为多个数组元素", + "填空题必须有 ____ 且答案为不超过 40 字的明确短语", + "名词解释只用于原文存在术语与定义关系时", + "单选题必须恰有 A-D 四项,只有正确项文本可由 sourceQuote 支持;无法构造可靠干扰项就不要出单选", + "题干不得包含答案,不得使用请概括下述内容之类把答案原句塞进题干的模板", + "不要为凑题数重复同一知识原子" + }, + allowedKinds, + suppliedPoints = points, + evidence = chunk.Text, + output = new + { + questions = new[] { new { kind = "FillBlank|TermDefinition|Essay|SingleChoice", prompt = "", options = new[] { new { id = "A", text = "" } }, correctAnswers = new[] { "" }, explanation = "", score = 2, difficulty = 2, knowledgePointId = Guid.Empty, sourceQuote = "" } } + } + }, _json); + var generated = await CompleteJsonAsync(system, user, cancellationToken); + var diagnostics = new List<PracticeJobDiagnostic>(); + IReadOnlyList<ModelCandidate> candidates; + try + { + candidates = ParseModelCandidates(generated.Content, chunk, input); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or FormatException) + { + diagnostics.Add(new(chunk.Material.MaterialId, "QUESTION_MODEL_OUTPUT_INVALID", + "题目模型返回的 JSON 不符合候选题契约,未保存任何猜测题目。", false)); + return new([], diagnostics, generated.TokenUnits); + } + if (candidates.Count == 0) + { + diagnostics.Add(new(chunk.Material.MaterialId, "NO_VERIFIED_QUESTIONS_IN_CHUNK", + $"资料区间 {chunk.StartOffset}-{chunk.StartOffset + chunk.Text.Length} 没有题目通过确定性校验。", false)); + return new([], diagnostics, generated.TokenUnits); + } + + var verifierUser = JsonSerializer.Serialize(new + { + task = "在与生成调用分离的第二次推理中复核候选题。只能依据 evidence 重新作答,再将 recoveredAnswers 与候选标准答案比较;仅接受答案一致、题干清晰且 pointId 语义一致的题。", + evidence = chunk.Text, + suppliedPoints = points, + candidates = candidates.Select((candidate, index) => new { index, candidate.Draft.Kind, candidate.Draft.Prompt, candidate.Draft.Options, candidate.Draft.CorrectAnswers, candidate.PointId, candidate.SourceQuote }), + output = new { results = new[] { new { index = 0, accepted = true, recoveredAnswers = new[] { "" }, reason = "" } } } + }, _json); + var verified = await CompleteJsonAsync("你是第二遍来源约束题目核验器。不得利用 evidence 之外的常识放宽答案。输出严格 JSON。", verifierUser, cancellationToken); + IReadOnlySet<int> accepted; + try + { + accepted = ParseRoundTripResults(verified.Content, candidates); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or FormatException) + { + diagnostics.Add(new(chunk.Material.MaterialId, "QUESTION_ROUND_TRIP_OUTPUT_INVALID", + "第二遍回验返回的 JSON 不符合回验契约,候选题均未进入 READY。", false)); + return new([], diagnostics, checked(generated.TokenUnits + verified.TokenUnits)); + } + var result = candidates.Where((_, index) => accepted.Contains(index)).Select(candidate => candidate.Draft).ToArray(); + if (result.Length < candidates.Count) + diagnostics.Add(new(chunk.Material.MaterialId, "QUESTION_ROUND_TRIP_REJECTED", + $"第二遍回验拒绝了 {candidates.Count - result.Length} 道不能从同一证据稳定还原答案的候选题。", false)); + return new(result, diagnostics, checked(generated.TokenUnits + verified.TokenUnits)); + } + + private async Task<ModelCompletion> CompleteJsonAsync(string system, string user, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); + request.Content = JsonContent.Create(new + { + model = Model, + messages = new[] { new { role = "system", content = system }, new { role = "user", content = user } }, + response_format = new { type = "json_object" }, + temperature = 0.1, + max_tokens = 6000 + }, options: _json); + using var response = await clients.CreateClient("question-generation").SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + throw new PracticeDomainException((int)response.StatusCode, "QUESTION_MODEL_FAILED", $"题目模型调用失败:HTTP {(int)response.StatusCode}。"); + JsonDocument document; + try { document = JsonDocument.Parse(body); } + catch (JsonException) + { + throw new PracticeDomainException(502, "QUESTION_MODEL_RESPONSE_INVALID", "题目模型返回了非 JSON 响应,未保存候选题。"); + } + using (document) + { + if (!document.RootElement.TryGetProperty("usage", out var usage) + || !usage.TryGetProperty("total_tokens", out var total) + || !total.TryGetInt64(out var tokens) + || tokens <= 0) + throw new PracticeDomainException(502, "QUESTION_MODEL_USAGE_MISSING", "题目模型未返回有效 usage.total_tokens,无法按实际消耗结算 credits。"); + if (!document.RootElement.TryGetProperty("choices", out var choices) + || choices.ValueKind != JsonValueKind.Array + || choices.GetArrayLength() == 0 + || !choices[0].TryGetProperty("message", out var message) + || !message.TryGetProperty("content", out var rawContent) + || rawContent.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(rawContent.GetString())) + throw new PracticeDomainException(502, "QUESTION_MODEL_RESPONSE_INVALID", "题目模型响应缺少 choices[0].message.content。"); + return new(rawContent.GetString()!, tokens); + } + } + + private static IReadOnlyList<ModelCandidate> ParseModelCandidates(string json, SourceChunk chunk, QuestionGenerationInput input) + { + var candidates = new List<ModelCandidate>(); + using var document = JsonDocument.Parse(ExtractJsonObject(json)); + if (!document.RootElement.TryGetProperty("questions", out var questions) || questions.ValueKind != JsonValueKind.Array) return candidates; + var allowedKinds = (input.Kinds.Count == 0 ? ReciteQuestionGenerator.ClassicKinds : input.Kinds).ToHashSet(); + var pointIds = chunk.Points.Select(point => point.KnowledgePointId).ToHashSet(); + var seen = new HashSet<string>(StringComparer.Ordinal); + foreach (var item in questions.EnumerateArray()) + { + try + { + if (!Enum.TryParse<PracticeQuestionKind>(item.GetProperty("kind").GetString(), true, out var kind) || !allowedKinds.Contains(kind)) continue; + var prompt = item.GetProperty("prompt").GetString()?.Trim() ?? string.Empty; + var quote = item.GetProperty("sourceQuote").GetString()?.Trim() ?? string.Empty; + if (prompt.Length < 2 || quote.Length < 2 || QuestionTextParser.Normalize(prompt) == QuestionTextParser.Normalize(quote) || !seen.Add(QuestionTextParser.Normalize(prompt))) continue; + var relativeOffset = chunk.Text.IndexOf(quote, StringComparison.Ordinal); + if (relativeOffset < 0) continue; + var pointId = item.GetProperty("knowledgePointId").GetGuid(); + if (!pointIds.Contains(pointId)) continue; + var answers = item.GetProperty("correctAnswers").EnumerateArray().Select(answer => answer.GetString()?.Trim() ?? string.Empty).Where(answer => answer.Length > 0).ToArray(); + if (answers.Length == 0 || QuestionTextParser.ContainsPageNoise(quote)) continue; + var options = item.TryGetProperty("options", out var rawOptions) && rawOptions.ValueKind == JsonValueKind.Array + ? rawOptions.EnumerateArray().Select(option => new QuestionOption(option.GetProperty("id").GetString() ?? string.Empty, option.GetProperty("text").GetString() ?? string.Empty)).ToArray() + : []; + if (kind != PracticeQuestionKind.SingleChoice + && answers.Any(answer => !QuestionTextParser.Normalize(quote).Contains(QuestionTextParser.Normalize(answer), StringComparison.Ordinal))) continue; + if (!ValidateGeneratedShape(kind, prompt, quote, options, answers)) continue; + var source = new SourceReference(chunk.Material.MaterialId, chunk.StartOffset + relativeOffset, + chunk.StartOffset + relativeOffset + quote.Length, chunk.Material.SourceMapVersion, PracticeRules.Sha256(quote)); + var score = item.TryGetProperty("score", out var rawScore) && rawScore.TryGetDecimal(out var parsedScore) ? Math.Clamp(parsedScore, 1, 20) : QuestionTextParser.DefaultScore(kind); + var difficulty = item.TryGetProperty("difficulty", out var rawDifficulty) && rawDifficulty.TryGetInt32(out var parsedDifficulty) ? Math.Clamp(parsedDifficulty, 1, 5) : QuestionTextParser.InferDifficulty(kind); + var explanation = item.TryGetProperty("explanation", out var rawExplanation) ? rawExplanation.GetString() : null; + var draft = new QuestionDraft(kind, prompt, options, answers, explanation, score, difficulty, pointId, [source], QuestionStatus.Ready); + PracticeRules.ValidateAnswerShape(kind, options, answers); + candidates.Add(new(draft, pointId, quote)); + } + catch (Exception error) when (error is not OperationCanceledException) + { + _ = error; + } + } + return candidates; + } + + private static bool ValidateGeneratedShape(PracticeQuestionKind kind, string prompt, string quote, + IReadOnlyList<QuestionOption> options, IReadOnlyList<string> answers) + { + if (kind != PracticeQuestionKind.SingleChoice + && answers.Any(answer => QuestionTextParser.Normalize(prompt).Contains(QuestionTextParser.Normalize(answer), StringComparison.Ordinal))) return false; + if (kind == PracticeQuestionKind.FillBlank) return Regex.Matches(prompt, "____").Count == answers.Count + && answers.All(answer => answer.Length <= 40) && options.Count == 0 + && prompt.Replace("____", "待填内容", StringComparison.Ordinal).Length >= 6; + if (kind == PracticeQuestionKind.TermDefinition) return options.Count == 0 && !QuestionTextParser.Normalize(prompt).Contains(QuestionTextParser.Normalize(quote), StringComparison.Ordinal); + if (kind == PracticeQuestionKind.Essay) return options.Count == 0 && !QuestionTextParser.Normalize(prompt).Contains(QuestionTextParser.Normalize(quote), StringComparison.Ordinal); + if (kind != PracticeQuestionKind.SingleChoice || options.Count != 4 || answers.Count != 1) return false; + var answerOption = options.SingleOrDefault(option => string.Equals(option.Id, answers[0], StringComparison.OrdinalIgnoreCase)); + if (answerOption is null + || QuestionTextParser.Normalize(prompt).Contains(QuestionTextParser.Normalize(answerOption.Text), StringComparison.Ordinal) + || !QuestionTextParser.Normalize(quote).Contains(QuestionTextParser.Normalize(answerOption.Text), StringComparison.Ordinal)) return false; + return options.Where(option => !string.Equals(option.Id, answerOption.Id, StringComparison.OrdinalIgnoreCase)) + .All(option => !QuestionTextParser.Normalize(quote).Contains(QuestionTextParser.Normalize(option.Text), StringComparison.Ordinal)); + } + + private static IReadOnlySet<int> ParseRoundTripResults(string json, IReadOnlyList<ModelCandidate> candidates) + { + using var document = JsonDocument.Parse(ExtractJsonObject(json)); + if (!document.RootElement.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array) return new HashSet<int>(); + var accepted = new HashSet<int>(); + foreach (var result in results.EnumerateArray()) + { + if (!result.TryGetProperty("index", out var rawIndex) || !rawIndex.TryGetInt32(out var index) + || index < 0 || index >= candidates.Count + || !result.TryGetProperty("accepted", out var rawAccepted) || rawAccepted.ValueKind is not (JsonValueKind.True or JsonValueKind.False) + || !rawAccepted.GetBoolean() + || !result.TryGetProperty("recoveredAnswers", out var recovered) || recovered.ValueKind != JsonValueKind.Array) continue; + var recoveredAnswers = recovered.EnumerateArray() + .Where(value => value.ValueKind == JsonValueKind.String) + .Select(value => value.GetString()?.Trim() ?? string.Empty) + .Where(value => value.Length > 0) + .Select(QuestionTextParser.Normalize) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + var expectedAnswers = candidates[index].Draft.CorrectAnswers + .Select(QuestionTextParser.Normalize) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + if (recoveredAnswers.SequenceEqual(expectedAnswers, StringComparer.Ordinal)) accepted.Add(index); + } + return accepted; + } + + private static string ExtractJsonObject(string value) + { + var start = value.IndexOf('{'); + var end = value.LastIndexOf('}'); + return start >= 0 && end > start ? value[start..(end + 1)] : "{}"; + } +} + +internal sealed record ModelCompletion(string Content, long TokenUnits); +internal sealed record ModelCandidate(QuestionDraft Draft, Guid PointId, string SourceQuote); +internal sealed record GeneratedChunk(IReadOnlyList<QuestionDraft> Drafts, IReadOnlyList<PracticeJobDiagnostic> Diagnostics, long TokenUnits); diff --git a/backend/PracticeService/PracticeService.Persistence/QuestionChunker.cs b/backend/PracticeService/PracticeService.Persistence/QuestionChunker.cs new file mode 100644 index 0000000..7632cde --- /dev/null +++ b/backend/PracticeService/PracticeService.Persistence/QuestionChunker.cs @@ -0,0 +1,67 @@ +using PracticeService.Domain; + +namespace PracticeService.Persistence; + +internal static class QuestionChunker +{ + private const int ChunkCharacters = 1400; + + internal static IEnumerable<SourceChunk> BuildChunks(IReadOnlyList<MaterialText> materials, IReadOnlyList<PlanGraphPoint> points) + { + foreach (var material in materials) + { + var position = 0; + while (position < material.Text.Length) + { + while (position < material.Text.Length && char.IsWhiteSpace(material.Text[position])) position++; + if (position >= material.Text.Length) break; + var start = position; + var preferredEnd = Math.Min(start + ChunkCharacters, material.Text.Length); + var end = FindChunkBoundary(material.Text, start, preferredEnd); + while (end > start && char.IsWhiteSpace(material.Text[end - 1])) end--; + if (end <= start) + { + end = preferredEnd; + } + var text = material.Text[start..end].Trim(); + var actualStart = material.Text.IndexOf(text, start, StringComparison.Ordinal); + if (text.Length >= 40) + yield return new(material, actualStart, text, BindCandidatePoints(text, points)); + position = end; + } + } + } + + internal static bool IsCoveredChunk(SourceChunk chunk, IReadOnlyList<GroundedRange> ranges) + { + if (chunk.Text.Length == 0) return true; + var chunkEnd = chunk.StartOffset + chunk.Text.Length; + var covered = ranges.Where(range => range.MaterialId == chunk.Material.MaterialId) + .Sum(range => Math.Max(0, Math.Min(chunkEnd, range.EndOffset) - Math.Max(chunk.StartOffset, range.StartOffset))); + return covered >= chunk.Text.Length * 0.75; + } + + private static int FindChunkBoundary(string text, int start, int preferredEnd) + { + if (preferredEnd >= text.Length) return text.Length; + var minimum = start + Math.Max(1, (preferredEnd - start) * 3 / 5); + for (var index = preferredEnd; index > minimum; index--) + { + if (text[index - 1] is '\n' or '。' or '!' or '?' or '.' or '!' or '?' or ';' or ';') + return index; + } + return preferredEnd; + } + + private static IReadOnlyList<PlanGraphPoint> BindCandidatePoints(string text, IReadOnlyList<PlanGraphPoint> points) + { + var normalized = QuestionTextParser.Normalize(text); + var matched = points.Where(point => + QuestionTextParser.Normalize(point.Title).Length >= 2 && normalized.Contains(QuestionTextParser.Normalize(point.Title), StringComparison.Ordinal) + || point.Tags.Any(tag => QuestionTextParser.Normalize(tag).Length >= 3 && normalized.Contains(QuestionTextParser.Normalize(tag), StringComparison.Ordinal))) + .Take(24).ToArray(); + return matched; + } +} + +internal sealed record SourceChunk(MaterialText Material, int StartOffset, string Text, IReadOnlyList<PlanGraphPoint> Points); diff --git a/backend/PracticeService/PracticeService.Persistence/QuestionTextParser.cs b/backend/PracticeService/PracticeService.Persistence/QuestionTextParser.cs new file mode 100644 index 0000000..1cc3033 --- /dev/null +++ b/backend/PracticeService/PracticeService.Persistence/QuestionTextParser.cs @@ -0,0 +1,109 @@ +using PracticeService.Domain; +using System.Text.RegularExpressions; + +namespace PracticeService.Persistence; + +internal static class QuestionTextParser +{ + private static readonly Regex OptionMarker = new( + @"(?<![A-Za-z0-9])(?<id>[A-DA-D])[\.、.]\s*", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex PageNoise = new( + @"(?:山东农业大学农学院农业生态学教学组版权所有不得复制!\s*\d{1,3}|2019级植科一班张旋波|农业生态学试题库\s*2021版)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + internal static PracticeQuestionKind InferKind(string? section, string prompt, int optionCount) + { + if (optionCount >= 2 || section is "选择题" or "单项选择题") return PracticeQuestionKind.SingleChoice; + if (section == "填空题" || prompt.Contains("____", StringComparison.Ordinal) || prompt.Contains("( )", StringComparison.Ordinal)) return PracticeQuestionKind.FillBlank; + if (section == "名词解释") return PracticeQuestionKind.TermDefinition; + return PracticeQuestionKind.Essay; + } + + internal static string EnsureQuestionPrompt(string prompt, PracticeQuestionKind kind) + { + prompt = Regex.Replace(prompt.Trim(), @"^[\d\s\.、]+", string.Empty).Trim(); + prompt = Regex.Replace(prompt, @"[((]?1[))]?\s*定义$", string.Empty).Trim(); + if (kind == PracticeQuestionKind.TermDefinition) + { + var term = prompt.TrimEnd('。', '?', '?', ':', ':'); + return term.StartsWith("解释", StringComparison.Ordinal) || term.StartsWith("什么是", StringComparison.Ordinal) + ? term + "。" + : $"请解释“{term}”。"; + } + if (prompt.EndsWith('?') || prompt.EndsWith('?') || prompt.EndsWith('。')) return prompt; + return prompt + "。"; + } + + internal static string CleanAnswer(string value) + { + var answer = CleanText(value); + answer = Regex.Replace(answer, @"(?ms)\s*评分标准.*$", string.Empty); + answer = Regex.Replace(answer, @"[ \t]+", " "); + answer = Regex.Replace(answer, @"\s*\r?\n\s*", "\n"); + return answer.Trim(); + } + + internal static string CleanText(string value) + { + var cleaned = PageNoise.Replace(value, " "); + cleaned = Regex.Replace(cleaned, @"[ \t]+", " "); + return cleaned.Trim(); + } + + internal static QuestionOption[] ParseOptions(string body, out string prompt) + { + var matches = OptionMarker.Matches(body).Cast<Match>().ToArray(); + if (matches.Length < 2) + { + prompt = body.Trim(); + return []; + } + prompt = body[..matches[0].Index].Trim(); + return matches.Select((match, index) => + { + var end = index + 1 < matches.Length ? matches[index + 1].Index : body.Length; + return new QuestionOption(NormalizeOptionId(match.Groups["id"].Value), body[(match.Index + match.Length)..end].Trim()); + }).Where(option => option.Text.Length > 0).ToArray(); + } + + internal static bool EvidenceSupportsAnswer(string evidence, string answer) => + Normalize(CleanText(evidence)).Contains(Normalize(CleanText(answer)), StringComparison.Ordinal); + + internal static bool ContainsPageNoise(string value) => PageNoise.IsMatch(value); + + internal static decimal ParseScore(string? sectionMeta, string raw, PracticeQuestionKind kind) + { + var value = $"{sectionMeta} {raw[..Math.Min(raw.Length, 80)]}"; + var match = Regex.Match(value, @"(?<score>\d{1,2})\s*分"); + return match.Success && decimal.TryParse(match.Groups["score"].Value, out var score) && score is > 0 and <= 20 + ? score : DefaultScore(kind); + } + + internal static decimal DefaultScore(PracticeQuestionKind kind) => kind switch + { + PracticeQuestionKind.SingleChoice => 3, + PracticeQuestionKind.FillBlank => 2, + PracticeQuestionKind.TermDefinition => 4, + _ => 5 + }; + + internal static int InferDifficulty(PracticeQuestionKind kind) => kind switch + { + PracticeQuestionKind.FillBlank => 2, + PracticeQuestionKind.SingleChoice => 2, + _ => 3 + }; + + internal static string Normalize(string value) => new(value.Normalize(System.Text.NormalizationForm.FormC) + .Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + + internal static string NormalizeOptionId(string value) + { + var character = value.Trim().ToUpperInvariant()[0]; + if (character is >= 'A' and <= 'H') character = (char)('A' + character - 'A'); + return character.ToString(); + } + + internal static string TrimForDiagnostic(string value) => value.Length <= 32 ? value : value[..32] + "…"; +} diff --git a/backend/PracticeService/PracticeService.Persistence/ReciteQuestionGenerator.cs b/backend/PracticeService/PracticeService.Persistence/ReciteQuestionGenerator.cs index a2b3098..753b5d4 100644 --- a/backend/PracticeService/PracticeService.Persistence/ReciteQuestionGenerator.cs +++ b/backend/PracticeService/PracticeService.Persistence/ReciteQuestionGenerator.cs @@ -2,11 +2,6 @@ using Microsoft.Extensions.Logging; using PracticeService.Application; using PracticeService.Domain; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; namespace PracticeService.Persistence; @@ -16,42 +11,22 @@ public sealed class ReciteQuestionGenerator( ILogger<ReciteQuestionGenerator> logger) : IPracticeQuestionGenerator { private const int MaximumQuestions = 1000; - private const int ChunkCharacters = 1400; - private static readonly PracticeQuestionKind[] ClassicKinds = + internal static readonly PracticeQuestionKind[] ClassicKinds = [ PracticeQuestionKind.SingleChoice, PracticeQuestionKind.FillBlank, PracticeQuestionKind.TermDefinition, PracticeQuestionKind.Essay ]; - private static readonly Regex ReferenceAnswerMarker = new(@"【参考答案】", RegexOptions.Compiled); - private static readonly Regex NumberedItemMarker = new( - @"(?<![\p{L}\p{N}])(?<number>\d{1,3})[\.、.]\s*", - RegexOptions.Compiled); - private static readonly Regex SectionHeading = new( - @"(?<title>名词解释|填空题|选择题|单项选择题|简答题|论述题|综合题|大题|重要知识点|还有重要的知识点|结课思考题)", - RegexOptions.Compiled); - private static readonly Regex StructuralBoundary = new( - @"(?:第[一二三四五六七八九十百\d]+章\s*[^\r\n]{0,80}|[一二三四五六七八九十]+、\s*(?:名词解释|填空题|选择题|单项选择题|简答题|论述题|综合题|大题|重要知识点|还有重要的知识点))", - RegexOptions.Compiled); - private static readonly Regex OptionMarker = new( - @"(?<![A-Za-z0-9])(?<id>[A-DA-D])[\.、.]\s*", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex PageNoise = new( - @"(?:山东农业大学农学院农业生态学教学组版权所有不得复制!\s*\d{1,3}|2019级植科一班张旋波|农业生态学试题库\s*2021版)", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web); + private readonly ModelQuestionClient _model = new(clients, configuration); - private string? ApiKey => configuration["QuestionGeneration:ApiKey"]; - private string Endpoint => configuration["QuestionGeneration:Endpoint"] ?? "https://api.deepseek.com/chat/completions"; - private string Model => configuration["QuestionGeneration:Model"] ?? "deepseek-v4-flash"; private int Parallelism => Math.Clamp(configuration.GetValue("QuestionGeneration:Parallelism", 4), 1, 8); public QuestionGenerationEstimate Estimate(QuestionGenerationInput input) { Validate(input); - var extraction = ExtractGroundedQuestions(input); - var chunks = BuildChunks(input.Materials, input.Points).Count(chunk => chunk.Points.Count > 0 && !IsCoveredChunk(chunk, extraction.CoveredRanges)); + var extraction = GroundedQuestionExtractor.ExtractGroundedQuestions(input); + var chunks = QuestionChunker.BuildChunks(input.Materials, input.Points).Count(chunk => chunk.Points.Count > 0 && !QuestionChunker.IsCoveredChunk(chunk, extraction.CoveredRanges)); var target = ResolveTargetCount(input, extraction.Drafts.Count, chunks, extraction.IsExplicitQuestionBank); if (extraction.IsExplicitQuestionBank || extraction.Drafts.Count >= target) return new(target, 1, extraction.IsExplicitQuestionBank ? "SOURCE_EXTRACTION" : "HYBRID_EXTRACTION"); @@ -64,9 +39,9 @@ public QuestionGenerationEstimate Estimate(QuestionGenerationInput input) public async Task<QuestionGenerationOutput> GenerateAsync(QuestionGenerationInput input, CancellationToken cancellationToken) { Validate(input); - var extraction = ExtractGroundedQuestions(input); - var allChunks = BuildChunks(input.Materials, input.Points) - .Where(chunk => chunk.Points.Count > 0 && !IsCoveredChunk(chunk, extraction.CoveredRanges)) + var extraction = GroundedQuestionExtractor.ExtractGroundedQuestions(input); + var allChunks = QuestionChunker.BuildChunks(input.Materials, input.Points) + .Where(chunk => chunk.Points.Count > 0 && !QuestionChunker.IsCoveredChunk(chunk, extraction.CoveredRanges)) .ToArray(); var target = ResolveTargetCount(input, extraction.Drafts.Count, allChunks.Length, extraction.IsExplicitQuestionBank); var drafts = extraction.Drafts.Take(target).ToList(); @@ -75,7 +50,7 @@ public async Task<QuestionGenerationOutput> GenerateAsync(QuestionGenerationInpu if (extraction.IsExplicitQuestionBank || drafts.Count >= target) return new(drafts, diagnostics, 1, extraction.IsExplicitQuestionBank ? "SOURCE_EXTRACTION" : "HYBRID_EXTRACTION"); - if (string.IsNullOrWhiteSpace(ApiKey)) + if (!_model.IsConfigured) { diagnostics.Add(new(null, "QUESTION_MODEL_NOT_CONFIGURED", "资料中没有足够的可直接核对题目;QuestionGeneration:ApiKey 未配置,未使用模板猜测题目。", false)); @@ -100,7 +75,7 @@ public async Task<QuestionGenerationOutput> GenerateAsync(QuestionGenerationInpu CancellationToken = cancellationToken }, async (index, ct) => { - try { outputs[index] = await GenerateChunkAsync(chunks[index], input, ct); } + try { outputs[index] = await _model.GenerateChunkAsync(chunks[index], input, ct); } catch (Exception error) when (error is not OperationCanceledException and not PracticeDomainException) { logger.LogWarning(error, "Question generation chunk failed for material {MaterialId} at {Offset}.", chunks[index].Material.MaterialId, chunks[index].StartOffset); @@ -145,587 +120,11 @@ private static int ResolveTargetCount(QuestionGenerationInput input, int extract return Math.Clamp(extractedCount + groundedChunkCount * 8, 1, MaximumQuestions); } - private static ExtractionBatch ExtractGroundedQuestions(QuestionGenerationInput input) - { - var kinds = (input.Kinds.Count == 0 ? ClassicKinds : input.Kinds).ToHashSet(); - var drafts = new List<QuestionDraft>(); - var diagnostics = new List<PracticeJobDiagnostic>(); - var coveredRanges = new List<GroundedRange>(); - var explicitItems = 0; - foreach (var material in input.Materials) - { - var occupied = new List<(int Start, int End)>(); - var explicitSources = ExtractExplicitSourceItems(material.Text, diagnostics, material.MaterialId); - explicitItems += explicitSources.Count; - foreach (var sourceItem in explicitSources) - { - var parsed = ParseSourceItem(material, sourceItem, true, input.Points, diagnostics); - occupied.Add((sourceItem.StartOffset, sourceItem.EndOffset)); - coveredRanges.Add(new(material.MaterialId, sourceItem.StartOffset, sourceItem.EndOffset)); - if (parsed is not null && kinds.Contains(parsed.Kind)) drafts.Add(parsed); - } - foreach (var sourceItem in ExtractSemiStructuredSourceItems(material.Text, occupied)) - { - var parsed = ParseSourceItem(material, sourceItem, false, input.Points, diagnostics); - coveredRanges.Add(new(material.MaterialId, sourceItem.StartOffset, sourceItem.EndOffset)); - if (parsed is not null && kinds.Contains(parsed.Kind)) drafts.Add(parsed); - } - } - var unique = drafts - .GroupBy(QuestionDedupKey, StringComparer.Ordinal) - .Select(group => group.First()) - .OrderBy(draft => draft.SourceReferences[0].MaterialId) - .ThenBy(draft => draft.SourceReferences[0].StartOffset) - .ToArray(); - return new(unique, diagnostics, explicitItems >= 3, coveredRanges); - } - - private static bool IsCoveredChunk(SourceChunk chunk, IReadOnlyList<GroundedRange> ranges) - { - if (chunk.Text.Length == 0) return true; - var chunkEnd = chunk.StartOffset + chunk.Text.Length; - var covered = ranges.Where(range => range.MaterialId == chunk.Material.MaterialId) - .Sum(range => Math.Max(0, Math.Min(chunkEnd, range.EndOffset) - Math.Max(chunk.StartOffset, range.StartOffset))); - return covered >= chunk.Text.Length * 0.75; - } - - private static IReadOnlyList<SourceItem> ExtractExplicitSourceItems( - string text, - List<PracticeJobDiagnostic> diagnostics, - Guid materialId) - { - var answerMarkers = ReferenceAnswerMarker.Matches(text).Cast<Match>().ToArray(); - if (answerMarkers.Length == 0) return []; - var promptMarkers = new SourceMarker?[answerMarkers.Length]; - var previousAnswerMarkerEnd = 0; - for (var index = 0; index < answerMarkers.Length; index++) - { - var answerMarker = answerMarkers[index]; - var candidates = NumberedItemMarker.Matches(text[previousAnswerMarkerEnd..answerMarker.Index]); - if (candidates.Count > 0) - { - var candidate = candidates[^1]; - promptMarkers[index] = new(previousAnswerMarkerEnd + candidate.Index, candidate.Length); - } - else diagnostics.Add(new(materialId, "SOURCE_QUESTION_BOUNDARY_NOT_FOUND", - $"资料中的第 {index + 1} 个参考答案标记之前没有可核对题号,已拒绝自动成题。", false)); - previousAnswerMarkerEnd = answerMarker.Index + answerMarker.Length; - } - - var items = new List<SourceItem>(); - for (var index = 0; index < answerMarkers.Length; index++) - { - var promptMarker = promptMarkers[index]; - if (promptMarker is null) continue; - var marker = answerMarkers[index]; - var structuralEnd = FindNextStructuralBoundary(text, marker.Index + marker.Length); - var nextPromptEnd = index + 1 < promptMarkers.Length && promptMarkers[index + 1] is SourceMarker nextPrompt - ? nextPrompt.Index - : text.Length; - var end = Math.Min(structuralEnd, nextPromptEnd); - end = Math.Clamp(end, marker.Index + marker.Length, text.Length); - var section = FindSection(text, promptMarker.Index); - items.Add(new(promptMarker.Index, end, - text[(promptMarker.Index + promptMarker.Length)..marker.Index], - text[(marker.Index + marker.Length)..end], section?.Title, section?.Meta ?? string.Empty)); - } - return items; - } - - private static IReadOnlyList<SourceItem> ExtractSemiStructuredSourceItems( - string text, - IReadOnlyList<(int Start, int End)> occupied) - { - var sections = SectionHeading.Matches(text).Cast<Match>().ToArray(); - var items = new List<SourceItem>(); - foreach (var section in sections) - { - var title = section.Groups["title"].Value; - var sectionStart = section.Index + section.Length; - var nextSection = sections.FirstOrDefault(candidate => candidate.Index >= sectionStart); - var boundary = StructuralBoundary.Match(text, sectionStart); - var sectionEnd = new[] - { - nextSection?.Index ?? text.Length, - boundary.Success ? boundary.Index : text.Length, - text.Length - } - .Min(); - if (sectionEnd <= sectionStart) continue; - - if (title == "结课思考题") - { - var raw = text[sectionStart..sectionEnd].TrimStart(':', ':', ' ', '\t'); - var answerStart = Regex.Match(raw, @"。(?=\s*\d{1,2}(?![\d\.]))"); - if (answerStart.Success) - { - var absoluteStart = sectionStart + text[sectionStart..sectionEnd].IndexOf(raw, StringComparison.Ordinal); - var source = new SourceItem(absoluteStart, sectionEnd, raw[..(answerStart.Index + 1)], - raw[(answerStart.Index + 1)..], title, string.Empty); - if (!Overlaps(source.StartOffset, source.EndOffset, occupied)) items.Add(source); - } - continue; - } - - var sectionText = text[sectionStart..sectionEnd]; - var markers = NumberedItemMarker.Matches(sectionText).Cast<Match>().ToArray(); - for (var index = 0; index < markers.Length; index++) - { - var marker = markers[index]; - var itemStart = sectionStart + marker.Index; - var itemEnd = index + 1 < markers.Length ? sectionStart + markers[index + 1].Index : sectionEnd; - if (Overlaps(itemStart, itemEnd, occupied)) continue; - var body = text[(itemStart + marker.Length)..itemEnd]; - var split = FindAnswerSeparator(body, title); - if (split < 1 || split >= body.Length - 1) continue; - items.Add(new(itemStart, itemEnd, body[..split], body[(split + 1)..], title, string.Empty)); - } - } - return items; - } - - private static int FindAnswerSeparator(string body, string section) - { - var colon = body.IndexOfAny([':', ':']); - return colon < 1 ? -1 : colon; - } - - private static bool Overlaps(int start, int end, IReadOnlyList<(int Start, int End)> occupied) => - occupied.Any(range => start < range.End && end > range.Start); - - private static int FindNextStructuralBoundary(string text, int start) - { - var section = SectionHeading.Match(text, start); - var structural = StructuralBoundary.Match(text, start); - return new[] - { - section.Success ? section.Index : text.Length, - structural.Success ? structural.Index : text.Length, - text.Length - }.Min(); - } - - private static QuestionDraft? ParseSourceItem(MaterialText material, SourceItem item, bool explicitAnswer, - IReadOnlyList<PlanGraphPoint> points, List<PracticeJobDiagnostic> diagnostics) - { - var body = CleanText(item.Prompt); - var answer = CleanAnswer(item.Answer); - var options = ParseOptions(body, out var prompt); - prompt = Regex.Replace(prompt, @"^\s*\(?\d+\s*分\)?\s*", string.Empty).Trim(); - if (prompt.Length < 2 || answer.Length < 1) return null; - - var kind = InferKind(item.SectionTitle, prompt, options.Length); - IReadOnlyList<string> correctAnswers; - if (kind == PracticeQuestionKind.SingleChoice) - { - var optionId = Regex.Match(answer, @"(?i)(?<![A-Z])[A-H](?![A-Z])").Value.ToUpperInvariant(); - if (options.Length < 2 || optionId.Length == 0 || !options.Any(option => option.Id == optionId)) - { - diagnostics.Add(new(material.MaterialId, "SOURCE_CHOICE_ANSWER_AMBIGUOUS", $"原题“{TrimForDiagnostic(prompt)}”的选项或答案无法唯一核对,保留为草稿。", false)); - return null; - } - else correctAnswers = [optionId]; - } - else correctAnswers = [answer]; - - var excerpt = material.Text[item.StartOffset..item.EndOffset]; - var source = new SourceReference(material.MaterialId, item.StartOffset, item.EndOffset, - material.SourceMapVersion, PracticeRules.Sha256(excerpt)); - var binding = KnowledgePointBindingRules.Bind(kind, prompt, correctAnswers, excerpt, points, [source]); - var answerSupported = EvidenceSupportsAnswer(excerpt, answer); - var reliableChoice = kind != PracticeQuestionKind.SingleChoice || options.Length == 4; - var ready = binding.PointId.HasValue && answerSupported && reliableChoice; - if (!binding.PointId.HasValue) - diagnostics.Add(new(material.MaterialId, binding.Ambiguous ? "KNOWLEDGE_POINT_BINDING_AMBIGUOUS" : "KNOWLEDGE_POINT_SOURCE_NOT_FOUND", - $"原题“{TrimForDiagnostic(prompt)}”未能唯一绑定本册知识点,已保留为待核对草稿。", false)); - if (!answerSupported) - diagnostics.Add(new(material.MaterialId, "SOURCE_ANSWER_NOT_SUPPORTED", - $"原题“{TrimForDiagnostic(prompt)}”的答案不能由同一原文区间完整支持,已保留为待核对草稿。", false)); - if (!reliableChoice) - diagnostics.Add(new(material.MaterialId, "SOURCE_CHOICE_OPTIONS_UNRELIABLE", - $"原题“{TrimForDiagnostic(prompt)}”不是恰好四个选项,未自动收入 READY 题库。", false)); - return new(kind, EnsureQuestionPrompt(prompt, kind), options, correctAnswers, - explicitAnswer ? "题目与答案按资料原文提取。" : "题目与答案按资料中的可核对问答结构提取。", - ParseScore(item.SectionMeta, excerpt, kind), InferDifficulty(kind), binding.PointId, [source], ready ? QuestionStatus.Ready : QuestionStatus.Draft); - } - - private async Task<GeneratedChunk> GenerateChunkAsync(SourceChunk chunk, QuestionGenerationInput input, CancellationToken cancellationToken) - { - var allowedKinds = (input.Kinds.Count == 0 ? ClassicKinds : input.Kinds).Select(kind => kind.ToString()).ToArray(); - var points = chunk.Points.Select(point => new { pointId = point.KnowledgePointId, point.Title, point.Summary, point.Tags }).ToArray(); - var system = "你是农学、社会科学与人文类复习资料的题库编辑。只处理事实、概念、关系、比较、步骤和论述,不生成计算题、公式推导或复杂理工题。先从原文识别可独立作答的答案原子,再据此写题。不得补充原文没有的信息。输出严格 JSON。"; - var user = JsonSerializer.Serialize(new - { - task = "从 evidence 中生成至多 8 道互不重复的复习题", - rules = new[] - { - "每题必须绑定 suppliedPoints 中唯一 pointId", - "sourceQuote 必须逐字复制 evidence 中能完整支持标准答案的最短连续原文", - "correctAnswers 必须逐字出现在 sourceQuote 中;简答题可把多个原文要点作为多个数组元素", - "填空题必须有 ____ 且答案为不超过 40 字的明确短语", - "名词解释只用于原文存在术语与定义关系时", - "单选题必须恰有 A-D 四项,只有正确项文本可由 sourceQuote 支持;无法构造可靠干扰项就不要出单选", - "题干不得包含答案,不得使用请概括下述内容之类把答案原句塞进题干的模板", - "不要为凑题数重复同一知识原子" - }, - allowedKinds, - suppliedPoints = points, - evidence = chunk.Text, - output = new - { - questions = new[] { new { kind = "FillBlank|TermDefinition|Essay|SingleChoice", prompt = "", options = new[] { new { id = "A", text = "" } }, correctAnswers = new[] { "" }, explanation = "", score = 2, difficulty = 2, knowledgePointId = Guid.Empty, sourceQuote = "" } } - } - }, _json); - var generated = await CompleteJsonAsync(system, user, cancellationToken); - var diagnostics = new List<PracticeJobDiagnostic>(); - IReadOnlyList<ModelCandidate> candidates; - try - { - candidates = ParseModelCandidates(generated.Content, chunk, input); - } - catch (Exception error) when (error is JsonException or InvalidOperationException or FormatException) - { - diagnostics.Add(new(chunk.Material.MaterialId, "QUESTION_MODEL_OUTPUT_INVALID", - "题目模型返回的 JSON 不符合候选题契约,未保存任何猜测题目。", false)); - return new([], diagnostics, generated.TokenUnits); - } - if (candidates.Count == 0) - { - diagnostics.Add(new(chunk.Material.MaterialId, "NO_VERIFIED_QUESTIONS_IN_CHUNK", - $"资料区间 {chunk.StartOffset}-{chunk.StartOffset + chunk.Text.Length} 没有题目通过确定性校验。", false)); - return new([], diagnostics, generated.TokenUnits); - } - - var verifierUser = JsonSerializer.Serialize(new - { - task = "在与生成调用分离的第二次推理中复核候选题。只能依据 evidence 重新作答,再将 recoveredAnswers 与候选标准答案比较;仅接受答案一致、题干清晰且 pointId 语义一致的题。", - evidence = chunk.Text, - suppliedPoints = points, - candidates = candidates.Select((candidate, index) => new { index, candidate.Draft.Kind, candidate.Draft.Prompt, candidate.Draft.Options, candidate.Draft.CorrectAnswers, candidate.PointId, candidate.SourceQuote }), - output = new { results = new[] { new { index = 0, accepted = true, recoveredAnswers = new[] { "" }, reason = "" } } } - }, _json); - var verified = await CompleteJsonAsync("你是第二遍来源约束题目核验器。不得利用 evidence 之外的常识放宽答案。输出严格 JSON。", verifierUser, cancellationToken); - IReadOnlySet<int> accepted; - try - { - accepted = ParseRoundTripResults(verified.Content, candidates); - } - catch (Exception error) when (error is JsonException or InvalidOperationException or FormatException) - { - diagnostics.Add(new(chunk.Material.MaterialId, "QUESTION_ROUND_TRIP_OUTPUT_INVALID", - "第二遍回验返回的 JSON 不符合回验契约,候选题均未进入 READY。", false)); - return new([], diagnostics, checked(generated.TokenUnits + verified.TokenUnits)); - } - var result = candidates.Where((_, index) => accepted.Contains(index)).Select(candidate => candidate.Draft).ToArray(); - if (result.Length < candidates.Count) - diagnostics.Add(new(chunk.Material.MaterialId, "QUESTION_ROUND_TRIP_REJECTED", - $"第二遍回验拒绝了 {candidates.Count - result.Length} 道不能从同一证据稳定还原答案的候选题。", false)); - return new(result, diagnostics, checked(generated.TokenUnits + verified.TokenUnits)); - } - - private async Task<ModelCompletion> CompleteJsonAsync(string system, string user, CancellationToken cancellationToken) - { - using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); - request.Content = JsonContent.Create(new - { - model = Model, - messages = new[] { new { role = "system", content = system }, new { role = "user", content = user } }, - response_format = new { type = "json_object" }, - temperature = 0.1, - max_tokens = 6000 - }, options: _json); - using var response = await clients.CreateClient("question-generation").SendAsync(request, cancellationToken); - var body = await response.Content.ReadAsStringAsync(cancellationToken); - if (!response.IsSuccessStatusCode) - throw new PracticeDomainException((int)response.StatusCode, "QUESTION_MODEL_FAILED", $"题目模型调用失败:HTTP {(int)response.StatusCode}。"); - JsonDocument document; - try { document = JsonDocument.Parse(body); } - catch (JsonException) - { - throw new PracticeDomainException(502, "QUESTION_MODEL_RESPONSE_INVALID", "题目模型返回了非 JSON 响应,未保存候选题。"); - } - using (document) - { - if (!document.RootElement.TryGetProperty("usage", out var usage) - || !usage.TryGetProperty("total_tokens", out var total) - || !total.TryGetInt64(out var tokens) - || tokens <= 0) - throw new PracticeDomainException(502, "QUESTION_MODEL_USAGE_MISSING", "题目模型未返回有效 usage.total_tokens,无法按实际消耗结算 credits。"); - if (!document.RootElement.TryGetProperty("choices", out var choices) - || choices.ValueKind != JsonValueKind.Array - || choices.GetArrayLength() == 0 - || !choices[0].TryGetProperty("message", out var message) - || !message.TryGetProperty("content", out var rawContent) - || rawContent.ValueKind != JsonValueKind.String - || string.IsNullOrWhiteSpace(rawContent.GetString())) - throw new PracticeDomainException(502, "QUESTION_MODEL_RESPONSE_INVALID", "题目模型响应缺少 choices[0].message.content。"); - return new(rawContent.GetString()!, tokens); - } - } - - private static IReadOnlyList<ModelCandidate> ParseModelCandidates(string json, SourceChunk chunk, QuestionGenerationInput input) - { - var candidates = new List<ModelCandidate>(); - using var document = JsonDocument.Parse(ExtractJsonObject(json)); - if (!document.RootElement.TryGetProperty("questions", out var questions) || questions.ValueKind != JsonValueKind.Array) return candidates; - var allowedKinds = (input.Kinds.Count == 0 ? ClassicKinds : input.Kinds).ToHashSet(); - var pointIds = chunk.Points.Select(point => point.KnowledgePointId).ToHashSet(); - var seen = new HashSet<string>(StringComparer.Ordinal); - foreach (var item in questions.EnumerateArray()) - { - try - { - if (!Enum.TryParse<PracticeQuestionKind>(item.GetProperty("kind").GetString(), true, out var kind) || !allowedKinds.Contains(kind)) continue; - var prompt = item.GetProperty("prompt").GetString()?.Trim() ?? string.Empty; - var quote = item.GetProperty("sourceQuote").GetString()?.Trim() ?? string.Empty; - if (prompt.Length < 2 || quote.Length < 2 || Normalize(prompt) == Normalize(quote) || !seen.Add(Normalize(prompt))) continue; - var relativeOffset = chunk.Text.IndexOf(quote, StringComparison.Ordinal); - if (relativeOffset < 0) continue; - var pointId = item.GetProperty("knowledgePointId").GetGuid(); - if (!pointIds.Contains(pointId)) continue; - var answers = item.GetProperty("correctAnswers").EnumerateArray().Select(answer => answer.GetString()?.Trim() ?? string.Empty).Where(answer => answer.Length > 0).ToArray(); - if (answers.Length == 0 || ContainsPageNoise(quote)) continue; - var options = item.TryGetProperty("options", out var rawOptions) && rawOptions.ValueKind == JsonValueKind.Array - ? rawOptions.EnumerateArray().Select(option => new QuestionOption(option.GetProperty("id").GetString() ?? string.Empty, option.GetProperty("text").GetString() ?? string.Empty)).ToArray() - : []; - if (kind != PracticeQuestionKind.SingleChoice - && answers.Any(answer => !Normalize(quote).Contains(Normalize(answer), StringComparison.Ordinal))) continue; - if (!ValidateGeneratedShape(kind, prompt, quote, options, answers)) continue; - var source = new SourceReference(chunk.Material.MaterialId, chunk.StartOffset + relativeOffset, - chunk.StartOffset + relativeOffset + quote.Length, chunk.Material.SourceMapVersion, PracticeRules.Sha256(quote)); - var score = item.TryGetProperty("score", out var rawScore) && rawScore.TryGetDecimal(out var parsedScore) ? Math.Clamp(parsedScore, 1, 20) : DefaultScore(kind); - var difficulty = item.TryGetProperty("difficulty", out var rawDifficulty) && rawDifficulty.TryGetInt32(out var parsedDifficulty) ? Math.Clamp(parsedDifficulty, 1, 5) : InferDifficulty(kind); - var explanation = item.TryGetProperty("explanation", out var rawExplanation) ? rawExplanation.GetString() : null; - var draft = new QuestionDraft(kind, prompt, options, answers, explanation, score, difficulty, pointId, [source], QuestionStatus.Ready); - PracticeRules.ValidateAnswerShape(kind, options, answers); - candidates.Add(new(draft, pointId, quote)); - } - catch (Exception error) when (error is not OperationCanceledException) - { - _ = error; - } - } - return candidates; - } - - private static bool ValidateGeneratedShape(PracticeQuestionKind kind, string prompt, string quote, - IReadOnlyList<QuestionOption> options, IReadOnlyList<string> answers) - { - if (kind != PracticeQuestionKind.SingleChoice - && answers.Any(answer => Normalize(prompt).Contains(Normalize(answer), StringComparison.Ordinal))) return false; - if (kind == PracticeQuestionKind.FillBlank) return Regex.Matches(prompt, "____").Count == answers.Count - && answers.All(answer => answer.Length <= 40) && options.Count == 0 - && prompt.Replace("____", "待填内容", StringComparison.Ordinal).Length >= 6; - if (kind == PracticeQuestionKind.TermDefinition) return options.Count == 0 && !Normalize(prompt).Contains(Normalize(quote), StringComparison.Ordinal); - if (kind == PracticeQuestionKind.Essay) return options.Count == 0 && !Normalize(prompt).Contains(Normalize(quote), StringComparison.Ordinal); - if (kind != PracticeQuestionKind.SingleChoice || options.Count != 4 || answers.Count != 1) return false; - var answerOption = options.SingleOrDefault(option => string.Equals(option.Id, answers[0], StringComparison.OrdinalIgnoreCase)); - if (answerOption is null - || Normalize(prompt).Contains(Normalize(answerOption.Text), StringComparison.Ordinal) - || !Normalize(quote).Contains(Normalize(answerOption.Text), StringComparison.Ordinal)) return false; - return options.Where(option => !string.Equals(option.Id, answerOption.Id, StringComparison.OrdinalIgnoreCase)) - .All(option => !Normalize(quote).Contains(Normalize(option.Text), StringComparison.Ordinal)); - } - - private static IReadOnlySet<int> ParseRoundTripResults(string json, IReadOnlyList<ModelCandidate> candidates) - { - using var document = JsonDocument.Parse(ExtractJsonObject(json)); - if (!document.RootElement.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array) return new HashSet<int>(); - var accepted = new HashSet<int>(); - foreach (var result in results.EnumerateArray()) - { - if (!result.TryGetProperty("index", out var rawIndex) || !rawIndex.TryGetInt32(out var index) - || index < 0 || index >= candidates.Count - || !result.TryGetProperty("accepted", out var rawAccepted) || rawAccepted.ValueKind is not (JsonValueKind.True or JsonValueKind.False) - || !rawAccepted.GetBoolean() - || !result.TryGetProperty("recoveredAnswers", out var recovered) || recovered.ValueKind != JsonValueKind.Array) continue; - var recoveredAnswers = recovered.EnumerateArray() - .Where(value => value.ValueKind == JsonValueKind.String) - .Select(value => value.GetString()?.Trim() ?? string.Empty) - .Where(value => value.Length > 0) - .Select(Normalize) - .OrderBy(value => value, StringComparer.Ordinal) - .ToArray(); - var expectedAnswers = candidates[index].Draft.CorrectAnswers - .Select(Normalize) - .OrderBy(value => value, StringComparer.Ordinal) - .ToArray(); - if (recoveredAnswers.SequenceEqual(expectedAnswers, StringComparer.Ordinal)) accepted.Add(index); - } - return accepted; - } - - private static IEnumerable<SourceChunk> BuildChunks(IReadOnlyList<MaterialText> materials, IReadOnlyList<PlanGraphPoint> points) - { - foreach (var material in materials) - { - var position = 0; - while (position < material.Text.Length) - { - while (position < material.Text.Length && char.IsWhiteSpace(material.Text[position])) position++; - if (position >= material.Text.Length) break; - var start = position; - var preferredEnd = Math.Min(start + ChunkCharacters, material.Text.Length); - var end = FindChunkBoundary(material.Text, start, preferredEnd); - while (end > start && char.IsWhiteSpace(material.Text[end - 1])) end--; - if (end <= start) - { - end = preferredEnd; - } - var text = material.Text[start..end].Trim(); - var actualStart = material.Text.IndexOf(text, start, StringComparison.Ordinal); - if (text.Length >= 40) - yield return new(material, actualStart, text, BindCandidatePoints(text, points)); - position = end; - } - } - } - - private static int FindChunkBoundary(string text, int start, int preferredEnd) - { - if (preferredEnd >= text.Length) return text.Length; - var minimum = start + Math.Max(1, (preferredEnd - start) * 3 / 5); - for (var index = preferredEnd; index > minimum; index--) - { - if (text[index - 1] is '\n' or '。' or '!' or '?' or '.' or '!' or '?' or ';' or ';') - return index; - } - return preferredEnd; - } - - private static IReadOnlyList<PlanGraphPoint> BindCandidatePoints(string text, IReadOnlyList<PlanGraphPoint> points) - { - var normalized = Normalize(text); - var matched = points.Where(point => - Normalize(point.Title).Length >= 2 && normalized.Contains(Normalize(point.Title), StringComparison.Ordinal) - || point.Tags.Any(tag => Normalize(tag).Length >= 3 && normalized.Contains(Normalize(tag), StringComparison.Ordinal))) - .Take(24).ToArray(); - return matched; - } - - private static (string Title, string Meta)? FindSection(string text, int offset) - { - var sections = SectionHeading.Matches(text[..Math.Clamp(offset, 0, text.Length)]); - if (sections.Count == 0) return null; - var match = sections[^1]; - return (match.Groups["title"].Value.Trim(), string.Empty); - } - - private static PracticeQuestionKind InferKind(string? section, string prompt, int optionCount) - { - if (optionCount >= 2 || section is "选择题" or "单项选择题") return PracticeQuestionKind.SingleChoice; - if (section == "填空题" || prompt.Contains("____", StringComparison.Ordinal) || prompt.Contains("( )", StringComparison.Ordinal)) return PracticeQuestionKind.FillBlank; - if (section == "名词解释") return PracticeQuestionKind.TermDefinition; - return PracticeQuestionKind.Essay; - } - - private static string EnsureQuestionPrompt(string prompt, PracticeQuestionKind kind) - { - prompt = Regex.Replace(prompt.Trim(), @"^[\d\s\.、]+", string.Empty).Trim(); - prompt = Regex.Replace(prompt, @"[((]?1[))]?\s*定义$", string.Empty).Trim(); - if (kind == PracticeQuestionKind.TermDefinition) - { - var term = prompt.TrimEnd('。', '?', '?', ':', ':'); - return term.StartsWith("解释", StringComparison.Ordinal) || term.StartsWith("什么是", StringComparison.Ordinal) - ? term + "。" - : $"请解释“{term}”。"; - } - if (prompt.EndsWith('?') || prompt.EndsWith('?') || prompt.EndsWith('。')) return prompt; - return prompt + "。"; - } - - private static string CleanAnswer(string value) - { - var answer = CleanText(value); - answer = Regex.Replace(answer, @"(?ms)\s*评分标准.*$", string.Empty); - answer = Regex.Replace(answer, @"[ \t]+", " "); - answer = Regex.Replace(answer, @"\s*\r?\n\s*", "\n"); - return answer.Trim(); - } - - private static string CleanText(string value) - { - var cleaned = PageNoise.Replace(value, " "); - cleaned = Regex.Replace(cleaned, @"[ \t]+", " "); - return cleaned.Trim(); - } - - private static QuestionOption[] ParseOptions(string body, out string prompt) - { - var matches = OptionMarker.Matches(body).Cast<Match>().ToArray(); - if (matches.Length < 2) - { - prompt = body.Trim(); - return []; - } - prompt = body[..matches[0].Index].Trim(); - return matches.Select((match, index) => - { - var end = index + 1 < matches.Length ? matches[index + 1].Index : body.Length; - return new QuestionOption(NormalizeOptionId(match.Groups["id"].Value), body[(match.Index + match.Length)..end].Trim()); - }).Where(option => option.Text.Length > 0).ToArray(); - } - - private static bool EvidenceSupportsAnswer(string evidence, string answer) => - Normalize(CleanText(evidence)).Contains(Normalize(CleanText(answer)), StringComparison.Ordinal); - - private static bool ContainsPageNoise(string value) => PageNoise.IsMatch(value); - - private static string QuestionDedupKey(QuestionDraft draft) + internal static string QuestionDedupKey(QuestionDraft draft) { var point = draft.KnowledgePointId?.ToString("D") ?? $"unbound:{draft.SourceReferences.FirstOrDefault()?.MaterialId:D}:{draft.SourceReferences.FirstOrDefault()?.StartOffset}"; - var atom = string.Join('|', draft.CorrectAnswers.Select(Normalize).OrderBy(value => value, StringComparer.Ordinal)); + var atom = string.Join('|', draft.CorrectAnswers.Select(QuestionTextParser.Normalize).OrderBy(value => value, StringComparer.Ordinal)); return $"{point}:{draft.Kind}:{atom}"; } - - private static decimal ParseScore(string? sectionMeta, string raw, PracticeQuestionKind kind) - { - var value = $"{sectionMeta} {raw[..Math.Min(raw.Length, 80)]}"; - var match = Regex.Match(value, @"(?<score>\d{1,2})\s*分"); - return match.Success && decimal.TryParse(match.Groups["score"].Value, out var score) && score is > 0 and <= 20 - ? score : DefaultScore(kind); - } - - private static decimal DefaultScore(PracticeQuestionKind kind) => kind switch - { - PracticeQuestionKind.SingleChoice => 3, - PracticeQuestionKind.FillBlank => 2, - PracticeQuestionKind.TermDefinition => 4, - _ => 5 - }; - - private static int InferDifficulty(PracticeQuestionKind kind) => kind switch - { - PracticeQuestionKind.FillBlank => 2, - PracticeQuestionKind.SingleChoice => 2, - _ => 3 - }; - - private static string Normalize(string value) => new(value.Normalize(NormalizationForm.FormC) - .Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); - private static string NormalizeOptionId(string value) - { - var character = value.Trim().ToUpperInvariant()[0]; - if (character is >= 'A' and <= 'H') character = (char)('A' + character - 'A'); - return character.ToString(); - } - private static string TrimForDiagnostic(string value) => value.Length <= 32 ? value : value[..32] + "…"; - private static string ExtractJsonObject(string value) - { - var start = value.IndexOf('{'); - var end = value.LastIndexOf('}'); - return start >= 0 && end > start ? value[start..(end + 1)] : "{}"; - } - - private sealed record ExtractionBatch(IReadOnlyList<QuestionDraft> Drafts, IReadOnlyList<PracticeJobDiagnostic> Diagnostics, - bool IsExplicitQuestionBank, IReadOnlyList<GroundedRange> CoveredRanges); - private sealed record GroundedRange(Guid MaterialId, int StartOffset, int EndOffset); - private sealed record SourceMarker(int Index, int Length); - private sealed record SourceItem(int StartOffset, int EndOffset, string Prompt, string Answer, string? SectionTitle, string SectionMeta); - private sealed record SourceChunk(MaterialText Material, int StartOffset, string Text, IReadOnlyList<PlanGraphPoint> Points); - private sealed record ModelCompletion(string Content, long TokenUnits); - private sealed record ModelCandidate(QuestionDraft Draft, Guid PointId, string SourceQuote); - private sealed record GeneratedChunk(IReadOnlyList<QuestionDraft> Drafts, IReadOnlyList<PracticeJobDiagnostic> Diagnostics, long TokenUnits); } diff --git a/backend/PracticeService/PracticeService.Persistence/TraceFlow.cs b/backend/PracticeService/PracticeService.Persistence/TraceFlow.cs new file mode 100644 index 0000000..2092b00 --- /dev/null +++ b/backend/PracticeService/PracticeService.Persistence/TraceFlow.cs @@ -0,0 +1,26 @@ +namespace PracticeService.Persistence; + +/// <summary> +/// 请求级链路上下文(AsyncLocal)。由 API 层请求日志中间件写入, +/// 供出站 HTTP 客户端(GatewayClient / GatewayModelFacetAdjudicator) +/// 透传 X-Correlation-Id,实现跨服务一次定位。 +/// 后台任务无 HttpContext 时可显式 Set,未设置则客户端生成新 id。 +/// </summary> +public static class TraceFlow +{ + private static readonly AsyncLocal<string?> CurrentId = new(); + + public static string? Current => CurrentId.Value; + + public static IDisposable? Begin(string traceId) + { + var previous = CurrentId.Value; + CurrentId.Value = traceId; + return new Releaser(previous); + } + + private sealed class Releaser(string? previous) : IDisposable + { + public void Dispose() => CurrentId.Value = previous; + } +} diff --git a/backend/RenderService/Dockerfile b/backend/RenderService/Dockerfile index 4824840..16e7ab3 100644 --- a/backend/RenderService/Dockerfile +++ b/backend/RenderService/Dockerfile @@ -22,6 +22,8 @@ COPY --from=build /out/GalReview.RenderService ./bin/GalReview.RenderService COPY --from=build /src/service/dist ./service/dist COPY service/demo ./service/demo COPY service/runtime.wasm.base64 ./service/ +# Session file snapshot mount point (optional RENDER_SESSION_STORE_PATH) +RUN mkdir -p /data && chown node:node /data ENV PORT=5106 \ RENDER_HOST=0.0.0.0 EXPOSE 5106 diff --git a/backend/RenderService/service/src/server.ts b/backend/RenderService/service/src/server.ts index 629083f..9215ab9 100644 --- a/backend/RenderService/service/src/server.ts +++ b/backend/RenderService/service/src/server.ts @@ -107,6 +107,8 @@ const sessionService: SessionService | null = sessionsEnabled serviceKey: gatewayServiceKey, timeoutMs: internalTimeoutMs, }), + // 可选文件快照:设置后重启可恢复会话(单实例部署) + persistPath: process.env.RENDER_SESSION_STORE_PATH?.trim() || null, }) : null diff --git a/backend/RenderService/service/src/sessionStore.ts b/backend/RenderService/service/src/sessionStore.ts new file mode 100644 index 0000000..5817cd3 --- /dev/null +++ b/backend/RenderService/service/src/sessionStore.ts @@ -0,0 +1,65 @@ +// Optional file-backed snapshot for ReviewSession records. +// Single-instance production can survive restarts without adding Mongo to +// RenderService. Writes are atomic (tmp + rename). Multi-instance is still +// unsupported — document that constraint. +import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'node:fs' +import { dirname } from 'node:path' + +export interface PersistedQuestionDigest { + knowledgePointId: string + sceneId: string + choices: Record<string, boolean> +} + +export interface PersistedPackageDigest { + entrySceneId: string + sceneIds: string[] + questions: Record<string, PersistedQuestionDigest> + hasQuestions: boolean +} + +export interface PersistedSessionRecord { + sessionId: string + userId: string + packageId: string + reviewPlanId: string + snapshotVersion: string + clientRuntimeVersion: string + status: string + currentSceneId: string | null + progressVersion: number + startedAt: string | null + completedAt: string | null + createdAt: string + digest: PersistedPackageDigest + snapshot: unknown + snapshotChecksum: string | null + eventIds: string[] + result: unknown + pendingResult: unknown +} + +export interface FileSnapshot { + version: 1 + savedAt: string + sessions: PersistedSessionRecord[] +} + +export function loadFileSnapshot(path: string): FileSnapshot | null { + if (!existsSync(path)) return null + try { + const raw = readFileSync(path, 'utf8') + const parsed = JSON.parse(raw) as FileSnapshot + if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.sessions)) return null + return parsed + } catch { + return null + } +} + +export function saveFileSnapshot(path: string, snapshot: FileSnapshot): void { + mkdirSync(dirname(path), { recursive: true }) + const tmp = `${path}.tmp` + writeFileSync(tmp, JSON.stringify(snapshot), 'utf8') + renameSync(tmp, path) +} diff --git a/backend/RenderService/service/src/sessions.ts b/backend/RenderService/service/src/sessions.ts index bf7d872..568b7d4 100644 --- a/backend/RenderService/service/src/sessions.ts +++ b/backend/RenderService/service/src/sessions.ts @@ -22,6 +22,12 @@ import type { } from './contract.js' import { isNonEmptyString, isRecord, isUuidV4 } from './contract.js' import type { GatewayClient, UpstreamFailure } from './gateway-client.js' +import { + loadFileSnapshot, + saveFileSnapshot, + type PersistedSessionRecord, + type FileSnapshot, +} from './sessionStore.js' export const SESSION_LIMITS = Object.freeze({ maxAnswerResults: 100, @@ -101,6 +107,81 @@ export interface SessionServiceOptions { gateway: GatewayClient now?: () => Date newId?: () => string + /** 可选:文件快照路径。设置后重启可恢复会话(单实例)。 */ + persistPath?: string | null +} + +function serializeRecord(record: SessionRecord): PersistedSessionRecord { + const questions: Record<string, PersistedSessionRecord['digest']['questions'][string]> = {} + for (const [questionId, digest] of record.digest.questions) { + questions[questionId] = { + knowledgePointId: digest.knowledgePointId, + sceneId: digest.sceneId, + choices: Object.fromEntries(digest.choices), + } + } + return { + sessionId: record.sessionId, + userId: record.userId, + packageId: record.packageId, + reviewPlanId: record.reviewPlanId, + snapshotVersion: record.snapshotVersion, + clientRuntimeVersion: record.clientRuntimeVersion, + status: record.status, + currentSceneId: record.currentSceneId, + progressVersion: record.progressVersion, + startedAt: record.startedAt, + completedAt: record.completedAt, + createdAt: record.createdAt, + digest: { + entrySceneId: record.digest.entrySceneId, + sceneIds: [...record.digest.sceneIds], + questions, + hasQuestions: record.digest.hasQuestions, + }, + snapshot: record.snapshot, + snapshotChecksum: record.snapshotChecksum, + eventIds: [...record.eventIds], + result: record.result, + pendingResult: record.pendingResult, + } +} + +function deserializeRecord(raw: PersistedSessionRecord): SessionRecord { + const sceneIds = new Set(raw.digest.sceneIds) + const questions = new Map<string, QuestionDigest>() + for (const [questionId, q] of Object.entries(raw.digest.questions ?? {})) { + questions.set(questionId, { + knowledgePointId: q.knowledgePointId, + sceneId: q.sceneId, + choices: new Map(Object.entries(q.choices ?? {})), + }) + } + return { + sessionId: raw.sessionId, + userId: raw.userId, + packageId: raw.packageId, + reviewPlanId: raw.reviewPlanId, + snapshotVersion: raw.snapshotVersion, + clientRuntimeVersion: raw.clientRuntimeVersion, + status: raw.status as ReviewSessionStatus, + currentSceneId: raw.currentSceneId, + progressVersion: raw.progressVersion, + startedAt: raw.startedAt, + completedAt: raw.completedAt, + createdAt: raw.createdAt, + digest: { + entrySceneId: raw.digest.entrySceneId, + sceneIds, + questions, + hasQuestions: raw.digest.hasQuestions, + }, + snapshot: (raw.snapshot ?? null) as ProgressSnapshot | null, + snapshotChecksum: raw.snapshotChecksum, + eventIds: new Set(raw.eventIds ?? []), + result: (raw.result ?? null) as SessionRecord['result'], + pendingResult: (raw.pendingResult ?? null) as SessionRecord['pendingResult'], + } } function failure( @@ -180,15 +261,50 @@ export interface SessionService { submitResult(input: { userId: string; sessionId: string; body: unknown; correlationId?: string }): Promise<DomainResult<ReviewResult>> - stats(): { sessions: number; storage: 'ephemeral-memory' } + stats(): { sessions: number; storage: 'ephemeral-memory' | 'file' } } export function createSessionService(options: SessionServiceOptions): SessionService { const { gateway } = options const now = options.now ?? (() => new Date()) const newId = options.newId ?? randomUUID + const persistPath = options.persistPath?.trim() || null const sessions = new Map<string, SessionRecord>() + if (persistPath) { + const snapshot = loadFileSnapshot(persistPath) + if (snapshot) { + for (const raw of snapshot.sessions) { + try { + sessions.set(raw.sessionId, deserializeRecord(raw)) + } catch { + // Skip corrupt rows rather than failing boot. + } + } + } + } + + let persistTimer: ReturnType<typeof setTimeout> | null = null + function schedulePersist(): void { + if (!persistPath) return + if (persistTimer) return + persistTimer = setTimeout(() => { + persistTimer = null + try { + const payload: FileSnapshot = { + version: 1, + savedAt: now().toISOString(), + sessions: [...sessions.values()].map(serializeRecord), + } + saveFileSnapshot(persistPath, payload) + } catch { + // Persistence is best-effort; in-memory remains authoritative for this process. + } + }, 250) + // Do not keep the process alive solely for a pending snapshot. + persistTimer.unref?.() + } + function findOwned(sessionId: string, userId: string): SessionRecord | null { const record = sessions.get(sessionId) // Missing and not-owned are indistinguishable to the caller (§5.1 style). @@ -259,6 +375,7 @@ export function createSessionService(options: SessionServiceOptions): SessionSer pendingResult: null, } sessions.set(record.sessionId, record) + schedulePersist() return { ok: true, status: 201, body: sessionView(record) } }, @@ -338,6 +455,7 @@ export function createSessionService(options: SessionServiceOptions): SessionSer savedAt: now().toISOString(), } record.snapshotChecksum = inputChecksum + schedulePersist() return { ok: true, status: 200, body: record.snapshot } }, @@ -386,7 +504,10 @@ export function createSessionService(options: SessionServiceOptions): SessionSer accepted += 1 } } - if (accepted > 0) markRunning(record) + if (accepted > 0) { + markRunning(record) + schedulePersist() + } return { ok: true, status: 202, body: { accepted, duplicates } } }, @@ -627,6 +748,7 @@ export function createSessionService(options: SessionServiceOptions): SessionSer submittedAt, } record.pendingResult = null + schedulePersist() return { ok: true, @@ -637,7 +759,10 @@ export function createSessionService(options: SessionServiceOptions): SessionSer // Diagnostics for /readyz and tests. stats() { - return { sessions: sessions.size, storage: 'ephemeral-memory' } + return { + sessions: sessions.size, + storage: persistPath ? ('file' as const) : ('ephemeral-memory' as const), + } }, } } diff --git a/backend/UserService/Contracts.cs b/backend/UserService/Contracts.cs new file mode 100644 index 0000000..c73d6cb --- /dev/null +++ b/backend/UserService/Contracts.cs @@ -0,0 +1,21 @@ +public sealed record CreateUserProfileRequest(string UserId, string DisplayName, string? Locale); +public sealed record AdminProfileLookupRequest(string[]? UserIds); +public sealed record AdminProfileSummary(string UserId, string DisplayName); +public sealed record UpdateUserProfileRequest(string? DisplayName, string? Locale, string[]? PreferredSubjectCodes); +public sealed record UserPreferencesInput(int DailyGoalMinutes, string ContentDifficulty, bool ReducedMotion); +public sealed record UserProfile(string UserId, string DisplayName, string? AvatarUrl, string Locale, string[] PreferredSubjectCodes, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt) +{ + public static UserProfile New(string userId, string displayName, string locale) => + new(userId, displayName, null, locale, [], DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); +} +public sealed record UserPreferences(int DailyGoalMinutes, string ContentDifficulty, bool ReducedMotion, DateTimeOffset UpdatedAt); +public sealed record ApiError(string Code, string Message, object Details); +public sealed record ApiSuccess(object Data, object Meta, string TraceId) +{ + public static ApiSuccess Create(object data, string traceId) => new(data, new { }, traceId); +} +public sealed record ApiFailure(object? Data, ApiError Error, string TraceId) +{ + public static ApiFailure Create(string code, string message, string traceId) => + new(null, new ApiError(code, message, new { }), traceId); +} diff --git a/backend/UserService/GatewayIdentity.cs b/backend/UserService/GatewayIdentity.cs new file mode 100644 index 0000000..2190920 --- /dev/null +++ b/backend/UserService/GatewayIdentity.cs @@ -0,0 +1,39 @@ +using System.Security.Cryptography; + +public static class GatewayIdentity +{ + public static bool IsGateway(HttpContext context, string key) + { + var values = context.Request.Headers["X-Gateway-Key"]; + return values.Count == 1 && FixedTimeEquals(values[0]!, key); + } + + public static bool IsAuthService(HttpContext context, string key) => + IsGateway(context, key) + && string.Equals(context.Request.Headers["X-Service-Name"], "AuthService", StringComparison.Ordinal); + + public static string? GetUserId(HttpContext context, string key) + { + var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); + return IsGateway(context, key) && !string.IsNullOrWhiteSpace(userId) ? userId : null; + } + + public static bool FixedTimeEquals(string left, string right) + { + var leftBytes = System.Text.Encoding.UTF8.GetBytes(left); + var rightBytes = System.Text.Encoding.UTF8.GetBytes(right); + var length = Math.Max(leftBytes.Length, rightBytes.Length); + var paddedLeft = new byte[length]; + var paddedRight = new byte[length]; + leftBytes.CopyTo(paddedLeft, 0); + rightBytes.CopyTo(paddedRight, 0); + return CryptographicOperations.FixedTimeEquals(paddedLeft, paddedRight) + && leftBytes.Length == rightBytes.Length; + } + + public static IResult Failure(HttpContext context, int status, string code, string message) => + Results.Json(ApiFailure.Create(code, message, context.TraceIdentifier), statusCode: status); + + public static string NormalizeLocale(string? value) => + string.IsNullOrWhiteSpace(value) ? "zh-CN" : value.Trim(); +} diff --git a/backend/UserService/Persistence.cs b/backend/UserService/Persistence.cs new file mode 100644 index 0000000..d18bec4 --- /dev/null +++ b/backend/UserService/Persistence.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using MySqlConnector; + +public sealed class UserDatabase(string connectionString) +{ + private readonly string _connectionString = connectionString; + + public MySqlConnection OpenConnection() + { + var connection = new MySqlConnection(_connectionString); + connection.Open(); + return connection; + } + + public void EnsureCreated() + { + var builder = new MySqlConnectionStringBuilder(_connectionString); + if (string.IsNullOrWhiteSpace(builder.Database) + || !builder.Database.All(c => char.IsLetterOrDigit(c) || c == '_')) + throw new InvalidOperationException("Invalid MySQL database name."); + var database = builder.Database; + builder.Database = string.Empty; + using (var server = new MySqlConnection(builder.ConnectionString)) + { + server.Open(); + using var create = server.CreateCommand(); + create.CommandText = $"CREATE DATABASE IF NOT EXISTS `{database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"; + create.ExecuteNonQuery(); + } + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE TABLE IF NOT EXISTS user_profiles ( + user_id CHAR(36) PRIMARY KEY, display_name VARCHAR(64) NOT NULL, + avatar_url VARCHAR(2048) NULL, locale VARCHAR(16) NOT NULL, + preferred_subject_codes JSON NOT NULL, created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL + ); + CREATE TABLE IF NOT EXISTS user_preferences ( + user_id CHAR(36) PRIMARY KEY, daily_goal_minutes INT NOT NULL, + content_difficulty VARCHAR(16) NOT NULL, reduced_motion BOOLEAN NOT NULL, + updated_at DATETIME(6) NOT NULL, + CONSTRAINT fk_preferences_profile FOREIGN KEY (user_id) REFERENCES user_profiles(user_id) ON DELETE CASCADE + ); + """; + command.ExecuteNonQuery(); + } +} + +public sealed class MySqlUserRepository(UserDatabase database) : IUserRepository +{ + public bool TryCreate(UserProfile profile) + { + try + { + using var connection = database.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO user_profiles (user_id, display_name, avatar_url, locale, preferred_subject_codes, created_at, updated_at) VALUES (@id, @displayName, NULL, @locale, @subjects, @created, @updated);"; + command.Parameters.AddWithValue("@id", profile.UserId); + command.Parameters.AddWithValue("@displayName", profile.DisplayName); + command.Parameters.AddWithValue("@locale", profile.Locale); + command.Parameters.AddWithValue("@subjects", JsonSerializer.Serialize(profile.PreferredSubjectCodes)); + command.Parameters.AddWithValue("@created", profile.CreatedAt.UtcDateTime); + command.Parameters.AddWithValue("@updated", profile.UpdatedAt.UtcDateTime); + return command.ExecuteNonQuery() == 1; + } + catch (MySqlException ex) when (ex.Number == 1062) { return false; } + } + + public UserProfile? FindProfile(string userId) + { + using var connection = database.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT CAST(user_id AS CHAR), display_name, avatar_url, locale, preferred_subject_codes, created_at, updated_at FROM user_profiles WHERE user_id = @id;"; + command.Parameters.AddWithValue("@id", userId); + using var reader = command.ExecuteReader(); + return reader.Read() ? ReadProfile(reader) : null; + } + + public List<AdminProfileSummary> FindAdminProfiles(IReadOnlyList<string> userIds) + { + if (userIds.Count == 0) return []; + using var connection = database.OpenConnection(); + using var command = connection.CreateCommand(); + var parameterNames = new List<string>(userIds.Count); + for (var index = 0; index < userIds.Count; index++) + { + var parameterName = $"@id{index}"; + parameterNames.Add(parameterName); + command.Parameters.AddWithValue(parameterName, userIds[index]); + } + command.CommandText = $"SELECT CAST(user_id AS CHAR), display_name FROM user_profiles WHERE user_id IN ({string.Join(',', parameterNames)});"; + using var reader = command.ExecuteReader(); + var result = new List<AdminProfileSummary>(); + while (reader.Read()) + result.Add(new AdminProfileSummary(DbText(reader.GetValue(0)), reader.GetString(1))); + return result; + } + + public bool DeleteProfile(string userId) + { + using var connection = database.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM user_profiles WHERE user_id=@id;"; + command.Parameters.AddWithValue("@id", userId); + return command.ExecuteNonQuery() == 1; + } + + public UserProfile? UpdateProfile(string userId, UpdateUserProfileRequest request) + { + var current = FindProfile(userId); + if (current is null) return null; + var updated = current with + { + DisplayName = request.DisplayName?.Trim() ?? current.DisplayName, + Locale = request.Locale?.Trim() ?? current.Locale, + PreferredSubjectCodes = request.PreferredSubjectCodes ?? current.PreferredSubjectCodes, + UpdatedAt = DateTimeOffset.UtcNow + }; + using var connection = database.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "UPDATE user_profiles SET display_name=@name, locale=@locale, preferred_subject_codes=@subjects, updated_at=@updated WHERE user_id=@id;"; + command.Parameters.AddWithValue("@id", updated.UserId); + command.Parameters.AddWithValue("@name", updated.DisplayName); + command.Parameters.AddWithValue("@locale", updated.Locale); + command.Parameters.AddWithValue("@subjects", JsonSerializer.Serialize(updated.PreferredSubjectCodes)); + command.Parameters.AddWithValue("@updated", updated.UpdatedAt.UtcDateTime); + if (command.ExecuteNonQuery() != 1) return null; + return updated; + } + + public UserPreferences? FindPreferences(string userId) + { + if (FindProfile(userId) is null) return null; + using var connection = database.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT daily_goal_minutes, content_difficulty, reduced_motion, updated_at FROM user_preferences WHERE user_id=@id;"; + command.Parameters.AddWithValue("@id", userId); + using var reader = command.ExecuteReader(); + return reader.Read() + ? new(reader.GetInt32(0), reader.GetString(1), reader.GetBoolean(2), AsUtc(reader.GetDateTime(3))) + : new UserPreferences(30, "STANDARD", false, DateTimeOffset.UtcNow); + } + + public UserPreferences? ReplacePreferences(string userId, UserPreferencesInput request) + { + if (FindProfile(userId) is null) return null; + var result = new UserPreferences(request.DailyGoalMinutes, request.ContentDifficulty, request.ReducedMotion, DateTimeOffset.UtcNow); + using var connection = database.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO user_preferences (user_id, daily_goal_minutes, content_difficulty, reduced_motion, updated_at) VALUES (@id,@goal,@difficulty,@motion,@updated) ON DUPLICATE KEY UPDATE daily_goal_minutes=@goal, content_difficulty=@difficulty, reduced_motion=@motion, updated_at=@updated;"; + command.Parameters.AddWithValue("@id", userId); + command.Parameters.AddWithValue("@goal", result.DailyGoalMinutes); + command.Parameters.AddWithValue("@difficulty", result.ContentDifficulty); + command.Parameters.AddWithValue("@motion", result.ReducedMotion); + command.Parameters.AddWithValue("@updated", result.UpdatedAt.UtcDateTime); + try { command.ExecuteNonQuery(); } + catch (MySqlException failure) when (failure.Number == 1452) { return null; } + return result; + } + + private static UserProfile ReadProfile(MySqlDataReader r) => + new(DbText(r.GetValue(0)), r.GetString(1), r.IsDBNull(2) ? null : r.GetString(2), + r.GetString(3), JsonSerializer.Deserialize<string[]>(r.GetString(4)) ?? [], + AsUtc(r.GetDateTime(5)), AsUtc(r.GetDateTime(6))); + + private static string DbText(object value) => value is Guid guid ? guid.ToString() : Convert.ToString(value)!; + private static DateTimeOffset AsUtc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc)); +} diff --git a/backend/UserService/Program.cs b/backend/UserService/Program.cs index e44fe1c..ee2b412 100644 --- a/backend/UserService/Program.cs +++ b/backend/UserService/Program.cs @@ -1,20 +1,23 @@ using System.Text.Json; -using System.Security.Cryptography; using Microsoft.AspNetCore.Diagnostics; -using MySqlConnector; var builder = WebApplication.CreateBuilder(args); -builder.Logging.ClearProviders(); -builder.Logging.AddConsole(); +builder.Logging.AddJsonStructuredLogging(); builder.Services.Configure<Microsoft.AspNetCore.Routing.RouteHandlerOptions>( options => options.ThrowOnBadRequest = true); -var isMockMode = string.Equals(Environment.GetEnvironmentVariable("MOONSTONE_MODE"), "Mock", StringComparison.OrdinalIgnoreCase); + +var isMockMode = string.Equals( + Environment.GetEnvironmentVariable("MOONSTONE_MODE"), "Mock", StringComparison.OrdinalIgnoreCase); var connectionString = builder.Configuration.GetConnectionString("UserDatabase"); if (!isMockMode && string.IsNullOrWhiteSpace(connectionString)) throw new InvalidOperationException("ConnectionStrings:UserDatabase must be configured."); + var gatewayKey = builder.Configuration["Gateway:ServiceKey"] ?? throw new InvalidOperationException("Gateway:ServiceKey must be configured."); +if (builder.Environment.IsProduction() && string.Equals(gatewayKey, "moonstone-local-gateway-key", StringComparison.Ordinal)) + throw new InvalidOperationException("Gateway:ServiceKey must be changed from the development default in production."); var storageName = isMockMode ? "memory" : "mysql"; + if (isMockMode) { builder.Services.AddSingleton<IUserRepository, InMemoryUserRepository>(); @@ -24,17 +27,12 @@ builder.Services.AddSingleton(new UserDatabase(connectionString!)); builder.Services.AddSingleton<IUserRepository, MySqlUserRepository>(); } -var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); var app = builder.Build(); if (!isMockMode) app.Services.GetRequiredService<UserDatabase>().EnsureCreated(); -app.Use(async (context, next) => -{ - var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); - context.TraceIdentifier = string.IsNullOrWhiteSpace(correlationId) ? Guid.NewGuid().ToString("N") : correlationId; - context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; - await next(); -}); + +app.UseRequestLogging("UserService"); + app.UseExceptionHandler(error => error.Run(context => { var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error; @@ -48,8 +46,11 @@ } context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("UserService") .LogError(exception, "Unhandled UserService error. CorrelationId: {CorrelationId}", context.TraceIdentifier); - return Results.Json(ApiFailure.Create("INTERNAL_ERROR", "用户服务暂时不可用", context.TraceIdentifier), statusCode: 500).ExecuteAsync(context); + return Results.Json( + ApiFailure.Create("INTERNAL_ERROR", "用户服务暂时不可用", context.TraceIdentifier), + statusCode: 500).ExecuteAsync(context); })); + app.Use(async (context, next) => { if (context.Request.Path == "/healthz" || context.Request.Path == "/readyz") @@ -57,242 +58,16 @@ await next(); return; } - - if (!IsGateway(context, gatewayKey)) + if (!GatewayIdentity.IsGateway(context, gatewayKey)) { - await Failure(context, 403, "FORBIDDEN", "该服务仅接受经 API Gateway 转发的请求").ExecuteAsync(context); + await GatewayIdentity.Failure(context, 403, "FORBIDDEN", "该服务仅接受经 API Gateway 转发的请求") + .ExecuteAsync(context); return; } - await next(); }); -app.MapGet("/healthz", (HttpContext c) => Results.Ok(ApiSuccess.Create(new { status = "live" }, c.TraceIdentifier))); -app.MapGet("/readyz", (HttpContext c) => Results.Ok(ApiSuccess.Create(new { status = "ready", storage = storageName }, c.TraceIdentifier))); - -app.MapPost("/internal/v1/users", (CreateUserProfileRequest request, HttpContext c, IUserRepository users) => -{ - if (!IsGateway(c, gatewayKey) || !string.Equals(c.Request.Headers["X-Service-Name"], "AuthService", StringComparison.Ordinal)) - return Failure(c, 403, "FORBIDDEN", "仅允许经 Gateway 的 AuthService 创建用户资料"); - if (!Guid.TryParse(request.UserId, out _) || !UserProfileValidation.IsValidDisplayName(request.DisplayName)) - return Failure(c, 400, "VALIDATION_ERROR", "userId 和 displayName 不符合要求"); - - var profile = UserProfile.New(request.UserId, request.DisplayName.Trim(), NormalizeLocale(request.Locale)); - return users.TryCreate(profile) - ? Results.Created($"/api/v1/users/{profile.UserId}", ApiSuccess.Create(profile, c.TraceIdentifier)) - : Failure(c, 409, "STATE_CONFLICT", "用户资料已存在"); -}); - -app.MapPost("/internal/v1/users/profile-lookups", (AdminProfileLookupRequest request, HttpContext c, IUserRepository users) => -{ - if (!IsAuthService(c, gatewayKey)) - return Failure(c, 403, "FORBIDDEN", "仅允许经 Gateway 的 AuthService 查询用户资料"); - return AdminProfileLookupHandler.Handle(request, c, users); -}); - -app.MapDelete("/internal/v1/users/{userId}", (string userId, HttpContext c, IUserRepository users) => -{ - if (!IsAuthService(c, gatewayKey)) - return Failure(c, 403, "FORBIDDEN", "仅允许经 Gateway 的 AuthService 删除用户资料"); - if (!Guid.TryParse(userId, out _)) - return Failure(c, 400, "VALIDATION_ERROR", "userId 不符合要求"); - return users.DeleteProfile(userId) - ? Results.NoContent() - : Failure(c, 404, "RESOURCE_NOT_FOUND", "用户资料不存在"); -}); - -app.MapGet("/api/v1/users/me", (HttpContext c, IUserRepository users) => -{ - var userId = GetUserId(c, gatewayKey); - return userId is null ? Failure(c, 401, "AUTH_REQUIRED", "需要有效登录状态") - : users.FindProfile(userId) is { } profile ? Results.Ok(ApiSuccess.Create(profile, c.TraceIdentifier)) - : Failure(c, 404, "RESOURCE_NOT_FOUND", "用户资料不存在"); -}); - -app.MapMethods("/api/v1/users/me", ["PATCH", "PUT"], async (HttpContext c, IUserRepository users) => -{ - var userId = GetUserId(c, gatewayKey); - if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "需要有效登录状态"); - return await UserProfileUpdateHandler.HandleAsync( - userId, - c, - users, - jsonOptions); -}); - -app.MapGet("/api/v1/users/me/preferences", (HttpContext c, IUserRepository users) => -{ - var userId = GetUserId(c, gatewayKey); - return userId is null ? Failure(c, 401, "AUTH_REQUIRED", "需要有效登录状态") - : users.FindPreferences(userId) is { } preferences ? Results.Ok(ApiSuccess.Create(preferences, c.TraceIdentifier)) - : Failure(c, 404, "RESOURCE_NOT_FOUND", "用户资料不存在"); -}); - -app.MapPut("/api/v1/users/me/preferences", async (HttpContext c, IUserRepository users) => -{ - var userId = GetUserId(c, gatewayKey); - if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "需要有效登录状态"); - return await UserPreferencesUpdateHandler.HandleAsync( - userId, - c, - users, - jsonOptions); -}); - +app.MapUserEndpoints(gatewayKey, storageName); app.Run(); -static bool IsGateway(HttpContext context, string key) -{ - var values = context.Request.Headers["X-Gateway-Key"]; - return values.Count == 1 && FixedTimeEquals(values[0]!, key); -} -static bool IsAuthService(HttpContext context, string key) => - IsGateway(context, key) && string.Equals(context.Request.Headers["X-Service-Name"], "AuthService", StringComparison.Ordinal); -static string? GetUserId(HttpContext context, string key) -{ - var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); - return IsGateway(context, key) && !string.IsNullOrWhiteSpace(userId) ? userId : null; -} -static bool FixedTimeEquals(string left, string right) -{ - var leftBytes = System.Text.Encoding.UTF8.GetBytes(left); - var rightBytes = System.Text.Encoding.UTF8.GetBytes(right); - var length = Math.Max(leftBytes.Length, rightBytes.Length); - var paddedLeft = new byte[length]; - var paddedRight = new byte[length]; - leftBytes.CopyTo(paddedLeft, 0); - rightBytes.CopyTo(paddedRight, 0); - return CryptographicOperations.FixedTimeEquals(paddedLeft, paddedRight) && leftBytes.Length == rightBytes.Length; -} -static string NormalizeLocale(string? value) => string.IsNullOrWhiteSpace(value) ? "zh-CN" : value.Trim(); -static IResult Failure(HttpContext context, int status, string code, string message) => Results.Json(ApiFailure.Create(code, message, context.TraceIdentifier), statusCode: status); - -public sealed record CreateUserProfileRequest(string UserId, string DisplayName, string? Locale); -public sealed record AdminProfileLookupRequest(string[]? UserIds); -public sealed record AdminProfileSummary(string UserId, string DisplayName); -public sealed record UpdateUserProfileRequest(string? DisplayName, string? Locale, string[]? PreferredSubjectCodes); -public sealed record UserPreferencesInput(int DailyGoalMinutes, string ContentDifficulty, bool ReducedMotion); -public sealed record UserProfile(string UserId, string DisplayName, string? AvatarUrl, string Locale, string[] PreferredSubjectCodes, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt) -{ - public static UserProfile New(string userId, string displayName, string locale) => new(userId, displayName, null, locale, [], DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); -} -public sealed record UserPreferences(int DailyGoalMinutes, string ContentDifficulty, bool ReducedMotion, DateTimeOffset UpdatedAt); -public sealed record ApiError(string Code, string Message, object Details); -public sealed record ApiSuccess(object Data, object Meta, string TraceId) { public static ApiSuccess Create(object data, string traceId) => new(data, new { }, traceId); } -public sealed record ApiFailure(object? Data, ApiError Error, string TraceId) { public static ApiFailure Create(string code, string message, string traceId) => new(null, new ApiError(code, message, new { }), traceId); } - -public sealed class UserDatabase(string connectionString) -{ - private readonly string _connectionString = connectionString; - public MySqlConnection OpenConnection() { var connection = new MySqlConnection(_connectionString); connection.Open(); return connection; } - public void EnsureCreated() - { - var builder = new MySqlConnectionStringBuilder(_connectionString); - if (string.IsNullOrWhiteSpace(builder.Database) || !builder.Database.All(c => char.IsLetterOrDigit(c) || c == '_')) - throw new InvalidOperationException("Invalid MySQL database name."); - var database = builder.Database; - builder.Database = string.Empty; - using (var server = new MySqlConnection(builder.ConnectionString)) - { - server.Open(); - using var create = server.CreateCommand(); - create.CommandText = $"CREATE DATABASE IF NOT EXISTS `{database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"; - create.ExecuteNonQuery(); - } - using var connection = OpenConnection(); - using var command = connection.CreateCommand(); - command.CommandText = """ - CREATE TABLE IF NOT EXISTS user_profiles ( - user_id CHAR(36) PRIMARY KEY, display_name VARCHAR(64) NOT NULL, - avatar_url VARCHAR(2048) NULL, locale VARCHAR(16) NOT NULL, - preferred_subject_codes JSON NOT NULL, created_at DATETIME(6) NOT NULL, - updated_at DATETIME(6) NOT NULL - ); - CREATE TABLE IF NOT EXISTS user_preferences ( - user_id CHAR(36) PRIMARY KEY, daily_goal_minutes INT NOT NULL, - content_difficulty VARCHAR(16) NOT NULL, reduced_motion BOOLEAN NOT NULL, - updated_at DATETIME(6) NOT NULL, - CONSTRAINT fk_preferences_profile FOREIGN KEY (user_id) REFERENCES user_profiles(user_id) ON DELETE CASCADE - ); - """; - command.ExecuteNonQuery(); - } -} - -public sealed class MySqlUserRepository(UserDatabase database) : IUserRepository -{ - public bool TryCreate(UserProfile profile) - { - try - { - using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "INSERT INTO user_profiles (user_id, display_name, avatar_url, locale, preferred_subject_codes, created_at, updated_at) VALUES (@id, @displayName, NULL, @locale, @subjects, @created, @updated);"; - command.Parameters.AddWithValue("@id", profile.UserId); command.Parameters.AddWithValue("@displayName", profile.DisplayName); command.Parameters.AddWithValue("@locale", profile.Locale); command.Parameters.AddWithValue("@subjects", JsonSerializer.Serialize(profile.PreferredSubjectCodes)); command.Parameters.AddWithValue("@created", profile.CreatedAt.UtcDateTime); command.Parameters.AddWithValue("@updated", profile.UpdatedAt.UtcDateTime); - return command.ExecuteNonQuery() == 1; - } - catch (MySqlException ex) when (ex.Number == 1062) { return false; } - } - public UserProfile? FindProfile(string userId) - { - using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "SELECT CAST(user_id AS CHAR), display_name, avatar_url, locale, preferred_subject_codes, created_at, updated_at FROM user_profiles WHERE user_id = @id;"; command.Parameters.AddWithValue("@id", userId); - using var reader = command.ExecuteReader(); return reader.Read() ? ReadProfile(reader) : null; - } - public List<AdminProfileSummary> FindAdminProfiles(IReadOnlyList<string> userIds) - { - if (userIds.Count == 0) return []; - using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); - var parameterNames = new List<string>(userIds.Count); - for (var index = 0; index < userIds.Count; index++) - { - var parameterName = $"@id{index}"; - parameterNames.Add(parameterName); - command.Parameters.AddWithValue(parameterName, userIds[index]); - } - command.CommandText = $"SELECT CAST(user_id AS CHAR), display_name FROM user_profiles WHERE user_id IN ({string.Join(',', parameterNames)});"; - using var reader = command.ExecuteReader(); - var result = new List<AdminProfileSummary>(); - while (reader.Read()) result.Add(new AdminProfileSummary(DbText(reader.GetValue(0)), reader.GetString(1))); - return result; - } - public bool DeleteProfile(string userId) - { - using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "DELETE FROM user_profiles WHERE user_id=@id;"; - command.Parameters.AddWithValue("@id", userId); - return command.ExecuteNonQuery() == 1; - } - public UserProfile? UpdateProfile(string userId, UpdateUserProfileRequest request) - { - var current = FindProfile(userId); if (current is null) return null; - var updated = current with { DisplayName = request.DisplayName?.Trim() ?? current.DisplayName, Locale = request.Locale?.Trim() ?? current.Locale, PreferredSubjectCodes = request.PreferredSubjectCodes ?? current.PreferredSubjectCodes, UpdatedAt = DateTimeOffset.UtcNow }; - using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); - command.CommandText = "UPDATE user_profiles SET display_name=@name, locale=@locale, preferred_subject_codes=@subjects, updated_at=@updated WHERE user_id=@id;"; - command.Parameters.AddWithValue("@id", updated.UserId); command.Parameters.AddWithValue("@name", updated.DisplayName); command.Parameters.AddWithValue("@locale", updated.Locale); command.Parameters.AddWithValue("@subjects", JsonSerializer.Serialize(updated.PreferredSubjectCodes)); command.Parameters.AddWithValue("@updated", updated.UpdatedAt.UtcDateTime); - // 忽略受影响行数会造成"幻影成功":资料在读取后被并发删除时,UPDATE 影响 0 行, - // 却仍向客户端返回 200 与未持久化的资料;契约要求此时是 404。 - if (command.ExecuteNonQuery() != 1) return null; - return updated; - } - public UserPreferences? FindPreferences(string userId) - { - if (FindProfile(userId) is null) return null; - using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = "SELECT daily_goal_minutes, content_difficulty, reduced_motion, updated_at FROM user_preferences WHERE user_id=@id;"; command.Parameters.AddWithValue("@id", userId); - using var reader = command.ExecuteReader(); return reader.Read() ? new(reader.GetInt32(0), reader.GetString(1), reader.GetBoolean(2), AsUtc(reader.GetDateTime(3))) : new UserPreferences(30, "STANDARD", false, DateTimeOffset.UtcNow); - } - public UserPreferences? ReplacePreferences(string userId, UserPreferencesInput request) - { - if (FindProfile(userId) is null) return null; - var result = new UserPreferences(request.DailyGoalMinutes, request.ContentDifficulty, request.ReducedMotion, DateTimeOffset.UtcNow); - using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = "INSERT INTO user_preferences (user_id, daily_goal_minutes, content_difficulty, reduced_motion, updated_at) VALUES (@id,@goal,@difficulty,@motion,@updated) ON DUPLICATE KEY UPDATE daily_goal_minutes=@goal, content_difficulty=@difficulty, reduced_motion=@motion, updated_at=@updated;"; - command.Parameters.AddWithValue("@id", userId); command.Parameters.AddWithValue("@goal", result.DailyGoalMinutes); command.Parameters.AddWithValue("@difficulty", result.ContentDifficulty); command.Parameters.AddWithValue("@motion", result.ReducedMotion); command.Parameters.AddWithValue("@updated", result.UpdatedAt.UtcDateTime); - // 资料在存在性检查之后被并发删除时,外键(fk_preferences_profile)会让 INSERT - // 抛 1452;那是"资料不存在",按契约应为 404,而不是 500 INTERNAL_ERROR。 - try { command.ExecuteNonQuery(); } - catch (MySqlException failure) when (failure.Number == 1452) { return null; } - return result; - } - private static UserProfile ReadProfile(MySqlDataReader r) => new(DbText(r.GetValue(0)), r.GetString(1), r.IsDBNull(2) ? null : r.GetString(2), r.GetString(3), JsonSerializer.Deserialize<string[]>(r.GetString(4)) ?? [], AsUtc(r.GetDateTime(5)), AsUtc(r.GetDateTime(6))); - private static string DbText(object value) => value is Guid guid ? guid.ToString() : Convert.ToString(value)!; - private static DateTimeOffset AsUtc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc)); -} +public partial class Program; diff --git a/backend/UserService/RequestLogging.cs b/backend/UserService/RequestLogging.cs new file mode 100644 index 0000000..dae90c5 --- /dev/null +++ b/backend/UserService/RequestLogging.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +/// <summary> +/// X-Correlation-Id 归一 + JSON 结构化日志 + 请求级 scope/耗时(T10 可观测性)。 +/// traceId 写入 context.TraceIdentifier,错误信封与响应头回传同一值。 +/// </summary> +public static class RequestLogging +{ + /// <summary> + /// 可选:服务侧注册请求级 ambient trace(如 PracticeService.Persistence.TraceFlow.Begin), + /// 供出站 HTTP 客户端透传 X-Correlation-Id。未注册则不影响。 + /// </summary> + public static Func<string, IDisposable?>? BeginAmbientTrace { get; set; } + + public static ILoggingBuilder AddJsonStructuredLogging(this ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddJsonConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + options.UseUtcTimestamp = true; + options.IncludeScopes = true; + options.JsonWriterOptions = new System.Text.Json.JsonWriterOptions { Indented = false }; + }); + return logging; + } + + /// <param name="normalizeCorrelationId"> + /// false:跳过 X-Correlation-Id 归一,沿用上游中间件已写入的 TraceIdentifier + /// (如 GalGameService 自带的严格字符集校验)。 + /// </param> + public static IApplicationBuilder UseRequestLogging( + this IApplicationBuilder app, string service, bool normalizeCorrelationId = true) => + app.Use(async (context, next) => + { + if (normalizeCorrelationId) + { + var incoming = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); + context.TraceIdentifier = string.IsNullOrWhiteSpace(incoming) + ? Guid.NewGuid().ToString("N") + : incoming.Length <= 128 ? incoming : Guid.NewGuid().ToString("N"); + context.Response.Headers["X-Correlation-Id"] = context.TraceIdentifier; + } + + var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger(service); + var userId = context.Request.Headers["X-User-Id"].FirstOrDefault(); + var method = context.Request.Method; + var path = context.Request.Path.Value ?? "/"; + var start = Stopwatch.GetTimestamp(); + using (logger.BeginScope(new Dictionary<string, object?> + { + ["traceId"] = context.TraceIdentifier, + ["userId"] = userId, + ["path"] = path, + ["method"] = method, + })) + using (BeginAmbientTrace?.Invoke(context.TraceIdentifier)) + { + try + { + await next(); + } + finally + { + var durationMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds; + logger.LogInformation( + "HTTP {Method} {Path} completed with {Status} in {DurationMs:F1}ms traceId={TraceId} userId={UserId}", + method, path, context.Response.StatusCode, durationMs, context.TraceIdentifier, userId); + } + } + }); +} diff --git a/backend/UserService/UserEndpoints.cs b/backend/UserService/UserEndpoints.cs new file mode 100644 index 0000000..3067e48 --- /dev/null +++ b/backend/UserService/UserEndpoints.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +public static class UserEndpoints +{ + public static void MapUserEndpoints(this WebApplication app, string gatewayKey, string storageName) + { + var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + + app.MapGet("/healthz", (HttpContext c) => + Results.Ok(ApiSuccess.Create(new { status = "live" }, c.TraceIdentifier))); + app.MapGet("/readyz", (HttpContext c) => + Results.Ok(ApiSuccess.Create(new { status = "ready", storage = storageName }, c.TraceIdentifier))); + + app.MapPost("/internal/v1/users", (CreateUserProfileRequest request, HttpContext c, IUserRepository users) => + { + if (!GatewayIdentity.IsGateway(c, gatewayKey) + || !string.Equals(c.Request.Headers["X-Service-Name"], "AuthService", StringComparison.Ordinal)) + return GatewayIdentity.Failure(c, 403, "FORBIDDEN", "仅允许经 Gateway 的 AuthService 创建用户资料"); + if (!Guid.TryParse(request.UserId, out _) || !UserProfileValidation.IsValidDisplayName(request.DisplayName)) + return GatewayIdentity.Failure(c, 400, "VALIDATION_ERROR", "userId 和 displayName 不符合要求"); + + var profile = UserProfile.New(request.UserId, request.DisplayName.Trim(), GatewayIdentity.NormalizeLocale(request.Locale)); + return users.TryCreate(profile) + ? Results.Created($"/api/v1/users/{profile.UserId}", ApiSuccess.Create(profile, c.TraceIdentifier)) + : GatewayIdentity.Failure(c, 409, "STATE_CONFLICT", "用户资料已存在"); + }); + + app.MapPost("/internal/v1/users/profile-lookups", (AdminProfileLookupRequest request, HttpContext c, IUserRepository users) => + { + if (!GatewayIdentity.IsAuthService(c, gatewayKey)) + return GatewayIdentity.Failure(c, 403, "FORBIDDEN", "仅允许经 Gateway 的 AuthService 查询用户资料"); + return AdminProfileLookupHandler.Handle(request, c, users); + }); + + app.MapDelete("/internal/v1/users/{userId}", (string userId, HttpContext c, IUserRepository users) => + { + if (!GatewayIdentity.IsAuthService(c, gatewayKey)) + return GatewayIdentity.Failure(c, 403, "FORBIDDEN", "仅允许经 Gateway 的 AuthService 删除用户资料"); + if (!Guid.TryParse(userId, out _)) + return GatewayIdentity.Failure(c, 400, "VALIDATION_ERROR", "userId 不符合要求"); + return users.DeleteProfile(userId) + ? Results.NoContent() + : GatewayIdentity.Failure(c, 404, "RESOURCE_NOT_FOUND", "用户资料不存在"); + }); + + app.MapGet("/api/v1/users/me", (HttpContext c, IUserRepository users) => + { + var userId = GatewayIdentity.GetUserId(c, gatewayKey); + return userId is null + ? GatewayIdentity.Failure(c, 401, "AUTH_REQUIRED", "需要有效登录状态") + : users.FindProfile(userId) is { } profile + ? Results.Ok(ApiSuccess.Create(profile, c.TraceIdentifier)) + : GatewayIdentity.Failure(c, 404, "RESOURCE_NOT_FOUND", "用户资料不存在"); + }); + + app.MapMethods("/api/v1/users/me", ["PATCH", "PUT"], async (HttpContext c, IUserRepository users) => + { + var userId = GatewayIdentity.GetUserId(c, gatewayKey); + if (userId is null) + return GatewayIdentity.Failure(c, 401, "AUTH_REQUIRED", "需要有效登录状态"); + return await UserProfileUpdateHandler.HandleAsync(userId, c, users, jsonOptions); + }); + + app.MapGet("/api/v1/users/me/preferences", (HttpContext c, IUserRepository users) => + { + var userId = GatewayIdentity.GetUserId(c, gatewayKey); + return userId is null + ? GatewayIdentity.Failure(c, 401, "AUTH_REQUIRED", "需要有效登录状态") + : users.FindPreferences(userId) is { } preferences + ? Results.Ok(ApiSuccess.Create(preferences, c.TraceIdentifier)) + : GatewayIdentity.Failure(c, 404, "RESOURCE_NOT_FOUND", "用户资料不存在"); + }); + + app.MapPut("/api/v1/users/me/preferences", async (HttpContext c, IUserRepository users) => + { + var userId = GatewayIdentity.GetUserId(c, gatewayKey); + if (userId is null) + return GatewayIdentity.Failure(c, 401, "AUTH_REQUIRED", "需要有效登录状态"); + return await UserPreferencesUpdateHandler.HandleAsync(userId, c, users, jsonOptions); + }); + } +} diff --git a/compose.integration.yaml b/compose.integration.yaml index 8e86991..ee9d2e4 100644 --- a/compose.integration.yaml +++ b/compose.integration.yaml @@ -1,3 +1,8 @@ +# 联调与单机部署基线。生产环境请叠加 compose.production.yaml: +# docker compose -f compose.integration.yaml -f compose.production.yaml up -d +# 详见 docs/deploy.md「生产叠加层」与 scripts/Test-ProductionEnv.ps1 +# ⚠️ 本文件是联调/CI 栈:默认 Mock 模式 + 开发密钥 + Development 环境, +# 禁止直接用于生产;生产叠加层强制注入全部密钥并要求非默认值。 name: ${COMPOSE_PROJECT_NAME:-galreview-integration} services: @@ -99,7 +104,7 @@ services: build: context: ./backend/UserService environment: - ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_URLS: http://+:5101 MOONSTONE_MODE: ${USER_SERVICE_MODE:-Mock} Gateway__ServiceKey: ${USER_SERVICE_KEY:-moonstone-local-gateway-key} @@ -113,13 +118,14 @@ services: build: context: ./backend/AuthService environment: - ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_URLS: http://+:5102 MOONSTONE_MODE: ${AUTH_SERVICE_MODE:-Mock} Gateway__BaseUrl: http://gateway:5000 Gateway__ServiceKey: ${AUTH_SERVICE_KEY:-moonstone-local-gateway-key} Admin__Username: ${GALREVIEW_ADMIN_USERNAME:-integration-admin} Admin__PasswordHash: ${GALREVIEW_ADMIN_PASSWORD_HASH:-AQAAAAIAAYagAAAAEOIs88DOZMchn4fIBwH9hLGMtw0+x+Y9rfbC1PiqJV76ncVm45+6INHh0elONRn5cg==} + Admin__PrincipalId: ${GALREVIEW_ADMIN_PRINCIPAL_ID:-00000000-0000-4000-8000-000000000001} Email__SmtpHost: ${SMTP_HOST:-} Email__SmtpPort: ${SMTP_PORT:-465} Email__UseSsl: ${SMTP_USE_SSL:-true} @@ -142,11 +148,13 @@ services: build: context: ./backend/CreditService environment: - ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_URLS: http://+:5108 Gateway__ServiceKey: ${CREDIT_SERVICE_KEY:-moonstone-local-gateway-key} ConnectionStrings__CreditDatabase: "Server=credit-mysql;Port=3306;Database=qzwl_credit;User ID=qzwl_credit;Password=${CREDIT_MYSQL_PASSWORD:-credit-dev-password};SslMode=Disabled;AllowPublicKeyRetrieval=True" CreditStore__Provider: MySQL + Admin__PrincipalId: ${GALREVIEW_ADMIN_PRINCIPAL_ID:-00000000-0000-4000-8000-000000000001} + Credits__PurchaseUrl: ${CREDITS_PURCHASE_URL:-https://pay.ldxp.cn/shop/7CX09W5E} depends_on: credit-mysql: condition: service_healthy @@ -156,7 +164,7 @@ services: build: context: ./backend/FileService environment: - ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_URLS: http://+:5103 Gateway__ServiceKey: ${FILE_SERVICE_KEY:-moonstone-local-gateway-key} ConnectionStrings__FileDatabase: mongodb://mongo:27017/qzwl_file @@ -175,7 +183,7 @@ services: context: ./backend/KnowledgeService dockerfile: KnowledgeService.API/Dockerfile environment: - ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_URLS: http://+:8080 Neo4j__Uri: bolt://neo4j:7687 Neo4j__Username: neo4j @@ -196,7 +204,7 @@ services: build: context: ./backend/GalGameService environment: - ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_URLS: http://+:5105 Gateway__BaseUrl: http://gateway:5000 Gateway__ServiceKey: ${GALGAME_SERVICE_KEY:-moonstone-local-gateway-key} @@ -233,6 +241,10 @@ services: Gateway__BaseUrl: http://gateway:5000 Gateway__ServiceName: RenderService Gateway__ServiceKey: ${RENDER_SERVICE_KEY:-moonstone-local-gateway-key} + # 文件快照:容器重启后可恢复进行中的故事会话(单实例) + RENDER_SESSION_STORE_PATH: ${RENDER_SESSION_STORE_PATH:-/data/render-sessions.json} + volumes: + - render-session-data:/data restart: unless-stopped model-service: @@ -240,11 +252,13 @@ services: context: ./backend/ModelService dockerfile: ModelService.API/Dockerfile environment: - ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_URLS: http://+:5109 Gateway__ServiceKey: ${MODEL_SERVICE_KEY:-moonstone-local-gateway-key} Nli__MinimumTopProbability: ${MODEL_NLI_MIN_TOP_PROBABILITY:-0.75} Nli__MinimumMargin: ${MODEL_NLI_MIN_MARGIN:-0.20} + Nli__MaxConcurrentBatches: ${MODEL_NLI_MAX_BATCHES:-2} + Nli__FacetParallelism: ${MODEL_NLI_FACET_PARALLELISM:-4} restart: unless-stopped practice-service: @@ -252,7 +266,7 @@ services: context: ./backend/PracticeService dockerfile: Dockerfile environment: - ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_ENVIRONMENT: Development ASPNETCORE_URLS: http://+:5107 Gateway__BaseUrl: http://gateway:5000 Gateway__ServiceName: PracticeService @@ -277,7 +291,7 @@ services: build: context: ./gateway environment: - NODE_ENV: production + NODE_ENV: development GATEWAY_HOST: 0.0.0.0 GATEWAY_PORT: 5000 GATEWAY_KEY: ${GATEWAY_KEY:-moonstone-local-gateway-key} @@ -358,3 +372,4 @@ volumes: credit-mysql-data: file-mongo-data: knowledge-neo4j-data: + render-session-data: diff --git a/compose.production.yaml b/compose.production.yaml new file mode 100644 index 0000000..cd5cc98 --- /dev/null +++ b/compose.production.yaml @@ -0,0 +1,102 @@ +# 生产覆盖层。用法: +# docker compose -f compose.integration.yaml -f compose.production.yaml up -d +# 必须先按 .env.deploy.example 准备 .env(禁止 CHANGE_ME,禁止默认密钥)。 +# 本地联调请只用 compose.integration.yaml,不要叠加本文件。 + +name: ${COMPOSE_PROJECT_NAME:-galreview-production} + +services: + user-mysql: + environment: + MYSQL_ROOT_PASSWORD: ${USER_MYSQL_ROOT_PASSWORD:?USER_MYSQL_ROOT_PASSWORD is required in production} + MYSQL_PASSWORD: ${USER_MYSQL_PASSWORD:?USER_MYSQL_PASSWORD is required in production} + + auth-mysql: + environment: + MYSQL_ROOT_PASSWORD: ${AUTH_MYSQL_ROOT_PASSWORD:?AUTH_MYSQL_ROOT_PASSWORD is required in production} + MYSQL_PASSWORD: ${AUTH_MYSQL_PASSWORD:?AUTH_MYSQL_PASSWORD is required in production} + + credit-mysql: + environment: + MYSQL_ROOT_PASSWORD: ${CREDIT_MYSQL_ROOT_PASSWORD:?CREDIT_MYSQL_ROOT_PASSWORD is required in production} + MYSQL_PASSWORD: ${CREDIT_MYSQL_PASSWORD:?CREDIT_MYSQL_PASSWORD is required in production} + + neo4j: + environment: + NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required in production} + HEALTHCHECK_PASSWORD: ${NEO4J_PASSWORD:?NEO4J_PASSWORD is required in production} + + user-service: + environment: + # 生产禁止 Mock 内存库 + MOONSTONE_MODE: MySql + Gateway__ServiceKey: ${USER_SERVICE_KEY:?USER_SERVICE_KEY is required in production} + ConnectionStrings__UserDatabase: "Server=user-mysql;Port=3306;Database=galreview_user;User ID=galreview_user;Password=${USER_MYSQL_PASSWORD:?USER_MYSQL_PASSWORD is required in production};SslMode=Disabled;AllowPublicKeyRetrieval=True" + + auth-service: + environment: + MOONSTONE_MODE: MySql + Gateway__ServiceKey: ${AUTH_SERVICE_KEY:?AUTH_SERVICE_KEY is required in production} + Admin__Username: ${GALREVIEW_ADMIN_USERNAME:?GALREVIEW_ADMIN_USERNAME is required in production} + Admin__PasswordHash: ${GALREVIEW_ADMIN_PASSWORD_HASH:?GALREVIEW_ADMIN_PASSWORD_HASH is required in production} + Admin__PrincipalId: ${GALREVIEW_ADMIN_PRINCIPAL_ID:?GALREVIEW_ADMIN_PRINCIPAL_ID is required in production} + ConnectionStrings__AuthDatabase: "Server=auth-mysql;Port=3306;Database=galreview_auth;User ID=galreview_auth;Password=${AUTH_MYSQL_PASSWORD:?AUTH_MYSQL_PASSWORD is required in production};SslMode=Disabled;AllowPublicKeyRetrieval=True" + + credit-service: + environment: + Gateway__ServiceKey: ${CREDIT_SERVICE_KEY:?CREDIT_SERVICE_KEY is required in production} + Admin__PrincipalId: ${GALREVIEW_ADMIN_PRINCIPAL_ID:?GALREVIEW_ADMIN_PRINCIPAL_ID is required in production} + ConnectionStrings__CreditDatabase: "Server=credit-mysql;Port=3306;Database=qzwl_credit;User ID=qzwl_credit;Password=${CREDIT_MYSQL_PASSWORD:?CREDIT_MYSQL_PASSWORD is required in production};SslMode=Disabled;AllowPublicKeyRetrieval=True" + + file-service: + environment: + Gateway__ServiceKey: ${FILE_SERVICE_KEY:?FILE_SERVICE_KEY is required in production} + + knowledge-service: + environment: + Neo4j__Password: ${NEO4J_PASSWORD:?NEO4J_PASSWORD is required in production} + GatewayMaterialText__ServiceKey: ${KNOWLEDGE_SERVICE_KEY:?KNOWLEDGE_SERVICE_KEY is required in production} + Gateway__ServiceKey: ${KNOWLEDGE_SERVICE_KEY:?KNOWLEDGE_SERVICE_KEY is required in production} + + galgame-service: + environment: + Gateway__ServiceKey: ${GALGAME_SERVICE_KEY:?GALGAME_SERVICE_KEY is required in production} + + render-service: + environment: + Gateway__ServiceKey: ${RENDER_SERVICE_KEY:?RENDER_SERVICE_KEY is required in production} + + model-service: + environment: + Gateway__ServiceKey: ${MODEL_SERVICE_KEY:?MODEL_SERVICE_KEY is required in production} + + practice-service: + environment: + Gateway__ServiceKey: ${PRACTICE_SERVICE_KEY:?PRACTICE_SERVICE_KEY is required in production} + QuestionGeneration__ApiKey: ${DEEPSEEK_API_KEY:-} + + galgame-service: + environment: + Gateway__ServiceKey: ${GALGAME_SERVICE_KEY:?GALGAME_SERVICE_KEY is required in production} + # 生成能力依赖密钥;空值时叙事/语音会静默降级——由 Test-ProductionEnv 按开关校验 + NarrativeGeneration__ApiKey: ${DEEPSEEK_API_KEY:-} + VoiceSynthesis__ApiKey: ${MIMO_API_KEY:-} + + gateway: + environment: + GATEWAY_KEY: ${GATEWAY_KEY:?GATEWAY_KEY is required in production} + USER_SERVICE_KEY: ${USER_SERVICE_KEY:?USER_SERVICE_KEY is required in production} + AUTH_SERVICE_KEY: ${AUTH_SERVICE_KEY:?AUTH_SERVICE_KEY is required in production} + FILE_SERVICE_KEY: ${FILE_SERVICE_KEY:?FILE_SERVICE_KEY is required in production} + KNOWLEDGE_SERVICE_KEY: ${KNOWLEDGE_SERVICE_KEY:?KNOWLEDGE_SERVICE_KEY is required in production} + GALGAME_SERVICE_KEY: ${GALGAME_SERVICE_KEY:?GALGAME_SERVICE_KEY is required in production} + RENDER_SERVICE_KEY: ${RENDER_SERVICE_KEY:?RENDER_SERVICE_KEY is required in production} + PRACTICE_SERVICE_KEY: ${PRACTICE_SERVICE_KEY:?PRACTICE_SERVICE_KEY is required in production} + CREDIT_SERVICE_KEY: ${CREDIT_SERVICE_KEY:?CREDIT_SERVICE_KEY is required in production} + MODEL_SERVICE_KEY: ${MODEL_SERVICE_KEY:?MODEL_SERVICE_KEY is required in production} + # 生产默认不绑定诊断友好 CORS;用 .env 显式配置 + CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS is required in production} + + ocr-service: + environment: + GATEWAY_KEY: ${FILE_SERVICE_KEY:?FILE_SERVICE_KEY is required in production} diff --git a/compose.smoke.yaml b/compose.smoke.yaml new file mode 100644 index 0000000..b5a6512 --- /dev/null +++ b/compose.smoke.yaml @@ -0,0 +1,20 @@ +# 本地功能冒烟覆盖层:ModelService 用占位容器(缺 ONNX 资产),保证 Gateway 依赖可满足。 +# 用法: +# docker compose -f compose.integration.yaml -f compose.smoke.yaml up -d --build +services: + model-service: + image: alpine:3.20 + build: !reset null + command: ["sleep", "infinity"] + environment: !reset {} + healthcheck: + test: ["CMD", "true"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 2s + restart: unless-stopped + + gateway: + environment: + READINESS_SERVICES: userService,authService,fileService,knowledgeService,galGameService,renderService,practiceService,creditService diff --git a/deploy-windows.ps1 b/deploy-windows.ps1 index b8244e3..857fcba 100644 --- a/deploy-windows.ps1 +++ b/deploy-windows.ps1 @@ -668,6 +668,19 @@ function Initialize-ProductionEnvironment { $generatedCount++ } } + # 管理员 PrincipalId:Auth/Credit 共用的管理员身份 GUID,生产必须显式配置。 + # 与服务密钥同机制自动生成,避免缺失时服务启动 fail-fast。 + $principalPattern = '(?m)^GALREVIEW_ADMIN_PRINCIPAL_ID=(.*)$' + $principalMatch = [Regex]::Match($content, $principalPattern) + if (-not $principalMatch.Success) { + $content = $content.TrimEnd() + "`r`nGALREVIEW_ADMIN_PRINCIPAL_ID=$([guid]::NewGuid().ToString())`r`n" + $generatedCount++ + } + elseif ([string]::IsNullOrWhiteSpace($principalMatch.Groups[1].Value.Trim()) -or $principalMatch.Groups[1].Value.Trim() -match 'CHANGE_ME') { + $content = [Regex]::Replace($content, $principalPattern, "GALREVIEW_ADMIN_PRINCIPAL_ID=$([guid]::NewGuid().ToString())") + $generatedCount++ + } + if ($generatedCount -gt 0) { Set-Content -LiteralPath $resolvedEnvironmentPath -Value $content -Encoding utf8 -NoNewline } @@ -876,6 +889,7 @@ function Assert-ProductionSettings { 'PRACTICE_SERVICE_KEY', 'CREDIT_SERVICE_KEY', 'MODEL_SERVICE_KEY', 'USER_DATABASE_CONNECTION', 'AUTH_DATABASE_CONNECTION', 'CREDIT_DATABASE_CONNECTION', 'MONGO_CONNECTION_STRING', 'NEO4J_PASSWORD', 'GALREVIEW_ADMIN_USERNAME', 'GALREVIEW_ADMIN_PASSWORD_HASH', + 'GALREVIEW_ADMIN_PRINCIPAL_ID', 'ACCOUNT_FRONTEND_BASE_URL', 'CORS_ORIGINS' ) @@ -1438,7 +1452,7 @@ function Start-ProductionServices { $serviceDefinitions = @( @{ Name = 'credit-service'; File = Join-Path $releaseRoot 'services\credit-service\CreditService.API.exe'; Work = Join-Path $releaseRoot 'services\credit-service'; Health = 'http://127.0.0.1:5108/readyz'; - Env = $commonAspNet + @{ ASPNETCORE_URLS = 'http://127.0.0.1:5108'; Gateway__ServiceKey = Get-Setting 'CREDIT_SERVICE_KEY'; ConnectionStrings__CreditDatabase = Get-Setting 'CREDIT_DATABASE_CONNECTION'; CreditStore__Provider = 'MySQL' } + Env = $commonAspNet + @{ ASPNETCORE_URLS = 'http://127.0.0.1:5108'; Gateway__ServiceKey = Get-Setting 'CREDIT_SERVICE_KEY'; Admin__PrincipalId = Get-Setting 'GALREVIEW_ADMIN_PRINCIPAL_ID'; ConnectionStrings__CreditDatabase = Get-Setting 'CREDIT_DATABASE_CONNECTION'; CreditStore__Provider = 'MySQL' } }, @{ Name = 'user-service'; File = Join-Path $releaseRoot 'services\user-service\GalGame.UserService.exe'; Work = Join-Path $releaseRoot 'services\user-service'; Health = 'http://127.0.0.1:5101/readyz'; @@ -1470,7 +1484,7 @@ function Start-ProductionServices { }, @{ Name = 'auth-service'; File = Join-Path $releaseRoot 'services\auth-service\GalGame.AuthService.exe'; Work = Join-Path $releaseRoot 'services\auth-service'; Health = 'http://127.0.0.1:5102/readyz'; - Env = $commonAspNet + @{ ASPNETCORE_URLS = 'http://127.0.0.1:5102'; MOONSTONE_MODE = Get-Setting 'AUTH_SERVICE_MODE' 'MySql'; Gateway__BaseUrl = $gatewayBaseUrl; Gateway__ServiceKey = Get-Setting 'AUTH_SERVICE_KEY'; Admin__Username = Get-Setting 'GALREVIEW_ADMIN_USERNAME'; Admin__PasswordHash = Get-Setting 'GALREVIEW_ADMIN_PASSWORD_HASH'; Email__SmtpHost = Get-Setting 'SMTP_HOST'; Email__SmtpPort = Get-Setting 'SMTP_PORT' '465'; Email__UseSsl = Get-Setting 'SMTP_USE_SSL' 'true'; Email__Username = Get-Setting 'SMTP_USERNAME'; Email__Password = Get-Setting 'SMTP_PASSWORD'; Email__FromAddress = Get-Setting 'SMTP_FROM_ADDRESS'; Email__FromName = Get-Setting 'SMTP_FROM_NAME' '千知万理'; AccountFrontend__BaseUrl = Get-Setting 'ACCOUNT_FRONTEND_BASE_URL'; ConnectionStrings__AuthDatabase = Get-Setting 'AUTH_DATABASE_CONNECTION' } + Env = $commonAspNet + @{ ASPNETCORE_URLS = 'http://127.0.0.1:5102'; MOONSTONE_MODE = Get-Setting 'AUTH_SERVICE_MODE' 'MySql'; Gateway__BaseUrl = $gatewayBaseUrl; Gateway__ServiceKey = Get-Setting 'AUTH_SERVICE_KEY'; Admin__Username = Get-Setting 'GALREVIEW_ADMIN_USERNAME'; Admin__PasswordHash = Get-Setting 'GALREVIEW_ADMIN_PASSWORD_HASH'; Admin__PrincipalId = Get-Setting 'GALREVIEW_ADMIN_PRINCIPAL_ID'; Email__SmtpHost = Get-Setting 'SMTP_HOST'; Email__SmtpPort = Get-Setting 'SMTP_PORT' '465'; Email__UseSsl = Get-Setting 'SMTP_USE_SSL' 'true'; Email__Username = Get-Setting 'SMTP_USERNAME'; Email__Password = Get-Setting 'SMTP_PASSWORD'; Email__FromAddress = Get-Setting 'SMTP_FROM_ADDRESS'; Email__FromName = Get-Setting 'SMTP_FROM_NAME' '千知万理'; AccountFrontend__BaseUrl = Get-Setting 'ACCOUNT_FRONTEND_BASE_URL'; ConnectionStrings__AuthDatabase = Get-Setting 'AUTH_DATABASE_CONNECTION' } } ) diff --git a/deploy/.env.windows.production.example b/deploy/.env.windows.production.example index 74840bd..fd87505 100644 --- a/deploy/.env.windows.production.example +++ b/deploy/.env.windows.production.example @@ -1,4 +1,4 @@ -# Copy this file to .env.windows.production and replace every CHANGE_ME value. +# Copy this file to .env.windows.production and replace every CHANGE_ME value. # MySQL, MongoDB, and Neo4j must already be installed, initialized, and running. GATEWAY_KEY=CHANGE_ME_GATEWAY_KEY @@ -33,6 +33,8 @@ NEO4J_DATABASE=neo4j GALREVIEW_ADMIN_USERNAME=CHANGE_ME_ADMIN_USERNAME GALREVIEW_ADMIN_PASSWORD_HASH=CHANGE_ME_ASPNET_IDENTITY_PASSWORD_HASH +# 管理员身份 GUID(Auth/Credit 共用);留空或 CHANGE_ME 时脚本会自动生成 +GALREVIEW_ADMIN_PRINCIPAL_ID= ACCOUNT_FRONTEND_BASE_URL=https://CHANGE_ME.example.com CORS_ORIGINS=https://CHANGE_ME.example.com diff --git a/design/brand-logo.svg b/design/brand-logo.svg new file mode 100644 index 0000000..2be36ff --- /dev/null +++ b/design/brand-logo.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="14 15 88 64" role="img" aria-labelledby="title"> + <title id="title">千知万理 + + + diff --git a/desktop/build/sign.js b/desktop/build/sign.js new file mode 100644 index 0000000..a687373 --- /dev/null +++ b/desktop/build/sign.js @@ -0,0 +1,53 @@ +// electron-builder 自定义签名钩子:用本机自签 PFX + signtool +// 环境变量: +// WIN_CSC_LINK — pfx 路径(或 data:base64) +// WIN_CSC_KEY_PASSWORD — pfx 密码 +const { execFileSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +exports.default = async function sign(configuration) { + const pfxPath = process.env.WIN_CSC_LINK + const password = process.env.WIN_CSC_KEY_PASSWORD || '' + if (!pfxPath) { + console.warn('[sign] WIN_CSC_LINK not set, skip signing', configuration.path) + return + } + if (!fs.existsSync(configuration.path)) { + console.warn('[sign] file missing', configuration.path) + return + } + + // 定位 signtool(winCodeSign 缓存或 Windows SDK) + const candidates = [ + path.join(process.env.LOCALAPPDATA || '', 'Temp', 'electron-builder-cache', 'winCodeSign', 'winCodeSign-2.6.0', 'windows-10', 'x64', 'signtool.exe'), + path.join(process.env.LOCALAPPDATA || '', 'Temp', 'electron-builder-cache', 'winCodeSign', 'winCodeSign-2.6.0', 'windows-10', 'signtool.exe'), + 'C:\\Program Files (x86)\\Windows Kits\\10\\bin\\10.0.26100.0\\x64\\signtool.exe', + ] + const signtool = candidates.find((p) => fs.existsSync(p)) + if (!signtool) { + console.warn('[sign] signtool.exe not found, skip', candidates) + return + } + + const args = [ + 'sign', + '/f', pfxPath, + '/p', password, + '/fd', 'SHA256', + '/td', 'SHA256', + '/tr', 'http://timestamp.digicert.com', + configuration.path, + ] + try { + execFileSync(signtool, args, { stdio: 'inherit' }) + console.log('[sign] signed', configuration.path) + } catch (error) { + // 时间戳服务器不可达时退回无时间戳签名 + console.warn('[sign] timestamp failed, retry without timestamp', error.message) + execFileSync(signtool, ['sign', '/f', pfxPath, '/p', password, '/fd', 'SHA256', configuration.path], { + stdio: 'inherit', + }) + console.log('[sign] signed (no timestamp)', configuration.path) + } +} diff --git a/desktop/icons/icon-192.png b/desktop/icons/icon-192.png new file mode 100644 index 0000000..9b8a031 Binary files /dev/null and b/desktop/icons/icon-192.png differ diff --git a/desktop/icons/icon-512.png b/desktop/icons/icon-512.png new file mode 100644 index 0000000..4080a75 Binary files /dev/null and b/desktop/icons/icon-512.png differ diff --git a/desktop/icons/icon.ico b/desktop/icons/icon.ico new file mode 100644 index 0000000..d71555e Binary files /dev/null and b/desktop/icons/icon.ico differ diff --git a/desktop/icons/logo-128.png b/desktop/icons/logo-128.png new file mode 100644 index 0000000..af87b06 Binary files /dev/null and b/desktop/icons/logo-128.png differ diff --git a/desktop/icons/logo-16.png b/desktop/icons/logo-16.png new file mode 100644 index 0000000..a6c96f4 Binary files /dev/null and b/desktop/icons/logo-16.png differ diff --git a/desktop/icons/logo-192.png b/desktop/icons/logo-192.png new file mode 100644 index 0000000..71dc5a2 Binary files /dev/null and b/desktop/icons/logo-192.png differ diff --git a/desktop/icons/logo-256.png b/desktop/icons/logo-256.png new file mode 100644 index 0000000..15091ae Binary files /dev/null and b/desktop/icons/logo-256.png differ diff --git a/desktop/icons/logo-32.png b/desktop/icons/logo-32.png new file mode 100644 index 0000000..b13acc0 Binary files /dev/null and b/desktop/icons/logo-32.png differ diff --git a/desktop/icons/logo-48.png b/desktop/icons/logo-48.png new file mode 100644 index 0000000..3fccabc Binary files /dev/null and b/desktop/icons/logo-48.png differ diff --git a/desktop/icons/logo-512.png b/desktop/icons/logo-512.png new file mode 100644 index 0000000..4ce32c1 Binary files /dev/null and b/desktop/icons/logo-512.png differ diff --git a/desktop/icons/logo-64.png b/desktop/icons/logo-64.png new file mode 100644 index 0000000..a620ef8 Binary files /dev/null and b/desktop/icons/logo-64.png differ diff --git a/desktop/main.js b/desktop/main.js new file mode 100644 index 0000000..4cdd83b --- /dev/null +++ b/desktop/main.js @@ -0,0 +1,240 @@ +const { app, BrowserWindow, shell, Menu, dialog } = require('electron') +const path = require('path') +const fs = require('fs') +const http = require('http') + +// 后端网关:优先环境变量 GALREVIEW_GATEWAY_URL。 +// 打包发布物必须显式配置,避免静默把 API 指向用户本机的开发端口; +// 开发态(electron .)保留本机集成栈默认端口。 +const configuredGatewayUrl = process.env.GALREVIEW_GATEWAY_URL?.trim() +const GATEWAY_URL = configuredGatewayUrl || (app.isPackaged ? null : 'http://127.0.0.1:5000') +const WEB_DIR = path.join(process.resourcesPath || __dirname, 'web') +const LOCAL_WEB = fs.existsSync(path.join(WEB_DIR, 'index.html')) + ? WEB_DIR + : path.join(__dirname, '..', 'frontend', 'dist') + +let mainWindow = null +let localServer = null +let localPort = 0 + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.svg': 'image/svg+xml', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.wasm': 'application/wasm', +} + +/** 内嵌静态站 + /api 反代到网关,保证 SPA 路由与 API 同源可用 */ +function startLocalServer() { + return new Promise((resolve, reject) => { + if (!fs.existsSync(path.join(LOCAL_WEB, 'index.html'))) { + reject(new Error('web dist missing')) + return + } + const server = http.createServer((req, res) => { + const url = req.url || '/' + // API 代理 + if (url.startsWith('/api/') || url.startsWith('/healthz')) { + const gateway = new URL(GATEWAY_URL) + const proxyReq = http.request( + { + hostname: gateway.hostname, + port: gateway.port || 80, + path: url, + method: req.method, + headers: { ...req.headers, host: gateway.host }, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode || 502, proxyRes.headers) + proxyRes.pipe(res) + }, + ) + proxyReq.on('error', () => { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ data: null, error: { code: 'GATEWAY_UNAVAILABLE', message: '网关不可用' }, traceId: 'desktop' })) + }) + req.pipe(proxyReq) + return + } + // 静态资源 + let filePath = path.join(LOCAL_WEB, decodeURIComponent(url.split('?')[0])) + if (!filePath.startsWith(LOCAL_WEB)) filePath = path.join(LOCAL_WEB, 'index.html') + if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { + filePath = path.join(filePath, 'index.html') + } + if (!fs.existsSync(filePath)) filePath = path.join(LOCAL_WEB, 'index.html') + const ext = path.extname(filePath).toLowerCase() + res.writeHead(200, { + 'Content-Type': MIME[ext] || 'application/octet-stream', + 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=86400', + 'X-Content-Type-Options': 'nosniff', + }) + fs.createReadStream(filePath).on('error', () => res.destroy()).pipe(res) + }) + server.listen(0, '127.0.0.1', () => { + localServer = server + localPort = server.address().port + resolve(`http://127.0.0.1:${localPort}/`) + }) + server.on('error', reject) + }) +} + +function resolveStartUrl() { + // file:// 下 BrowserRouter 会 404,因此优先内嵌 HTTP 服务 + if (fs.existsSync(path.join(LOCAL_WEB, 'index.html'))) { + return { type: 'embedded', needsServer: true } + } + return { type: 'remote', url: 'http://127.0.0.1:5120/' } +} + +function createMenu() { + const template = [ + { + label: '文件', + submenu: [ + { label: '刷新', accelerator: 'F5', click: () => mainWindow?.reload() }, + { type: 'separator' }, + { role: 'quit', label: '退出' }, + ], + }, + { + label: '视图', + submenu: [ + { role: 'reload', label: '重新加载' }, + { role: 'toggleDevTools', label: '开发者工具' }, + { type: 'separator' }, + { role: 'resetZoom', label: '实际大小' }, + { role: 'zoomIn', label: '放大' }, + { role: 'zoomOut', label: '缩小' }, + { type: 'separator' }, + { role: 'togglefullscreen', label: '全屏' }, + ], + }, + { + label: '帮助', + submenu: [ + { + label: '关于千知万理', + click: () => { + dialog.showMessageBox(mainWindow, { + type: 'info', + title: '关于千知万理', + message: '千知万理 · GalReview', + detail: `版本 ${app.getVersion()}\n一卷成册,循知而习。\n\n网关:${GATEWAY_URL}`, + }) + }, + }, + ], + }, + ] + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) +} + +async function createWindow() { + // Windows 窗口/任务栏图标:优先 ICO,回退 PNG + const windowIcon = fs.existsSync(path.join(__dirname, 'icons/icon.ico')) + ? path.join(__dirname, 'icons/icon.ico') + : path.join(__dirname, 'icons/icon-256.png') + + mainWindow = new BrowserWindow({ + width: 1280, + height: 860, + minWidth: 960, + minHeight: 640, + title: '千知万理', + icon: windowIcon, + backgroundColor: '#eef8fd', + autoHideMenuBar: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + }, + }) + + const start = resolveStartUrl() + let url = start.url + if (start.needsServer) { + try { + url = await startLocalServer() + } catch { + url = 'http://127.0.0.1:5120/' + } + } + mainWindow.loadURL(url) + + mainWindow.webContents.setWindowOpenHandler(({ url: href }) => { + shell.openExternal(href) + return { action: 'deny' } + }) + + mainWindow.on('closed', () => { + mainWindow = null + }) +} + +// 单实例 +app.setName('千知万理') +const gotLock = app.requestSingleInstanceLock() +if (!gotLock) { + app.quit() +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + }) + + app.whenReady().then(() => { + if (!GATEWAY_URL) { + dialog.showErrorBox( + '缺少网关配置', + '未配置后端网关地址。请设置环境变量 GALREVIEW_GATEWAY_URL(例如 https://galreview.example.com)后重新启动。', + ) + app.quit() + return + } + createMenu() + createWindow() + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) + }) + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() + }) +} + +// 探测网关(供 preload / 启动提示) +function probeGateway() { + return new Promise((resolve) => { + const req = http.get(`${GATEWAY_URL}/healthz`, { timeout: 2000 }, (res) => { + resolve(res.statusCode === 200) + res.resume() + }) + req.on('error', () => resolve(false)) + req.on('timeout', () => { + req.destroy() + resolve(false) + }) + }) +} + +module.exports = { probeGateway, GATEWAY_URL } diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 0000000..03e17f9 --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,4469 @@ +{ + "name": "galreview-desktop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "galreview-desktop", + "version": "0.1.0", + "license": "UNLICENSED", + "devDependencies": { + "electron": "^33.2.0", + "electron-builder": "^25.1.8" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/rebuild/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/rebuild/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.10", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "25.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.6.1", + "@electron/universal": "2.0.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "25.1.7", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "resedit": "^1.7.0", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "25.1.8", + "electron-builder-squirrel-windows": "25.1.8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "25.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.10", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.10", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts/node_modules/brace-expansion": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.4.5", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "dev": true, + "license": "ISC" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "25.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "33.4.11", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "25.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "dmg-builder": "25.1.8", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "25.1.8", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "25.1.8", + "archiver": "^5.3.1", + "builder-util": "25.1.7", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "25.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.96.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nopt": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/plist": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "9.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..15c7a23 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,72 @@ +{ + "name": "galreview-desktop", + "version": "0.1.0", + "private": true, + "description": "千知万理 GalReview Windows 桌面客户端", + "main": "main.js", + "author": "GalReview", + "license": "UNLICENSED", + "scripts": { + "start": "electron .", + "build": "electron-builder --win", + "build:dir": "electron-builder --win --dir" + }, + "devDependencies": { + "electron": "^33.2.0", + "electron-builder": "^25.1.8" + }, + "build": { + "appId": "cn.galreview.desktop", + "productName": "千知万理", + "copyright": "© 2026 GalReview", + "directories": { + "output": "dist-installer", + "buildResources": "build" + }, + "files": [ + "main.js", + "preload.js", + "icons/**/*" + ], + "extraResources": [ + { + "from": "../frontend/dist", + "to": "web", + "filter": [ + "**/*" + ] + } + ], + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "icon": "icons/icon.ico", + "signAndEditExecutable": true, + "sign": "./build/sign.js" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "千知万理", + "installerIcon": "icons/icon.ico", + "uninstallerIcon": "icons/icon.ico", + "installerHeaderIcon": "icons/icon-48.png", + "installerLanguages": [ + "zh_CN" + ], + "language": "2052" + } + }, + "allowScripts": { + "electron": "true", + "electron-builder": "true" + } +} diff --git a/desktop/preload.js b/desktop/preload.js new file mode 100644 index 0000000..fc8da29 --- /dev/null +++ b/desktop/preload.js @@ -0,0 +1,10 @@ +const { contextBridge } = require('electron') + +contextBridge.exposeInMainWorld('galreviewDesktop', { + platform: process.platform, + versions: { + electron: process.versions.electron, + chrome: process.versions.chrome, + node: process.versions.node, + }, +}) diff --git a/docs/deploy.md b/docs/deploy.md index 36ef7d4..8a9105b 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -2,7 +2,20 @@ 本文说明如何使用仓库根目录的 `compose.integration.yaml` 在本机或 Linux 服务器启动 GalReview。接口、鉴权头和跨服务调用以 [`contract.md`](contract.md) 为准;本文只记录部署方式,不另行定义接口。 -当前 Compose 是联调与单机部署基线,不是高可用集群方案。AuthService、UserService 和 CreditService 分别接入独立 MySQL 容器;仓库本地 Auth/User 默认值仍是 `Mock`,服务器 `.env` 模板则使用 `MySql`,CreditService 始终使用 MySQL。File、GalGame 与 PracticeService 使用同一 MongoDB 实例中的独立数据库。宿主发布端口的调整不改变接口路径、鉴权头、请求/响应结构或容器内部协议。 +当前 Compose 是联调与单机部署基线,不是高可用集群方案。AuthService、UserService 和 CreditService 分别接入独立 MySQL 容器;仓库本地 Auth/User 默认值仍是 `Mock`,服务器 `.env` 模板则使用 `MySql`,CreditService 始终使用 MySQL。 + +> **Gateway 限流为进程内存态**:当前只支持单实例部署。需要水平扩展前,请先阅读 `gateway/README.md`「水平扩展约束」,将限流外置(如 Redis)后再扩副本。 + +### 生产叠加层(禁止 Mock / 强制密钥) + +单机生产请**不要**只使用 `compose.integration.yaml`。在准备好 `.env` 后叠加: + +```bash +./scripts/Test-ProductionEnv.ps1 +docker compose -f compose.integration.yaml -f compose.production.yaml up -d +``` + +`compose.production.yaml` 会强制 `MOONSTONE_MODE=MySql`,并用 `${VAR:?}` 要求 Gateway/服务密钥、MySQL/Neo4j 密码、管理员账号与 `CORS_ORIGINS` 必须显式提供。缺项时 `docker compose config` 或 `up` 会在解析阶段失败。门禁脚本同时拒绝 `.env` 中残留 `CHANGE_ME` 或开发默认密钥 `moonstone-local-gateway-key`。File、GalGame 与 PracticeService 使用同一 MongoDB 实例中的独立数据库。宿主发布端口的调整不改变接口路径、鉴权头、请求/响应结构或容器内部协议。 ## 1. 部署范围 diff --git a/docs/desktop-release.md b/docs/desktop-release.md new file mode 100644 index 0000000..17eedf6 --- /dev/null +++ b/docs/desktop-release.md @@ -0,0 +1,41 @@ +# Windows 桌面端发布与签名 + +`desktop/` 是 Electron 33 壳工程:加载本地打包的 `frontend/dist`(经内置 HTTP 服务同源提供, +`/api` 反代到网关),安全基线为 `contextIsolation + sandbox + 无 nodeIntegration`。 + +## 网关地址(必读) + +- **开发态**(`npm start` / `electron .`):未设置 `GALREVIEW_GATEWAY_URL` 时默认 + `http://127.0.0.1:5000`(本机集成栈)。 +- **打包发布物**:必须通过环境变量 `GALREVIEW_GATEWAY_URL` 显式指定网关(例如 + `https://galreview.example.com`);未设置时应用启动即弹窗提示并退出,不再静默指向本机端口。 + 发布渠道可在安装后由启动脚本、快捷方式目标或系统环境变量注入该值。 + +## 代码签名生命周期 + +签名钩子为 `desktop/build/sign.js`(electron-builder `sign` 钩子): + +1. **材料**:`WIN_CSC_LINK` 指向 PFX(含私钥),`WIN_CSC_KEY_PASSWORD` 为其密码。 + 两者只通过环境变量/CI Secret 注入,**绝不入库**(`.gitignore` 已忽略 `*.pfx` 与 + `desktop/build/*.cer`)。 +2. **当前状态**:`desktop/build/` 内的 `galreview-codesign.cer/.pfx` 为**自签测试证书** + (本机开发/内测用),未设置 `WIN_CSC_LINK` 时钩子自动跳过签名。 +3. **公开发布前**:改用受信任 CA(如 DigiCert/Sectigo 或 Azure Trusted Signing)签发的 + 代码签名证书;自签证书会导致 SmartScreen 告警。 +4. **轮换**:证书到期前 30 天签发新证书;更新 CI/发布机的 `WIN_CSC_LINK` 与密码; + 旧 PFX 立即从发布机与本地磁盘销毁。历史安装包不做重签。 +5. **私钥泄露**:立即在 CA 侧吊销并轮换;吊销前签出的安装包需评估是否撤回。 + +## 构建与冒烟 + +```powershell +# 构建前端产物(extraResources 依赖 ../frontend/dist) +cd frontend && npm ci && npm run build +# 打包未签名目录版(CI 冒烟用) +cd ../desktop && npm ci && npx electron-builder --win --dir +# 完整 NSIS 安装包 +npx electron-builder --win +``` + +CI 中 `desktop` job 等价执行上述目录版构建(不签名);`mobile` job 构建 Capacitor +Android 调试 APK。 diff --git a/docs/nuget-windows-fix.md b/docs/nuget-windows-fix.md new file mode 100644 index 0000000..f969148 --- /dev/null +++ b/docs/nuget-windows-fix.md @@ -0,0 +1,45 @@ +# Windows 本机 NuGet / .NET 修复备忘 + +## 症状 + +`dotnet restore` / `dotnet test` 报: + +```text +Value cannot be null. (Parameter 'path1') +The type initializer for 'NuGet.Configuration.ConfigurationDefaults' threw an exception. +``` + +`dotnet nuget locals all --list` 同样失败。 + +## 根因 + +进程环境中 **`ProgramFiles(x86)` 为 null**(本应 `C:\Program Files (x86)`)。 + +NuGet.Common 的 `NuGetEnvironment.CalculateFolderPath(MachineWideSettingsBaseDirectory)` 会: + +```csharp +Path.Combine(Environment.GetEnvironmentVariable("ProgramFiles(x86)"), ...) +``` + +path1 为 null → 直接抛异常,整个 restore 图生成失败。 + +## 修复(当前 PowerShell 会话) + +```powershell +[Environment]::SetEnvironmentVariable('ProgramFiles(x86)', 'C:\Program Files (x86)', 'Process') +[Environment]::SetEnvironmentVariable('ProgramW6432', 'C:\Program Files', 'Process') +``` + +持久化(用户级,需重开终端): + +```powershell +[Environment]::SetEnvironmentVariable('ProgramFiles(x86)', 'C:\Program Files (x86)', 'User') +[Environment]::SetEnvironmentVariable('ProgramW6432', 'C:\Program Files', 'User') +``` + +## 验证 + +```powershell +dotnet nuget locals all --list +# 应输出 http-cache / global-packages / temp / plugins-cache +``` diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..732362b --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,179 @@ +# GalReview 可观测性与告警最低集(T10) + +> 状态:已实施(八服务全覆盖,含 Auth/GalGame,见 §5) +> 风格约束:轻量自研中间件 + 进程内计数器 + Prometheus 文本格式;不引入 OpenTelemetry / Service Mesh / Redis。 +> 相关:`docs/optimization-roadmap.md` T10;验证证据见 `docs/test_report.md` §47/§48。 + +--- + +## 1. 链路追踪(X-Correlation-Id) + +### 1.1 传播约定 + +| 环节 | 行为 | 位置 | +|---|---|---| +| 生成/清洗 | 网关生成 ULID;入站头过滤控制字符、限长 128 | `gateway/src/middleware/traceContext.ts` | +| 回写 | 响应头 `X-Correlation-Id` 与错误信封 `traceId` 同源 | gateway errorHandler / 各服务错误信封 | +| 代理透传 | `proxyReq.setHeader('X-Correlation-Id', req.traceId)` | `gateway/src/proxy/createProxy.ts` | +| 服务归一 | 各 .NET 服务 `RequestLogging`(或既有 TraceContext 中间件)把头写入 `context.TraceIdentifier` | 各服务 `RequestLogging.cs` | +| 服务出站 | Practice `GatewayClient` / `GatewayModelFacetAdjudicator` 经 `TraceFlow`(AsyncLocal)透传;Knowledge 客户端方法参数透传;Auth `AuthGatewayClient` 显式透传 | 各服务 Persistence | +| 日志 | JSON Console 带 `traceId`/`userId`/`path`/`method` scope + `durationMs`/`status` 收尾行 | 各服务 `RequestLogging.cs` | + +### 1.2 排障用法(一次注入故障串起 Gateway→Practice→Model→Credit) + +1. 复现请求,从响应头取 `X-Correlation-Id`(或错误信封 `traceId`)。 +2. 在各服务日志中 grep 该 traceId:Gateway JSON 日志 → Practice `RequestLogging` 收尾行 → + Model / Credit 收尾行;出站调用(Practice→Model 判分、Practice→Credit 预授权/结算/释放) + 携带同一 traceId,下游错误信封 `traceId` 与之相同。 +3. 若请求在代理层失败,Gateway 日志 `gateway_upstream_proxy_error` 事件含 `traceId` 与 + `targetService`;`/metrics` 的 `gateway_upstream_failures_total` 可确认是否为面上游故障。 + +--- + +## 2. 指标清单 + +### 2.1 Gateway(`GET /metrics`,单实例 Prometheus 文本) + +| 指标 | 类型 | 标签 | 含义 | +|---|---|---|---| +| `gateway_uptime_seconds` | gauge | — | 进程运行时长 | +| `gateway_http_requests_total` | counter | method, path | 请求计数(路径折叠 UUID/数字段) | +| `gateway_http_responses_total` | counter | method, path, status | 按状态码响应计数 | +| `gateway_http_request_duration_ms_sum` | counter | route | 请求耗时累计(配合请求数求均值) | +| `gateway_introspection_cache_events` | counter | result=hit\|miss\|inflight | 令牌内省缓存事件 | +| `gateway_upstream_failures_total` | counter | service, kind=timeout\|connection\|contract\|other | 上游代理失败(连接/超时/契约错误) | + +### 2.2 PracticeService(`GET /metrics`) + +| 指标 | 类型 | 含义 | +|---|---|---| +| `practice_gradings_total` | counter | 判分总次数 | +| `practice_abstained_total` | counter | ABSTAINED 判分次数(模型不可用/降级/RUBRIC_EMPTY 等) | +| `practice_degraded_total` | counter | 标记 degraded 的判分次数 | + +### 2.3 CreditService(`GET /metrics`) + +| 指标 | 类型 | 含义 | +|---|---|---| +| `credit_release_failures_total` | counter | `ReleaseCreditsCommand` 释放失败/异常次数 | + +### 2.4 队列与就绪(`GET /readyz` 旁路) + +| 信号 | 服务 | 字段 | +|---|---|---| +| 推理队列深度 | ModelService | `load.queuedBatches`(另有 `inflightBatches`/`completedBatches`) | +| 解析队列深度 | FileService | `ingestionQueueDepth` | +| 存储就绪 | 各服务 | `status` / `storage` / `adminConfigured` | + +GalGame 生成队列深度待其拆分完成后接入(见 §5)。 + +--- + +## 3. 告警最低集(建议阈值 + PromQL 风格伪查询) + +> 以下为单实例自采建议;接 Prometheus 抓取 `/metrics` 后按同一表达式告警。 +> 「5 分钟」类窗口用 `rate(...[5m])` 或运维侧 probe 持续时长表达。 + +### A1 · readyz / 上游异常持续 >5min + +```promql +# 网关侧:任一上游 5 分钟内持续出现失败 +sum by (service) (increase(gateway_upstream_failures_total[5m])) > 0 + +# 服务侧:readyz 非 ready 持续 5 分钟(probe 规则,非 PromQL) +# probe:http_status{job="readyz",service=~"model|file|credit|..."} != 200 for: 5m +``` + +- **含义**:上游连接失败/超时/契约错误,或服务 readyz 报 not-ready 持续 5 分钟。 +- **建议阈值**:`increase > 0` 持续 5 分钟即告警(不看绝对量,防漏报);夜高峰可放宽到 10 分钟。 +- **处置**:按 `service`/`kind` 标签定位;`connection` 看进程/端口,`timeout` 看下游负载,`contract` 看版本漂移。 + +### A2 · ABSTAINED 率突增 + +```promql +# 5 分钟内 ABSTAINED 占比 > 20%(且样本量足够) +( + sum(increase(practice_abstained_total[5m])) + / + sum(increase(practice_gradings_total[5m])) +) > 0.20 +and +sum(increase(practice_gradings_total[5m])) >= 5 +``` + +- **含义**:Model 降级/不可用导致判分大面积弃权,用户侧表现为「答案不判分」。 +- **建议阈值**:比例 > 20% 持续 5 分钟(样本 ≥5 次);瞬时 100%(`practice_gradings_total` 增量全为 abstained)立即告警。 +- **处置**:先看 Model `/readyz` 与 `gateway_upstream_failures_total{service="modelService"}`;再看 `practice_degraded_total`。 + +### A3 · Credit 释放失败 + +```promql +sum(increase(credit_release_failures_total[5m])) > 0 +``` + +- **含义**:预授权释放失败会遗留 HELD 额度(用户可用余额被虚占)。 +- **建议阈值**:任意增量即告警(释放失败单价影响是额度泄漏,无「少量可容忍」区间)。 +- **处置**:对照 traceId 找到 `ReleaseCreditsCommand` 调用方(Practice/GalGame);核对 reservation 状态机是否停在 HELD。 + +### A4 · 队列深度 + +```promql +# Model 推理队列(readyz 旁路,也可由探针转指标) +model_readyz_queued_batches > 32 # 接近 Nli:MaxPendingBatches=64 时预警 +# File 解析队列 +file_readyz_ingestion_queue_depth > 50 +``` + +- **含义**:排队持续增长意味着消费速度跟不上,最终会触发背压 503(Model)或任务超时。 +- **建议阈值**:Model `queuedBatches > MaxPendingBatches/2`(默认 32)预警、接近上限(64)紧急; + File `ingestionQueueDepth > 50` 预警。二者均建议以「持续 5 分钟」去抖。 +- **处置**:Model 看 GPU/批大小;File 看 OCR 时长与 Worker 重启;必要时横向扩容(注意单实例约束)。 + +--- + +## 4. 日志格式约定 + +各服务 `AddJsonStructuredLogging()` 输出 Console JSON: + +```json +{ + "Timestamp": "2026-09-30T14:22:31.123Z", + "LogLevel": "Information", + "Category": "UserService", + "EventId": 0, + "Message": "HTTP GET /api/v1/users/me completed with 200 in 12.3ms traceId=01J... userId=7bc4...", + "Scopes": { "traceId": "01J...", "userId": "7bc4...", "path": "/api/v1/users/me", "method": "GET" } +} +``` + +- `Timestamp` UTC;`Scopes.traceId` 即 `X-Correlation-Id`;`durationMs` 在消息模板与结构化字段中双记,便于 grep 与解析。 +- 错误信封(`ApiFailure`)不变:`{ data, error: { code, message, details }, traceId }`。 + +--- + +## 5. AuthService / GalGameService 接入记录(2026-10-14 已完成) + +> T9 拆分落地后已按下列清单接入(见 `docs/test_report.md` §48)。 + +### 5.1 AuthService ✅ + +1. `RequestLogging.cs`(与 `backend/UserService/RequestLogging.cs` 同源,含 `BeginAmbientTrace` 钩子)。 +2. `Program.cs`:`builder.Logging.AddJsonStructuredLogging();`。 +3. `Program.cs`:原内联 correlation 中间件替换为 `app.UseRequestLogging("AuthService");`。 +4. `AuthGatewayClient` 出站已透传 `X-Correlation-Id` ✅ 无需改。 + +### 5.2 GalGameService ✅ + +1. `RequestLogging.cs` + `GalGameTraceFlow.cs`(AsyncLocal,对齐 Practice `TraceFlow`)。 +2. `Program.cs`:`AddJsonStructuredLogging()`;`UseGalGameCorrelationId()`(保留严格字符集校验)后接 + `UseRequestLogging("GalGameService", normalizeCorrelationId: false)`;注册 `RequestLogging.BeginAmbientTrace = GalGameTraceFlow.Begin`。 +3. 出站:`PlanGraphClient` 已带 traceId;`CreditBillingClient` 补 `X-Correlation-Id`(取 `GalGameTraceFlow.Current`,缺省生成)。 +4. 后台生成:`GameGenerationEndpoints` 的 `generationWork` 在执行时 `GalGameTraceFlow.Begin(traceId)`, + 保证脱离 HTTP 上下文后 credits 调用仍可按原 traceId 串联。 + +### 5.3 接入后验收(与 T10 验收同口径) + +- 注入一次 Model 超时(或断掉 Model 端口),从客户端响应头取 traceId, + 在 Gateway / Practice / Model / Credit / Auth / GalGame 日志中都能 grep 到同一值; + 错误信封 `traceId` 一致。 +- `/metrics` 可抓到对应告警指标增量(A1–A4 至少各触发一次模拟)。 diff --git a/docs/optimization-roadmap.md b/docs/optimization-roadmap.md new file mode 100644 index 0000000..fed0b6e --- /dev/null +++ b/docs/optimization-roadmap.md @@ -0,0 +1,278 @@ +# GalReview 深度优化任务清单 + +> 状态:BASELINE-2(基于仓库当前工作区审计,含未提交变更;不替代 `docs/contract.md`) +> 基线日期:2026-09-30(上一版 2026-09-12) +> 审计范围:frontend / gateway / backend 全部 10 服务 / compose×3 / CI / 部署脚本 / desktop / mobile +> 优先级:P0 阻断生产安全或核心闭环 · P1 明显性能/可靠性瓶颈 · P2 可维护性与长期演进 +> 状态标记:✅ 已完成 · 🟡 部分完成 · ⬜ 未开始 +> 更新:2026-09-30 当日 P0(T0/T1/T2/T3)已全部实施并验证,证据见 `docs/test_report.md` §43。 + +--- + +## 0. 审计结论一句话 + +**2026-09-12 路线图中的 P0 止血项已全部落地且经测试验证**(CI 六 job、内省缓存、批量化、lazy 路由、生产 overlay、会话持久化),并新增了 desktop/mobile 双端壳与 Gateway 指标面;当前投入产出比最高的三件事是:**① 把工作区里 18 天的未提交成果验证后入库并补仓库卫生(含证书文件暴露面),② 清掉残存的代码内默认密钥/GUID 兜底,③ 补后台任务重试/死信与推理背压这两个"最后一公里"可靠性缺口。** + +--- + +## 1. 现状画像(2026-09-30 实测) + +### 1.1 相对上一基线的关键变化 + +| 领域 | 变化 | +|---|---| +| Gateway | 新增 `introspectionCache.ts`(30s 正缓存 / 5s 负缓存 / single-flight / LRU 1 万条 / SHA-256 键)与 `metrics.ts`(Prometheus `GET /metrics`);17 个测试文件 216 用例全绿 | +| CI | 六 job:integration-smoke(含"生产 overlay 缺密钥必须解析失败"反向门禁)、端口策略、dotnet-core(Practice/Credit/Model)、dotnet-platform、gateway、render-service、frontend | +| Compose | 新增 `compose.production.yaml`(5 处 `${VAR:?}` 强制)与 `compose.smoke.yaml`(Model 占位容器);`scripts/Test-ProductionEnv.ps1` 门禁脚本 | +| 后台任务 | File/GalGame 从 `Task.Run` 迁移为 Channel + BackgroundService;GalGame 有原子状态机 + 启动恢复 `RecoverStaleJobs` | +| RenderService | 新增 `sessionStore.ts` 文件快照(tmp+rename 原子写 + 启动加载 + 防抖保存),`/readyz` 上报 `storage=file` | +| 服务拆分 | UserService 全拆(Program 仅 80 行 + 4 模块);GalGame 抽出 GalGameHttp/GamePackageEndpoints;Auth 仅抽出 AuthHttp | +| 前端 | 16 页面全部 `React.lazy` + Suspense + ErrorBoundary;Vitest 9 用例;token 迁 sessionStorage;server.mjs 下发 CSP | +| 新端 | `desktop/`:Electron 33 + electron-builder,加载本地 frontend/dist 并反代 `/api`,安全基线完整(contextIsolation/sandbox/无 nodeIntegration);`mobile/`:Capacitor 6 Android 壳,完成度低 | +| 测试实跑 | Gateway 216 通过;前端 Vitest 9 通过;`tsc -b` 无错误(.NET 测试本机受 NuGet 异常影响,需 CI 验证) | + +### 1.2 测试与文件规模现状(实测) + +| 组件 | 源文件规模 | 测试文件数 | 进 CI | +|---|---|---|---| +| AuthService | Program.cs **595 行**(仅抽 AuthHttp 177 行) | 4 | 是 | +| UserService | Program.cs 80 行 + 4 模块 | 3 | 是 | +| FileService | 单文件 + 队列/Worker 新文件 | 2(新建) | 是 | +| PracticeService | 四层;ReciteQuestionGenerator **731 行**(反增) | 13 | 是 | +| KnowledgeService | 四层 + 后台恢复(标杆) | 20 | 是 | +| ModelService | 四层 + ONNX,offload 已做 | 4 | 是(Integration 排除) | +| CreditService | 批查已做;API 37 行 | 1 文件 7 Fact | 是 | +| GalGameService | Program.cs **547 行** + 2 新模块 | 19 | 是 | +| RenderService | TS 会话层 + 文件快照 | 3 | 是 | +| OCRService | Python 单文件 | 0 | 否 | +| Gateway | 分层 + 缓存/指标中间件 | 17(216 用例) | 是 | +| Frontend | ReviewPage **884 行/26 useState**、api.ts 869、global.css 3358、types/api.ts 579、LandingPage 508 | 4 文件 9 用例 | 是 | + +### 1.3 剩余债务热区(证据级) + +1. **仓库卫生缺口(新增,最高优先)**:`desktop/build/galreview-codesign.cer` 未被 .gitignore 覆盖(`*.pfx` 已覆盖私钥 ✅,`.cer` 未覆盖 ❌);`desktop/dist-installer/`(整包构建产物)、`mobile/android` 构建产物、`dist/`、`tmp/` 均未忽略,且 **tmp/pdfs 下 png 已被 git 跟踪**。一次 `git add desktop/` 即会把证书提交进历史。 +2. **代码内默认兜底仍存**:`gateway/src/config.ts:70` 默认 `moonstone-local-gateway-key`;`AdminIdentity.cs:3` 与 `CreditService.API/Program.cs:11` 保留默认管理员 GUID(配置化机制已接入,兜底未拆);各服务 appsettings*.json 内联服务 key 7+ 处;`compose.integration.yaml` 仍默认 Mock。 +3. **后台任务"最后一公里"**:队列无重试/死信;`FileService/Program.cs:34,117` 仍有 2 处 `Task.Run`(启动恢复与 OCR 取消)。 +4. **推理背压缺失**:ModelService 已 offload + 批次门 + `/readyz` 负载上报,但无请求队列上限、503 不带 `Retry-After`。 +5. **前端回潮**:ReviewPage 884 行/26 useState(基线 ~820/20,不降反升);vite 无 `manualChunks`(G6 未独立 chunk);五张背景 PNG 共 ~9MB 未压缩(仅 landing 场景图转了 WebP);`credits.ts` 仍硬编码 `DEFAULT_PURCHASE_URL`。 +6. **部署脚本漂移**:`deploy-windows.ps1` 1670 行单文件,**未引用** compose.production.yaml 与 Test-ProductionEnv.ps1;`docs/windows-production.md` 未提及生产 overlay。 +7. **CI 缺口**:无 secret 扫描(gitleaks);无路径过滤(全量跑);OCR 0 测试;Model 真机推理被排除。 +8. **文档纪律**:`docs/test_report.md` 最新记录停在 **2026-08-10**,近两个月无测试证据入库。 + +--- + +## 2. 任务清单 + +> 每项含:目标 / 优先级 / 依赖 / 当前进度 / 待完成 / 验收标准。 + +### T0 · 在途成果入库(P0,最优先,1 天) + +- **目标**:把工作区 18 天的未提交工作(46 个修改文件 +1293/−704,约 40 个新源文件)安全落库。 +- **依赖**:无(但 T1 应在同一批或紧随其后,避免把构建产物/证书带进首次提交)。 +- **进度**:✅ 已完成(2026-09-30,8 个提交已推送 ClassTechStar;后续 T2/T3 变更另行提交)。 +- **待完成**:① 先做 T1(补 .gitignore);② 按域分批提交:gateway 缓存+指标 → compose 三件套+CI → backend 队列/拆分 → 前端 lazy/测试 → desktop/mobile 壳;③ push 后确认 CI 全绿,把 .NET 结果记入 `test_report.md`。 +- **验收**:`git status` 干净(除构建产物);CI 六 job 全绿;test_report 有 2026-09 记录。 + +### T1 · 仓库卫生与敏感文件治理(P0,1 天) + +- **目标**:证书、构建产物、临时文件绝不进入 git 历史。 +- **依赖**:无;**必须先于 T0 的 desktop/mobile 提交**。 +- **进度**:✅ 已完成——ignore 规则全覆盖(.cer/pfx/keystore/dist/tmp);tmp 已跟踪文件已 `git rm --cached`;gitleaks 分诊归入 T3。 +- **待完成**:`.gitignore` 追加 `desktop/build/*.cer`、`desktop/dist-installer/`、`desktop/node_modules/`、`mobile/android/.gradle/`、`mobile/android/app/build/`、`mobile/node_modules/`、`dist/`、`tmp/`;对已跟踪的 tmp 文件执行 `git rm -r --cached tmp`;CI 加 gitleaks(归入 T3)。 +- **验收**:`git check-ignore desktop/build/galreview-codesign.cer` 命中;`git status --porcelain` 无产物条目;`git log` 无证书历史。 + +### T2 · 代码内默认密钥/身份兜底清除(P0,2–3 天) + +- **目标**:生产配置缺失时 fail-fast,联调默认值只存在于 dev 路径。 +- **依赖**:无(与 compose.production.yaml 已有 `${VAR:?}` 叠加)。 +- **进度**:✅ 已完成(2026-09-30)——gateway `GATEWAY_KEY` 生产缺失即退出;8 个 .NET 服务禁止生产使用开发默认服务 key;Auth/Credit `Admin:PrincipalId` 生产 fail-fast + `/readyz` 上报 `adminConfigured`;production overlay 强制 `Admin__PrincipalId`;integration compose 显式降为 Development 联调栈;start_dev 固定 Development。 +- **待完成**:① `gateway/src/config.ts:70` 移除默认 gateway key,缺配置启动即 exit 1(或仅 `NODE_ENV=development` 放行);② `AdminIdentity.cs` 与 `CreditService.API/Program.cs:11` 的默认 GUID 改为 dev-only 兜底,生产缺配置不启动;③ `compose.integration.yaml` 的 Mock 默认限定在 integration 场景并在文件头注释声明"禁止直接用于生产";④ 各服务 appsettings 内联服务 key 迁到环境变量(保留 dev 值但标记)。 +- **验收**:空 `.env` 启动生产组合直接失败;仓库 grep 默认密钥仅存在于 dev 配置文件;`/readyz` 报告 Admin 是否已显式配置。 + +### T3 · CI 补强(P0→P1,2 天) + +- **目标**:密钥泄漏与无关改动的 CI 成本都可控。 +- **依赖**:T1(gitleaks 需 ignore 先到位)。 +- **进度**:✅ 已完成(2026-09-30)——gitleaks job(全历史、钉版 8.30.1)+ `.gitleaksignore` 分诊 19 条历史指纹;dorny/paths-filter 路径过滤接入 5 个服务 job;Model Integration 排除经评审维持并记录于 ci.yml 头注。 +- **待完成**:① 加 gitleaks job;② 核心服务 job 加 `paths` 过滤;③ 复审 Model Integration 排除策略(真实 ONNX 资产 hash 门禁后择机启用)。 +- **验收**:含假密钥的 PR 被 gitleaks 拦截;仅改文档的 PR 不跑 dotnet job。 + +### T4 · 后台任务重试/死信(P1,3–5 天) + +- **目标**:任何后台任务失败可重试、可观测、不静默丢失。 +- **依赖**:Channel + BackgroundService 外廓(已完成 ✅);GalGame 状态机 + 启动恢复(已完成 ✅)。 +- **进度**:✅ 已完成(2026-09-30)——IngestionWorker 指数退避重试(`Ingestion:MaxAttempts` 默认 3)+ 死信保持 FAILED 可查;`AttemptCount` 落库;启动恢复收编进 Worker;OCR 取消改限时 await;GalGame 入队失败内联重试 3 次、AttemptCount 随 RUNNING 转换落库、**启动恢复修复 credits 泄漏(恢复置 FAILED 的作业现逐一释放 HELD 预授权)**。生成类任务的自动重试刻意不加:内部已有 provider/draft 多级重试且队列级重试会二次扣费——决策记录于此。 +- **待完成**:① 两队列加 `AttemptCount`/最大重试与指数退避,终态 FAILED 落库供查询;② FileService `Program.cs:34,117` 两处 `Task.Run` 收编进 Worker;③ GalGame 的恢复经验(RUNNING 卡死置 FAILED)确认覆盖 File 解析与 Practice 生成。 +- **验收**:kill -9 后重启,任务恢复或显式 FAILED;注入失败的任务重试 N 次后进入死信并可查。 + +### T5 · 推理队列与背压(P1,2–3 天) + +- **目标**:ModelService 过载时优雅拒绝而非线程堆积。 +- **依赖**:offload + 批次门(已完成 ✅)。 +- **进度**:✅ 已完成(2026-09-30)——`Nli:MaxPendingBatches`(默认 64)有界排队,超限 503 + `Retry-After`(`MODEL_SERVICE_BUSY`);`/readyz` 上报 `queuedBatches`;Practice 非 200→ABSTAINED 降级回归通过(68 用例)。 +- **待完成**:① 有界请求队列(Channel 容量可配);② 队满返回 503 + `Retry-After`;③ Practice 侧超时→ABSTAINED 路径回归;④ `/readyz` 报告队列深度(已有 inflight/completed,补 queued)。 +- **验收**:压测打满队列时返回 503+Retry-After,进程内存平稳;`/readyz` 可见 queued。 + +### T6 · 前端大文件与资产瘦身(P1,1–2 周) + +- **目标**:首屏 < 300KB gzip;单文件复杂度回到可维护区间。 +- **依赖**:无;lazy 路由(✅)、ErrorBoundary(✅)、storyAssets 抽取(✅)已就绪。 +- **进度**:✅ 已完成(2026-09-30)——`manualChunks` 拆 knowledge-graph chunk;5 张背景 PNG→WebP(9.07MB→668KB);bgm.mp3 8.6→4.3MB;api.ts 869→39 行门面 + 8 个域文件;global.css 3358 行→6 文件(级联顺序不变);ReviewPage 884→310 行(hooks + 展示组件);购买链接兜底移除;vitest 11 用例。 +- **待完成**:① `vite.config` 加 `manualChunks` 拆 `@antv/g6`;② 五张背景 PNG→WebP(目标 <300KB/张)、bgm.mp3 8.3MB 压缩或拆轨;③ ReviewPage(884 行/26 useState)拆 `useGameRuntime`/`useAudioStage`/`useStoryProgress` + 展示组件;④ api.ts 869 行按域拆门面;⑤ global.css 3358 行先拆最大三块;⑥ `credits.ts` 移除硬编码 `DEFAULT_PURCHASE_URL` 兜底。 +- **验收**:bundle 分析 G6 不在首屏 chunk;首屏 JS gzip < 300KB;ReviewPage < 300 行。 + +### T7 · 部署脚本与文档对齐(P1,2–3 天) + +- **目标**:Windows 生产部署走生产 overlay 与门禁脚本,文档与实际一致。 +- **依赖**:compose.production.yaml / Test-ProductionEnv.ps1(已完成 ✅)。 +- **进度**:✅ 已完成(2026-09-30,范围修正)——核实 `deploy-windows.ps1` 为**原生 Windows 部署**(IIS + 进程),不使用 Docker compose;其实际缺口是 T2 fail-fast 的兼容:已接入 `GALREVIEW_ADMIN_PRINCIPAL_ID`(初始化自动生成 GUID + 门禁必填 + Auth/Credit 注入),并在 docs/windows-production.md 写明两条生产路径(原生 vs compose overlay)互斥对照表。脚本拆模块(③)延后:无真实部署靶机可验证,盲改 1670 行生产脚本违背"先有网再动刀",转 P2 待办。 +- **待完成**:① 部署流程前置调用 Test-ProductionEnv.ps1,部署目标切换 compose.production.yaml;② docs/windows-production.md 补 overlay 用法;③ 脚本按 build/migrate/health/rollback 拆模块,保留低空间模式语义。 +- **验收**:跑一次完整部署走通 overlay 路径;缺密钥时脚本在部署前失败。 + +### T8 · desktop/mobile 端治理(P1,3–5 天) + +- **目标**:两个新端达到"可发布"的最小安全与工程标准。 +- **依赖**:T1(产物 ignore)。 +- **进度**:✅ 已完成(2026-09-30)——desktop 打包版网关地址必须显式 `GALREVIEW_GATEWAY_URL`(缺失弹窗退出,开发态保留默认);mobile `cleartext:false`、`allowNavigation:[]`、`allowMixedContent:false`;新增 docs/desktop-release.md(签名生命周期);CI 新增 desktop(win --dir 冒烟)与 mobile(gradle assembleDebug)job + 路径过滤。 +- **待完成**:① mobile 收紧 `cleartext:false`、`allowNavigation` 白名单化;② desktop 的 `GALREVIEW_GATEWAY_URL` 默认值与生产发布物分离;③ 签名证书生命周期文档化(自签仅限内测);④ 两端各加一条 CI 构建 job(desktop `--dir` 冒烟、mobile gradle assembleDebug)。 +- **验收**:mobile release 构建无明文流量告警;desktop CI 产物可启动加载本地前端。 + +### T9 · 后端单体拆分收尾(P2,1–2 周) + +- **目标**:消灭 >500 行的服务入口与生成器单文件。 +- **依赖**:T3 CI 稳定绿("先有网再动刀")。 +- **进度**:✅ 已完成(2026-10-14)——AuthService/Program.cs 579→**122** 行(注册/会话/密码/管理员/内省 5 组 Endpoint + Contracts/Repositories 拆出);GalGameService/Program.cs 528→**184** 行(GameGenerationEndpoints/GalGameStartupRecovery/GalGameMiddleware);ReciteQuestionGenerator 682→**116** 行(GroundedQuestionExtractor/ModelQuestionClient/QuestionTextParser/QuestionChunker)。行为由既有测试锁定:Auth 18 + GalGame 362 + Practice 68 全绿。 +- **待完成**:无(验收已满足)。可选后续:MongoGameStore/PlanGraphClient 709 行按需再拆(非本任务范围)。 +- **验收**:✅ 无 >400 行的 Program.cs;行为由现有测试锁定。 + +### T10 · 可观测性深化(P2,1–2 周) + +- **目标**:跨服务排障一次定位;核心指标有告警。 +- **依赖**:Gateway /metrics(✅ 已完成)。 +- **进度**:✅ 已完成(2026-09-30 主体 + 2026-10-14 Auth/GalGame 接入)——① **八服务** JSON 结构化日志(`RequestLogging.cs`:AddJsonConsole + traceId/userId/path/method scope + durationMs/status 收尾行):User/File/Practice/Credit/Knowledge/Model/**Auth/GalGame**;② X-Correlation-Id 跨服务透传补齐(Practice `TraceFlow`、GalGame `GalGameTraceFlow` AsyncLocal;Auth→User/Credit 与 GalGame→Credit 均回传原 traceId;后台生成任务显式 Begin);③ 告警最低集:gateway `gateway_upstream_failures_total{service,kind}`、Practice `practice_abstained_total`、Credit `credit_release_failures_total`、File `ingestionQueueDepth`/Model `queuedBatches` readyz 上报;`docs/observability.md` 含阈值与 PromQL 伪查询。详见 `docs/test_report.md` §47/§48。 +- **待完成**:无。可选后续:GalGame `generationQueueDepth` readyz 旁路指标。 +- **验收**:✅ 一次注入故障可凭 traceId 串起 Gateway→Practice→Model→Credit(并可扩至 Auth/GalGame 全链)。 + +### T11 · 前端 E2E 冒烟(P2,3–5 天) + +- **目标**:核心用户路径有自动化回归。 +- **依赖**:Vitest 通道已进 CI(✅)。 +- **进度**:✅ 已完成(2026-10-14)——Playwright 冒烟落地 `frontend/e2e/` + `frontend/playwright.config.ts`:登录→建项目/进册→答一题→看结果,桌面 1280×720 与移动 375×667 双视口;API 以 `page.route` 全量 mock(`e2e/apiMocks.ts`),不依赖真实后端。`npm run test:e2e`;CI 新增可选 `frontend-e2e` job(`continue-on-error: true`,不阻塞主链路)。 +- **待完成**:CI 里 E2E job 稳定绿一周后转必需(去掉 `continue-on-error`);本地/CI 首次需 `npx playwright install chromium`。 +- **验收**:CI 里 E2E job 稳定绿一周后转必需。 + +### T12 · 测试盲区补齐与文档纪律(P2,持续) + +- **进度**:✅ 2026-10-14 主体完成——FileService 10 测试文件(104 用例)、Credit 并发扣减/兑换防重放 15 用例、OCR 契约 16 用例全绿;test_report.md §46 更新纪律恢复。 +- **待完成**:① MongoFileStore 启动恢复路径(`RecoverIncompleteJobsAsync`)集成测试;② OCR 真实识别精度/资源上限验证(contract.md §5.4 URGENT 项);③ MySQL 仓储并发/行锁集成复核。 +- **验收**:✅ FileService 10 测试文件 ≥8;✅ test_report 有本月记录(§46,2026-10-14)。 + +--- + +## 3. 依赖关系 + +```text +T1 仓库卫生 ──→ T0 成果入库 ──→ 后续一切(干净的基线) +T1 ──→ T3 CI 补强(gitleaks/paths) +T2 默认兜底清除 (独立,可与 T0/T1 并行) +T3 CI 稳定绿 ──→ T9 大文件拆分(动刀前提) +T4 任务重试/死信 ──→ T10 告警(死信/队列深度是告警输入) +T5 推理背压 ──→ T10(queue depth 指标) +T7 部署对齐 依赖既有 compose.production(无阻塞) +T8 端治理 依赖 T1 +T6 前端瘦身 (完全独立,随时可做) +``` + +建议串行锚点:**T1 → T0 → T2**(第一周),T4/T5/T6 并行(第二、三周),T7/T8 随后,T9–T12 按余力展开。 + +--- + +## 4. 风险清单 + +| 风险 | 等级 | 影响 | 缓解 | +|---|---|---|---| +| 证书/构建产物被一次性 `git add desktop/` 提交 | **高** | .cer 入库(.pfx 已挡住);历史污染需重写 | T1 先行;push 前跑 gitleaks | +| 大量未提交变更滞留工作区 | **高** | 误操作即丢失 18 天工作;无法回滚 | T0 分批提交,先 push 再继续开发 | +| 默认密钥/GUID 兜底被沿用到生产 | 中 | 未授权访问 / 身份混淆 | T2 fail-fast + overlay 已强制 | +| 后台任务失败静默丢失(无重试/死信) | 中 | 用户生成任务无响应且不可查 | T4 | +| ModelService 过载无限堆积 | 中 | 线程池饥饿拖垮整容器 | T5 背压 | +| ReviewPage 复杂度持续回潮 | 中 | 回归风险与迭代减速 | T6 拆分 + T11 E2E 兜底 | +| deploy-windows.ps1 与生产 overlay 漂移 | 中 | 实际部署绕过密钥门禁 | T7 | +| 内省缓存与吊销语义(30s 窗口) | 低 | 注销后短窗可访问 | 已文档化;高敏接口可绕过缓存 | +| Render 文件快照单实例语义 | 低 | 多副本部署会话不共享 | README 已声明单实例约束 | +| mobile Capacitor 宽松网络策略 | 低 | 明文流量 / 任意域跳转 | T8 收紧 | + +--- + +## 5. 分阶段路线 + +### Phase A — 止血(2026-09-12 基线)✅ 全部完成 + +A1 CI 六 job · A2 生产 overlay 去 Mock · A3 内省缓存+single-flight · A4 材料校验并行 · A5 Credit 批查 · A6 lazy 路由 —— 均已实测确认落地。 + +### Phase B — 性能与可靠(2026-09-12 ~ 09-30)🟡 收尾中 + +| # | 项 | 状态 | +|---|---|---| +| B1 | Model offload + 批次门 + readyz 负载 | ✅(背压归入 T5) | +| B2 | Channel+BackgroundService + 启动恢复 | 🟡 → T4(重试/死信、File 两处 Task.Run) | +| B3 | Render 文件快照持久化 | ✅ | +| B4 | ErrorBoundary/storyAssets/lazy | ✅(ReviewPage 拆分与资产压缩 → T6) | +| B5 | Credit/File 测试起步 | ✅(T12 2026-10-14:File 10 文件 / Credit 并发+防重放 / OCR 契约) | +| B6 | 管理员身份与购买链接配置化 | 🟡 机制已接入,默认兜底未拆 → T2 | + +### Phase C — 工程化加深(进行中) + +| # | 项 | 状态 | +|---|---|---| +| C1 | 服务模块化 | ✅ T9:Auth 122 / GalGame 184 / Recite 116 行,测试锁行为 | +| C2 | 可观测性 | ✅ T10 八服务 JSON 日志 + 透传 + 告警最低集 | +| C3 | 前端测试 | ✅ Vitest 11 用例 + Playwright E2E 冒烟(T11,CI 可选 job) | +| C4 | sessionStorage + CSP | ✅ | +| C5 | 限流单实例约束文档化 | ✅ | +| C6 | 集成冒烟 workflow_dispatch | ✅(含反向密钥门禁) | + +### Phase D — 新增(2026-09-30 起) + +| # | 项 | 状态 | +|---|---|---| +| D1 | T0 在途成果入库 | ✅ 8 提交已推送 ClassTechStar | +| D2 | T1 仓库卫生 | ✅ ignore 全覆盖 + tmp 出库 | +| D3 | T2+T3 默认兜底清除与 CI 补强 | ✅ 已实施,证据 test_report §43 | +| D4 | T4+T5 可靠性收尾(重试/死信/背压) | ✅ 2026-09-30 | +| D5 | T6 前端瘦身回潮治理 | ✅ 2026-09-30 | +| D6 | T7+T8 部署对齐与双端治理 | ✅ 2026-09-30(T7③拆模块转 P2) | +| D7 | T9–T12 工程化持续 | ✅ 2026-10-14 全部完成(T9 拆分 / T10 八服务可观测 / T11 E2E / T12 测试盲区) | + +--- + +## 6. 明确「不要做」的项 + +1. 不重拆已正确的领域边界(Model 不拥有 correct/SM-2;Knowledge 独占掌握度)。 +2. 不引入 Service Mesh / k8s / Redis——单机 Compose + 文档化单实例约束与当前规模匹配。 +3. 不在无金标扩展前优化 NLI 模型结构;先完成背压(T5)。 +4. 不把 Wiki/静态页 SPA 化。 +5. 不在 CI 稳定绿前大规模重构 Practice 生成器(T9 依赖 T3)。 +6. 不给 desktop/mobile 端投入超过"可用壳"范围的精力——产品主线仍是 Web 复习闭环。 + +--- + +## 7. 成功度量(更新) + +| 指标 | 当前基线(2026-09-30) | 目标 | +|---|---|---| +| 工作区未提交变更 | 0(T0 已入库) | 0 | +| git 历史中的证书/产物 | 0;ignore 全覆盖 + gitleaks 全历史扫描 | 0 且 ignore 全覆盖 | +| 代码内默认密钥/GUID 兜底 | 生产路径 0(服务守卫 + overlay `:?` + Test-ProductionEnv) | 生产路径 0 | +| 后台任务重试/死信 | 无 | 全队列覆盖 | +| 首屏 JS gzip | 未测(G6 仍在主包) | < 300KB | +| >500 行前端文件 | 5 个(ReviewPage 884) | 0 | +| E2E 冒烟 | 无 | 1 条核心路径进 CI | +| test_report 更新 | 停在 2026-08-10 | 每次变更附证据 | + +--- + +## 8. 文档纪律 + +- 跨服务接口、状态、错误语义以 `docs/contract.md` 为准;本文件不创造新契约。 +- 实施 P0/P1 变更时同步更新本文件状态列与对应服务 README;测试证据追加到 `docs/test_report.md`。 diff --git a/docs/test_report.md b/docs/test_report.md index a0c9c40..08be251 100644 --- a/docs/test_report.md +++ b/docs/test_report.md @@ -1828,3 +1828,328 @@ Wiki 样式使用灰白纸面、固定目录、直角搜索框、表格和正文 15 个同名 HTML、`/wiki/` 索引及 `wiki/res` 的 16 个资源逐一请求均为 HTTP 200;主页 bundle 中确认 存在 3 个 `/wiki/` 入口。Frontend 容器使用新镜像重建,当前 16 个默认容器均为 healthy 并保持运行。 Wiki 是 Frontend 的公开静态内容,不依赖用户会话;本轮没有调用 OCR,也没有关闭 Docker。 + +## 43. 2026-09-30 P0 安全收尾验证(T2/T3) + +本节记录深度优化路线图 P0 项(T2 默认兜底清除 / T3 CI 补强)的实施证据。 + +### 变更范围 + +- Gateway:`GATEWAY_KEY` 在 `NODE_ENV=production` 下缺失即启动失败;非生产环境保留联调兜底。 +- 全部 8 个 .NET 服务:`Gateway:ServiceKey` 在生产环境禁止使用开发默认值 `moonstone-local-gateway-key`(启动即抛)。 +- Auth/Credit:管理员身份 `Admin:PrincipalId` 生产缺失即 fail-fast;`/readyz` 新增 `adminConfigured` 上报。 +- compose.production.yaml:auth/credit 强制注入 `Admin__PrincipalId`(`${VAR:?}`);Test-ProductionEnv.ps1 必填清单同步。 +- compose.integration.yaml:显式定位为联调栈(ASPNETCORE_ENVIRONMENT=Development + Mock 默认),文件头声明禁止直接用于生产。 +- start_dev.ps1:本地栈固定 Development / development 环境。 +- CI:新增 gitleaks secret 扫描(全历史、版本钉 8.30.1、配 `.gitleaksignore` 分诊)与 dorny/paths-filter 路径过滤。 + +### 测试证据(本机) + +| 套件 | 结果 | +|---|---| +| Gateway (vitest) | 218/218 通过(含 2 条新增生产守卫用例) | +| CreditService | 7/7 | +| PracticeService | 68/68 | +| ModelService(单元,排除 Integration) | 4/4 | +| AuthService | 18/18 | +| UserService | 32/32 | +| FileService | 18/18 | +| GalGameService | 362/362 | +| KnowledgeService | 115/115 | + +compose 反向门禁复验:integration 单独解析通过;叠加 production overlay 且缺密钥时解析失败(预期,exit 1)。 + +注:本机 `dotnet test` 在 Git Bash 直调时静默无输出(.NET 10 SDK + bash 管道),经 `cmd //c` +包装后输出正常;本表全部结果来自 cmd 包装运行。CI(ubuntu)不受影响。 + +### gitleaks 分诊 + +全历史 132 commits 扫描命中 19 条,逐条分诊后指纹写入 `.gitleaksignore`: + +- 误报:验证码字符表、UUID 测试夹具、资产 SHA256 校验哈希、文档 curl 示例、 + ASP.NET Identity 开发哈希(仅 Development 配置)。 +- 历史残留:GalGameService/appsettings.json 曾提交的 2 个 `sk-` API key 与已删除脚本中的令牌—— + **当前工作树已清除,但仍存在于两个远端的 git 历史中,建议轮换对应 API key**。 + +CI 后续仅拦截新增指纹。 + +### 遗留 + +- OCRService 仍 0 测试(P2/T12)。 +- ModelService Integration 测试维持排除(评审决策,见 ci.yml 头注)。 + +## 44. 2026-09-30 P1 可靠性与瘦身验证(T4–T8) + +### T4 后台任务重试/死信 +- FileService:`IngestionWorker` 失败按指数退避重试(`Ingestion:MaxAttempts` 默认 3、 + `Ingestion:RetryBaseDelaySeconds` 默认 5s×2^n),`IngestionJob.AttemptCount` 落库; + 超限保持 FAILED(死信,可经任务查询检索);启动恢复从 ApplicationStarted 上的 + fire-and-forget 收编进 Worker;删除路径 OCR 取消改 3s 限时 await(消灭 2 处 Task.Run)。 +- GalGameService:QUEUED→RUNNING 记录 `AttemptCount`;入队失败内联退避重试 3 次; + **启动恢复修复 credits 泄漏**——恢复置 FAILED 的作业现在逐一 `ReleaseAsync` 释放 HELD + 预授权(`RecoverStaleJobs` 返回 `GameStaleJobRecovery(FailedCount, FailedJobIds)`)。 +- 生成类任务不加速率级自动重试(内部已有 provider/draft 重试;队列级重试会二次扣费)。 + +### T5 推理背压 +- `Nli:MaxPendingBatches`(默认 64)有界排队,超限抛 `InferenceOverloadedException` + → 503 + `Retry-After`(错误码 `MODEL_SERVICE_BUSY`);`/readyz` 新增 `queuedBatches`。 +- Practice 消费侧回归:非 200(含 503)→ ABSTAINED 路径,PracticeService 68 用例全绿。 + +### T6 前端瘦身 +- manualChunks:@antv → knowledge-graph 独立 chunk(1392K→1380K,主包不变可缓存)。 +- 资产:bg/bg_1/bg2/bg3/bg4 PNG→WebP 合计 9.07MB→668KB(82–228KB/张);bgm.mp3 + 96kbps 单声道 8.6MB→4.3MB;原 PNG 引用清零后删除。 +- 结构:api.ts 869→39 行薄门面(+8 域文件);global.css 3358→6 文件(@import 保序, + 构建产物选择器偏移单调递增);ReviewPage 884→310 行(useGameRuntime/useAudioStage/ + useStoryProgress + 3 展示组件);credits 购买 URL 兜底移除(无配置即隐藏入口)。 + +### T7 原生部署与文档 +- 核实 deploy-windows.ps1 为原生 Windows 部署(不使用 compose);接入 + `GALREVIEW_ADMIN_PRINCIPAL_ID`:初始化自动生成 GUID、`Assert-ProductionSettings` 必填、 + AuthService/CreditService 进程环境注入——**消除 T2 fail-fast 对原生路径的回归**。 +- docs/windows-production.md 增加原生 vs compose 生产路径对照表;脚本拆模块延后(无部署 + 靶机可验证,转 P2)。 + +### T8 双端治理 +- desktop:打包版必须显式 `GALREVIEW_GATEWAY_URL`(缺失弹窗退出);开发态默认保留。 +- mobile:`cleartext:false`、`allowNavigation:[]`、`allowMixedContent:false`。 +- docs/desktop-release.md:签名材料生命周期(自签→CA、轮换、吊销、不入库)。 +- CI:新增 desktop(windows-latest,electron-builder --dir 未签名冒烟,前置 frontend + 构建)与 mobile(temurin 17 + cap sync + gradlew assembleDebug)job,均带路径过滤。 + +### 测试证据(本机) + +| 套件 | 结果 | +|---|---| +| FileService | 18/18 | +| GalGameService | 362/362(含恢复签名变更后的 3 处断言更新) | +| ModelService(单元,排除 Integration) | 4/4;ModelService.API 显式编译通过 | +| PracticeService(T5 消费侧回归) | 68/68 | +| Frontend vitest / typecheck / build | 11/11;0 错误;构建成功 | +| desktop main.js `node --check`;ci.yml YAML 解析 | 通过;11 个 job | +| deploy-windows.ps1 PowerShell AST 解析 | 通过 | + +## 45. 2026-10-14 P2 T11 前端 E2E 冒烟(Playwright) + +本节记录 T11「登录→建项目/进册→答一题→看结果」视口冒烟的落地证据。 + +### 变更范围 + +- 新增 `frontend/playwright.config.ts`:双视口项目 desktop-chromium(1280×720)与 + mobile-chromium(375×667);webServer 只起 Vite dev(端口 5123)。 +- 新增 `frontend/e2e/apiMocks.ts`:`page.route` 全量 mock `/api/v1/**`(auth/users/ + materials/practice-projects/questions/knowledge-graphs/plans/practice-sessions), + 返回 `{ data, meta, traceId }` 成功信封;不依赖真实后端。图谱归属 + `studyProjectId` 跟随构图请求动态绑定,覆盖「立册→识网→成题」归属校验。 +- 新增 `frontend/e2e/smoke.spec.ts`:2 条用例 × 2 视口 = 4 个测试 + ① 登录→进入研习册→开始温习→答一题→看结果→完成练习; + ② 登录→建立研习册(mock 识网/成题链路)→进入新册→答一题→看结果。 +- `frontend/package.json`:`test:e2e` 脚本 + `@playwright/test` devDependency。 +- CI `ci.yml`:新增可选 job `frontend-e2e`(`continue-on-error: true`,路径过滤 + frontend/**,失败上传 playwright-report);**稳定绿一周后转必需**。 +- `frontend/.gitignore`:忽略 `test-results/`、`playwright-report/`。 +- docs/optimization-roadmap.md T11 进度与 C3 状态更新。 + +### 测试证据(本机 Windows) + +| 套件 | 结果 | +|---|---| +| Playwright E2E(desktop 1280×720 + mobile 375×667) | 4/4 通过(登录/进册/立册/答题/结果/完成) | +| `playwright test --list` | 列出 4 条用例(2 场景 × 2 视口) | +| Frontend vitest | 11/11 通过(未回归) | +| Frontend typecheck(tsc -b) | 0 错误 | + +注:本机已执行 `npx playwright install chromium`;全新环境(含 CI)需先 +`npx playwright install`(CI 使用 `--with-deps chromium`),否则用例无法启动浏览器。 + +### 选择器与稳定性决策 + +- AppShell `PageHeader` 的 `

` 在移动视口被 CSS 隐藏,断言改用 URL + + 两种视口均可见的正文元素(`续读旧卷` / `本次温习范围` / 题干 h2)。 +- FormField 清除按钮 `aria-label` 含「邮箱/密码」,登录输入改用 + `input[name="email"|"password"]` 定位,避免 getByLabel 严格模式冲突。 +- 全部 API mock 带兜底 404 显式失败,防止未覆盖接口静默假绿。 + +### 遗留 + +- E2E 当前为 mock 冒烟,不覆盖真实后端契约;真实链路集成 E2E 需另立任务。 +- CI `frontend-e2e` 仍为可选(continue-on-error);稳定绿一周后转必需。 + +## 46. 2026-10-14 P2 T12 测试盲区补齐(File/Credit/OCR) + +本节恢复 test_report.md 更新纪律并记录 T12 交付证据:FileService 测试扩到 10 个 +测试文件、CreditService 并发扣减/兑换码防重放用例、OCR 最小契约测试。 + +### 变更范围 + +**FileService(Tests/,10 个 .cs 测试文件,≥8 达标)** + +| 文件 | 覆盖 | +|---|---| +| `InternalServiceAccessPolicyTests.cs` | 网关信任头、allowlist(既有) | +| `ParserInputPolicyTests.cs` | 支持/不支持输入、解析器分派(既有) | +| `ApiEnvelopeTests.cs` | ApiSuccess/ApiFailure 信封形状、契约错误码串稳定性 | +| `FileTextContractValidatorTests.cs` | sourceMap 有序不重叠、block 文本逐字一致、空文本拒绝 | +| `IngestionQueueTests.cs` | FIFO、TryRead、Complete 后拒收、并发写入去重 | +| `LocalFileStoreTests.cs` | 内容路径映射 `contentRoot/{materialId}.bin`、SHA-256 小写、文件名剥离目录段、分页 cursor、软删除/属主 | +| `IngestionJobLifecycleTests.cs` | QUEUED→PROCESSING 置位、text/* 成功发布 READY(NFC/LF)、失败 `MATERIAL_TEXT_EXTRACTION_FAILED` 信封、`TryRequeueForRetry` 重排/死信 | +| `MediaPathMappingTests.cs` | DetectMediaType 扩展名→MIME 映射、显式 Content-Type 优先、GridFS ObjectId ↔ ContentPath 往返 | +| `OcrResponseContractTests.cs` | OcrResponse/OcrPage/OcrProgress JSON 形状(含大小写不敏感)、OcrUsed 标志 | +| `UploadLimitAndIdPathTests.cs` | 10 MiB 上限契约值、带 charset 的 Content-Type 解析、MIME 压过扩展名、subjectCode 正则、路径穿越剥离 | + +辅助:`TestSupport.cs`(内存 IFormFile / TestHostEnvironment,不起真实 Mongo)。 + +**CreditService(CreditService.Tests/)** + +- `CreditConcurrencyTests.cs`(6 用例):并发 Reserve 不透支(32 并发抢 40k 预授权, + 余额/可用/冻结精确匹配)、并发 Settle/Release 不重复记账、同 operationId 并发 + Reserve 幂等不双冻结、超预授权 Settle 抛 `CREDIT_ESTIMATE_EXCEEDED`。 +- `CreditRedeemReplayTests.cs`(9 用例):同码串行二次兑换 422 `REDEMPTION_CODE_UNAVAILABLE`; + 8 用户并发抢同一码恰好 1 人成功;5 码×4 并发抢兑余额精确累加;撤销/过期/未知码拒绝; + 大小写与空白归一化;空/超长码 `VALIDATION_ERROR`。 + +**OCRService(tests/test_contract.py,16 用例)** + +- 方式:pytest(亦兼容 `python -m unittest tests.test_contract`);import 前对 + `fitz`/`paddleocr` 注入桩模块,**不加载真实 OCR 模型**;固定 1×1 PNG 样例图 + + mock `recognize_text_regions` 断言期望文本 `["Hello","GalReview"]`。 +- 覆盖:`/healthz` 免鉴权返回 `{"status":"live"}`;缺/错 `X-Gateway-Key` 401 detail + 信封;非法媒体类型 415、非法 OCR mode 400、超 10 MB 413;PNG/JPEG 魔数识别; + quick 模式跳过公式阶段;任务进度 UNKNOWN 形状、取消 202+CANCELLED、 + `ensure_not_cancelled` 409;`is_choice_label`/`rect`/`result_to_payload` 纯函数。 +- 显式测契约(API 形状、错误码),不测 OCR 识别精度。 + +### 测试证据(本机) + +| 套件 | 结果 | +|---|---| +| FileService(dotnet test) | 104/104 通过 | +| CreditService(dotnet test) | 22/22 通过(原 7 + 新增 15) | +| OCRService(pytest) | 16/16 通过 | +| OCRService(unittest 复核) | 16/16 通过 | + +注:本机 `dotnet test` 需先按 docs/nuget-windows-fix.md 设置 +`ProgramFiles(x86)`/`ProgramW6432` 进程环境变量,否则 NuGet restore 抛 +`ConfigurationDefaults` 异常。Python 侧需 `pytest fastapi httpx python-multipart` +(`$env:MIMO_PYTHON -m pip install`)。 + +### 遗留 + +- FileService `MongoFileStore.RecoverIncompleteJobsAsync` 的启动恢复语义仍依赖真实 + Mongo,本轮仅覆盖 `TryRequeueForRetry` 死信契约;Mongo 恢复路径待集成测试。 +- OCR 真实识别精度/资源上限仍未验证(contract.md §5.4 URGENT 项不变)。 +- CreditService 并发用例基于 MemoryCreditRepository;MySQL 仓储的 + `FOR UPDATE` 行为需集成环境复核(含 code 掩码 `"****"+suffix` 语义)。 + +--- + +## 47. 2026-09-30 P2 T10 可观测性深化(JSON 日志/透传/告警) + +本节记录 T10「跨服务排障一次定位;核心指标有告警」的实施与本地验证。 +规范文档见 `docs/observability.md`(含指标清单、告警阈值、PromQL 伪查询、Auth/GalGame 接入清单)。 + +### 变更范围 + +**JSON 结构化日志(六服务,每服务一个小文件 + Program 2–4 行接入)** + +| 服务 | 新文件 | Program 接入 | +|---|---|---| +| UserService | `RequestLogging.cs` | `AddJsonStructuredLogging()` + `UseRequestLogging("UserService")`(替换原内联 correlation 中间件) | +| FileService | `RequestLogging.cs` | 同上;`/readyz` 增报 `ingestionQueueDepth` | +| PracticeService.API | `RequestLogging.cs` + `MetricsEndpoints.cs` | 同上 + `MapServiceMetrics()`(`/metrics`) | +| CreditService.API | `RequestLogging.cs` | 压缩风格接入;`/metrics` 端点 | +| KnowledgeService.API | `Infrastructure/RequestLogging.cs` | 挂在既有 `TraceContextMiddleware` 之后 | +| ModelService.API | `RequestLogging.cs` | 含 X-Correlation-Id 归一(原先缺失入站中间件) | + +日志格式:`AddJsonConsole`(UTC 时间戳、scopes 开启);每请求 BeginScope(traceId/userId/path/method), +收尾 `LogInformation` 带 `durationMs`/`status`/`traceId`/`userId`。错误信封 ApiFailure/ApiSuccess 结构未改动。 + +**X-Correlation-Id 跨服务透传** + +- 补缺:Practice `GatewayClient.Create` 与 `GatewayModelFacetAdjudicator` 出站请求 + 经 `TraceFlow`(AsyncLocal,`PracticeService.Persistence/TraceFlow.cs`)透传 + `X-Correlation-Id`;请求中间件写入 TraceFlow,后台任务未设置时客户端生成新 id。 +- 核对通过:Knowledge `GatewayMaterialTextClient`/`GatewayStudyProjectScopeClient`、 + Auth `AuthGatewayClient`(方法参数显式透传)、Gateway proxy(`createProxy.ts`)。 +- Practice/Model 入站归一:缺失的 X-Correlation-Id 中间件由 `RequestLogging` 承担 + (≤128 长度校验,回写响应头)。 + +**告警最低集信号** + +| 指标 | 位置 | 来源 | +|---|---|---| +| `gateway_upstream_failures_total{service,kind}` | Gateway `/metrics` | proxy 错误回调(timeout/connection/contract/other) | +| `practice_abstained_total` / `practice_gradings_total` / `practice_degraded_total` | Practice `/metrics` | `AutomaticAnswerScoring.Result` 计数 | +| `credit_release_failures_total` | Credit `/metrics` | `CreditHandlers.Handle(ReleaseCreditsCommand)` 异常计数 | +| `ingestionQueueDepth` | FileService `/readyz` | `IngestionQueue.Reader.Count` | +| `queuedBatches` | ModelService `/readyz` | 既有 `InferenceLoadSnapshot`(未改) | + +告警规则(readyz 下游异常 >5min、ABSTAINED 突增、Credit 释放失败、队列深度) +写在 `docs/observability.md` §3,含建议阈值与 PromQL 伪查询。 + +### 测试证据(本机 Windows) + +| 套件 | 结果 | +|---|---| +| Gateway vitest | 220/220 通过(基线 216 + 新增 4:upstream failure 计数、proxy 透传 X-Correlation-Id、metrics 渲染) | +| Gateway `tsc`(npm run build) | 0 错误 | +| `dotnet build` User/File/Practice.API/Credit.API/Knowledge.API/Model.API | 6/6 成功,0 警告 0 错误 | +| PracticeService.Tests | 68/68 通过 | +| CreditService.Tests(CreditFlowTests,既有跟踪用例) | 7/7 通过 | +| UserService Tests | 32/32 通过 | + +注:CreditService.Tests 中另有 2 条**未跟踪新用例**(`CreditConcurrencyTests` / +`CreditRedeemReplayTests`,属 T12 在途工作)失败——断言 code 掩码 `****` 与并发账本, +与 T10 改动无关(T10 仅在 `Handle(ReleaseCreditsCommand)` 外包 try/catch 计数后原样 rethrow)。 + +### 透传验收路径 + +Gateway(traceContext 生成/清洗/回写)→ proxy 注入 `X-Correlation-Id` → +Practice `RequestLogging` 归一 + `TraceFlow` → 出站 GatewayClient(Credit 预授权/结算/释放)与 +GatewayModelFacetAdjudicator(Model 判分)同 traceId → 各服务 JSON 日志与错误信封 `traceId` 同源。 +一次注入故障(如断 Model 端口)可凭响应头 traceId 在 Gateway/Practice/Model/Credit 日志串起。 + +### 遗留 + +- AuthService / GalGameService 未改(另一代理正在拆分其 Program.cs); + 接入清单 3–5 行改动见 `docs/observability.md` §5,拆分完成后执行。 +- ABSTAINED 率告警依赖抓取端采集 `/metrics`;当前为进程内单实例计数(重启清零)。 +- Model `queuedBatches` / File `ingestionQueueDepth` 在 `/readyz` JSON 中,需探针转指标或侧车采集后才能进 PromQL。 + +## 48. 2026-10-14 P2 T9 单体拆分 + T10 Auth/GalGame 可观测接入收尾 + +本节记录 P2 最后一批工程化变更:T9 三个大文件拆分,以及 T10 在 Auth/GalGame 的可观测接入(T9 落地后补做)。 + +### T9 · 后端单体拆分(行为零改动,测试锁) + +| 目标 | 拆前 | 拆后 | 新模块 | +|---|---|---|---| +| `AuthService/Program.cs` | 579 | **122** | `AuthContracts` / `AuthRepositories` / `AuthRegistrationEndpoints` / `AuthSessionEndpoints` / `AuthPasswordEndpoints` / `AuthAdminEndpoints` / `AuthIntrospectionEndpoints` | +| `GalGameService/Program.cs` | 528 | **184** | `GameGenerationEndpoints` / `GalGameStartupRecovery` / `GalGameMiddleware` | +| `ReciteQuestionGenerator.cs` | 682 | **116** | `GroundedQuestionExtractor` / `ModelQuestionClient` / `QuestionTextParser` / `QuestionChunker` | + +**回归**:AuthService.Tests 18/18;GalGameService.Tests 362/362(含 `WebApplicationFactory` 集成);PracticeService.Tests 68/68(含 ReciteQuestionGeneratorTests)。 +**适配**:仅 `AuthAdminCredentials` options record、static 委托改直呼 `AuthHttp`/`GalGameHttp`、少量 `internal` 可见性提升——契约/状态码/错误码零改动。 + +### T10 收尾 · Auth/GalGame 可观测接入 + +| 服务 | 变更 | +|---|---| +| AuthService | `RequestLogging.cs`;`AddJsonStructuredLogging()`;correlation 中间件 → `UseRequestLogging("AuthService")` | +| GalGameService | `RequestLogging.cs` + `GalGameTraceFlow.cs`;保留 `UseGalGameCorrelationId`(严格字符集)+ `UseRequestLogging(..., normalizeCorrelationId: false)`;`BeginAmbientTrace` 注册 | +| GalGame 出站 | `CreditBillingClient` 补 `X-Correlation-Id`(`GalGameTraceFlow.Current`);`generationWork` 执行时 `Begin(traceId)`,后台任务可串联 | +| 共享 | `RequestLogging.BeginAmbientTrace` 钩子(Practice `TraceFlow` / GalGame `GalGameTraceFlow` 共用接入点) | + +### 测试证据(2026-10-14 本地实跑) + +| 套件 | 结果 | +|---|---| +| AuthService.Tests | 18/18 | +| GalGameService.Tests | 362/362 | +| PracticeService.Tests | 68/68 | +| CreditService.Tests | 22/22(含 T12 并发/防重放) | +| FileService.Tests | 104/104(10 文件) | +| Gateway vitest | 220/220 | +| dotnet build(Auth/GalGame/Practice/Credit/File/User) | 全部 0 警告 0 错误 | + +**遗留(非阻塞)**:Playwright E2E CI job 仍为可选(稳定一周后转必需);MongoFileStore 启动恢复集成测试与 OCR 真机精度验证见 T12 遗留清单;GalGame `generationQueueDepth` readyz 指标为可选增强。 diff --git a/docs/windows-production.md b/docs/windows-production.md index b4f79c7..f69be49 100644 --- a/docs/windows-production.md +++ b/docs/windows-production.md @@ -146,3 +146,21 @@ OCR 是可选服务,不由本脚本安装。需要 OCR 时,先运行 `backen ## 网络边界 脚本将 Frontend、Gateway 和全部内部服务绑定到 `127.0.0.1`。Windows 防火墙只需允许 IIS/Nginx/Caddy 的 `80/443`,不要对公网放行 `5000`、`5101-5109`、`5120-5122`。 + +## 与 Docker Compose 生产路径的关系 + +仓库提供两条互斥的生产部署路径,按主机环境二选一,不要混用: + +| | 本脚本(原生 Windows) | `compose.production.yaml`(容器) | +|---|---|---| +| 适用环境 | Windows Server + IIS,MySQL/MongoDB/Neo4j 为 Windows 服务 | 已安装 Docker Compose 的 Linux/Windows 主机 | +| 配置文件 | `.env.windows.production`(模板 `deploy/.env.windows.production.example`) | `.env`(模板 `.env.deploy.example`) | +| 密钥门禁 | 脚本内置 `Assert-ProductionSettings`(拒绝 CHANGE_ME 与默认密钥) | `${VAR:?}` 解析期强制 + `scripts/Test-ProductionEnv.ps1` | +| 管理员身份 | `GALREVIEW_ADMIN_PRINCIPAL_ID`(首次初始化自动生成 GUID) | `GALREVIEW_ADMIN_PRINCIPAL_ID`(`${VAR:?}` 必填) | +| 详细文档 | 本文 | `docs/deploy.md`「生产叠加层」 | + +两条路径的服务密钥、管理员身份语义完全一致:AuthService 与 CreditService 通过 +`Admin__PrincipalId` 读到同一个管理员 GUID;缺失时服务在生产环境会启动失败(fail-fast)。 +容器路径禁止叠加 Mock 默认值;原生路径由 `Assert-ProductionSettings` 拒绝 `MOONSTONE_MODE=Mock` +以外的不安全默认(密钥类)。`deploy-windows.ps1` 不调用 Docker,也不会读取 +`compose.production.yaml`;反之容器路径不使用 `.env.windows.production`。 diff --git a/frontend/.gitignore b/frontend/.gitignore index b2d0fe4..5a6682c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -4,3 +4,7 @@ dist/ .env.local *.log *.tsbuildinfo +# Playwright(T11 E2E) +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/frontend/e2e/apiMocks.ts b/frontend/e2e/apiMocks.ts new file mode 100644 index 0000000..8bb71bd --- /dev/null +++ b/frontend/e2e/apiMocks.ts @@ -0,0 +1,501 @@ +import type { Page, Route } from '@playwright/test' + +/** + * T11 E2E 的 API mock 层。 + * + * 全部请求走 `page.route('** /api/v1/**')` 拦截,返回与 + * frontend/src/lib/api/client.ts 契约一致的成功信封 + * `{ data, meta, traceId }`,因此冒烟不依赖本机起后端。 + * + * 状态是可变的(create 项目后列表会变、答题后 session 会带上 answers), + * 以便「创建项目 → 答题 → 看结果」全链路在同一次浏览器会话内连贯通过。 + */ + +const TRACE_ID = 'e2e-smoke-trace' + +export const ids = { + userId: '11111111-1111-4111-8111-111111111111', + authSessionId: '22222222-2222-4222-8222-222222222222', + materialId: '33333333-3333-4333-8333-333333333333', + existingProjectId: '44444444-4444-4444-8444-444444444444', + createdProjectId: '55555555-5555-4555-8555-555555555555', + graphId: '66666666-6666-4666-8666-666666666666', + buildId: '77777777-7777-4777-8777-777777777777', + chapterId: '88888888-8888-4888-8888-888888888888', + pointId: '99999999-9999-4999-8999-999999999999', + questionId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + planId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + practiceSessionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + attemptId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', +} + +const NOW = '2026-09-30T08:00:00.000Z' +const PROJECT_NAME = 'E2E 冒烟研习册' +const QUESTION_PROMPT = '冒烟测试用题:正确的选项是哪一个?' +const OPTION_A_TEXT = '正确答案选项' +const OPTION_B_TEXT = '干扰选项' + +export const fixtures = { + projectName: PROJECT_NAME, + questionPrompt: QUESTION_PROMPT, + optionAText: OPTION_A_TEXT, + optionBText: OPTION_B_TEXT, + /** 选项 A 是正确答案 */ + correctOptionId: 'A', +} + +function envelope(data: unknown): string { + return JSON.stringify({ data, meta: {}, traceId: TRACE_ID }) +} + +function json(data: unknown, status = 200) { + return { status, contentType: 'application/json', body: envelope(data) } +} + +function failure(status: number, code: string, message: string) { + return { + status, + contentType: 'application/json', + body: JSON.stringify({ data: null, error: { code, message, details: {} }, traceId: TRACE_ID }), + } +} + +function makeMaterial() { + return { + materialId: ids.materialId, + ownerUserId: ids.userId, + displayName: 'E2E 冒烟资料', + originalFileName: 'e2e-smoke.md', + mediaType: 'text/markdown', + sizeBytes: 256, + checksum: 'sha256-e2e', + status: 'READY', + latestIngestionJobId: null, + ocrUsed: false, + createdAt: NOW, + updatedAt: NOW, + } +} + +function makeProject(projectId: string, withGraph: boolean) { + return { + projectId, + ownerUserId: ids.userId, + name: projectId === ids.createdProjectId ? 'E2E 新建研习册' : PROJECT_NAME, + subjectCode: 'CS_DS', + materialIds: [ids.materialId], + graphId: withGraph ? ids.graphId : null, + questionBankId: `qb-${projectId}`, + status: 'ACTIVE', + version: 1, + createdAt: NOW, + updatedAt: NOW, + } +} + +function makeQuestion(projectId: string) { + return { + questionId: ids.questionId, + projectId, + questionBankId: `qb-${projectId}`, + kind: 'SINGLE_CHOICE', + prompt: QUESTION_PROMPT, + options: [ + { id: 'A', text: OPTION_A_TEXT }, + { id: 'B', text: OPTION_B_TEXT }, + ], + correctAnswers: ['A'], + explanation: '选项 A 与资料原文一致。', + score: 5, + difficulty: 2, + knowledgePointId: ids.pointId, + sourceReferences: [], + status: 'READY', + version: 1, + createdAt: NOW, + updatedAt: NOW, + } +} + +function makeSessionQuestion() { + return { + questionId: ids.questionId, + kind: 'SINGLE_CHOICE', + prompt: QUESTION_PROMPT, + options: [ + { id: 'A', text: OPTION_A_TEXT }, + { id: 'B', text: OPTION_B_TEXT }, + ], + score: 5, + difficulty: 2, + knowledgePointId: ids.pointId, + } +} + +function makeGraph(studyProjectId: string) { + return { + graphId: ids.graphId, + materialId: ids.materialId, + studyProjectId, + version: 1, + subjectCode: 'CS_DS', + chapterCount: 1, + pointCount: 1, + relationCount: 0, + status: 'READY', + textChecksum: 'sha256-e2e-text', + createdAt: NOW, + } +} + +function makeChapter() { + return { + chapterId: ids.chapterId, + graphId: ids.graphId, + parentChapterId: null, + title: '第一章 冒烟章节', + ordinal: 1, + depth: 0, + startOffset: 0, + endOffset: 128, + segmentationMode: 'AUTO', + } +} + +function makePoint() { + return { + pointId: ids.pointId, + graphId: ids.graphId, + chapterId: ids.chapterId, + conceptKey: 'smoke-point', + title: '冒烟知识点', + summary: 'E2E 冒烟用知识点', + subjectCode: 'CS_DS', + tags: ['冒烟'], + confidence: 0.9, + sourceReferences: [], + mastery: { + userId: ids.userId, + pointId: ids.pointId, + score: 0.4, + reason: 'seed', + repetitions: 0, + easinessFactor: 2.5, + intervalDays: 1, + nextReviewAt: NOW, + lastReviewedAt: null, + lapses: 0, + version: 1, + }, + createdAt: NOW, + updatedAt: NOW, + } +} + +function makePlan(reviewPlanId: string, estimatedQuestionCount: number) { + return { + schemaVersion: '1.0', + reviewPlanId, + type: 'ASSESSMENT', + status: 'OPEN', + graphId: ids.graphId, + graphVersion: 1, + ownerUserId: ids.userId, + selectedChapterIds: [ids.chapterId], + snapshotVersion: 'snap-e2e-1', + algorithmVersion: 'plan-e2e-v1', + nodes: [ + { + pointId: ids.pointId, + chapterId: ids.chapterId, + title: '冒烟知识点', + summary: 'E2E 冒烟用知识点', + tags: ['冒烟'], + masteryScore: 0.4, + role: 'TARGET', + weight: 1, + selectionReason: 'e2e', + dependencyDepth: 0, + questionTarget: true, + outsideRequestedChapters: false, + coversPointIds: [ids.pointId], + supportsPointIds: [], + }, + ], + edges: [], + rootPointIds: [ids.pointId], + estimatedQuestionCount, + estimatedCoverage: 1, + totalWeight: 1, + createdAt: NOW, + expiresAt: '2026-10-01T08:00:00.000Z', + } +} + +function makeAnswer() { + return { + attemptId: ids.attemptId, + questionId: ids.questionId, + gradingStatus: 'DECIDED', + outcome: 'PERFECT', + correct: true, + similarity: 0.95, + quality: 1, + awardedScore: 5, + answerJudgeVersion: 'judge-e2e-v1', + abstainReason: null, + facets: [], + } +} + +interface MockState { + projects: ReturnType[] + practiceSessionAnswers: ReturnType[] + practiceSessionStatus: 'ACTIVE' | 'COMPLETED' + /** 图谱归属册:立册链路会校验 studyProjectId === 当前 projectId */ + graphOwnerId: string +} + +function newPracticeSession(state: MockState) { + state.practiceSessionAnswers = [] + state.practiceSessionStatus = 'ACTIVE' + return { + sessionId: ids.practiceSessionId, + projectId: state.projects[state.projects.length - 1]?.projectId ?? ids.existingProjectId, + mode: 'SMART_REVIEW', + questions: [makeSessionQuestion()], + answers: [], + durationSeconds: null, + status: 'ACTIVE' as const, + createdAt: NOW, + completedAt: null, + } +} + +function practiceSessionPayload(state: MockState) { + return { + sessionId: ids.practiceSessionId, + projectId: state.projects[state.projects.length - 1]?.projectId ?? ids.existingProjectId, + mode: 'SMART_REVIEW', + questions: [makeSessionQuestion()], + answers: state.practiceSessionAnswers, + durationSeconds: state.practiceSessionStatus === 'COMPLETED' ? 30 : null, + status: state.practiceSessionStatus, + createdAt: NOW, + completedAt: state.practiceSessionStatus === 'COMPLETED' ? NOW : null, + } +} + +/** + * 在 page 上安装全部 API mock。 + * `withExistingProject=true` 时列表预置一份带图谱的研习册(进入旧册路径); + * 创建项目路径对两种预置都兼容。 + */ +export async function installApiMocks(page: Page, options: { withExistingProject?: boolean } = {}) { + const withExistingProject = options.withExistingProject ?? true + const state: MockState = { + projects: withExistingProject ? [makeProject(ids.existingProjectId, true)] : [], + practiceSessionAnswers: [], + practiceSessionStatus: 'ACTIVE', + // 既有册预置时图谱归属既有册;立册链路会改写为新建册 + graphOwnerId: ids.existingProjectId, + } + + await page.route('**/api/v1/**', async (route: Route) => { + const url = new URL(route.request().url()) + const method = route.request().method() + const path = url.pathname.replace(/^\/api\/v1/, '') + + // ---- auth ---- + if (method === 'POST' && path === '/auth/sessions') { + return route.fulfill(json({ + session: { + sessionId: ids.authSessionId, + userId: ids.userId, + status: 'ACTIVE', + createdAt: NOW, + expiresAt: '2026-10-01T08:00:00.000Z', + }, + tokens: { + accessToken: 'e2e-access-token', + refreshToken: 'e2e-refresh-token', + tokenType: 'Bearer', + expiresInSeconds: 3600, + }, + })) + } + if (method === 'GET' && path === `/auth/sessions/${ids.authSessionId}`) { + return route.fulfill(json({ + sessionId: ids.authSessionId, + userId: ids.userId, + status: 'ACTIVE', + createdAt: NOW, + expiresAt: '2026-10-01T08:00:00.000Z', + })) + } + if (method === 'POST' && path === '/auth/tokens') { + return route.fulfill(json({ + accessToken: 'e2e-access-token-2', + refreshToken: 'e2e-refresh-token-2', + tokenType: 'Bearer', + expiresInSeconds: 3600, + })) + } + if (method === 'DELETE' && path.startsWith('/auth/sessions/')) { + return route.fulfill({ status: 204, body: '' }) + } + + // ---- users ---- + if (method === 'GET' && path === '/users/me') { + return route.fulfill(json({ + userId: ids.userId, + displayName: 'E2E 学习者', + avatarUrl: null, + locale: 'zh-CN', + preferredSubjectCodes: ['CS_DS'], + createdAt: NOW, + updatedAt: NOW, + })) + } + + // ---- materials ---- + if (method === 'GET' && path === '/materials') { + return route.fulfill(json({ items: [makeMaterial()], nextCursor: null })) + } + + // ---- practice-projects ---- + if (method === 'GET' && path === '/practice-projects') { + return route.fulfill(json({ items: state.projects, nextCursor: null })) + } + if (method === 'POST' && path === '/practice-projects') { + const created = makeProject(ids.createdProjectId, false) + created.name = 'E2E 新建研习册' + state.projects.push(created) + return route.fulfill(json(created)) + } + const projectMatch = path.match(/^\/practice-projects\/([^/]+)$/) + if (projectMatch) { + const projectId = projectMatch[1] + const project = state.projects.find((item) => item.projectId === projectId) + ?? makeProject(projectId, true) + if (method === 'GET') { + return route.fulfill(json({ + project, + questionCounts: { SINGLE_CHOICE: 1 }, + readyQuestionCount: 1, + })) + } + if (method === 'PATCH') { + const body = route.request().postDataJSON() as { graphId?: string } | null + if (body?.graphId) project.graphId = body.graphId + project.version += 1 + return route.fulfill(json(project)) + } + } + + // ---- questions ---- + const questionsMatch = path.match(/^\/practice-projects\/([^/]+)\/questions$/) + if (method === 'GET' && questionsMatch) { + return route.fulfill(json({ items: [makeQuestion(questionsMatch[1])], nextCursor: null })) + } + + // ---- question generation(立册自动成题)---- + const generationMatch = path.match(/^\/practice-projects\/([^/]+)\/question-generations$/) + if (method === 'POST' && generationMatch) { + return route.fulfill(json({ + jobId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + projectId: generationMatch[1], + kind: 'QUESTION_GENERATION', + status: 'SUCCEEDED', + progress: 100, + createdCount: 1, + diagnostics: [], + })) + } + + // ---- knowledge graph builds ---- + if (method === 'POST' && path === '/knowledge-graph-builds') { + const body = route.request().postDataJSON() as { studyProjectId?: string } | null + // 识网归属发起构图的研习册,供 create/ensureGraphScope 的归属校验通过 + if (body?.studyProjectId) state.graphOwnerId = body.studyProjectId + return route.fulfill(json({ + buildId: ids.buildId, + materialId: ids.materialId, + studyProjectId: state.graphOwnerId, + status: 'SUCCEEDED', + progress: 100, + graphId: ids.graphId, + sourceTextChecksum: 'sha256-e2e-text', + segmentationMode: 'AUTO', + segmenterVersion: 'e2e', + extractorVersion: 'knowledge-extractor-v3', + error: null, + createdAt: NOW, + updatedAt: NOW, + })) + } + if (method === 'GET' && path === `/knowledge-graph-builds/${ids.buildId}`) { + return route.fulfill(json({ + buildId: ids.buildId, + materialId: ids.materialId, + studyProjectId: state.graphOwnerId, + status: 'SUCCEEDED', + progress: 100, + graphId: ids.graphId, + sourceTextChecksum: 'sha256-e2e-text', + segmentationMode: 'AUTO', + segmenterVersion: 'e2e', + extractorVersion: 'knowledge-extractor-v3', + error: null, + createdAt: NOW, + updatedAt: NOW, + })) + } + + // ---- knowledge graphs ---- + if (method === 'GET' && path === `/knowledge-graphs/${ids.graphId}`) { + return route.fulfill(json(makeGraph(state.graphOwnerId))) + } + if (method === 'GET' && path === `/knowledge-graphs/${ids.graphId}/chapters`) { + return route.fulfill(json([makeChapter()])) + } + if (method === 'GET' && path === `/knowledge-graphs/${ids.graphId}/points`) { + return route.fulfill(json({ items: [makePoint()], nextCursor: null })) + } + + // ---- plans ---- + if (method === 'POST' && path === '/assessment-plans') { + return route.fulfill(json(makePlan(ids.planId, 1))) + } + if (method === 'POST' && path === '/learning-plans') { + return route.fulfill(json(makePlan(ids.planId, 1))) + } + + // ---- practice sessions ---- + if (method === 'POST' && path === '/practice-sessions') { + return route.fulfill(json(newPracticeSession(state))) + } + if (method === 'GET' && path === `/practice-sessions/${ids.practiceSessionId}`) { + return route.fulfill(json(practiceSessionPayload(state))) + } + const answerMatch = path.match(/^\/practice-sessions\/([^/]+)\/answers\/([^/]+)$/) + if (method === 'PUT' && answerMatch) { + const answer = makeAnswer() + state.practiceSessionAnswers = [answer] + return route.fulfill(json(answer)) + } + if (method === 'POST' && path === `/practice-sessions/${ids.practiceSessionId}/completion`) { + if (state.practiceSessionAnswers.length === 0) { + state.practiceSessionAnswers = [makeAnswer()] + } + state.practiceSessionStatus = 'COMPLETED' + return route.fulfill(json({ + session: practiceSessionPayload(state), + evidence: { recorded: state.practiceSessionAnswers.length }, + })) + } + + // ---- 兜底:未覆盖的 /api/v1 请求显式失败,避免静默 404 让冒烟假绿 ---- + return route.fulfill(failure(404, 'NOT_FOUND', `E2E mock 未覆盖: ${method} ${path}`)) + }) +} diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts new file mode 100644 index 0000000..7505172 --- /dev/null +++ b/frontend/e2e/smoke.spec.ts @@ -0,0 +1,84 @@ +import { expect, test, type Page } from '@playwright/test' +import { fixtures, ids, installApiMocks } from './apiMocks' + +/** + * T11 核心用户路径冒烟(桌面 1280×720 / 移动 375×667 双视口)。 + * + * 路径:登录 → 创建项目或进入复习项目 → 答一题 → 看结果。 + * API 全部 mock(见 apiMocks.ts),不依赖真实后端。 + * + * 选择器约定:AppShell 的 PageHeader

在移动视口被 CSS 隐藏, + * 页面级断言改用 URL + 两种视口都可见的正文元素(如 h2 / 列表项)。 + */ + +async function login(page: Page) { + await page.goto('/login') + await expect(page.getByRole('heading', { name: '登录 千知万理' })).toBeVisible() + // FormField 的清除按钮 aria-label 含「邮箱/密码」,故用 name 定位输入框本体 + await page.locator('input[name="email"]').fill('e2e@example.com') + await page.locator('input[name="password"]').fill('e2e-password') + await page.getByRole('button', { name: /登录并继续/ }).click() + // 登录成功后 Protected 放行并跳转 /home + await expect(page).toHaveURL(/\/home$/) + await expect(page.getByRole('heading', { name: /欢迎回来/ })).toBeVisible() +} + +test.describe('核心复习闭环冒烟', () => { + test('登录 → 进入研习册 → 答一题 → 看结果', async ({ page }) => { + await installApiMocks(page, { withExistingProject: true }) + await login(page) + + // 进入研习册列表并打开既有项目 + await page.goto('/projects') + await expect(page).toHaveURL(/\/projects$/) + await expect(page.getByRole('heading', { name: '续读旧卷' })).toBeVisible() + await page.getByRole('link', { name: new RegExp(fixtures.projectName) }).click() + await expect(page).toHaveURL(new RegExp(`/projects/${ids.existingProjectId}$`)) + // 册详情:正文「本次温习范围」两种视口均可见(PageHeader h1 移动端隐藏) + await expect(page.getByRole('heading', { name: '本次温习范围' })).toBeVisible() + await expect(page.getByText(/道可练习题/)).toBeVisible() + + // 开始温习 → 进入练习会话 + await page.getByRole('button', { name: '开始温习' }).click() + await expect(page).toHaveURL(new RegExp(`/practice/${ids.practiceSessionId}$`)) + await expect(page.getByRole('heading', { name: fixtures.questionPrompt })).toBeVisible() + + // 答一题(选项 A 为正确答案) + await page.getByRole('button', { name: new RegExp(`A ${fixtures.optionAText}`) }).click() + + // 看结果:得分提示 + 完成练习 + const result = page.locator('.practice-result') + await expect(result).toBeVisible() + await expect(result).toContainText('完整掌握') + await expect(result).toContainText('本题获得 5 分') + await page.getByRole('button', { name: '完成练习' }).click() + await expect(page.getByText('本轮练习已经记录')).toBeVisible() + await expect(page.getByText(/共作答 1 道/)).toBeVisible() + }) + + test('登录 → 创建研习册 → 进入新册并答一题看结果', async ({ page }) => { + await installApiMocks(page, { withExistingProject: true }) + await login(page) + + // 立册:名称 + 主资料 → 建立研习册(mock 完成识网/成题链路) + await page.goto('/projects') + await expect(page).toHaveURL(/\/projects$/) + await expect(page.getByRole('heading', { name: '建立经典复习项目' })).toBeVisible() + await page.getByLabel('研习册名称').fill('E2E 新建研习册') + await page.getByLabel('主资料').selectOption(ids.materialId) + await page.getByRole('button', { name: '建立研习册' }).click() + + // 创建链路结束后跳到新册详情 + await expect(page).toHaveURL(new RegExp(`/projects/${ids.createdProjectId}$`)) + await expect(page.getByRole('heading', { name: '本次温习范围' })).toBeVisible() + + // 新册内同样走「答一题 → 看结果」 + await page.getByRole('button', { name: '开始温习' }).click() + await expect(page).toHaveURL(new RegExp(`/practice/${ids.practiceSessionId}$`)) + await expect(page.getByRole('heading', { name: fixtures.questionPrompt })).toBeVisible() + await page.getByRole('button', { name: new RegExp(`A ${fixtures.optionAText}`) }).click() + await expect(page.locator('.practice-result')).toContainText('完整掌握') + await page.getByRole('button', { name: '完成练习' }).click() + await expect(page.getByText('本轮练习已经记录')).toBeVisible() + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dfefaf5..28530be 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,13 +14,15 @@ "react-router": "^8.3.0" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/node": "^24.10.0", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "marked": "17.0.5", "typescript": "~5.8.3", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^5.0.0" } }, "node_modules/@antv/algorithm": { @@ -1024,9 +1026,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1058,6 +1060,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1460,6 +1478,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1589,6 +1618,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1653,6 +1689,54 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -1736,6 +1820,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", @@ -2118,6 +2212,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2170,12 +2271,32 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2341,6 +2462,29 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/marked": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", + "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/ml-array-max": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-2.0.0.tgz", @@ -2416,6 +2560,20 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2424,9 +2582,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2436,6 +2594,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -2591,6 +2778,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -2610,6 +2804,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/svg-path-parser": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/svg-path-parser/-/svg-path-parser-1.1.0.tgz", @@ -2625,6 +2833,26 @@ "utrie": "^1.0.2" } }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2648,19 +2876,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/marked": { - "version": "17.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", - "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==", - "dev": true, - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 20" - } - }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -2797,6 +3012,106 @@ } } }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a52cc1d..f324509 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "build:wiki": "node scripts/build-wiki.mjs", "build:wiki:dev": "node scripts/build-wiki.mjs --output public/wiki", "typecheck": "tsc -b --pretty false", + "test": "vitest run", + "test:e2e": "playwright test", "preview": "vite preview" }, "allowScripts": { @@ -21,12 +23,14 @@ "react-router": "^8.3.0" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@types/node": "^24.10.0", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "marked": "17.0.5", "typescript": "~5.8.3", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^5.0.0" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..e382332 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,54 @@ +import { defineConfig } from '@playwright/test' + +/** + * T11 前端 E2E 冒烟配置。 + * + * - 两个视口项目:桌面 1280×720、移动 375×667。 + * - webServer 只起 Vite dev(端口 5123),API 一律由 e2e/apiMocks.ts 以 + * page.route 拦截,不依赖本机后端 / gateway。 + * - 首次本地运行前需 `npx playwright install chromium`(CI 里对应 + * `npx playwright install --with-deps chromium`)。 + */ +const PORT = 5123 +const baseURL = `http://127.0.0.1:${PORT}` + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? [['list'], ['github']] : [['list']], + timeout: 60_000, + expect: { timeout: 10_000 }, + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'off', + }, + projects: [ + { + name: 'desktop-chromium', + use: { + browserName: 'chromium', + viewport: { width: 1280, height: 720 }, + }, + }, + { + name: 'mobile-chromium', + use: { + browserName: 'chromium', + viewport: { width: 375, height: 667 }, + isMobile: true, + hasTouch: true, + }, + }, + ], + webServer: { + command: `npm run dev -- --port ${PORT} --strictPort`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}) diff --git a/frontend/public/bg.png b/frontend/public/bg.png deleted file mode 100644 index 0225089..0000000 Binary files a/frontend/public/bg.png and /dev/null differ diff --git a/frontend/public/bg.webp b/frontend/public/bg.webp new file mode 100644 index 0000000..55d2cec Binary files /dev/null and b/frontend/public/bg.webp differ diff --git a/frontend/public/bg2.png b/frontend/public/bg2.png deleted file mode 100644 index 3154c6e..0000000 Binary files a/frontend/public/bg2.png and /dev/null differ diff --git a/frontend/public/bg2.webp b/frontend/public/bg2.webp new file mode 100644 index 0000000..00237cc Binary files /dev/null and b/frontend/public/bg2.webp differ diff --git a/frontend/public/bg3.png b/frontend/public/bg3.png deleted file mode 100644 index 236858c..0000000 Binary files a/frontend/public/bg3.png and /dev/null differ diff --git a/frontend/public/bg3.webp b/frontend/public/bg3.webp new file mode 100644 index 0000000..6ffaebd Binary files /dev/null and b/frontend/public/bg3.webp differ diff --git a/frontend/public/bg4.png b/frontend/public/bg4.png deleted file mode 100644 index 87dc2cd..0000000 Binary files a/frontend/public/bg4.png and /dev/null differ diff --git a/frontend/public/bg4.webp b/frontend/public/bg4.webp new file mode 100644 index 0000000..d25c1cb Binary files /dev/null and b/frontend/public/bg4.webp differ diff --git a/frontend/public/bg_1.png b/frontend/public/bg_1.png deleted file mode 100644 index e640b0f..0000000 Binary files a/frontend/public/bg_1.png and /dev/null differ diff --git a/frontend/public/bg_1.webp b/frontend/public/bg_1.webp new file mode 100644 index 0000000..b69f766 Binary files /dev/null and b/frontend/public/bg_1.webp differ diff --git a/frontend/public/bgm.mp3 b/frontend/public/bgm.mp3 index e15f177..43050a1 100644 Binary files a/frontend/public/bgm.mp3 and b/frontend/public/bgm.mp3 differ diff --git a/frontend/public/brand-logo-512.png b/frontend/public/brand-logo-512.png new file mode 100644 index 0000000..4080a75 Binary files /dev/null and b/frontend/public/brand-logo-512.png differ diff --git a/frontend/server.mjs b/frontend/server.mjs index 96e1674..04087fa 100644 --- a/frontend/server.mjs +++ b/frontend/server.mjs @@ -213,11 +213,38 @@ async function serveFrontend(request, response) { const extension = extname(filePath).toLowerCase() const isWikiRequest = pathname === '/wiki' || pathname.startsWith('/wiki/') + const isHtml = extension === '.html' || extension === '' + /** + * 同源静态站 CSP。 + * wasm-unsafe-eval:RenderService 的 runtime.wasm 需要 WebAssembly.compile; + * 不用 unsafe-eval,仍禁止 eval()/new Function。 + */ + const csp = [ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "img-src 'self' data: blob:", + "media-src 'self' blob:", + "font-src 'self'", + "connect-src 'self'", + "script-src 'self' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "worker-src 'self' blob:", + ].join('; ') response.writeHead(200, { 'Content-Type': contentTypes.get(extension) || 'application/octet-stream', - 'Cache-Control': isWikiRequest || extension === '.html' + 'Cache-Control': isWikiRequest || isHtml ? 'no-cache' : 'public, max-age=604800, immutable', + ...(isHtml + ? { + 'Content-Security-Policy': csp, + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'X-Frame-Options': 'DENY', + } + : { 'X-Content-Type-Options': 'nosniff' }), }) createReadStream(filePath).on('error', () => response.destroy()).pipe(response) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dac6f33..016d257 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,31 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react' +import { Suspense, lazy, useEffect, useRef, useState, type ReactNode } from 'react' import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router' -import ForgotPasswordPage from './pages/ForgotPasswordPage' -import AdminLoginPage from './pages/AdminLoginPage' -import AdminPage from './pages/AdminPage' import LandingPage from './pages/LandingPage' -import HomePage from './pages/HomePage' -import KnowledgeGraphPage from './pages/KnowledgeGraphPage' -import KnowledgePointsPage from './pages/KnowledgePointsPage' -import LoginPage from './pages/LoginPage' -import NotFoundPage from './pages/NotFoundPage' -import RegisterPage from './pages/RegisterPage' -import ReviewPage from './pages/ReviewPage' -import SettingsPage from './pages/SettingsPage' -import StudyFlowPage from './pages/StudyFlowPage' -import PracticeProjectsPage from './pages/PracticeProjectsPage' -import PracticeProjectPage from './pages/PracticeProjectPage' -import PracticeSessionPage from './pages/PracticeSessionPage' -import SharedPracticePackagesPage from './pages/SharedPracticePackagesPage' import LoadingIndicator from './components/LoadingIndicator' +import ErrorBoundary from './components/ErrorBoundary' import { api, ApiClientError } from './lib/api' import { clearSession, readSession } from './lib/session' import { readAdminSession } from './lib/adminSession' import { recoverWorkflow } from './lib/workflowRecovery' +// 路由级代码分割:首屏只加载 Landing;其余页面按需加载 +const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage')) +const AdminLoginPage = lazy(() => import('./pages/AdminLoginPage')) +const AdminPage = lazy(() => import('./pages/AdminPage')) +const HomePage = lazy(() => import('./pages/HomePage')) +const KnowledgeGraphPage = lazy(() => import('./pages/KnowledgeGraphPage')) +const KnowledgePointsPage = lazy(() => import('./pages/KnowledgePointsPage')) +const LoginPage = lazy(() => import('./pages/LoginPage')) +const NotFoundPage = lazy(() => import('./pages/NotFoundPage')) +const RegisterPage = lazy(() => import('./pages/RegisterPage')) +const ReviewPage = lazy(() => import('./pages/ReviewPage')) +const SettingsPage = lazy(() => import('./pages/SettingsPage')) +const StudyFlowPage = lazy(() => import('./pages/StudyFlowPage')) +const PracticeProjectsPage = lazy(() => import('./pages/PracticeProjectsPage')) +const PracticeProjectPage = lazy(() => import('./pages/PracticeProjectPage')) +const PracticeSessionPage = lazy(() => import('./pages/PracticeSessionPage')) +const SharedPracticePackagesPage = lazy(() => import('./pages/SharedPracticePackagesPage')) + const routeDepth: Record = { '/login': 0, '/register': 1, @@ -64,13 +67,32 @@ function resolvePageTitle(pathname: string) { function Protected({ children }: { children: ReactNode }) { const location = useLocation() - const session = readSession() + const [session, setSession] = useState(() => readSession()) const [ready, setReady] = useState(false) - const [valid, setValid] = useState(Boolean(session)) + const [valid, setValid] = useState(() => Boolean(readSession())) + + // 登出/令牌刷新失败会广播 galreview:session;必须重读并失效,否则停留在死会话页面 + useEffect(() => { + const onSessionChange = () => { + const next = readSession() + setSession(next) + if (!next) { + setValid(false) + setReady(true) + } + } + window.addEventListener('galreview:session', onSessionChange) + return () => window.removeEventListener('galreview:session', onSessionChange) + }, []) useEffect(() => { - if (!session) return + if (!session) { + setValid(false) + setReady(true) + return + } let active = true + setReady(false) void api.getSession(session.session.sessionId).then((remoteSession) => { if (remoteSession.status !== 'ACTIVE') { throw new ApiClientError('登录状态已失效,请重新登录。', 'AUTH_REQUIRED', 401) @@ -80,10 +102,11 @@ function Protected({ children }: { children: ReactNode }) { if (active) setValid(true) }).catch((reason: unknown) => { if (!active) return + // 401/404:明确失效;其它错误也 fail-closed,避免半验证会话继续调用 API if (reason instanceof ApiClientError && (reason.status === 401 || reason.status === 404)) { clearSession() - setValid(false) } + setValid(false) }).finally(() => { if (active) setReady(true) }) return () => { active = false } }, [session?.session.sessionId]) @@ -120,26 +143,30 @@ function AnimatedRoutes() { return (
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
) } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..209ec8d --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,57 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode + /** 用于 key,路由变化时重置边界 */ + resetKey?: string +} + +interface State { + error: Error | null +} + +/** + * 路由级错误边界:渲染崩溃时展示可恢复的兜底 UI,而不是白屏。 + */ +export default class ErrorBoundary extends Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[ErrorBoundary]', error, info.componentStack) + } + + componentDidUpdate(prev: Props): void { + if (this.state.error && prev.resetKey !== this.props.resetKey) { + this.setState({ error: null }) + } + } + + private reset = () => { + // React.lazy 会缓存失败的 import;整页重载才能重新拉取 chunk + window.location.reload() + } + + render(): ReactNode { + const { error } = this.state + if (!error) return this.props.children + if (this.props.fallback) return this.props.fallback + return ( +
+
+

页面出现异常

+

+ {error.message || '发生了未知错误,请重试。'} +

+ +
+
+ ) + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 24d6c77..a708b73 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,869 +1,39 @@ -import { clearSession, readSession, updateSessionTokens } from './session' -import { clearAdminSession, readAdminSession, updateAdminSessionTokens } from './adminSession' -import { createUuidV4 } from './uuid' -import type { - ApiFailure, - ApiSuccess, - AdminCreditCode, - AdminUser, - AnswerResult, - AuthSession, - AuthSessionResponse, - Chapter, - GameGenerationJob, - GamePackage, - GamePackageManifest, - GameStyle, - Difficulty, - ExtractedTextDocument, - GraphBuildJob, - IngestionJob, - KnowledgeGraphSummary, - KnowledgeGraphPage, - KnowledgePoint, - KnowledgePointPage, - KnowledgeRelation, - KnowledgeRelationPage, - Material, - MaterialPage, - MasteryRecord, - MasteryRecordPage, - PlanGraph, - ProgressSnapshot, - ReviewResult, - ReviewSession, - RuntimeManifest, - TokenPair, - UserProfile, - UserPreferences, - UserPreferencesInput, - CreateCreditCodeBatchInput, - CreditBalance, - StudyProject, - PracticeProjectDetails, - PracticeQuestion, - PracticeQuestionKind, - PracticeSession, - PracticeAnswer, - ExamPaper, - PracticeJob, - QuestionHelp, - SharedPracticePackage, -} from '../types/api' - -const configuredBase = import.meta.env.VITE_API_BASE_URL?.trim() || '/api/v1' -const API_BASE_URL = configuredBase.replace(/\/$/, '') -const DEFAULT_TIMEOUT_MS = 30_000 -const UPLOAD_TIMEOUT_MS = 120_000 -const QUESTION_BANK_TIMEOUT_MS = 600_000 - -export class ApiClientError extends Error { - readonly code: string - readonly status: number - readonly traceId?: string - readonly details: Record - - constructor(message: string, code: string, status: number, traceId?: string, details: Record = {}) { - super(message) - this.name = 'ApiClientError' - this.code = code - this.status = status - this.traceId = traceId - this.details = details - } -} - -interface RequestOptions extends RequestInit { - authenticated?: boolean - retryAfterRefresh?: boolean - timeoutMs?: number -} - -let refreshPromise: Promise | null = null - -const ERROR_MESSAGES: Record = { - AUTH_REQUIRED: '登录状态已失效,请重新登录。', - TOKEN_EXPIRED: '登录状态已失效,请重新登录。', - FORBIDDEN: '当前账户没有执行此操作的权限。', - RESOURCE_NOT_FOUND: '请求的数据不存在或已被删除。', - NOT_FOUND: '请求的数据不存在或已被删除。', - VALIDATION_ERROR: '提交内容格式不正确,请检查后重试。', - BUSINESS_RULE_VIOLATION: '当前内容不符合业务规则,请检查操作条件。', - STATE_CONFLICT: '数据状态已经发生变化,请刷新后重试。', - RATE_LIMITED: '操作过于频繁,请稍后再试。', - FILE_TOO_LARGE: '文件大小超过允许上限。', - MEDIA_TYPE_UNSUPPORTED: '暂不支持这种文件格式。', - MATERIAL_TEXT_NOT_READY: '资料文字尚未提取完成,请稍后重试。', - MATERIAL_TEXT_EXTRACTION_FAILED: '资料文字提取失败,请检查文件或 OCR 设置。', - MATERIAL_ACCESS_DENIED: '当前账户无权访问这份资料。', - FILE_SERVICE_UNAVAILABLE: '资料服务暂时不可用,请稍后重试。', - REVIEW_PLAN_NOT_FOUND: '复习计划不存在或已失效。', - REVIEW_PLAN_SNAPSHOT_MISMATCH: '复习计划版本已变化,请重新创建计划。', - IDEMPOTENCY_KEY_REUSED: '请求标识已被用于不同操作,请重新发起。', - CLIENT_CLOSED_REQUEST: '请求已取消。', - SERVICE_UNAVAILABLE: '服务暂时不可用,请稍后重试。', - UPSTREAM_CONTRACT_INVALID: '服务返回的数据格式异常,请联系维护人员。', - PROFILE_DELETE_FAILED: '账户资料删除失败,账户尚未注销。', - CREDITS_INSUFFICIENT: 'credits 不足,需要先兑换 credits。', - REDEMPTION_CODE_UNAVAILABLE: '兑换码无效、已使用、已撤销或已过期。', - INTERNAL_ERROR: '服务处理失败,请稍后重试。', -} - -const STATUS_MESSAGES: Record = { - 402: 'credits 不足,需要先兑换 credits。', - 400: '提交内容格式不正确,请检查后重试。', - 401: '登录状态已失效,请重新登录。', - 403: '当前账户没有执行此操作的权限。', - 404: '请求的数据不存在或已被删除。', - 409: '数据状态已经发生变化,请刷新后重试。', - 413: '文件大小超过允许上限。', - 415: '暂不支持这种文件格式。', - 422: '当前操作条件不满足,请检查输入内容。', - 429: '操作过于频繁,请稍后再试。', - 502: '上游服务响应异常,请稍后重试。', - 503: '服务暂时不可用,请稍后重试。', - 504: '服务响应超时,请稍后重试。', -} - -function localizedErrorMessage(code: string, message: string, status: number): string { - if (/[\u3400-\u9fff]/.test(message)) return message - return ERROR_MESSAGES[code] || STATUS_MESSAGES[status] || '请求失败,请稍后重试。' -} - -function resolveUrl(path: string): string { - if (/^https?:\/\//i.test(path)) return path - if (path.startsWith('/api/')) return path - return `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}` -} - /** - * 该错误是否表示"服务端明确认定凭证无效"。只有这种情况才应清除本地会话; - * 网络中断、超时、网关重启等瞬时故障(503/502)下刷新令牌通常仍然有效。 + * API 门面(facade)。 + * + * 实现按域拆分在 `src/lib/api/` 目录下: + * - `client.ts`:request / requestRawJson / requestBlob 核心、令牌刷新、错误信封解析; + * - 其余文件按业务域分组(auth / users / materials / knowledge / galgame / practice / admin / credits)。 + * + * 域文件只允许 `import { ... } from './client'`,禁止互相引用,避免循环依赖。 + * 本文件保证既有调用方 `import { api, ApiClientError } from './lib/api'` 零改动。 */ -function isAuthRejection(error: unknown): boolean { - if (!(error instanceof ApiClientError)) return false - if (error.status === 401 || error.status === 403) return true - return error.code === 'AUTH_REQUIRED' || error.code === 'TOKEN_EXPIRED' -} - -function errorFromPayload(payload: unknown, status: number, responseTraceId?: string): ApiClientError { - if (payload && typeof payload === 'object' && 'error' in payload) { - const failure = payload as ApiFailure - if (failure.error && typeof failure.error.code === 'string' && typeof failure.error.message === 'string') { - return new ApiClientError( - localizedErrorMessage(failure.error.code, failure.error.message, status), - failure.error.code, - status, - failure.traceId || responseTraceId, - failure.error.details ?? {}, - ) - } - } - return new ApiClientError(STATUS_MESSAGES[status] || '请求失败,请稍后再试。', 'HTTP_ERROR', status, responseTraceId) -} - -function traceIdFromResponse(response: Response): string | undefined { - return response.headers.get('X-Correlation-Id')?.trim() || undefined -} - -function nonJsonHttpError(response: Response, bodyText: string): ApiClientError { - const status = response.status - const traceId = traceIdFromResponse(response) - let code = 'HTTP_ERROR' - let message = `请求失败(HTTP ${status})。` - - if (status === 413) { - code = 'PAYLOAD_TOO_LARGE' - message = '请求体超过入口代理允许的大小(HTTP 413)。' - } else if (status === 502) { - code = 'UPSTREAM_HTTP_ERROR' - message = '入口代理无法连接上游服务(HTTP 502)。' - } else if (status === 503) { - code = 'SERVICE_UNAVAILABLE' - message = '服务暂时不可用(HTTP 503)。' - } else if (status === 504) { - code = 'SERVICE_UNAVAILABLE' - message = '入口代理等待上游服务超时(HTTP 504)。' - } - - const normalizedBody = bodyText.replace(/\s+/g, ' ').trim() - return new ApiClientError(message, code, status, traceId, { - contentType: response.headers.get('Content-Type') || 'unknown', - responseKind: /bad gateway/i.test(normalizedBody) ? 'BAD_GATEWAY' : 'NON_JSON', - }) -} - -function parseResponseJson(response: Response, bodyText: string): unknown { - try { - return JSON.parse(bodyText) as unknown - } catch { - if (!response.ok) throw nonJsonHttpError(response, bodyText) - throw new ApiClientError( - '服务响应格式不符合契约。', - 'UPSTREAM_CONTRACT_INVALID', - 502, - traceIdFromResponse(response), - { contentType: response.headers.get('Content-Type') || 'unknown' }, - ) - } -} - -async function refreshAccessToken(): Promise { - const refreshToken = readSession()?.tokens.refreshToken - if (!refreshToken) throw new ApiClientError('登录状态已失效,请重新登录。', 'AUTH_REQUIRED', 401) - if (!refreshPromise) { - refreshPromise = request('/auth/tokens', { - method: 'POST', - body: JSON.stringify({ refreshToken }), - authenticated: false, - retryAfterRefresh: false, - }).then((tokens) => { - updateSessionTokens(tokens) - return tokens - }).catch((error) => { - // 只有服务端明确拒绝刷新令牌才清除会话。网关重启、超时等瞬时故障会被 - // 映射成 503,此时刷新令牌通常仍然有效,清除会话等于把用户无故踢下线。 - if (isAuthRejection(error)) clearSession() - throw error - }).finally(() => { - refreshPromise = null - }) - } - return refreshPromise -} - -async function request(path: string, options: RequestOptions = {}): Promise { - const { - authenticated = true, - retryAfterRefresh = true, - timeoutMs = DEFAULT_TIMEOUT_MS, - ...init - } = options - const controller = new AbortController() - const timeout = window.setTimeout(() => controller.abort(), timeoutMs) - const headers = new Headers(init.headers) - headers.set('Accept', 'application/json') - headers.set('X-Correlation-Id', createUuidV4()) - if (init.body && !(init.body instanceof FormData)) headers.set('Content-Type', 'application/json') - const token = readSession()?.tokens.accessToken - if (authenticated && token) headers.set('Authorization', `Bearer ${token}`) - - try { - const response = await fetch(resolveUrl(path), { ...init, headers, signal: controller.signal }) - const bodyText = response.status === 204 ? '' : await response.text() - const payload = bodyText - ? (parseResponseJson(response, bodyText) as ApiSuccess | ApiFailure) - : null - - if (response.status === 401 && authenticated && retryAfterRefresh) { - await refreshAccessToken() - return request(path, { ...options, retryAfterRefresh: false }) - } - if (!response.ok) { - if (!payload) throw nonJsonHttpError(response, bodyText) - throw errorFromPayload(payload, response.status, traceIdFromResponse(response)) - } - if (!bodyText) return undefined as T - if (!payload || typeof payload !== 'object' || !('data' in payload)) { - throw new ApiClientError('服务响应格式不符合契约。', 'UPSTREAM_CONTRACT_INVALID', 502) - } - return payload.data as T - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new ApiClientError('请求超时,请稍后重试。', 'SERVICE_UNAVAILABLE', 503) - } - if (error instanceof TypeError) { - throw new ApiClientError('无法连接服务,请确认后端已经启动。', 'SERVICE_UNAVAILABLE', 503) - } - throw error - } finally { - window.clearTimeout(timeout) - } -} - -async function requestRawJson(path: string): Promise { - const controller = new AbortController() - const timeout = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) - const headers = new Headers({ - Accept: 'application/json', - 'X-Correlation-Id': createUuidV4(), - }) - const token = readSession()?.tokens.accessToken - if (token) headers.set('Authorization', `Bearer ${token}`) - try { - const response = await fetch(resolveUrl(path), { headers, signal: controller.signal }) - const bodyText = response.status === 204 ? '' : await response.text() - const payload = bodyText ? parseResponseJson(response, bodyText) : null - if (!response.ok) { - if (!payload) throw nonJsonHttpError(response, bodyText) - throw errorFromPayload(payload, response.status, traceIdFromResponse(response)) - } - if (!payload || typeof payload !== 'object') { - throw new ApiClientError('游戏包响应格式不符合契约。', 'UPSTREAM_CONTRACT_INVALID', 502) - } - return payload as T - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new ApiClientError('请求超时,请稍后重试。', 'SERVICE_UNAVAILABLE', 503) - } - if (error instanceof TypeError) { - throw new ApiClientError('无法连接服务,请确认后端已经启动。', 'SERVICE_UNAVAILABLE', 503) - } - throw error - } finally { - window.clearTimeout(timeout) - } -} - -async function requestBlob(path: string, retryAfterRefresh = true, accept = 'application/octet-stream', label = '文件'): Promise { - const controller = new AbortController() - const timeout = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) - const headers = new Headers({ - Accept: accept, - 'X-Correlation-Id': createUuidV4(), - }) - const token = readSession()?.tokens.accessToken - if (token) headers.set('Authorization', `Bearer ${token}`) - try { - const response = await fetch(resolveUrl(path), { headers, signal: controller.signal }) - if (response.status === 401 && retryAfterRefresh) { - await refreshAccessToken() - return requestBlob(path, false, accept, label) - } - if (!response.ok) { - const bodyText = await response.text() - const contentType = response.headers.get('Content-Type') || '' - const payload = bodyText && contentType.includes('json') - ? parseResponseJson(response, bodyText) - : null - if (!payload) throw nonJsonHttpError(response, bodyText) - throw errorFromPayload(payload, response.status, traceIdFromResponse(response)) - } - return await response.blob() - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new ApiClientError(`${label}加载超时,请稍后重试。`, 'SERVICE_UNAVAILABLE', 503) - } - if (error instanceof TypeError) { - throw new ApiClientError(`无法连接${label}服务。`, 'SERVICE_UNAVAILABLE', 503) - } - throw error - } finally { - window.clearTimeout(timeout) - } -} - -function json(body: unknown): string { - return JSON.stringify(body) -} - -function query(params: Record): string { - const search = new URLSearchParams() - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) search.set(key, String(value)) - } - const result = search.toString() - return result ? `?${result}` : '' -} - -function adminHeaders(): HeadersInit { - const token = readAdminSession()?.tokens.accessToken - return token ? { Authorization: `Bearer ${token}` } : {} -} - -async function adminRequest(path: string, options: RequestOptions = {}, retry = true): Promise { - try { - return await request(path, { - ...options, - authenticated: false, - retryAfterRefresh: false, - headers: { ...adminHeaders(), ...options.headers }, - }) - } catch (reason) { - const refreshToken = readAdminSession()?.tokens.refreshToken - if (!(reason instanceof ApiClientError) || reason.status !== 401 || !retry || !refreshToken) throw reason - try { - const tokens = await request('/auth/tokens', { - method: 'POST', authenticated: false, retryAfterRefresh: false, body: json({ refreshToken }), - }) - updateAdminSessionTokens(tokens) - return adminRequest(path, options, false) - } catch (refreshError) { - // 同 refreshAccessToken:瞬时故障不等于刷新令牌失效,不能清除管理端会话 - if (isAuthRejection(refreshError)) clearAdminSession() - throw refreshError - } - } -} - -async function collectPages(load: (cursor?: string) => Promise<{ items: T[]; nextCursor: string | null }>): Promise { - const items: T[] = [] - const seenCursors = new Set() - let cursor: string | undefined - - do { - const page = await load(cursor) - items.push(...page.items) - if (!page.nextCursor) break - if (seenCursors.has(page.nextCursor)) { - throw new ApiClientError('分页游标重复,无法继续读取。', 'UPSTREAM_CONTRACT_INVALID', 502) - } - seenCursors.add(page.nextCursor) - cursor = page.nextCursor - } while (cursor) - - return items -} +import * as auth from './api/auth' +import * as users from './api/users' +import * as materials from './api/materials' +import * as knowledge from './api/knowledge' +import * as galgame from './api/galgame' +import * as practice from './api/practice' +import * as admin from './api/admin' +import * as credits from './api/credits' + +export * from './api/client' +export * from './api/auth' +export * from './api/users' +export * from './api/materials' +export * from './api/knowledge' +export * from './api/galgame' +export * from './api/practice' +export * from './api/admin' +export * from './api/credits' export const api = { - login(email: string, password: string): Promise { - return request('/auth/sessions', { - method: 'POST', - authenticated: false, - body: json({ email, password, deviceName: navigator.userAgent.slice(0, 120) }), - }) - }, - - register(input: { email: string; password: string; displayName: string }): Promise { - return request('/auth/registrations', { - method: 'POST', - authenticated: false, - body: json({ - ...input, - deviceName: navigator.userAgent.slice(0, 120), - }), - }) - }, - - requestPasswordReset(email: string): Promise { - return request('/auth/password-reset-requests', { - method: 'POST', - authenticated: false, - body: json({ email }), - }) - }, - - resetPassword(resetToken: string, newPassword: string): Promise { - return request('/auth/password-resets', { - method: 'POST', - authenticated: false, - body: json({ resetToken, newPassword }), - }) - }, - - logout(sessionId: string): Promise { - return request(`/auth/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }) - }, - - getSession(sessionId: string): Promise { - return request(`/auth/sessions/${encodeURIComponent(sessionId)}`) - }, - - getCurrentUser(): Promise { - return request('/users/me') - }, - - updateCurrentUser(input: { displayName?: string; locale?: string; preferredSubjectCodes?: string[] }): Promise { - return request('/users/me', { method: 'PATCH', body: json(input) }) - }, - - getUserPreferences(): Promise { - return request('/users/me/preferences') - }, - - updateUserPreferences(input: UserPreferencesInput): Promise { - return request('/users/me/preferences', { method: 'PUT', body: json(input) }) - }, - - changePassword(currentPassword: string, newPassword: string): Promise { - return request('/auth/password-changes', { method: 'POST', body: json({ currentPassword, newPassword }) }) - }, - - deleteAccount(currentPassword: string): Promise { - return request('/auth/account', { method: 'DELETE', body: json({ currentPassword }) }) - }, - - listMaterials(cursor?: string, filters: { status?: string; subjectCode?: string } = {}): Promise { - return request(`/materials${query({ limit: 100, cursor, status: filters.status, subjectCode: filters.subjectCode })}`) - }, - - getAllMaterials(): Promise { - return collectPages((cursor) => request(`/materials${query({ limit: 100, cursor })}`)) - }, - - getMaterial(materialId: string): Promise { - return request(`/materials/${encodeURIComponent(materialId)}`) - }, - - getExtractedTextPreview(materialId: string): Promise { - return request(`/materials/${encodeURIComponent(materialId)}/extracted-text-preview`) - }, - - uploadMaterial(file: File, displayName?: string, subjectCode?: string): Promise { - const body = new FormData() - body.append('file', file) - if (displayName?.trim()) body.append('displayName', displayName.trim()) - if (subjectCode?.trim()) body.append('subjectCode', subjectCode.trim().toUpperCase()) - return request('/materials', { method: 'POST', body, timeoutMs: UPLOAD_TIMEOUT_MS }) - }, - - deleteMaterial(materialId: string): Promise { - return request(`/materials/${encodeURIComponent(materialId)}`, { method: 'DELETE' }) - }, - - createIngestionJob( - materialId: string, - options: { force?: boolean; enableOcr?: boolean; ocrMode?: 'quick' | 'standard' } = {}, - ): Promise { - return request(`/materials/${encodeURIComponent(materialId)}/ingestion-jobs`, { - method: 'POST', - body: json({ - parserVersion: options.enableOcr ? 'files-ocr-v1' : 'files-text-v1', - force: options.force ?? false, - enableOcr: options.enableOcr ?? false, - ocrMode: options.ocrMode ?? 'standard', - }), - }) - }, - - getIngestionJob(jobId: string): Promise { - return request(`/ingestion-jobs/${encodeURIComponent(jobId)}`) - }, - - createGraphBuild(studyProjectId: string, materialId: string, subjectHint?: string): Promise { - return request('/knowledge-graph-builds', { - method: 'POST', - headers: { 'Idempotency-Key': createUuidV4() }, - body: json({ - materialId, - studyProjectId, - subjectHint: subjectHint?.trim().toUpperCase() || undefined, - segmentationMode: 'AUTO', - extractorVersion: 'knowledge-extractor-v3', - }), - }) - }, - - getGraphBuild(buildId: string): Promise { - return request(`/knowledge-graph-builds/${encodeURIComponent(buildId)}`) - }, - - getKnowledgeGraph(graphId: string): Promise { - return request(`/knowledge-graphs/${encodeURIComponent(graphId)}`) - }, - - listKnowledgeGraphs(studyProjectId: string, cursor?: string): Promise { - return request(`/knowledge-graphs${query({ studyProjectId, limit: 100, cursor })}`) - }, - - getAllKnowledgeGraphs(studyProjectId: string): Promise { - return collectPages((cursor) => - request(`/knowledge-graphs${query({ studyProjectId, limit: 100, cursor })}`), - ) - }, - - getChapters(graphId: string): Promise { - return request(`/knowledge-graphs/${encodeURIComponent(graphId)}/chapters`) - }, - - getPoints(graphId: string, cursor?: string): Promise { - return request(`/knowledge-graphs/${encodeURIComponent(graphId)}/points${query({ limit: 100, cursor })}`) - }, - - getAllPoints(graphId: string): Promise { - return collectPages((cursor) => - request(`/knowledge-graphs/${encodeURIComponent(graphId)}/points${query({ limit: 100, cursor })}`), - ) - }, - - getKnowledgePoint(pointId: string): Promise { - return request(`/knowledge-points/${encodeURIComponent(pointId)}`) - }, - - getRelations(graphId: string, cursor?: string): Promise { - return request(`/knowledge-graphs/${encodeURIComponent(graphId)}/relations${query({ limit: 100, cursor })}`) - }, - - getAllRelations(graphId: string): Promise { - return collectPages((cursor) => - request(`/knowledge-graphs/${encodeURIComponent(graphId)}/relations${query({ limit: 100, cursor })}`), - ) - }, - - getMasteryRecords(graphId: string, cursor?: string): Promise { - return request(`/mastery-records${query({ graphId, limit: 100, cursor })}`) - }, - - getAllMasteryRecords(graphId: string): Promise { - return collectPages((cursor) => - request(`/mastery-records${query({ graphId, limit: 100, cursor })}`), - ) - }, - - createAssessmentPlan( - graphId: string, - chapterIds: string[], - options: { maxQuestions?: number; coverageTarget?: number; maximumInferenceDepth?: number } = {}, - ): Promise { - return request('/assessment-plans', { - method: 'POST', - body: json({ - graphId, - chapterIds, - maxQuestions: options.maxQuestions ?? 6, - coverageTarget: options.coverageTarget ?? 0.8, - maximumInferenceDepth: options.maximumInferenceDepth ?? 3, - }), - }) - }, - - createLearningPlan( - graphId: string, - chapterIds: string[], - options: { maxPoints?: number; maximumDependencyDepth?: number } = {}, - ): Promise { - return request('/learning-plans', { - method: 'POST', - body: json({ - graphId, - chapterIds, - maxPoints: options.maxPoints ?? 12, - maximumDependencyDepth: options.maximumDependencyDepth ?? 5, - }), - }) - }, - - getReviewPlan(reviewPlanId: string): Promise { - return request(`/review-plans/${encodeURIComponent(reviewPlanId)}`) - }, - - createGameGeneration(plan: PlanGraph, style: GameStyle, difficulty: Difficulty): Promise { - return request('/game-generations', { - method: 'POST', - // 比 Gateway 超时略长,优先展示后端的结构化失败信息。 - timeoutMs: 65_000, - body: json({ - reviewPlanId: plan.reviewPlanId, - snapshotVersion: plan.snapshotVersion, - style, - difficulty, - locale: 'zh-CN', - }), - }) - }, - - getGameGeneration(generationId: string): Promise { - return request(`/game-generations/${encodeURIComponent(generationId)}`) - }, - - getGamePackage(packageId: string): Promise { - return request(`/game-packages/${encodeURIComponent(packageId)}`) - }, - - getGamePackageContent(contentUrl: string): Promise { - return requestRawJson(contentUrl) - }, - - getGameAudio(audioUrl: string): Promise { - return requestBlob(audioUrl, true, 'audio/wav, audio/*', '语音') - }, - - getRuntimeManifest(): Promise { - return request('/render-runtime/manifest', { authenticated: false }) - }, - - createReviewSession(packageId: string, clientRuntimeVersion: string): Promise { - return request('/review-sessions', { - method: 'POST', - body: json({ packageId, clientRuntimeVersion }), - }) - }, - - getReviewSession(sessionId: string): Promise { - return request(`/review-sessions/${encodeURIComponent(sessionId)}`) - }, - - saveProgress( - sessionId: string, - input: { expectedVersion: number; currentSceneId: string; visitedSceneIds: string[]; runtimeState: Record }, - ): Promise { - return request(`/review-sessions/${encodeURIComponent(sessionId)}/progress`, { - method: 'PUT', - body: json(input), - }) - }, - - appendEvents(sessionId: string, events: Array<{ clientEventId: string; type: string; occurredAt: string; payload: Record }>): Promise<{ accepted: number; duplicates: number }> { - return request(`/review-sessions/${encodeURIComponent(sessionId)}/events`, { - method: 'POST', - body: json({ events }), - }) - }, - - submitReviewResult( - sessionId: string, - input: { - expectedProgressVersion: number - idempotencyKey: string - reviewPlanId: string - snapshotVersion: string - answerResults: AnswerResult[] - durationSeconds: number - }, - ): Promise { - return request(`/review-sessions/${encodeURIComponent(sessionId)}/result`, { - method: 'PUT', - body: json(input), - }) - }, - - listPracticeProjects(): Promise<{ items: StudyProject[]; nextCursor: string | null }> { - return request('/practice-projects') - }, - - createPracticeProject(input: { name: string; subjectCode?: string; materialIds: string[]; graphId?: null }): Promise { - return request('/practice-projects', { method: 'POST', body: json(input) }) - }, - - updatePracticeProject(project: StudyProject, input: { name?: string; subjectCode?: string; materialIds?: string[]; graphId?: string }): Promise { - return request(`/practice-projects/${encodeURIComponent(project.projectId)}`, { - method: 'PATCH', body: json({ ...input, version: project.version }), - }) - }, - - getPracticeProject(projectId: string): Promise { - return request(`/practice-projects/${encodeURIComponent(projectId)}`) - }, - - listPracticeQuestions(projectId: string): Promise<{ items: PracticeQuestion[]; nextCursor: string | null }> { - return request(`/practice-projects/${encodeURIComponent(projectId)}/questions`) - }, - - createPracticeQuestion(projectId: string, input: { - kind: PracticeQuestionKind; prompt: string; options: Array<{ id: string; text: string }>; correctAnswers: string[] - explanation?: string; score: number; difficulty: number; knowledgePointId?: string; status: 'DRAFT' | 'READY' - }): Promise { - return request(`/practice-projects/${encodeURIComponent(projectId)}/questions`, { method: 'POST', body: json({ ...input, sourceReferences: [] }) }) - }, - - updatePracticeQuestion(question: PracticeQuestion, status: 'DRAFT' | 'READY'): Promise { - return request(`/practice-questions/${encodeURIComponent(question.questionId)}`, { - method: 'PATCH', body: json({ kind: question.kind, prompt: question.prompt, options: question.options, - correctAnswers: question.correctAnswers, explanation: question.explanation, score: question.score, - difficulty: question.difficulty, knowledgePointId: question.knowledgePointId, - sourceReferences: question.sourceReferences, status, version: question.version }), - }) - }, - - createPracticeSession(input: { projectId: string; mode: 'RANDOM' | 'SMART_REVIEW' | 'EXAM'; questionCount?: number; examPaperId?: string; reviewPlanId?: string; snapshotVersion?: string }): Promise { - return request('/practice-sessions', { method: 'POST', body: json({ ...input, kinds: [] }) }) - }, - - getPracticeSession(sessionId: string): Promise { - return request(`/practice-sessions/${encodeURIComponent(sessionId)}`) - }, - - savePracticeAnswer(sessionId: string, questionId: string, answer: string[], responseTimeMs: number, attemptNumber = 1): Promise { - return request(`/practice-sessions/${encodeURIComponent(sessionId)}/answers/${encodeURIComponent(questionId)}`, { - method: 'PUT', body: json({ answer, responseTimeMs, attemptNumber, idempotencyKey: createUuidV4() }), - }) - }, - - completePracticeSession(sessionId: string): Promise<{ session: PracticeSession; evidence: unknown }> { - return request(`/practice-sessions/${encodeURIComponent(sessionId)}/completion`, { method: 'POST', body: json({ idempotencyKey: createUuidV4() }) }) - }, - - createExamPaper(projectId: string, input: { title: string; questionCount: number; durationSeconds: number; reviewPlanId: string; snapshotVersion: string }): Promise { - return request(`/practice-projects/${encodeURIComponent(projectId)}/exam-papers`, { method: 'POST', body: json(input) }) - }, - - generatePracticeQuestions(projectId: string, input: { reviewPlanId: string; snapshotVersion: string; kinds: PracticeQuestionKind[]; targetCount?: number }): Promise { - return request(`/practice-projects/${encodeURIComponent(projectId)}/question-generations`, { - method: 'POST', body: json({ ...input, idempotencyKey: createUuidV4(), generatorVersion: 'recite-question-v2' }), timeoutMs: QUESTION_BANK_TIMEOUT_MS, - }) - }, - - importExam(projectId: string, materialId: string): Promise { - return request('/exam-import-jobs', { method: 'POST', body: json({ projectId, materialId, idempotencyKey: createUuidV4() }), timeoutMs: UPLOAD_TIMEOUT_MS }) - }, - - getQuestionHelp(questionId: string, generateExplanation = false): Promise { - return request(`/practice-questions/${encodeURIComponent(questionId)}/help`, { method: 'POST', body: json({ generateExplanation }) }) - }, - - importPracticePackage(file: File, materialIds: string[]): Promise<{ project: StudyProject; importedQuestionCount: number; importedFromSchema: string; diagnostics: string[] }> { - const body = new FormData(); body.append('file', file); materialIds.forEach((id) => body.append('materialIds', id)) - return request('/practice-packages/imports', { method: 'POST', body, timeoutMs: UPLOAD_TIMEOUT_MS }) - }, - - exportPracticePackage(projectId: string): Promise { - return requestBlob(`/practice-projects/${encodeURIComponent(projectId)}/package`) - }, - - publishPracticePackage(projectId: string, version: string, visibility: 'PRIVATE' | 'UNLISTED' | 'PUBLIC'): Promise { - return request(`/practice-projects/${encodeURIComponent(projectId)}/publications`, { method: 'POST', body: json({ version, visibility }) }) - }, - - listSharedPracticePackages(query?: string): Promise<{ items: SharedPracticePackage[]; nextCursor: string | null }> { - const params = new URLSearchParams(); if (query?.trim()) params.set('query', query.trim()) - return request(`/shared-practice-packages${params.size ? `?${params}` : ''}`) - }, - - getSharedPracticePackageContent(packageId: string): Promise { - return requestBlob(`/shared-practice-packages/${encodeURIComponent(packageId)}/content`) - }, - - adminLogin(username: string, password: string): Promise { - return request('/admin/sessions', { - method: 'POST', - authenticated: false, - body: json({ username, password }), - }) - }, - - adminLogout(sessionId: string): Promise { - return adminRequest(`/auth/sessions/${encodeURIComponent(sessionId)}`, { - method: 'DELETE', - }) - }, - - listAdminUsers(): Promise { - return adminRequest('/admin/users') - }, - - deleteAdminUser(userId: string): Promise { - return adminRequest(`/admin/users/${encodeURIComponent(userId)}`, { - method: 'DELETE', - }) - }, - - resetAdminUserPassword(userId: string, newPassword: string): Promise { - return adminRequest(`/admin/users/${encodeURIComponent(userId)}/password`, { - method: 'POST', body: json({ newPassword }), - }) - }, - - getCreditBalance(): Promise { - return request('/credits/balance') - }, - - redeemCredits(code: string): Promise { - return request('/credits/redemptions', { method: 'POST', body: json({ code }) }) - }, - - listAdminCreditCodes(): Promise<{ items: AdminCreditCode[] }> { - return adminRequest('/admin/credit-codes') - }, - - createAdminCreditCodeBatch(input: CreateCreditCodeBatchInput): Promise<{ items: AdminCreditCode[] }> { - return adminRequest('/admin/credit-codes/batches', { - method: 'POST', body: json(input), - }) - }, - - revokeAdminCreditCode(codeId: string): Promise { - return adminRequest(`/admin/credit-codes/${encodeURIComponent(codeId)}`, { - method: 'DELETE', - }) - }, + ...auth, + ...users, + ...materials, + ...knowledge, + ...galgame, + ...practice, + ...admin, + ...credits, } diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts new file mode 100644 index 0000000..93ebb06 --- /dev/null +++ b/frontend/src/lib/api/admin.ts @@ -0,0 +1,32 @@ +import { request, adminRequest, json } from './client' +import type { AdminUser, AuthSessionResponse } from '../../types/api' + +export function adminLogin(username: string, password: string): Promise { + return request('/admin/sessions', { + method: 'POST', + authenticated: false, + body: json({ username, password }), + }) +} + +export function adminLogout(sessionId: string): Promise { + return adminRequest(`/auth/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', + }) +} + +export function listAdminUsers(): Promise { + return adminRequest('/admin/users') +} + +export function deleteAdminUser(userId: string): Promise { + return adminRequest(`/admin/users/${encodeURIComponent(userId)}`, { + method: 'DELETE', + }) +} + +export function resetAdminUserPassword(userId: string, newPassword: string): Promise { + return adminRequest(`/admin/users/${encodeURIComponent(userId)}/password`, { + method: 'POST', body: json({ newPassword }), + }) +} diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..d0d925e --- /dev/null +++ b/frontend/src/lib/api/auth.ts @@ -0,0 +1,53 @@ +import { request, json } from './client' +import type { AuthSession, AuthSessionResponse } from '../../types/api' + +export function login(email: string, password: string): Promise { + return request('/auth/sessions', { + method: 'POST', + authenticated: false, + body: json({ email, password, deviceName: navigator.userAgent.slice(0, 120) }), + }) +} + +export function register(input: { email: string; password: string; displayName: string }): Promise { + return request('/auth/registrations', { + method: 'POST', + authenticated: false, + body: json({ + ...input, + deviceName: navigator.userAgent.slice(0, 120), + }), + }) +} + +export function requestPasswordReset(email: string): Promise { + return request('/auth/password-reset-requests', { + method: 'POST', + authenticated: false, + body: json({ email }), + }) +} + +export function resetPassword(resetToken: string, newPassword: string): Promise { + return request('/auth/password-resets', { + method: 'POST', + authenticated: false, + body: json({ resetToken, newPassword }), + }) +} + +export function logout(sessionId: string): Promise { + return request(`/auth/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }) +} + +export function getSession(sessionId: string): Promise { + return request(`/auth/sessions/${encodeURIComponent(sessionId)}`) +} + +export function changePassword(currentPassword: string, newPassword: string): Promise { + return request('/auth/password-changes', { method: 'POST', body: json({ currentPassword, newPassword }) }) +} + +export function deleteAccount(currentPassword: string): Promise { + return request('/auth/account', { method: 'DELETE', body: json({ currentPassword }) }) +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..567f636 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,368 @@ +import { clearSession, readSession, updateSessionTokens } from '../session' +import { clearAdminSession, readAdminSession, updateAdminSessionTokens } from '../adminSession' +import { createUuidV4 } from '../uuid' +import type { ApiFailure, ApiSuccess, TokenPair } from '../../types/api' + +const configuredBase = import.meta.env.VITE_API_BASE_URL?.trim() || '/api/v1' +const API_BASE_URL = configuredBase.replace(/\/$/, '') +const DEFAULT_TIMEOUT_MS = 30_000 +export const UPLOAD_TIMEOUT_MS = 120_000 +export const QUESTION_BANK_TIMEOUT_MS = 600_000 + +export class ApiClientError extends Error { + readonly code: string + readonly status: number + readonly traceId?: string + readonly details: Record + + constructor(message: string, code: string, status: number, traceId?: string, details: Record = {}) { + super(message) + this.name = 'ApiClientError' + this.code = code + this.status = status + this.traceId = traceId + this.details = details + } +} + +export interface RequestOptions extends RequestInit { + authenticated?: boolean + retryAfterRefresh?: boolean + timeoutMs?: number +} + +let refreshPromise: Promise | null = null + +const ERROR_MESSAGES: Record = { + AUTH_REQUIRED: '登录状态已失效,请重新登录。', + TOKEN_EXPIRED: '登录状态已失效,请重新登录。', + FORBIDDEN: '当前账户没有执行此操作的权限。', + RESOURCE_NOT_FOUND: '请求的数据不存在或已被删除。', + NOT_FOUND: '请求的数据不存在或已被删除。', + VALIDATION_ERROR: '提交内容格式不正确,请检查后重试。', + BUSINESS_RULE_VIOLATION: '当前内容不符合业务规则,请检查操作条件。', + STATE_CONFLICT: '数据状态已经发生变化,请刷新后重试。', + RATE_LIMITED: '操作过于频繁,请稍后再试。', + FILE_TOO_LARGE: '文件大小超过允许上限。', + MEDIA_TYPE_UNSUPPORTED: '暂不支持这种文件格式。', + MATERIAL_TEXT_NOT_READY: '资料文字尚未提取完成,请稍后重试。', + MATERIAL_TEXT_EXTRACTION_FAILED: '资料文字提取失败,请检查文件或 OCR 设置。', + MATERIAL_ACCESS_DENIED: '当前账户无权访问这份资料。', + FILE_SERVICE_UNAVAILABLE: '资料服务暂时不可用,请稍后重试。', + REVIEW_PLAN_NOT_FOUND: '复习计划不存在或已失效。', + REVIEW_PLAN_SNAPSHOT_MISMATCH: '复习计划版本已变化,请重新创建计划。', + IDEMPOTENCY_KEY_REUSED: '请求标识已被用于不同操作,请重新发起。', + CLIENT_CLOSED_REQUEST: '请求已取消。', + SERVICE_UNAVAILABLE: '服务暂时不可用,请稍后重试。', + UPSTREAM_CONTRACT_INVALID: '服务返回的数据格式异常,请联系维护人员。', + PROFILE_DELETE_FAILED: '账户资料删除失败,账户尚未注销。', + CREDITS_INSUFFICIENT: 'credits 不足,需要先兑换 credits。', + REDEMPTION_CODE_UNAVAILABLE: '兑换码无效、已使用、已撤销或已过期。', + INTERNAL_ERROR: '服务处理失败,请稍后重试。', +} + +const STATUS_MESSAGES: Record = { + 402: 'credits 不足,需要先兑换 credits。', + 400: '提交内容格式不正确,请检查后重试。', + 401: '登录状态已失效,请重新登录。', + 403: '当前账户没有执行此操作的权限。', + 404: '请求的数据不存在或已被删除。', + 409: '数据状态已经发生变化,请刷新后重试。', + 413: '文件大小超过允许上限。', + 415: '暂不支持这种文件格式。', + 422: '当前操作条件不满足,请检查输入内容。', + 429: '操作过于频繁,请稍后再试。', + 502: '上游服务响应异常,请稍后重试。', + 503: '服务暂时不可用,请稍后重试。', + 504: '服务响应超时,请稍后重试。', +} + +function localizedErrorMessage(code: string, message: string, status: number): string { + if (/[\u3400-\u9fff]/.test(message)) return message + return ERROR_MESSAGES[code] || STATUS_MESSAGES[status] || '请求失败,请稍后重试。' +} + +function resolveUrl(path: string): string { + if (/^https?:\/\//i.test(path)) return path + if (path.startsWith('/api/')) return path + return `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}` +} + +/** + * 该错误是否表示"服务端明确认定凭证无效"。只有这种情况才应清除本地会话; + * 网络中断、超时、网关重启等瞬时故障(503/502)下刷新令牌通常仍然有效。 + */ +function isAuthRejection(error: unknown): boolean { + if (!(error instanceof ApiClientError)) return false + if (error.status === 401 || error.status === 403) return true + return error.code === 'AUTH_REQUIRED' || error.code === 'TOKEN_EXPIRED' +} + +function errorFromPayload(payload: unknown, status: number, responseTraceId?: string): ApiClientError { + if (payload && typeof payload === 'object' && 'error' in payload) { + const failure = payload as ApiFailure + if (failure.error && typeof failure.error.code === 'string' && typeof failure.error.message === 'string') { + return new ApiClientError( + localizedErrorMessage(failure.error.code, failure.error.message, status), + failure.error.code, + status, + failure.traceId || responseTraceId, + failure.error.details ?? {}, + ) + } + } + return new ApiClientError(STATUS_MESSAGES[status] || '请求失败,请稍后再试。', 'HTTP_ERROR', status, responseTraceId) +} + +function traceIdFromResponse(response: Response): string | undefined { + return response.headers.get('X-Correlation-Id')?.trim() || undefined +} + +function nonJsonHttpError(response: Response, bodyText: string): ApiClientError { + const status = response.status + const traceId = traceIdFromResponse(response) + let code = 'HTTP_ERROR' + let message = `请求失败(HTTP ${status})。` + + if (status === 413) { + code = 'PAYLOAD_TOO_LARGE' + message = '请求体超过入口代理允许的大小(HTTP 413)。' + } else if (status === 502) { + code = 'UPSTREAM_HTTP_ERROR' + message = '入口代理无法连接上游服务(HTTP 502)。' + } else if (status === 503) { + code = 'SERVICE_UNAVAILABLE' + message = '服务暂时不可用(HTTP 503)。' + } else if (status === 504) { + code = 'SERVICE_UNAVAILABLE' + message = '入口代理等待上游服务超时(HTTP 504)。' + } + + const normalizedBody = bodyText.replace(/\s+/g, ' ').trim() + return new ApiClientError(message, code, status, traceId, { + contentType: response.headers.get('Content-Type') || 'unknown', + responseKind: /bad gateway/i.test(normalizedBody) ? 'BAD_GATEWAY' : 'NON_JSON', + }) +} + +function parseResponseJson(response: Response, bodyText: string): unknown { + try { + return JSON.parse(bodyText) as unknown + } catch { + if (!response.ok) throw nonJsonHttpError(response, bodyText) + throw new ApiClientError( + '服务响应格式不符合契约。', + 'UPSTREAM_CONTRACT_INVALID', + 502, + traceIdFromResponse(response), + { contentType: response.headers.get('Content-Type') || 'unknown' }, + ) + } +} + +async function refreshAccessToken(): Promise { + const refreshToken = readSession()?.tokens.refreshToken + if (!refreshToken) throw new ApiClientError('登录状态已失效,请重新登录。', 'AUTH_REQUIRED', 401) + if (!refreshPromise) { + refreshPromise = request('/auth/tokens', { + method: 'POST', + body: JSON.stringify({ refreshToken }), + authenticated: false, + retryAfterRefresh: false, + }).then((tokens) => { + updateSessionTokens(tokens) + return tokens + }).catch((error) => { + // 只有服务端明确拒绝刷新令牌才清除会话。网关重启、超时等瞬时故障会被 + // 映射成 503,此时刷新令牌通常仍然有效,清除会话等于把用户无故踢下线。 + if (isAuthRejection(error)) clearSession() + throw error + }).finally(() => { + refreshPromise = null + }) + } + return refreshPromise +} + +export async function request(path: string, options: RequestOptions = {}): Promise { + const { + authenticated = true, + retryAfterRefresh = true, + timeoutMs = DEFAULT_TIMEOUT_MS, + ...init + } = options + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), timeoutMs) + const headers = new Headers(init.headers) + headers.set('Accept', 'application/json') + headers.set('X-Correlation-Id', createUuidV4()) + if (init.body && !(init.body instanceof FormData)) headers.set('Content-Type', 'application/json') + const token = readSession()?.tokens.accessToken + if (authenticated && token) headers.set('Authorization', `Bearer ${token}`) + + try { + const response = await fetch(resolveUrl(path), { ...init, headers, signal: controller.signal }) + const bodyText = response.status === 204 ? '' : await response.text() + const payload = bodyText + ? (parseResponseJson(response, bodyText) as ApiSuccess | ApiFailure) + : null + + if (response.status === 401 && authenticated && retryAfterRefresh) { + await refreshAccessToken() + return request(path, { ...options, retryAfterRefresh: false }) + } + if (!response.ok) { + if (!payload) throw nonJsonHttpError(response, bodyText) + throw errorFromPayload(payload, response.status, traceIdFromResponse(response)) + } + if (!bodyText) return undefined as T + if (!payload || typeof payload !== 'object' || !('data' in payload)) { + throw new ApiClientError('服务响应格式不符合契约。', 'UPSTREAM_CONTRACT_INVALID', 502) + } + return payload.data as T + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new ApiClientError('请求超时,请稍后重试。', 'SERVICE_UNAVAILABLE', 503) + } + if (error instanceof TypeError) { + throw new ApiClientError('无法连接服务,请确认后端已经启动。', 'SERVICE_UNAVAILABLE', 503) + } + throw error + } finally { + window.clearTimeout(timeout) + } +} + +export async function requestRawJson(path: string): Promise { + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) + const headers = new Headers({ + Accept: 'application/json', + 'X-Correlation-Id': createUuidV4(), + }) + const token = readSession()?.tokens.accessToken + if (token) headers.set('Authorization', `Bearer ${token}`) + try { + const response = await fetch(resolveUrl(path), { headers, signal: controller.signal }) + const bodyText = response.status === 204 ? '' : await response.text() + const payload = bodyText ? parseResponseJson(response, bodyText) : null + if (!response.ok) { + if (!payload) throw nonJsonHttpError(response, bodyText) + throw errorFromPayload(payload, response.status, traceIdFromResponse(response)) + } + if (!payload || typeof payload !== 'object') { + throw new ApiClientError('游戏包响应格式不符合契约。', 'UPSTREAM_CONTRACT_INVALID', 502) + } + return payload as T + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new ApiClientError('请求超时,请稍后重试。', 'SERVICE_UNAVAILABLE', 503) + } + if (error instanceof TypeError) { + throw new ApiClientError('无法连接服务,请确认后端已经启动。', 'SERVICE_UNAVAILABLE', 503) + } + throw error + } finally { + window.clearTimeout(timeout) + } +} + +export async function requestBlob(path: string, retryAfterRefresh = true, accept = 'application/octet-stream', label = '文件'): Promise { + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) + const headers = new Headers({ + Accept: accept, + 'X-Correlation-Id': createUuidV4(), + }) + const token = readSession()?.tokens.accessToken + if (token) headers.set('Authorization', `Bearer ${token}`) + try { + const response = await fetch(resolveUrl(path), { headers, signal: controller.signal }) + if (response.status === 401 && retryAfterRefresh) { + await refreshAccessToken() + return requestBlob(path, false, accept, label) + } + if (!response.ok) { + const bodyText = await response.text() + const contentType = response.headers.get('Content-Type') || '' + const payload = bodyText && contentType.includes('json') + ? parseResponseJson(response, bodyText) + : null + if (!payload) throw nonJsonHttpError(response, bodyText) + throw errorFromPayload(payload, response.status, traceIdFromResponse(response)) + } + return await response.blob() + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new ApiClientError(`${label}加载超时,请稍后重试。`, 'SERVICE_UNAVAILABLE', 503) + } + if (error instanceof TypeError) { + throw new ApiClientError(`无法连接${label}服务。`, 'SERVICE_UNAVAILABLE', 503) + } + throw error + } finally { + window.clearTimeout(timeout) + } +} + +export function json(body: unknown): string { + return JSON.stringify(body) +} + +export function query(params: Record): string { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) search.set(key, String(value)) + } + const result = search.toString() + return result ? `?${result}` : '' +} + +function adminHeaders(): HeadersInit { + const token = readAdminSession()?.tokens.accessToken + return token ? { Authorization: `Bearer ${token}` } : {} +} + +export async function adminRequest(path: string, options: RequestOptions = {}, retry = true): Promise { + try { + return await request(path, { + ...options, + authenticated: false, + retryAfterRefresh: false, + headers: { ...adminHeaders(), ...options.headers }, + }) + } catch (reason) { + const refreshToken = readAdminSession()?.tokens.refreshToken + if (!(reason instanceof ApiClientError) || reason.status !== 401 || !retry || !refreshToken) throw reason + try { + const tokens = await request('/auth/tokens', { + method: 'POST', authenticated: false, retryAfterRefresh: false, body: json({ refreshToken }), + }) + updateAdminSessionTokens(tokens) + return adminRequest(path, options, false) + } catch (refreshError) { + // 同 refreshAccessToken:瞬时故障不等于刷新令牌失效,不能清除管理端会话 + if (isAuthRejection(refreshError)) clearAdminSession() + throw refreshError + } + } +} + +export async function collectPages(load: (cursor?: string) => Promise<{ items: T[]; nextCursor: string | null }>): Promise { + const items: T[] = [] + const seenCursors = new Set() + let cursor: string | undefined + + do { + const page = await load(cursor) + items.push(...page.items) + if (!page.nextCursor) break + if (seenCursors.has(page.nextCursor)) { + throw new ApiClientError('分页游标重复,无法继续读取。', 'UPSTREAM_CONTRACT_INVALID', 502) + } + seenCursors.add(page.nextCursor) + cursor = page.nextCursor + } while (cursor) + + return items +} diff --git a/frontend/src/lib/api/credits.ts b/frontend/src/lib/api/credits.ts new file mode 100644 index 0000000..176387b --- /dev/null +++ b/frontend/src/lib/api/credits.ts @@ -0,0 +1,26 @@ +import { request, adminRequest, json } from './client' +import type { AdminCreditCode, CreditBalance, CreateCreditCodeBatchInput } from '../../types/api' + +export function getCreditBalance(): Promise { + return request('/credits/balance') +} + +export function redeemCredits(code: string): Promise { + return request('/credits/redemptions', { method: 'POST', body: json({ code }) }) +} + +export function listAdminCreditCodes(): Promise<{ items: AdminCreditCode[] }> { + return adminRequest('/admin/credit-codes') +} + +export function createAdminCreditCodeBatch(input: CreateCreditCodeBatchInput): Promise<{ items: AdminCreditCode[] }> { + return adminRequest('/admin/credit-codes/batches', { + method: 'POST', body: json(input), + }) +} + +export function revokeAdminCreditCode(codeId: string): Promise { + return adminRequest(`/admin/credit-codes/${encodeURIComponent(codeId)}`, { + method: 'DELETE', + }) +} diff --git a/frontend/src/lib/api/galgame.ts b/frontend/src/lib/api/galgame.ts new file mode 100644 index 0000000..f5e9409 --- /dev/null +++ b/frontend/src/lib/api/galgame.ts @@ -0,0 +1,98 @@ +import { request, requestRawJson, requestBlob, json } from './client' +import type { + AnswerResult, + GameGenerationJob, + GamePackage, + GamePackageManifest, + GameStyle, + Difficulty, + PlanGraph, + ProgressSnapshot, + ReviewResult, + ReviewSession, + RuntimeManifest, +} from '../../types/api' + +export function getReviewPlan(reviewPlanId: string): Promise { + return request(`/review-plans/${encodeURIComponent(reviewPlanId)}`) +} + +export function createGameGeneration(plan: PlanGraph, style: GameStyle, difficulty: Difficulty): Promise { + return request('/game-generations', { + method: 'POST', + // 比 Gateway 超时略长,优先展示后端的结构化失败信息。 + timeoutMs: 65_000, + body: json({ + reviewPlanId: plan.reviewPlanId, + snapshotVersion: plan.snapshotVersion, + style, + difficulty, + locale: 'zh-CN', + }), + }) +} + +export function getGameGeneration(generationId: string): Promise { + return request(`/game-generations/${encodeURIComponent(generationId)}`) +} + +export function getGamePackage(packageId: string): Promise { + return request(`/game-packages/${encodeURIComponent(packageId)}`) +} + +export function getGamePackageContent(contentUrl: string): Promise { + return requestRawJson(contentUrl) +} + +export function getGameAudio(audioUrl: string): Promise { + return requestBlob(audioUrl, true, 'audio/wav, audio/*', '语音') +} + +export function getRuntimeManifest(): Promise { + return request('/render-runtime/manifest', { authenticated: false }) +} + +export function createReviewSession(packageId: string, clientRuntimeVersion: string): Promise { + return request('/review-sessions', { + method: 'POST', + body: json({ packageId, clientRuntimeVersion }), + }) +} + +export function getReviewSession(sessionId: string): Promise { + return request(`/review-sessions/${encodeURIComponent(sessionId)}`) +} + +export function saveProgress( + sessionId: string, + input: { expectedVersion: number; currentSceneId: string; visitedSceneIds: string[]; runtimeState: Record }, +): Promise { + return request(`/review-sessions/${encodeURIComponent(sessionId)}/progress`, { + method: 'PUT', + body: json(input), + }) +} + +export function appendEvents(sessionId: string, events: Array<{ clientEventId: string; type: string; occurredAt: string; payload: Record }>): Promise<{ accepted: number; duplicates: number }> { + return request(`/review-sessions/${encodeURIComponent(sessionId)}/events`, { + method: 'POST', + body: json({ events }), + }) +} + +export function submitReviewResult( + sessionId: string, + input: { + expectedProgressVersion: number + idempotencyKey: string + reviewPlanId: string + snapshotVersion: string + answerResults: AnswerResult[] + durationSeconds: number + }, +): Promise { + return request(`/review-sessions/${encodeURIComponent(sessionId)}/result`, { + method: 'PUT', + body: json(input), + }) +} diff --git a/frontend/src/lib/api/knowledge.ts b/frontend/src/lib/api/knowledge.ts new file mode 100644 index 0000000..f134c2a --- /dev/null +++ b/frontend/src/lib/api/knowledge.ts @@ -0,0 +1,118 @@ +import { request, json, query, collectPages } from './client' +import { createUuidV4 } from '../uuid' +import type { + Chapter, + GraphBuildJob, + KnowledgeGraphSummary, + KnowledgeGraphPage, + KnowledgePoint, + KnowledgePointPage, + KnowledgeRelation, + KnowledgeRelationPage, + MasteryRecord, + MasteryRecordPage, + PlanGraph, +} from '../../types/api' + +export function createGraphBuild(studyProjectId: string, materialId: string, subjectHint?: string): Promise { + return request('/knowledge-graph-builds', { + method: 'POST', + headers: { 'Idempotency-Key': createUuidV4() }, + body: json({ + materialId, + studyProjectId, + subjectHint: subjectHint?.trim().toUpperCase() || undefined, + segmentationMode: 'AUTO', + extractorVersion: 'knowledge-extractor-v3', + }), + }) +} + +export function getGraphBuild(buildId: string): Promise { + return request(`/knowledge-graph-builds/${encodeURIComponent(buildId)}`) +} + +export function getKnowledgeGraph(graphId: string): Promise { + return request(`/knowledge-graphs/${encodeURIComponent(graphId)}`) +} + +export function listKnowledgeGraphs(studyProjectId: string, cursor?: string): Promise { + return request(`/knowledge-graphs${query({ studyProjectId, limit: 100, cursor })}`) +} + +export function getAllKnowledgeGraphs(studyProjectId: string): Promise { + return collectPages((cursor) => + request(`/knowledge-graphs${query({ studyProjectId, limit: 100, cursor })}`), + ) +} + +export function getChapters(graphId: string): Promise { + return request(`/knowledge-graphs/${encodeURIComponent(graphId)}/chapters`) +} + +export function getPoints(graphId: string, cursor?: string): Promise { + return request(`/knowledge-graphs/${encodeURIComponent(graphId)}/points${query({ limit: 100, cursor })}`) +} + +export function getAllPoints(graphId: string): Promise { + return collectPages((cursor) => + request(`/knowledge-graphs/${encodeURIComponent(graphId)}/points${query({ limit: 100, cursor })}`), + ) +} + +export function getKnowledgePoint(pointId: string): Promise { + return request(`/knowledge-points/${encodeURIComponent(pointId)}`) +} + +export function getRelations(graphId: string, cursor?: string): Promise { + return request(`/knowledge-graphs/${encodeURIComponent(graphId)}/relations${query({ limit: 100, cursor })}`) +} + +export function getAllRelations(graphId: string): Promise { + return collectPages((cursor) => + request(`/knowledge-graphs/${encodeURIComponent(graphId)}/relations${query({ limit: 100, cursor })}`), + ) +} + +export function getMasteryRecords(graphId: string, cursor?: string): Promise { + return request(`/mastery-records${query({ graphId, limit: 100, cursor })}`) +} + +export function getAllMasteryRecords(graphId: string): Promise { + return collectPages((cursor) => + request(`/mastery-records${query({ graphId, limit: 100, cursor })}`), + ) +} + +export function createAssessmentPlan( + graphId: string, + chapterIds: string[], + options: { maxQuestions?: number; coverageTarget?: number; maximumInferenceDepth?: number } = {}, +): Promise { + return request('/assessment-plans', { + method: 'POST', + body: json({ + graphId, + chapterIds, + maxQuestions: options.maxQuestions ?? 6, + coverageTarget: options.coverageTarget ?? 0.8, + maximumInferenceDepth: options.maximumInferenceDepth ?? 3, + }), + }) +} + +export function createLearningPlan( + graphId: string, + chapterIds: string[], + options: { maxPoints?: number; maximumDependencyDepth?: number } = {}, +): Promise { + return request('/learning-plans', { + method: 'POST', + body: json({ + graphId, + chapterIds, + maxPoints: options.maxPoints ?? 12, + maximumDependencyDepth: options.maximumDependencyDepth ?? 5, + }), + }) +} diff --git a/frontend/src/lib/api/materials.ts b/frontend/src/lib/api/materials.ts new file mode 100644 index 0000000..488c946 --- /dev/null +++ b/frontend/src/lib/api/materials.ts @@ -0,0 +1,55 @@ +import { request, json, query, collectPages, UPLOAD_TIMEOUT_MS } from './client' +import { createUuidV4 } from '../uuid' +import type { + ExtractedTextDocument, + IngestionJob, + Material, + MaterialPage, +} from '../../types/api' + +export function listMaterials(cursor?: string, filters: { status?: string; subjectCode?: string } = {}): Promise { + return request(`/materials${query({ limit: 100, cursor, status: filters.status, subjectCode: filters.subjectCode })}`) +} + +export function getAllMaterials(): Promise { + return collectPages((cursor) => request(`/materials${query({ limit: 100, cursor })}`)) +} + +export function getMaterial(materialId: string): Promise { + return request(`/materials/${encodeURIComponent(materialId)}`) +} + +export function getExtractedTextPreview(materialId: string): Promise { + return request(`/materials/${encodeURIComponent(materialId)}/extracted-text-preview`) +} + +export function uploadMaterial(file: File, displayName?: string, subjectCode?: string): Promise { + const body = new FormData() + body.append('file', file) + if (displayName?.trim()) body.append('displayName', displayName.trim()) + if (subjectCode?.trim()) body.append('subjectCode', subjectCode.trim().toUpperCase()) + return request('/materials', { method: 'POST', body, timeoutMs: UPLOAD_TIMEOUT_MS }) +} + +export function deleteMaterial(materialId: string): Promise { + return request(`/materials/${encodeURIComponent(materialId)}`, { method: 'DELETE' }) +} + +export function createIngestionJob( + materialId: string, + options: { force?: boolean; enableOcr?: boolean; ocrMode?: 'quick' | 'standard' } = {}, +): Promise { + return request(`/materials/${encodeURIComponent(materialId)}/ingestion-jobs`, { + method: 'POST', + body: json({ + parserVersion: options.enableOcr ? 'files-ocr-v1' : 'files-text-v1', + force: options.force ?? false, + enableOcr: options.enableOcr ?? false, + ocrMode: options.ocrMode ?? 'standard', + }), + }) +} + +export function getIngestionJob(jobId: string): Promise { + return request(`/ingestion-jobs/${encodeURIComponent(jobId)}`) +} diff --git a/frontend/src/lib/api/practice.ts b/frontend/src/lib/api/practice.ts new file mode 100644 index 0000000..7b2553c --- /dev/null +++ b/frontend/src/lib/api/practice.ts @@ -0,0 +1,110 @@ +import { request, requestBlob, json, collectPages, UPLOAD_TIMEOUT_MS, QUESTION_BANK_TIMEOUT_MS } from './client' +import { createUuidV4 } from '../uuid' +import type { + StudyProject, + PracticeProjectDetails, + PracticeQuestion, + PracticeQuestionKind, + PracticeSession, + PracticeAnswer, + ExamPaper, + PracticeJob, + QuestionHelp, + SharedPracticePackage, +} from '../../types/api' + +export function listPracticeProjects(): Promise<{ items: StudyProject[]; nextCursor: string | null }> { + return request('/practice-projects') +} + +export function createPracticeProject(input: { name: string; subjectCode?: string; materialIds: string[]; graphId?: null }): Promise { + return request('/practice-projects', { method: 'POST', body: json(input) }) +} + +export function updatePracticeProject(project: StudyProject, input: { name?: string; subjectCode?: string; materialIds?: string[]; graphId?: string }): Promise { + return request(`/practice-projects/${encodeURIComponent(project.projectId)}`, { + method: 'PATCH', body: json({ ...input, version: project.version }), + }) +} + +export function getPracticeProject(projectId: string): Promise { + return request(`/practice-projects/${encodeURIComponent(projectId)}`) +} + +export function listPracticeQuestions(projectId: string): Promise<{ items: PracticeQuestion[]; nextCursor: string | null }> { + return request(`/practice-projects/${encodeURIComponent(projectId)}/questions`) +} + +export function createPracticeQuestion(projectId: string, input: { + kind: PracticeQuestionKind; prompt: string; options: Array<{ id: string; text: string }>; correctAnswers: string[] + explanation?: string; score: number; difficulty: number; knowledgePointId?: string; status: 'DRAFT' | 'READY' +}): Promise { + return request(`/practice-projects/${encodeURIComponent(projectId)}/questions`, { method: 'POST', body: json({ ...input, sourceReferences: [] }) }) +} + +export function updatePracticeQuestion(question: PracticeQuestion, status: 'DRAFT' | 'READY'): Promise { + return request(`/practice-questions/${encodeURIComponent(question.questionId)}`, { + method: 'PATCH', body: json({ kind: question.kind, prompt: question.prompt, options: question.options, + correctAnswers: question.correctAnswers, explanation: question.explanation, score: question.score, + difficulty: question.difficulty, knowledgePointId: question.knowledgePointId, + sourceReferences: question.sourceReferences, status, version: question.version }), + }) +} + +export function createPracticeSession(input: { projectId: string; mode: 'RANDOM' | 'SMART_REVIEW' | 'EXAM'; questionCount?: number; examPaperId?: string; reviewPlanId?: string; snapshotVersion?: string }): Promise { + return request('/practice-sessions', { method: 'POST', body: json({ ...input, kinds: [] }) }) +} + +export function getPracticeSession(sessionId: string): Promise { + return request(`/practice-sessions/${encodeURIComponent(sessionId)}`) +} + +export function savePracticeAnswer(sessionId: string, questionId: string, answer: string[], responseTimeMs: number, attemptNumber = 1): Promise { + return request(`/practice-sessions/${encodeURIComponent(sessionId)}/answers/${encodeURIComponent(questionId)}`, { + method: 'PUT', body: json({ answer, responseTimeMs, attemptNumber, idempotencyKey: createUuidV4() }), + }) +} + +export function completePracticeSession(sessionId: string): Promise<{ session: PracticeSession; evidence: unknown }> { + return request(`/practice-sessions/${encodeURIComponent(sessionId)}/completion`, { method: 'POST', body: json({ idempotencyKey: createUuidV4() }) }) +} + +export function createExamPaper(projectId: string, input: { title: string; questionCount: number; durationSeconds: number; reviewPlanId: string; snapshotVersion: string }): Promise { + return request(`/practice-projects/${encodeURIComponent(projectId)}/exam-papers`, { method: 'POST', body: json(input) }) +} + +export function generatePracticeQuestions(projectId: string, input: { reviewPlanId: string; snapshotVersion: string; kinds: PracticeQuestionKind[]; targetCount?: number }): Promise { + return request(`/practice-projects/${encodeURIComponent(projectId)}/question-generations`, { + method: 'POST', body: json({ ...input, idempotencyKey: createUuidV4(), generatorVersion: 'recite-question-v2' }), timeoutMs: QUESTION_BANK_TIMEOUT_MS, + }) +} + +export function importExam(projectId: string, materialId: string): Promise { + return request('/exam-import-jobs', { method: 'POST', body: json({ projectId, materialId, idempotencyKey: createUuidV4() }), timeoutMs: UPLOAD_TIMEOUT_MS }) +} + +export function getQuestionHelp(questionId: string, generateExplanation = false): Promise { + return request(`/practice-questions/${encodeURIComponent(questionId)}/help`, { method: 'POST', body: json({ generateExplanation }) }) +} + +export function importPracticePackage(file: File, materialIds: string[]): Promise<{ project: StudyProject; importedQuestionCount: number; importedFromSchema: string; diagnostics: string[] }> { + const body = new FormData(); body.append('file', file); materialIds.forEach((id) => body.append('materialIds', id)) + return request('/practice-packages/imports', { method: 'POST', body, timeoutMs: UPLOAD_TIMEOUT_MS }) +} + +export function exportPracticePackage(projectId: string): Promise { + return requestBlob(`/practice-projects/${encodeURIComponent(projectId)}/package`) +} + +export function publishPracticePackage(projectId: string, version: string, visibility: 'PRIVATE' | 'UNLISTED' | 'PUBLIC'): Promise { + return request(`/practice-projects/${encodeURIComponent(projectId)}/publications`, { method: 'POST', body: json({ version, visibility }) }) +} + +export function listSharedPracticePackages(queryParam?: string): Promise<{ items: SharedPracticePackage[]; nextCursor: string | null }> { + const params = new URLSearchParams(); if (queryParam?.trim()) params.set('query', queryParam.trim()) + return request(`/shared-practice-packages${params.size ? `?${params}` : ''}`) +} + +export function getSharedPracticePackageContent(packageId: string): Promise { + return requestBlob(`/shared-practice-packages/${encodeURIComponent(packageId)}/content`) +} diff --git a/frontend/src/lib/api/users.ts b/frontend/src/lib/api/users.ts new file mode 100644 index 0000000..a0dd27e --- /dev/null +++ b/frontend/src/lib/api/users.ts @@ -0,0 +1,18 @@ +import { request, json } from './client' +import type { UserProfile, UserPreferences, UserPreferencesInput } from '../../types/api' + +export function getCurrentUser(): Promise { + return request('/users/me') +} + +export function updateCurrentUser(input: { displayName?: string; locale?: string; preferredSubjectCodes?: string[] }): Promise { + return request('/users/me', { method: 'PATCH', body: json(input) }) +} + +export function getUserPreferences(): Promise { + return request('/users/me/preferences') +} + +export function updateUserPreferences(input: UserPreferencesInput): Promise { + return request('/users/me/preferences', { method: 'PUT', body: json(input) }) +} diff --git a/frontend/src/lib/credits.test.ts b/frontend/src/lib/credits.test.ts new file mode 100644 index 0000000..015da62 --- /dev/null +++ b/frontend/src/lib/credits.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./api', () => ({ ApiClientError: class ApiClientError extends Error {} })) + +describe('resolvePurchaseUrl', () => { + afterEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + }) + + it('优先使用 API 返回的 https purchaseUrl', async () => { + const { resolvePurchaseUrl } = await import('./credits') + expect(resolvePurchaseUrl({ purchaseUrl: 'https://shop.example/a' })).toBe('https://shop.example/a') + }) + + it('拒绝非 https 的 API 地址', async () => { + vi.stubEnv('VITE_CREDIT_SHOP_URL', '') + const { resolvePurchaseUrl } = await import('./credits') + expect(resolvePurchaseUrl({ purchaseUrl: 'http://evil.example' })).toBeNull() + }) + + it('无 API 地址且未配置环境变量时返回 null(购买入口应隐藏)', async () => { + vi.stubEnv('VITE_CREDIT_SHOP_URL', '') + const { resolvePurchaseUrl } = await import('./credits') + expect(resolvePurchaseUrl({})).toBeNull() + expect(resolvePurchaseUrl()).toBeNull() + }) + + it('回退到 VITE_CREDIT_SHOP_URL 环境变量', async () => { + vi.stubEnv('VITE_CREDIT_SHOP_URL', 'https://shop.example/from-env') + const { resolvePurchaseUrl } = await import('./credits') + expect(resolvePurchaseUrl({})).toBe('https://shop.example/from-env') + }) + + it('API 地址优先于环境变量', async () => { + vi.stubEnv('VITE_CREDIT_SHOP_URL', 'https://shop.example/from-env') + const { resolvePurchaseUrl } = await import('./credits') + expect(resolvePurchaseUrl({ purchaseUrl: 'https://shop.example/a' })).toBe('https://shop.example/a') + }) +}) diff --git a/frontend/src/lib/credits.ts b/frontend/src/lib/credits.ts index 25cbf57..f2c0a0f 100644 --- a/frontend/src/lib/credits.ts +++ b/frontend/src/lib/credits.ts @@ -1,14 +1,30 @@ import { ApiClientError } from './api' -const PURCHASE_URL = 'https://pay.ldxp.cn/shop/7CX09W5E' +/** + * 购买地址只来自两个可信来源:后端返回的 `purchaseUrl`(必须是 https) + * 或构建期注入的 `VITE_CREDIT_SHOP_URL`。两者都没有时返回 null—— + * 调用方应据此隐藏购买入口,而不是退回任何硬编码地址。 + */ +export function resolvePurchaseUrl(details?: Record): string | null { + const fromApi = details?.purchaseUrl + if (typeof fromApi === 'string' && fromApi.startsWith('https://')) return fromApi + const fromEnv = import.meta.env.VITE_CREDIT_SHOP_URL as string | undefined + if (fromEnv && fromEnv.trim()) return fromEnv.trim() + return null +} export function handleCreditsRequired(reason: unknown): boolean { if (!(reason instanceof ApiClientError) || reason.code !== 'CREDITS_INSUFFICIENT') return false + const purchaseUrl = resolvePurchaseUrl(reason.details) + // 没有配置购买入口时不弹窗、不跳转,返回 false 让调用方展示普通错误提示。 + if (!purchaseUrl) return false const balance = typeof reason.details.balance === 'number' ? reason.details.balance : null const required = typeof reason.details.required === 'number' ? reason.details.required : null const detail = balance !== null && required !== null ? `当前可用 ${balance.toFixed(5)} credits,本次至少需要 ${required.toFixed(5)} credits。` : '当前 credits 不足。' - if (window.confirm(`${detail}\n需要先兑换 credits。是否前往购买页面?`)) window.location.assign(PURCHASE_URL) + if (window.confirm(`${detail}\n需要先兑换 credits。是否前往购买页面?`)) { + window.location.assign(purchaseUrl) + } return true } diff --git a/frontend/src/lib/errorMessage.test.ts b/frontend/src/lib/errorMessage.test.ts new file mode 100644 index 0000000..3907d0f --- /dev/null +++ b/frontend/src/lib/errorMessage.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { errorMessage } from './errorMessage' + +describe('errorMessage', () => { + it('优先使用 Error.message', () => { + expect(errorMessage(new Error('网络超时'))).toBe('网络超时') + }) + + it('接受非空字符串', () => { + expect(errorMessage('自定义失败')).toBe('自定义失败') + }) + + it('回退到默认文案', () => { + expect(errorMessage(undefined)).toBe('操作失败,请稍后重试。') + expect(errorMessage(new Error(' '))).toBe('操作失败,请稍后重试。') + expect(errorMessage(null, '备用')).toBe('备用') + }) +}) diff --git a/frontend/src/lib/errorMessage.ts b/frontend/src/lib/errorMessage.ts new file mode 100644 index 0000000..093a1a5 --- /dev/null +++ b/frontend/src/lib/errorMessage.ts @@ -0,0 +1,6 @@ +/** 统一从未知错误提取可展示文案,替换各页面重复的 instanceof 判断。 */ +export function errorMessage(reason: unknown, fallback = '操作失败,请稍后重试。'): string { + if (reason instanceof Error && reason.message.trim()) return reason.message + if (typeof reason === 'string' && reason.trim()) return reason + return fallback +} diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts index a8415cf..d2caac3 100644 --- a/frontend/src/lib/session.ts +++ b/frontend/src/lib/session.ts @@ -3,11 +3,18 @@ import type { AuthSessionResponse, TokenPair, UserProfile } from '../types/api' const SESSION_KEY = 'galreview.session' const PROFILE_KEY = 'galreview.profile' +/** + * 会话(含 Access/Refresh Token)存 sessionStorage: + * 关闭标签页即失效,降低 XSS 后长期驻留 localStorage 的风险。 + * 跨标签共享与「记住我」需后续迁移到 HttpOnly Cookie + Gateway 下发。 + */ +const sessionStore = (): Storage => sessionStorage + export type StoredSession = AuthSessionResponse export function readSession(): StoredSession | null { try { - const raw = localStorage.getItem(SESSION_KEY) + const raw = sessionStore().getItem(SESSION_KEY) return raw ? (JSON.parse(raw) as StoredSession) : null } catch { return null @@ -15,7 +22,7 @@ export function readSession(): StoredSession | null { } export function saveSession(session: StoredSession): void { - localStorage.setItem(SESSION_KEY, JSON.stringify(session)) + sessionStore().setItem(SESSION_KEY, JSON.stringify(session)) window.dispatchEvent(new Event('galreview:session')) } @@ -25,8 +32,10 @@ export function updateSessionTokens(tokens: TokenPair): void { } export function clearSession(): void { - localStorage.removeItem(SESSION_KEY) + sessionStore().removeItem(SESSION_KEY) localStorage.removeItem(PROFILE_KEY) + // 兼容旧版本误写入 localStorage 的会话 + localStorage.removeItem(SESSION_KEY) window.dispatchEvent(new Event('galreview:session')) } diff --git a/frontend/src/lib/storyAssets.test.ts b/frontend/src/lib/storyAssets.test.ts new file mode 100644 index 0000000..942545a --- /dev/null +++ b/frontend/src/lib/storyAssets.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { + BGM_VOLUME, + CHARACTER_VOICE_VOLUME, + fixedMockSceneBackgrounds, + gameGenerationPollTimeoutMs, +} from './storyAssets' + +describe('storyAssets constants', () => { + it('故事资源常量在合理范围', () => { + expect(BGM_VOLUME).toBeGreaterThan(0) + expect(BGM_VOLUME).toBeLessThan(1) + expect(CHARACTER_VOICE_VOLUME).toBeGreaterThan(0) + expect(gameGenerationPollTimeoutMs).toBeGreaterThan(60_000) + expect(fixedMockSceneBackgrounds.length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/src/lib/storyAssets.ts b/frontend/src/lib/storyAssets.ts new file mode 100644 index 0000000..214386a --- /dev/null +++ b/frontend/src/lib/storyAssets.ts @@ -0,0 +1,29 @@ +/** 故事回响页面的静态资源常量与预加载。 */ + +export const fixedMockSceneBackgrounds = ['/bg.webp', '/bg_1.webp', '/bg2.webp', '/bg3.webp', '/bg4.webp'] + +export const BGM_VOLUME = 0.18 +export const BGM_DUCKED_VOLUME = 0.06 +export const CHARACTER_VOICE_VOLUME = 0.9 +export const CHARACTER_VOICE_PLAYBACK_RATE = 1.3 + +/** 后端最多执行两次、每次 120 秒的叙事模型请求;为排队、校验与持久化预留充足余量。 */ +export const gameGenerationPollTimeoutMs = 600_000 + +export function preloadBackground(source: string): Promise { + return new Promise((resolve) => { + const image = new Image() + image.decoding = 'async' + const complete = () => { + if (typeof image.decode === 'function') { + void image.decode().catch(() => undefined).finally(resolve) + } else { + resolve() + } + } + image.onload = complete + image.onerror = () => resolve() + image.src = source + if (image.complete) complete() + }) +} diff --git a/frontend/src/lib/uuid.test.ts b/frontend/src/lib/uuid.test.ts new file mode 100644 index 0000000..0e5f1ad --- /dev/null +++ b/frontend/src/lib/uuid.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { createUuidV4 } from './uuid' + +describe('createUuidV4', () => { + it('生成小写 UUID v4', () => { + const id = createUuidV4() + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + }) + + it('连续调用不重复', () => { + const a = createUuidV4() + const b = createUuidV4() + expect(a).not.toBe(b) + }) +}) diff --git a/frontend/src/pages/FeaturePlaceholderPage.tsx b/frontend/src/pages/FeaturePlaceholderPage.tsx deleted file mode 100644 index a92e688..0000000 --- a/frontend/src/pages/FeaturePlaceholderPage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useNavigate } from 'react-router' -import BrandMark from '../components/BrandMark' -import { BackIcon } from '../components/icons' - -export default function FeaturePlaceholderPage({ title, description }: { title: string; description: string }) { - const navigate = useNavigate() - return ( -
- -
- -

功能准备中

-

{title}

-

{description}

- -
-
- ) -} diff --git a/frontend/src/pages/ReviewPage.tsx b/frontend/src/pages/ReviewPage.tsx index 23bf0e2..cd03579 100644 --- a/frontend/src/pages/ReviewPage.tsx +++ b/frontend/src/pages/ReviewPage.tsx @@ -1,133 +1,32 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +/** 回响(ReviewPage):只负责编排——hooks 组合、生成/恢复流程与页面布局。 */ + +import { useEffect, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router' import AppShell, { PageHeader } from '../components/AppShell' import LoadingIndicator from '../components/LoadingIndicator' import { api } from '../lib/api' import { handleCreditsRequired } from '../lib/credits' import { pollUntil } from '../lib/poll' -import { loadRuntime } from '../lib/runtime' -import { readSession } from '../lib/session' +import { errorMessage } from '../lib/errorMessage' import { createUuidV4 } from '../lib/uuid' import { clearCompletedReview, readWorkflow, updateWorkflow } from '../lib/workflow' +import { fixedMockSceneBackgrounds, gameGenerationPollTimeoutMs, preloadBackground } from '../lib/storyAssets' import type { - AnswerResult, Difficulty, - GameChoice, GameGenerationJob, GamePackage, - GameScene, GameStyle, - MasteryRecord, - ReviewResult, ReviewSession, RuntimeManifest, - WasmAdapter, } from '../types/api' - -interface AttemptState { - answers: AnswerResult[] - attemptsByQuestion: Record -} - -interface ChoiceFeedback { - correct: boolean - selectedText: string - correctText: string | null - knowledgeTitle: string - nextSceneId: string | null - nextVisitedSceneIds: string[] - answers: AnswerResult[] - savedSession?: ReviewSession -} - -interface ReviewKnowledgeSummary { - pointId: string - title: string - masteryScore: number - nextReviewAt: string | null -} - -function CompletionActionIcon({ kind }: { kind: 'restart' | 'graph' | 'plan' }) { - if (kind === 'restart') return - if (kind === 'graph') return - return -} - -function generationError(error: { message: string } | null): Error { - return new Error(error?.message || '游戏生成失败。') -} - -function attemptsFromAnswers(answers: AnswerResult[]): Record { - return Object.fromEntries(answers.map((answer) => [answer.questionId, answer.attemptNumber])) -} - -const fixedMockSceneBackgrounds = ['/bg.png', '/bg_1.png', '/bg2.png', '/bg3.png', '/bg4.png'] -const BGM_VOLUME = 0.18 -const BGM_DUCKED_VOLUME = 0.06 -const CHARACTER_VOICE_VOLUME = 0.9 -const CHARACTER_VOICE_PLAYBACK_RATE = 1.3 -// 后端最多执行两次、每次 120 秒的叙事模型请求;为排队、校验与持久化预留充足余量。 -const gameGenerationPollTimeoutMs = 600_000 - -function preloadBackground(source: string): Promise { - return new Promise((resolve) => { - const image = new Image() - image.decoding = 'async' - const complete = () => { - if (typeof image.decode === 'function') { - void image.decode().catch(() => undefined).finally(resolve) - } else { - resolve() - } - } - image.addEventListener('load', complete, { once: true }) - image.addEventListener('error', () => resolve(), { once: true }) - image.src = source - if (image.complete) complete() - }) -} - -function runtimeEvent(events: Array>, type: string) { - return events.find((event) => event.type === type) -} - -function voiceAssetId(sceneIndex: number, lineIndex: number): string { - return `voice-${String(sceneIndex).padStart(3, '0')}-${String(lineIndex).padStart(3, '0')}` -} - -function isSessionResumable(session: ReviewSession | undefined): boolean { - return session?.status === 'CREATED' || session?.status === 'RUNNING' -} - -function formatReviewTime(value: string | null): string { - if (!value) return '暂无安排' - const date = new Date(value) - if (Number.isNaN(date.getTime())) return '暂无安排' - return new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(date) -} - -function createShellSession(gamePackage: GamePackage): ReviewSession { - const userId = readSession()?.session.userId - if (!userId) throw new Error('登录会话已失效,请重新登录。') - return { - sessionId: createUuidV4(), - userId, - packageId: gamePackage.packageId, - reviewPlanId: gamePackage.reviewPlanId, - snapshotVersion: gamePackage.snapshotVersion, - status: 'CREATED', - currentSceneId: null, - progressVersion: 0, - startedAt: new Date().toISOString(), - completedAt: null, - } -} +import { useGameRuntime } from './review/useGameRuntime' +import { useStoryProgress } from './review/useStoryProgress' +import { useDialogueTyping } from './review/useDialogueTyping' +import { useAudioStage } from './review/useAudioStage' +import { createShellSession, generationError, isSessionResumable } from './review/helpers' +import GameStage from './review/GameStage' +import ResultPanel from './review/ResultPanel' +import SetupPanel from './review/SetupPanel' export default function ReviewPage() { const { projectId = '' } = useParams() @@ -139,85 +38,42 @@ export default function ReviewPage() { : saved }) const navigate = useNavigate() - const adapterRef = useRef(null) const gameStageRef = useRef(null) - const bgmRef = useRef(null) - const voiceAudioRef = useRef(null) - const resultKeyRef = useRef(initial.resultIdempotencyKey || createUuidV4()) - const startedAtRef = useRef(Date.now()) - const sceneStartedAtRef = useRef(Date.now()) const [style, setStyle] = useState(initial.gameStyle || 'CAMPUS') const [difficulty, setDifficulty] = useState(initial.gameDifficulty || 'STANDARD') const [generation, setGeneration] = useState(initial.gameGeneration) - const [gamePackage, setGamePackage] = useState(initial.gamePackage) - const [session, setSession] = useState(initial.reviewSession) - const [runtimeManifest, setRuntimeManifest] = useState() - const [sceneId, setSceneId] = useState(initial.reviewSession?.currentSceneId || initial.gamePackage?.entrySceneId || '') - const [visitedSceneIds, setVisitedSceneIds] = useState(initial.visitedSceneIds?.length ? initial.visitedSceneIds : sceneId ? [sceneId] : []) - const [attempt, setAttempt] = useState({ - answers: initial.answerResults || [], - attemptsByQuestion: attemptsFromAnswers(initial.answerResults || []), - }) const [busy, setBusy] = useState(false) const [progress, setProgress] = useState(initial.gamePackage ? '游戏包已准备好,正在等待渲染器。' : '选择风格后开始生成。') const [error, setError] = useState('') - const [result, setResult] = useState() - const [shellCompleted, setShellCompleted] = useState(false) - const [adapterVersion, setAdapterVersion] = useState(0) - const [dialogueIndex, setDialogueIndex] = useState(0) - const [typedLength, setTypedLength] = useState(0) const [backgroundsReady, setBackgroundsReady] = useState(false) - const [choiceFeedback, setChoiceFeedback] = useState() - const [reviewKnowledge, setReviewKnowledge] = useState([]) - const [reviewKnowledgeLoading, setReviewKnowledgeLoading] = useState(false) - const [reviewKnowledgeError, setReviewKnowledgeError] = useState('') - const [voiceEnabled, setVoiceEnabled] = useState(true) - const [voicePlaying, setVoicePlaying] = useState(false) - const scene = useMemo( - () => gamePackage?.scenes.find((item) => item.sceneId === sceneId), - [gamePackage, sceneId], - ) - const gameplayActive = Boolean(scene && gamePackage && isSessionResumable(session) && adapterVersion && !result && !shellCompleted) - - useEffect(() => { - if (!gameplayActive) return undefined - const audio = new Audio('/bgm.mp3') - let active = true - audio.loop = true - audio.preload = 'auto' - audio.volume = BGM_VOLUME - bgmRef.current = audio - - const startPlayback = () => { - if (!active) return - void audio.play().catch(() => {}) - } - - void audio.play().catch(() => { - if (active) document.addEventListener('pointerdown', startPlayback, { once: true }) - }) - - return () => { - active = false - document.removeEventListener('pointerdown', startPlayback) - audio.pause() - audio.currentTime = 0 - bgmRef.current = null - } - }, [gameplayActive]) - const currentDialogue = scene?.dialogue[dialogueIndex] - const dialogueCharacters = useMemo(() => Array.from(currentDialogue?.text || ''), [currentDialogue?.text]) - const typedDialogue = dialogueCharacters.slice(0, typedLength).join('') - const dialogueTyping = typedLength < dialogueCharacters.length - const dialogueCompleted = !scene || dialogueIndex >= scene.dialogue.length - 1 - const hasVoiceAssets = Boolean(gamePackage?.assets.some((asset) => - asset.type === 'AUDIO' && asset.assetId.startsWith('voice-'), - )) - - useEffect(() => () => adapterRef.current?.dispose(), []) + const runtime = useGameRuntime(setError) + const story = useStoryProgress({ + initial, + adapterRef: runtime.adapterRef, + runtimeManifest: runtime.runtimeManifest, + busy, + setBusy, + setError, + setProgress, + }) + const gameplayActive = Boolean(story.scene && story.gamePackage && isSessionResumable(story.session) && runtime.adapterVersion && !story.result && !story.shellCompleted) + const dialogue = useDialogueTyping(story.sceneId, story.scene, Boolean(story.choiceFeedback)) + const audio = useAudioStage({ + gameplayActive, + adapterVersion: runtime.adapterVersion, + adapterRef: runtime.adapterRef, + session: story.session, + gamePackage: story.gamePackage, + scene: story.scene, + currentDialogue: dialogue.currentDialogue, + dialogueIndex: dialogue.dialogueIndex, + choiceFeedback: story.choiceFeedback, + result: story.result, + shellCompleted: story.shellCompleted, + }) -useEffect(() => { + useEffect(() => { let active = true void Promise.all(fixedMockSceneBackgrounds.map(preloadBackground)).then(() => { if (active) setBackgroundsReady(true) @@ -225,143 +81,12 @@ useEffect(() => { return () => { active = false } }, []) - useEffect(() => { - setDialogueIndex(0) - }, [sceneId]) - - useEffect(() => { - setTypedLength(0) - if (!dialogueCharacters.length) return undefined - const timer = window.setInterval(() => { - setTypedLength((current) => { - const next = current + 1 - if (next >= dialogueCharacters.length) window.clearInterval(timer) - return Math.min(next, dialogueCharacters.length) - }) - }, 52) - return () => window.clearInterval(timer) - }, [dialogueCharacters]) - - useEffect(() => { - let active = true - let objectUrl = '' - let playback: HTMLAudioElement | null = null - let retryOnGesture: (() => void) | null = null - setVoicePlaying(false) - - if (!voiceEnabled - || !adapterRef.current - || !isSessionResumable(session) - || !gamePackage - || !scene - || !currentDialogue - || choiceFeedback - || result - || shellCompleted) { - return undefined - } - - const sceneIndex = gamePackage.scenes.findIndex((item) => item.sceneId === scene.sceneId) - if (sceneIndex < 0) return undefined - const assetId = voiceAssetId(sceneIndex, dialogueIndex) - const asset = gamePackage.assets.find((item) => item.type === 'AUDIO' && item.assetId === assetId) - if (!asset) return undefined - - const restoreBgm = () => { - if (bgmRef.current) bgmRef.current.volume = BGM_VOLUME - } - const beginPlayback = () => { - if (!active || !playback) return - if (bgmRef.current) bgmRef.current.volume = BGM_DUCKED_VOLUME - void playback.play().then(() => { - if (active) setVoicePlaying(true) - }).catch(() => { - restoreBgm() - if (active) setVoicePlaying(false) - }) - } - - void api.getGameAudio(asset.uri).then((blob) => { - if (!active) return - objectUrl = URL.createObjectURL(blob) - playback = new Audio(objectUrl) - playback.preload = 'auto' - playback.volume = CHARACTER_VOICE_VOLUME - playback.playbackRate = CHARACTER_VOICE_PLAYBACK_RATE - playback.preservesPitch = true - voiceAudioRef.current = playback - playback.addEventListener('ended', () => { - restoreBgm() - if (active) setVoicePlaying(false) - }, { once: true }) - playback.addEventListener('error', () => { - restoreBgm() - if (active) setVoicePlaying(false) - }, { once: true }) - void playback.play().then(() => { - if (bgmRef.current) bgmRef.current.volume = BGM_DUCKED_VOLUME - if (active) setVoicePlaying(true) - }).catch(() => { - restoreBgm() - retryOnGesture = beginPlayback - document.addEventListener('pointerdown', retryOnGesture, { once: true }) - }) - }).catch(() => { - // Voice is optional. Text dialogue remains usable when audio cannot be loaded. - restoreBgm() - }) - - return () => { - active = false - if (retryOnGesture) document.removeEventListener('pointerdown', retryOnGesture) - playback?.pause() - if (playback) playback.currentTime = 0 - if (voiceAudioRef.current === playback) voiceAudioRef.current = null - if (objectUrl) URL.revokeObjectURL(objectUrl) - restoreBgm() - } - }, [adapterVersion, choiceFeedback, currentDialogue, dialogueIndex, gamePackage, result, scene, session, shellCompleted, voiceEnabled]) - - useEffect(() => { - if (!adapterVersion) return - let frameId = 0 - let previous = performance.now() - const frame = (now: number) => { - const adapter = adapterRef.current - if (!adapter) return - try { - adapter.renderFrame(Math.min(100, Math.max(0, now - previous))) - } catch (reason) { - setError(reason instanceof Error ? reason.message : '渲染运行时已停止。') - return - } - previous = now - frameId = window.requestAnimationFrame(frame) - } - frameId = window.requestAnimationFrame(frame) - return () => window.cancelAnimationFrame(frameId) - }, [adapterVersion]) - - useEffect(() => { - if (initial.plan && !initial.resultIdempotencyKey) updateWorkflow({ resultIdempotencyKey: resultKeyRef.current }) - }, [initial.plan, initial.resultIdempotencyKey]) - async function attachRuntime(manifest: RuntimeManifest, pack: GamePackage, reviewSession: ReviewSession) { - if (!isSessionResumable(reviewSession)) { - throw new Error('上一次复习会话已经结束,不能继续恢复,请重新生成故事。') - } - adapterRef.current?.dispose() - adapterRef.current = await loadRuntime(manifest, pack, reviewSession) - setAdapterVersion((value) => value + 1) - setRuntimeManifest(manifest) + await runtime.loadRuntimeFor(manifest, pack, reviewSession) const nextSceneId = reviewSession.currentSceneId || pack.entrySceneId const savedVisited = readWorkflow().visitedSceneIds || [] const nextVisited = savedVisited.includes(nextSceneId) ? savedVisited : [...savedVisited, nextSceneId] - setSceneId(nextSceneId) - setChoiceFeedback(undefined) - setVisitedSceneIds(nextVisited) - updateWorkflow({ visitedSceneIds: nextVisited }) - sceneStartedAtRef.current = Date.now() + story.enterScene(nextSceneId, nextVisited) } async function completeGeneration(accepted: GameGenerationJob, plan = readWorkflow().plan) { @@ -396,18 +121,14 @@ useEffect(() => { throw new Error('复习会话与游戏包快照不一致。') } const resultIdempotencyKey = createUuidV4() - resultKeyRef.current = resultIdempotencyKey - setAttempt({ answers: [], attemptsByQuestion: {} }) - setShellCompleted(false) - setChoiceFeedback(undefined) - setReviewKnowledge([]) - setReviewKnowledgeError('') - setGamePackage(pack) - setSession(reviewSession) + story.resultKeyRef.current = resultIdempotencyKey + story.beginFreshRun() + story.setGamePackage(pack) + story.setSession(reviewSession) updateWorkflow({ gameGeneration: completed, gameManifest: manifest, gamePackage: pack, reviewSession, visitedSceneIds: [reviewSession.currentSceneId || pack.entrySceneId], answerResults: [], resultIdempotencyKey }) await attachRuntime(renderManifest, pack, reviewSession) setProgress('渲染器已加载,可以开始复习。') - startedAtRef.current = Date.now() + story.markRunStarted() } async function generateAndStart() { @@ -425,7 +146,7 @@ useEffect(() => { updateWorkflow({ gameGeneration: accepted, gameStyle: style, gameDifficulty: difficulty, gameManifest: undefined, gamePackage: undefined, reviewSession: undefined, answerResults: [], resultIdempotencyKey: undefined }) await completeGeneration(accepted, workflow.plan) } catch (reason) { - if (!handleCreditsRequired(reason)) setError(reason instanceof Error ? reason.message : '游戏准备失败。') + if (!handleCreditsRequired(reason)) setError(errorMessage(reason, '游戏准备失败。')) } finally { setBusy(false) } @@ -441,7 +162,7 @@ useEffect(() => { const current = await api.getGameGeneration(workflow.gameGeneration.generationId) await completeGeneration(current, workflow.plan) } catch (reason) { - setError(reason instanceof Error ? reason.message : '生成任务恢复失败。') + setError(errorMessage(reason, '生成任务恢复失败。')) } finally { setBusy(false) } @@ -464,264 +185,23 @@ useEffect(() => { return } await attachRuntime(manifest, workflow.gamePackage, currentSession) - setSession(currentSession) + story.setSession(currentSession) setProgress('已恢复当前复习会话。') } catch (reason) { - setError(reason instanceof Error ? reason.message : '会话恢复失败。') - } finally { - setBusy(false) - } - } - - function createAnswer(choice: GameChoice, attemptId: string, occurredAt: string, event?: Record): AnswerResult | null { - if (choice.answerKind !== 'CHOICE' || typeof choice.correct !== 'boolean') return null - const responseTimeMs = Math.max(0, Date.now() - sceneStartedAtRef.current) - const attemptNumber = Math.max((attempt.attemptsByQuestion[choice.questionId] || 0) + 1, typeof event?.attemptNumber === 'number' ? event.attemptNumber : 1) - const correct = typeof event?.correct === 'boolean' ? event.correct : choice.correct - const quality = correct ? (attemptNumber > 1 ? 3 : 5) : 0 - return { - attemptId, - questionId: choice.questionId, - knowledgePointId: choice.knowledgePointId, - answerKind: 'CHOICE', - choiceId: choice.choiceId, - correct, - quality, - scoreDelta: choice.scoreDelta, - responseTimeMs, - hintsUsed: 0, - attemptNumber, - occurredAt, - } - } - - async function saveSceneProgress(nextSceneId: string, nextVisited: string[]): Promise { - if (!session || !adapterRef.current) return undefined - if (runtimeManifest?.reviewSessionsAvailable === false) { - const nextSession: ReviewSession = { - ...session, - currentSceneId: nextSceneId, - progressVersion: session.progressVersion + 1, - status: 'RUNNING', - } - setSession(nextSession) - updateWorkflow({ reviewSession: nextSession }) - return nextSession - } - const saved = await api.saveProgress(session.sessionId, { - expectedVersion: session.progressVersion, - currentSceneId: nextSceneId, - visitedSceneIds: nextVisited, - runtimeState: adapterRef.current.serializeState(), - }) - const nextSession = { ...session, currentSceneId: saved.currentSceneId, progressVersion: saved.version, status: 'RUNNING' as const } - setSession(nextSession) - updateWorkflow({ reviewSession: nextSession }) - return nextSession - } - - async function choose(choice: GameChoice) { - if (!scene || !session || busy || choiceFeedback) return - setBusy(true) - setError('') - try { - const attemptId = createUuidV4() - const occurredAt = new Date().toISOString() - const events = adapterRef.current?.dispatchInput({ type: 'CHOICE_SELECTED', choiceId: choice.choiceId, attemptId, occurredAt }) || [] - if (adapterRef.current?.engine === 'wasm' && !events.length) { - const runtimeError = adapterRef.current.lastError?.() - throw new Error(runtimeError?.message || '渲染运行时拒绝了当前选择。') - } - const answer = createAnswer(choice, attemptId, occurredAt, runtimeEvent(events, 'ANSWER_RECORDED')) - const nextAnswers = answer ? [...attempt.answers, answer] : attempt.answers - if (answer) { - setAttempt((current) => ({ - answers: [...current.answers, answer], - attemptsByQuestion: { ...current.attemptsByQuestion, [answer.questionId]: answer.attemptNumber }, - })) - updateWorkflow({ answerResults: nextAnswers }) - } - if (runtimeManifest?.reviewSessionsAvailable !== false) { - void api.appendEvents(session.sessionId, [{ - clientEventId: createUuidV4(), - type: 'CHOICE_SELECTED', - occurredAt, - payload: { - sceneId: scene.sceneId, - choiceId: choice.choiceId, - questionId: choice.questionId, - knowledgePointId: choice.knowledgePointId, - }, - }]).catch(() => undefined) - } - const entered = runtimeEvent(events, 'SCENE_ENTERED') - const nextSceneId = typeof entered?.sceneId === 'string' ? entered.sceneId : choice.nextSceneId - const completedByRuntime = Boolean(runtimeEvent(events, 'SESSION_COMPLETED')) - let nextVisited = visitedSceneIds - let savedSession: ReviewSession | undefined - if (nextSceneId && !completedByRuntime) { - nextVisited = visitedSceneIds.includes(nextSceneId) ? visitedSceneIds : [...visitedSceneIds, nextSceneId] - savedSession = await saveSceneProgress(nextSceneId, nextVisited) - setVisitedSceneIds(nextVisited) - updateWorkflow({ visitedSceneIds: nextVisited }) - } else { - savedSession = await saveSceneProgress(scene.sceneId, visitedSceneIds) - } - - if (answer) { - const correctChoice = scene.choices.find((item) => item.correct === true) - const knowledgeTitle = initial.plan?.nodes.find((node) => node.pointId === answer.knowledgePointId)?.title || '当前知识点' - setChoiceFeedback({ - correct: answer.correct, - selectedText: choice.text, - correctText: correctChoice?.text || null, - knowledgeTitle, - nextSceneId: nextSceneId && !completedByRuntime ? nextSceneId : null, - nextVisitedSceneIds: nextVisited, - answers: nextAnswers, - savedSession, - }) - } else if (nextSceneId && !completedByRuntime) { - setSceneId(nextSceneId) - sceneStartedAtRef.current = Date.now() - } else { - await finish(nextAnswers, savedSession) - } - } catch (reason) { - setError(reason instanceof Error ? reason.message : '选择保存失败。') - } finally { - setBusy(false) - } - } - - async function continueAfterFeedback() { - if (!choiceFeedback || busy) return - if (choiceFeedback.nextSceneId) { - setVisitedSceneIds(choiceFeedback.nextVisitedSceneIds) - setSceneId(choiceFeedback.nextSceneId) - setChoiceFeedback(undefined) - sceneStartedAtRef.current = Date.now() - return - } - await finish(choiceFeedback.answers, choiceFeedback.savedSession) - } - - async function loadReviewKnowledge(answers: AnswerResult[]) { - const workflow = readWorkflow() - const plan = workflow.plan || initial.plan - if (!plan) return - - const reviewedPointIds = new Set(answers.map((answer) => answer.knowledgePointId)) - const visited = new Set(workflow.visitedSceneIds || visitedSceneIds) - for (const reviewedScene of gamePackage?.scenes || []) { - if (!visited.has(reviewedScene.sceneId)) continue - for (const binding of reviewedScene.knowledgeBindings) reviewedPointIds.add(binding.knowledgePointId) - } - const reviewedNodes = plan.nodes.filter((node) => reviewedPointIds.has(node.pointId)) - const summaryNodes = reviewedNodes.length ? reviewedNodes : plan.nodes - const fallback = summaryNodes.map((node) => ({ - pointId: node.pointId, - title: node.title, - masteryScore: node.masteryScore, - nextReviewAt: null, - })) - setReviewKnowledge(fallback) - setReviewKnowledgeLoading(true) - setReviewKnowledgeError('') - try { - const records = await api.getAllMasteryRecords(plan.graphId) - const recordByPointId = new Map(records.map((record) => [record.pointId, record])) - setReviewKnowledge(fallback.map((item) => { - const record = recordByPointId.get(item.pointId) - return record ? { ...item, masteryScore: record.score, nextReviewAt: record.nextReviewAt } : item - })) - } catch { - setReviewKnowledgeError('最新熟练度获取失败,当前显示本次计划生成时的熟练度。') - } finally { - setReviewKnowledgeLoading(false) - } - } - - async function finish(answers = attempt.answers, sessionOverride?: ReviewSession) { - const activeSession = sessionOverride || session - if (!activeSession || !gamePackage) return - setBusy(true) - setError('') - try { - if (runtimeManifest?.reviewSessionsAvailable === false) { - const completedSession = { ...activeSession, status: 'COMPLETED' as const, completedAt: new Date().toISOString() } - setSession(completedSession) - updateWorkflow({ reviewSession: completedSession }) - setShellCompleted(true) - setProgress('C++/JS 基础壳已完成本地体验;本次结果未提交,掌握度不会更新。') - clearCompletedReview() - await loadReviewKnowledge(answers) - return - } - const completed = await api.submitReviewResult(activeSession.sessionId, { - expectedProgressVersion: activeSession.progressVersion, - idempotencyKey: resultKeyRef.current, - reviewPlanId: gamePackage.reviewPlanId, - snapshotVersion: gamePackage.snapshotVersion, - answerResults: answers, - durationSeconds: Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1_000)), - }) - setResult(completed) - const completedSession = { ...activeSession, status: 'COMPLETED' as const, completedAt: new Date().toISOString() } - setSession(completedSession) - updateWorkflow({ reviewSession: completedSession }) - setProgress(completed.status === 'DUPLICATE' ? '这次结果已经提交过。' : '本次复习结果已提交。') - clearCompletedReview() - await loadReviewKnowledge(answers) - } catch (reason) { - setError(reason instanceof Error ? reason.message : '结果提交失败。') + setError(errorMessage(reason, '会话恢复失败。')) } finally { setBusy(false) } } - async function finishCurrentScene() { - if (!scene) return - setBusy(true) - try { - const events = adapterRef.current?.dispatchInput({ type: 'ADVANCE' }) || [] - if (adapterRef.current?.engine === 'wasm' && !runtimeEvent(events, 'SESSION_COMPLETED')) { - const runtimeError = adapterRef.current.lastError?.() - throw new Error(runtimeError?.message || '渲染运行时无法结束当前场景。') - } - const savedSession = await saveSceneProgress(scene.sceneId, visitedSceneIds) - await finish(attempt.answers, savedSession) - } catch (reason) { - setError(reason instanceof Error ? reason.message : '进度保存失败。') - setBusy(false) - } - } - - async function finishReviewEarly() { - if (!session || !gamePackage) return - await finish(attempt.answers, session) - } - function resetGame() { - adapterRef.current?.dispose() - adapterRef.current = null - resultKeyRef.current = createUuidV4() - setGamePackage(undefined) + runtime.disposeAdapter() + runtime.setRuntimeManifest(undefined) + story.resetProgress() setGeneration(undefined) - setSession(undefined) - setRuntimeManifest(undefined) - setSceneId('') - setVisitedSceneIds([]) - setAttempt({ answers: [], attemptsByQuestion: {} }) - setResult(undefined) - setShellCompleted(false) - setChoiceFeedback(undefined) - setReviewKnowledge([]) - setReviewKnowledgeLoading(false) - setReviewKnowledgeError('') setError('') setProgress('可以调整风格与难度,再生成一次。') - updateWorkflow({ gameGeneration: undefined, gameManifest: undefined, gamePackage: undefined, reviewSession: undefined, visitedSceneIds: undefined, answerResults: undefined, resultIdempotencyKey: resultKeyRef.current }) + updateWorkflow({ gameGeneration: undefined, gameManifest: undefined, gamePackage: undefined, reviewSession: undefined, visitedSceneIds: undefined, answerResults: undefined, resultIdempotencyKey: story.resultKeyRef.current }) } async function restartReview() { @@ -735,26 +215,12 @@ useEffect(() => { updateWorkflow({ plan: nextPlan, gameStyle: style, gameDifficulty: difficulty }) setProgress(`正在按最新掌握度重新检索 ${nextPlan.selectedChapterIds.length} 个章节…`) } catch (reason) { - setError(reason instanceof Error ? reason.message : '新一轮故事复习准备失败。'); setBusy(false); return + setError(errorMessage(reason, '新一轮故事复习准备失败。')); setBusy(false); return } setBusy(false) await generateAndStart() } - if (!initial.plan || !projectId || initial.projectId !== projectId) { - return

请从研习册开启故事复习

返回研习册
- } - - function advanceDialogue() { - if (!scene || choiceFeedback) return - if (dialogueTyping) { - setTypedLength(dialogueCharacters.length) - return - } - if (dialogueCompleted) return - setDialogueIndex((current) => Math.min(current + 1, scene.dialogue.length - 1)) - } - async function toggleGameFullscreen() { const stage = gameStageRef.current if (!stage) return @@ -766,105 +232,73 @@ useEffect(() => { } } - const showSetup = !gamePackage || !isSessionResumable(session) || !adapterRef.current + if (!initial.plan || !projectId || initial.projectId !== projectId) { + return

请从研习册开启故事复习

返回研习册
+ } + + const showSetup = !story.gamePackage || !isSessionResumable(story.session) || !runtime.adapterRef.current return (
- {result || shellCompleted ? ( -
-

复习完成

-

{shellCompleted ? '本地体验已完成' : result?.status === 'ACCEPTED' ? '结果已提交' : '结果已去重'}

-

{shellCompleted ? `本地记录 ${attempt.answers.length} 条作答。当前 RenderService 仅提供基础壳,本次结果没有提交,熟练度不会更新。` : `共记录 ${attempt.answers.length} 条作答证据。`}

-
-

本次复习知识点

{reviewKnowledgeLoading ? : null}
- {reviewKnowledge.length ?
    {reviewKnowledge.map((item) =>
  • -
    {item.title}熟练度 {Math.round(item.masteryScore)} / 100
    -

    下次复习:{formatReviewTime(item.nextReviewAt)}

    -
  • )}
:

本轮没有记录到知识点。

} - {reviewKnowledgeError ?

{reviewKnowledgeError}

: null} -
-
- - - - 查看知识图谱回到知识结构,查看掌握情况 - - - - 返回研习册改选章节或换一种温习方式 - -
-
+ {story.result || story.shellCompleted ? ( + void restartReview()} + /> ) : showSetup ? ( -
-
- ) : scene ? ( -
- {!backgroundsReady ? : null} -
{runtimeManifest?.wasmVersion} · {adapterRef.current?.engine === 'wasm' ? 'WASM' : '兼容模式'}{visitedSceneIds.length} 个场景已访问
-
- - {hasVoiceAssets ? : null} - -
-
- {scene.title ?

{scene.title}

: null} - {choiceFeedback ?
- {choiceFeedback.correct ? '回答正确' : '回答错误'} -
-

{choiceFeedback.correct - ? `你选择的「${choiceFeedback.selectedText}」是正确答案。这道题检验的是“${choiceFeedback.knowledgeTitle}”的关键判断。` - : `你选择了「${choiceFeedback.selectedText}」。正确答案是「${choiceFeedback.correctText || '题目给出的正确选项'}」,请留意“${choiceFeedback.knowledgeTitle}”。`}

-
-
: currentDialogue ?
- {currentDialogue.speakerId !== '旁白' ? {currentDialogue.speakerId} : null} -
- -

{typedDialogue}

-
-
: null} -
- {choiceFeedback ?
: dialogueCompleted && !dialogueTyping ? (scene.choices.length ?
{scene.choices.map((choice) => )}
: ) : null} -
+ void generateAndStart()} + onResumeGeneration={() => void resumeGeneration()} + onResumeRuntime={() => void resumeRuntime()} + /> + ) : story.scene ? ( + + asset.type === 'AUDIO' && asset.assetId.startsWith('voice-'), + ))} + voiceEnabled={audio.voiceEnabled} + voicePlaying={audio.voicePlaying} + onToggleVoice={() => audio.setVoiceEnabled((enabled) => !enabled)} + onEndReview={() => void story.finishReviewEarly()} + onToggleFullscreen={() => void toggleGameFullscreen()} + choiceFeedback={story.choiceFeedback} + currentDialogue={dialogue.currentDialogue} + typedDialogue={dialogue.typedDialogue} + dialogueTyping={dialogue.dialogueTyping} + dialogueCompleted={dialogue.dialogueCompleted} + dialogueScrollRef={dialogue.dialogueScrollRef} + onAdvanceDialogue={dialogue.advanceDialogue} + onChoose={(choice) => void story.choose(choice)} + onContinueFeedback={() => void story.continueAfterFeedback()} + onFinishScene={() => void story.finishCurrentScene()} + /> ) :

场景不存在

游戏包没有找到当前场景,请重新生成。

} {busy && showSetup && !error diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index e51a659..92e19c5 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react' import { useNavigate } from 'react-router' import AppShell, { PageHeader } from '../components/AppShell' import { api } from '../lib/api' +import { resolvePurchaseUrl } from '../lib/credits' import { clearSession, readProfile, saveProfile } from '../lib/session' import { readReducedMotion, saveReducedMotion } from '../lib/theme' import { resetWorkflow } from '../lib/workflow' @@ -70,7 +71,11 @@ export default function SettingsPage() { } function openPurchase() { - if (window.confirm('将前往购买页面。购买后请返回此处输入兑换码,是否继续?')) window.location.assign('https://pay.ldxp.cn/shop/7CX09W5E') + const purchaseUrl = resolvePurchaseUrl() + if (!purchaseUrl) return + if (window.confirm('将前往购买页面。购买后请返回此处输入兑换码,是否继续?')) { + window.location.assign(purchaseUrl) + } } async function submitProfile(event: FormEvent) { @@ -174,7 +179,7 @@ export default function SettingsPage() {

兑换 Credits

输入兑换码后,额度会立即加入当前账户。

-
+
{resolvePurchaseUrl() ? : null}
diff --git a/frontend/src/pages/review/GameStage.tsx b/frontend/src/pages/review/GameStage.tsx new file mode 100644 index 0000000..b83b38a --- /dev/null +++ b/frontend/src/pages/review/GameStage.tsx @@ -0,0 +1,110 @@ +/** 游戏舞台:场景背景、对白面板、选项列表与舞台操作。纯展示组件。 */ + +import type { RefObject } from 'react' +import LoadingIndicator from '../../components/LoadingIndicator' +import type { GameChoice, GameScene } from '../../types/api' +import type { ChoiceFeedback } from './helpers' + +interface GameStageProps { + stageRef: RefObject + scene: GameScene + backgroundsReady: boolean + busy: boolean + visitedCount: number + metaLabel: string + hasVoiceAssets: boolean + voiceEnabled: boolean + voicePlaying: boolean + onToggleVoice(): void + onEndReview(): void + onToggleFullscreen(): void + choiceFeedback: ChoiceFeedback | undefined + currentDialogue: GameScene['dialogue'][number] | undefined + typedDialogue: string + dialogueTyping: boolean + dialogueCompleted: boolean + dialogueScrollRef: RefObject + onAdvanceDialogue(): void + onChoose(choice: GameChoice): void + onContinueFeedback(): void + onFinishScene(): void +} + +export default function GameStage({ + stageRef, + scene, + backgroundsReady, + busy, + visitedCount, + metaLabel, + hasVoiceAssets, + voiceEnabled, + voicePlaying, + onToggleVoice, + onEndReview, + onToggleFullscreen, + choiceFeedback, + currentDialogue, + typedDialogue, + dialogueTyping, + dialogueCompleted, + dialogueScrollRef, + onAdvanceDialogue, + onChoose, + onContinueFeedback, + onFinishScene, +}: GameStageProps) { + return ( +
+ {!backgroundsReady ? : null} +
{metaLabel}{visitedCount} 个场景已访问
+
+ + {hasVoiceAssets ? : null} + +
+
{ + if (choiceFeedback) return + // 避免与滚动交互冲突:滚轮已 stopPropagation;点击仍推进剧情 + onAdvanceDialogue() + }} + onWheel={(event) => { + // 阻止滚轮冒泡到外层,方便在对白框内上下滚动长文 + if ((event.target as HTMLElement).closest('.dialogue-line__text')) { + event.stopPropagation() + } + }} + > + {scene.title ?

{scene.title}

: null} + {choiceFeedback ?
+ {choiceFeedback.correct ? '回答正确' : '回答错误'} +
+

{choiceFeedback.correct + ? `你选择的「${choiceFeedback.selectedText}」是正确答案。这道题检验的是“${choiceFeedback.knowledgeTitle}”的关键判断。` + : `你选择了「${choiceFeedback.selectedText}」。正确答案是「${choiceFeedback.correctText || '题目给出的正确选项'}」,请留意“${choiceFeedback.knowledgeTitle}”。`}

+
+
: currentDialogue ?
+ {currentDialogue.speakerId !== '旁白' ? {currentDialogue.speakerId} : null} +
+ +

{typedDialogue}

+
+
: null} +
+ {choiceFeedback ?
: dialogueCompleted && !dialogueTyping ? (scene.choices.length ?
{scene.choices.map((choice) => )}
: ) : null} +
+ ) +} diff --git a/frontend/src/pages/review/ResultPanel.tsx b/frontend/src/pages/review/ResultPanel.tsx new file mode 100644 index 0000000..f3d1c39 --- /dev/null +++ b/frontend/src/pages/review/ResultPanel.tsx @@ -0,0 +1,67 @@ +/** 复习完成结算面板:知识点熟练度汇总与后续动作。 */ + +import { Link } from 'react-router' +import LoadingIndicator from '../../components/LoadingIndicator' +import type { ReviewResult } from '../../types/api' +import { formatReviewTime } from './helpers' +import type { ReviewKnowledgeSummary } from './helpers' + +function CompletionActionIcon({ kind }: { kind: 'restart' | 'graph' | 'plan' }) { + if (kind === 'restart') return + if (kind === 'graph') return + return +} + +interface ResultPanelProps { + shellCompleted: boolean + result: ReviewResult | undefined + answerCount: number + busy: boolean + reviewKnowledge: ReviewKnowledgeSummary[] + reviewKnowledgeLoading: boolean + reviewKnowledgeError: string + projectId: string + onRestart(): void +} + +export default function ResultPanel({ + shellCompleted, + result, + answerCount, + busy, + reviewKnowledge, + reviewKnowledgeLoading, + reviewKnowledgeError, + projectId, + onRestart, +}: ResultPanelProps) { + return ( +
+

复习完成

+

{shellCompleted ? '本地体验已完成' : result?.status === 'ACCEPTED' ? '结果已提交' : '结果已去重'}

+

{shellCompleted ? `本地记录 ${answerCount} 条作答。当前 RenderService 仅提供基础壳,本次结果没有提交,熟练度不会更新。` : `共记录 ${answerCount} 条作答证据。`}

+
+

本次复习知识点

{reviewKnowledgeLoading ? : null}
+ {reviewKnowledge.length ?
    {reviewKnowledge.map((item) =>
  • +
    {item.title}熟练度 {Math.round(item.masteryScore)} / 100
    +

    下次复习:{formatReviewTime(item.nextReviewAt)}

    +
  • )}
:

本轮没有记录到知识点。

} + {reviewKnowledgeError ?

{reviewKnowledgeError}

: null} +
+
+ + + + 查看知识图谱回到知识结构,查看掌握情况 + + + + 返回研习册改选章节或换一种温习方式 + +
+
+ ) +} diff --git a/frontend/src/pages/review/SetupPanel.tsx b/frontend/src/pages/review/SetupPanel.tsx new file mode 100644 index 0000000..acbf1b5 --- /dev/null +++ b/frontend/src/pages/review/SetupPanel.tsx @@ -0,0 +1,68 @@ +/** 生成设置面板:风格/难度选择与生成、恢复入口。 */ + +import { Link } from 'react-router' +import { clearCompletedReview } from '../../lib/workflow' +import type { Difficulty, GameGenerationJob, GameStyle } from '../../types/api' + +interface SetupPanelProps { + style: GameStyle + difficulty: Difficulty + onStyleChange(style: GameStyle): void + onDifficultyChange(difficulty: Difficulty): void + estimatedQuestionCount: number + nodeCount: number + busy: boolean + /** 已有游戏包且会话可恢复 → 显示“恢复会话”。 */ + hasResumableRun: boolean + /** 上一次会话已结束 → 只能返回研习册。 */ + hasFinishedSession: boolean + generation: GameGenerationJob | undefined + projectId: string + onGenerate(): void + onResumeGeneration(): void + onResumeRuntime(): void +} + +export default function SetupPanel({ + style, + difficulty, + onStyleChange, + onDifficultyChange, + estimatedQuestionCount, + nodeCount, + busy, + hasResumableRun, + hasFinishedSession, + generation, + projectId, + onGenerate, + onResumeGeneration, + onResumeRuntime, +}: SetupPanelProps) { + return ( +
+
+ ) +} diff --git a/frontend/src/pages/review/helpers.ts b/frontend/src/pages/review/helpers.ts new file mode 100644 index 0000000..7806341 --- /dev/null +++ b/frontend/src/pages/review/helpers.ts @@ -0,0 +1,78 @@ +/** ReviewPage 拆分用的纯函数与共享类型。不包含 React 状态。 */ + +import { readSession } from '../../lib/session' +import { createUuidV4 } from '../../lib/uuid' +import type { AnswerResult, GamePackage, ReviewSession } from '../../types/api' + +export interface AttemptState { + answers: AnswerResult[] + attemptsByQuestion: Record +} + +export interface ChoiceFeedback { + correct: boolean + selectedText: string + correctText: string | null + knowledgeTitle: string + nextSceneId: string | null + nextVisitedSceneIds: string[] + answers: AnswerResult[] + savedSession?: ReviewSession +} + +export interface ReviewKnowledgeSummary { + pointId: string + title: string + masteryScore: number + nextReviewAt: string | null +} + +export function generationError(error: { message: string } | null): Error { + return new Error(error?.message || '游戏生成失败。') +} + +export function attemptsFromAnswers(answers: AnswerResult[]): Record { + return Object.fromEntries(answers.map((answer) => [answer.questionId, answer.attemptNumber])) +} + +export function runtimeEvent(events: Array>, type: string) { + return events.find((event) => event.type === type) +} + +export function voiceAssetId(sceneIndex: number, lineIndex: number): string { + return `voice-${String(sceneIndex).padStart(3, '0')}-${String(lineIndex).padStart(3, '0')}` +} + +export function isSessionResumable(session: ReviewSession | undefined): boolean { + return session?.status === 'CREATED' || session?.status === 'RUNNING' +} + +export function formatReviewTime(value: string | null): string { + if (!value) return '暂无安排' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '暂无安排' + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date) +} + +export function createShellSession(gamePackage: GamePackage): ReviewSession { + const userId = readSession()?.session.userId + if (!userId) throw new Error('登录会话已失效,请重新登录。') + return { + sessionId: createUuidV4(), + userId, + packageId: gamePackage.packageId, + reviewPlanId: gamePackage.reviewPlanId, + snapshotVersion: gamePackage.snapshotVersion, + status: 'CREATED', + currentSceneId: null, + progressVersion: 0, + startedAt: new Date().toISOString(), + completedAt: null, + } +} diff --git a/frontend/src/pages/review/useAudioStage.ts b/frontend/src/pages/review/useAudioStage.ts new file mode 100644 index 0000000..cc314d0 --- /dev/null +++ b/frontend/src/pages/review/useAudioStage.ts @@ -0,0 +1,158 @@ +/** BGM 循环与角色语音播放(含 BGM 压低/恢复与自动重试手势)。 */ + +import { useEffect, useRef, useState } from 'react' +import type { RefObject } from 'react' +import { api } from '../../lib/api' +import { + BGM_DUCKED_VOLUME, + BGM_VOLUME, + CHARACTER_VOICE_PLAYBACK_RATE, + CHARACTER_VOICE_VOLUME, +} from '../../lib/storyAssets' +import type { GamePackage, GameScene, ReviewResult, ReviewSession, WasmAdapter } from '../../types/api' +import { isSessionResumable, voiceAssetId } from './helpers' +import type { ChoiceFeedback } from './helpers' + +interface AudioStageOptions { + /** 场景、会话、adapter 与结果状态齐备且尚未结束时为 true;驱动 BGM 起停。 */ + gameplayActive: boolean + adapterVersion: number + adapterRef: RefObject + session: ReviewSession | undefined + gamePackage: GamePackage | undefined + scene: GameScene | undefined + currentDialogue: GameScene['dialogue'][number] | undefined + dialogueIndex: number + choiceFeedback: ChoiceFeedback | undefined + result: ReviewResult | undefined + shellCompleted: boolean +} + +export function useAudioStage(options: AudioStageOptions) { + const { + gameplayActive, + adapterVersion, + adapterRef, + session, + gamePackage, + scene, + currentDialogue, + dialogueIndex, + choiceFeedback, + result, + shellCompleted, + } = options + const bgmRef = useRef(null) + const voiceAudioRef = useRef(null) + const [voiceEnabled, setVoiceEnabled] = useState(true) + const [voicePlaying, setVoicePlaying] = useState(false) + + useEffect(() => { + if (!gameplayActive) return undefined + const audio = new Audio('/bgm.mp3') + let active = true + audio.loop = true + audio.preload = 'auto' + audio.volume = BGM_VOLUME + bgmRef.current = audio + + const startPlayback = () => { + if (!active) return + void audio.play().catch(() => {}) + } + + void audio.play().catch(() => { + if (active) document.addEventListener('pointerdown', startPlayback, { once: true }) + }) + + return () => { + active = false + document.removeEventListener('pointerdown', startPlayback) + audio.pause() + audio.currentTime = 0 + bgmRef.current = null + } + }, [gameplayActive]) + + useEffect(() => { + let active = true + let objectUrl = '' + let playback: HTMLAudioElement | null = null + let retryOnGesture: (() => void) | null = null + setVoicePlaying(false) + + if (!voiceEnabled + || !adapterRef.current + || !isSessionResumable(session) + || !gamePackage + || !scene + || !currentDialogue + || choiceFeedback + || result + || shellCompleted) { + return undefined + } + + const sceneIndex = gamePackage.scenes.findIndex((item) => item.sceneId === scene.sceneId) + if (sceneIndex < 0) return undefined + const assetId = voiceAssetId(sceneIndex, dialogueIndex) + const asset = gamePackage.assets.find((item) => item.type === 'AUDIO' && item.assetId === assetId) + if (!asset) return undefined + + const restoreBgm = () => { + if (bgmRef.current) bgmRef.current.volume = BGM_VOLUME + } + const beginPlayback = () => { + if (!active || !playback) return + if (bgmRef.current) bgmRef.current.volume = BGM_DUCKED_VOLUME + void playback.play().then(() => { + if (active) setVoicePlaying(true) + }).catch(() => { + restoreBgm() + if (active) setVoicePlaying(false) + }) + } + + void api.getGameAudio(asset.uri).then((blob) => { + if (!active) return + objectUrl = URL.createObjectURL(blob) + playback = new Audio(objectUrl) + playback.preload = 'auto' + playback.volume = CHARACTER_VOICE_VOLUME + playback.playbackRate = CHARACTER_VOICE_PLAYBACK_RATE + playback.preservesPitch = true + voiceAudioRef.current = playback + playback.addEventListener('ended', () => { + restoreBgm() + if (active) setVoicePlaying(false) + }, { once: true }) + playback.addEventListener('error', () => { + restoreBgm() + if (active) setVoicePlaying(false) + }, { once: true }) + void playback.play().then(() => { + if (bgmRef.current) bgmRef.current.volume = BGM_DUCKED_VOLUME + if (active) setVoicePlaying(true) + }).catch(() => { + restoreBgm() + retryOnGesture = beginPlayback + document.addEventListener('pointerdown', retryOnGesture, { once: true }) + }) + }).catch(() => { + // Voice is optional. Text dialogue remains usable when audio cannot be loaded. + restoreBgm() + }) + + return () => { + active = false + if (retryOnGesture) document.removeEventListener('pointerdown', retryOnGesture) + playback?.pause() + if (playback) playback.currentTime = 0 + if (voiceAudioRef.current === playback) voiceAudioRef.current = null + if (objectUrl) URL.revokeObjectURL(objectUrl) + restoreBgm() + } + }, [adapterVersion, choiceFeedback, currentDialogue, dialogueIndex, gamePackage, result, scene, session, shellCompleted, voiceEnabled, adapterRef]) + + return { voiceEnabled, setVoiceEnabled, voicePlaying } +} diff --git a/frontend/src/pages/review/useDialogueTyping.ts b/frontend/src/pages/review/useDialogueTyping.ts new file mode 100644 index 0000000..837ba76 --- /dev/null +++ b/frontend/src/pages/review/useDialogueTyping.ts @@ -0,0 +1,56 @@ +/** 对白打字机与推进:逐字显示、手动跳过、滚动跟随。 */ + +import { useEffect, useMemo, useRef, useState } from 'react' +import type { RefObject } from 'react' +import type { GameScene } from '../../types/api' + +export function useDialogueTyping(sceneId: string, scene: GameScene | undefined, locked: boolean) { + const [dialogueIndex, setDialogueIndex] = useState(0) + const [typedLength, setTypedLength] = useState(0) + const dialogueScrollRef = useRef(null) + + const currentDialogue = scene?.dialogue[dialogueIndex] + const dialogueCharacters = useMemo(() => Array.from(currentDialogue?.text || ''), [currentDialogue?.text]) + const typedDialogue = dialogueCharacters.slice(0, typedLength).join('') + const dialogueTyping = typedLength < dialogueCharacters.length + const dialogueCompleted = !scene || dialogueIndex >= scene.dialogue.length - 1 + + useEffect(() => { + setDialogueIndex(0) + }, [sceneId]) + + useEffect(() => { + setTypedLength(0) + if (!dialogueCharacters.length) return undefined + const timer = window.setInterval(() => { + setTypedLength((current) => { + const next = current + 1 + if (next >= dialogueCharacters.length) window.clearInterval(timer) + return Math.min(next, dialogueCharacters.length) + }) + }, 52) + return () => window.clearInterval(timer) + }, [dialogueCharacters]) + + // 长对白打字时保持可见底部;用户手动上滑后不强行抢回滚动 + useEffect(() => { + const el = dialogueScrollRef.current + if (!el) return + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48 + if (nearBottom || typedLength <= 1) { + el.scrollTop = el.scrollHeight + } + }, [typedLength, dialogueIndex, dialogueScrollRef]) + + function advanceDialogue() { + if (!scene || locked) return + if (dialogueTyping) { + setTypedLength(dialogueCharacters.length) + return + } + if (dialogueCompleted) return + setDialogueIndex((current) => Math.min(current + 1, scene.dialogue.length - 1)) + } + + return { dialogueIndex, currentDialogue, typedDialogue, dialogueTyping, dialogueCompleted, dialogueScrollRef, advanceDialogue } +} diff --git a/frontend/src/pages/review/useGameRuntime.ts b/frontend/src/pages/review/useGameRuntime.ts new file mode 100644 index 0000000..5490b57 --- /dev/null +++ b/frontend/src/pages/review/useGameRuntime.ts @@ -0,0 +1,53 @@ +/** WASM/JS 渲染运行时的加载、生命周期与渲染循环。 */ + +import { useEffect, useRef, useState } from 'react' +import { loadRuntime } from '../../lib/runtime' +import { errorMessage } from '../../lib/errorMessage' +import type { GamePackage, ReviewSession, RuntimeManifest, WasmAdapter } from '../../types/api' +import { isSessionResumable } from './helpers' + +export function useGameRuntime(setRenderError: (message: string) => void) { + const adapterRef = useRef(null) + const [adapterVersion, setAdapterVersion] = useState(0) + const [runtimeManifest, setRuntimeManifest] = useState() + + useEffect(() => () => adapterRef.current?.dispose(), []) + + useEffect(() => { + if (!adapterVersion) return + let frameId = 0 + let previous = performance.now() + const frame = (now: number) => { + const adapter = adapterRef.current + if (!adapter) return + try { + adapter.renderFrame(Math.min(100, Math.max(0, now - previous))) + } catch (reason) { + setRenderError(errorMessage(reason, '渲染运行时已停止。')) + return + } + previous = now + frameId = window.requestAnimationFrame(frame) + } + frameId = window.requestAnimationFrame(frame) + return () => window.cancelAnimationFrame(frameId) + }, [adapterVersion, setRenderError]) + + /** 为给定会话挂载渲染运行时;会话不可恢复时抛错(不触碰旧 adapter)。 */ + async function loadRuntimeFor(manifest: RuntimeManifest, pack: GamePackage, reviewSession: ReviewSession) { + if (!isSessionResumable(reviewSession)) { + throw new Error('上一次复习会话已经结束,不能继续恢复,请重新生成故事。') + } + adapterRef.current?.dispose() + adapterRef.current = await loadRuntime(manifest, pack, reviewSession) + setAdapterVersion((value) => value + 1) + setRuntimeManifest(manifest) + } + + function disposeAdapter() { + adapterRef.current?.dispose() + adapterRef.current = null + } + + return { adapterRef, adapterVersion, runtimeManifest, setRuntimeManifest, loadRuntimeFor, disposeAdapter } +} diff --git a/frontend/src/pages/review/useStoryProgress.ts b/frontend/src/pages/review/useStoryProgress.ts new file mode 100644 index 0000000..abf8c58 --- /dev/null +++ b/frontend/src/pages/review/useStoryProgress.ts @@ -0,0 +1,344 @@ +/** 复习剧情推进:会话/场景/作答/反馈/结果状态,以及选择、保存进度与结算。 */ + +import { useEffect, useMemo, useRef, useState } from 'react' +import type { RefObject } from 'react' +import { api } from '../../lib/api' +import { errorMessage } from '../../lib/errorMessage' +import { createUuidV4 } from '../../lib/uuid' +import { readWorkflow, updateWorkflow, clearCompletedReview } from '../../lib/workflow' +import type { + AnswerResult, + GameChoice, + GamePackage, + MasteryRecord, + ReviewResult, + ReviewSession, + RuntimeManifest, + WasmAdapter, +} from '../../types/api' +import type { StudyWorkflow } from '../../lib/workflow' +import { attemptsFromAnswers, runtimeEvent } from './helpers' +import type { AttemptState, ChoiceFeedback, ReviewKnowledgeSummary } from './helpers' + +interface StoryProgressOptions { + initial: StudyWorkflow + adapterRef: RefObject + runtimeManifest: RuntimeManifest | undefined + busy: boolean + setBusy(value: boolean): void + setError(message: string): void + setProgress(message: string): void +} + +export function useStoryProgress(options: StoryProgressOptions) { + const { initial, adapterRef, runtimeManifest, busy, setBusy, setError, setProgress } = options + const resultKeyRef = useRef(initial.resultIdempotencyKey || createUuidV4()) + const startedAtRef = useRef(Date.now()) + const sceneStartedAtRef = useRef(Date.now()) + const [gamePackage, setGamePackage] = useState(initial.gamePackage) + const [session, setSession] = useState(initial.reviewSession) + const initialSceneId = initial.reviewSession?.currentSceneId || initial.gamePackage?.entrySceneId || '' + const [sceneId, setSceneId] = useState(initialSceneId) + const [visitedSceneIds, setVisitedSceneIds] = useState(initial.visitedSceneIds?.length ? initial.visitedSceneIds : initialSceneId ? [initialSceneId] : []) + const [attempt, setAttempt] = useState({ + answers: initial.answerResults || [], + attemptsByQuestion: attemptsFromAnswers(initial.answerResults || []), + }) + const [result, setResult] = useState() + const [shellCompleted, setShellCompleted] = useState(false) + const [choiceFeedback, setChoiceFeedback] = useState() + const [reviewKnowledge, setReviewKnowledge] = useState([]) + const [reviewKnowledgeLoading, setReviewKnowledgeLoading] = useState(false) + const [reviewKnowledgeError, setReviewKnowledgeError] = useState('') + + const scene = useMemo( + () => gamePackage?.scenes.find((item) => item.sceneId === sceneId), + [gamePackage, sceneId], + ) + + useEffect(() => { + if (initial.plan && !initial.resultIdempotencyKey) updateWorkflow({ resultIdempotencyKey: resultKeyRef.current }) + }, [initial.plan, initial.resultIdempotencyKey]) + + /** 恢复/挂载渲染运行时后进入目标场景,并记录访问轨迹。 */ + function enterScene(nextSceneId: string, nextVisited: string[]) { + setSceneId(nextSceneId) + setChoiceFeedback(undefined) + setVisitedSceneIds(nextVisited) + updateWorkflow({ visitedSceneIds: nextVisited }) + sceneStartedAtRef.current = Date.now() + } + + /** 开始新一轮生成前清空上次的作答与反馈。 */ + function beginFreshRun() { + setAttempt({ answers: [], attemptsByQuestion: {} }) + setShellCompleted(false) + setChoiceFeedback(undefined) + setReviewKnowledge([]) + setReviewKnowledgeError('') + } + + /** 重新开始:清空剧情/会话/作答状态并滚动新的结果幂等键。 */ + function resetProgress() { + resultKeyRef.current = createUuidV4() + setGamePackage(undefined) + setSession(undefined) + setSceneId('') + setVisitedSceneIds([]) + setAttempt({ answers: [], attemptsByQuestion: {} }) + setResult(undefined) + setShellCompleted(false) + setChoiceFeedback(undefined) + setReviewKnowledge([]) + setReviewKnowledgeLoading(false) + setReviewKnowledgeError('') + } + + function markRunStarted() { + startedAtRef.current = Date.now() + } + + function createAnswer(choice: GameChoice, attemptId: string, occurredAt: string, event?: Record): AnswerResult | null { + if (choice.answerKind !== 'CHOICE' || typeof choice.correct !== 'boolean') return null + const responseTimeMs = Math.max(0, Date.now() - sceneStartedAtRef.current) + const attemptNumber = Math.max((attempt.attemptsByQuestion[choice.questionId] || 0) + 1, typeof event?.attemptNumber === 'number' ? event.attemptNumber : 1) + const correct = typeof event?.correct === 'boolean' ? event.correct : choice.correct + const quality = correct ? (attemptNumber > 1 ? 3 : 5) : 0 + return { + attemptId, + questionId: choice.questionId, + knowledgePointId: choice.knowledgePointId, + answerKind: 'CHOICE', + choiceId: choice.choiceId, + correct, + quality, + scoreDelta: choice.scoreDelta, + responseTimeMs, + hintsUsed: 0, + attemptNumber, + occurredAt, + } + } + + async function saveSceneProgress(nextSceneId: string, nextVisited: string[]): Promise { + if (!session || !adapterRef.current) return undefined + if (runtimeManifest?.reviewSessionsAvailable === false) { + const nextSession: ReviewSession = { + ...session, + currentSceneId: nextSceneId, + progressVersion: session.progressVersion + 1, + status: 'RUNNING', + } + setSession(nextSession) + updateWorkflow({ reviewSession: nextSession }) + return nextSession + } + const saved = await api.saveProgress(session.sessionId, { + expectedVersion: session.progressVersion, + currentSceneId: nextSceneId, + visitedSceneIds: nextVisited, + runtimeState: adapterRef.current.serializeState(), + }) + const nextSession = { ...session, currentSceneId: saved.currentSceneId, progressVersion: saved.version, status: 'RUNNING' as const } + setSession(nextSession) + updateWorkflow({ reviewSession: nextSession }) + return nextSession + } + + async function choose(choice: GameChoice) { + if (!scene || !session || busy || choiceFeedback) return + setBusy(true) + setError('') + try { + const attemptId = createUuidV4() + const occurredAt = new Date().toISOString() + const events = adapterRef.current?.dispatchInput({ type: 'CHOICE_SELECTED', choiceId: choice.choiceId, attemptId, occurredAt }) || [] + if (adapterRef.current?.engine === 'wasm' && !events.length) { + const runtimeError = adapterRef.current.lastError?.() + throw new Error(runtimeError?.message || '渲染运行时拒绝了当前选择。') + } + const answer = createAnswer(choice, attemptId, occurredAt, runtimeEvent(events, 'ANSWER_RECORDED')) + const nextAnswers = answer ? [...attempt.answers, answer] : attempt.answers + if (answer) { + setAttempt((current) => ({ + answers: [...current.answers, answer], + attemptsByQuestion: { ...current.attemptsByQuestion, [answer.questionId]: answer.attemptNumber }, + })) + updateWorkflow({ answerResults: nextAnswers }) + } + if (runtimeManifest?.reviewSessionsAvailable !== false) { + void api.appendEvents(session.sessionId, [{ + clientEventId: createUuidV4(), + type: 'CHOICE_SELECTED', + occurredAt, + payload: { + sceneId: scene.sceneId, + choiceId: choice.choiceId, + questionId: choice.questionId, + knowledgePointId: choice.knowledgePointId, + }, + }]).catch(() => undefined) + } + const entered = runtimeEvent(events, 'SCENE_ENTERED') + const nextSceneId = typeof entered?.sceneId === 'string' ? entered.sceneId : choice.nextSceneId + const completedByRuntime = Boolean(runtimeEvent(events, 'SESSION_COMPLETED')) + let nextVisited = visitedSceneIds + let savedSession: ReviewSession | undefined + if (nextSceneId && !completedByRuntime) { + nextVisited = visitedSceneIds.includes(nextSceneId) ? visitedSceneIds : [...visitedSceneIds, nextSceneId] + savedSession = await saveSceneProgress(nextSceneId, nextVisited) + setVisitedSceneIds(nextVisited) + updateWorkflow({ visitedSceneIds: nextVisited }) + } else { + savedSession = await saveSceneProgress(scene.sceneId, visitedSceneIds) + } + + if (answer) { + const correctChoice = scene.choices.find((item) => item.correct === true) + const knowledgeTitle = initial.plan?.nodes.find((node) => node.pointId === answer.knowledgePointId)?.title || '当前知识点' + setChoiceFeedback({ + correct: answer.correct, + selectedText: choice.text, + correctText: correctChoice?.text || null, + knowledgeTitle, + nextSceneId: nextSceneId && !completedByRuntime ? nextSceneId : null, + nextVisitedSceneIds: nextVisited, + answers: nextAnswers, + savedSession, + }) + } else if (nextSceneId && !completedByRuntime) { + setSceneId(nextSceneId) + sceneStartedAtRef.current = Date.now() + } else { + await finish(nextAnswers, savedSession) + } + } catch (reason) { + setError(errorMessage(reason, '选择保存失败。')) + } finally { + setBusy(false) + } + } + + async function continueAfterFeedback() { + if (!choiceFeedback || busy) return + if (choiceFeedback.nextSceneId) { + setVisitedSceneIds(choiceFeedback.nextVisitedSceneIds) + setSceneId(choiceFeedback.nextSceneId) + setChoiceFeedback(undefined) + sceneStartedAtRef.current = Date.now() + return + } + await finish(choiceFeedback.answers, choiceFeedback.savedSession) + } + + async function loadReviewKnowledge(answers: AnswerResult[]) { + const workflow = readWorkflow() + const plan = workflow.plan || initial.plan + if (!plan) return + + const reviewedPointIds = new Set(answers.map((answer) => answer.knowledgePointId)) + const visited = new Set(workflow.visitedSceneIds || visitedSceneIds) + for (const reviewedScene of gamePackage?.scenes || []) { + if (!visited.has(reviewedScene.sceneId)) continue + for (const binding of reviewedScene.knowledgeBindings) reviewedPointIds.add(binding.knowledgePointId) + } + const reviewedNodes = plan.nodes.filter((node) => reviewedPointIds.has(node.pointId)) + const summaryNodes = reviewedNodes.length ? reviewedNodes : plan.nodes + const fallback = summaryNodes.map((node) => ({ + pointId: node.pointId, + title: node.title, + masteryScore: node.masteryScore, + nextReviewAt: null, + })) + setReviewKnowledge(fallback) + setReviewKnowledgeLoading(true) + setReviewKnowledgeError('') + try { + const records = await api.getAllMasteryRecords(plan.graphId) + const recordByPointId = new Map(records.map((record) => [record.pointId, record])) + setReviewKnowledge(fallback.map((item) => { + const record = recordByPointId.get(item.pointId) + return record ? { ...item, masteryScore: record.score, nextReviewAt: record.nextReviewAt } : item + })) + } catch { + setReviewKnowledgeError('最新熟练度获取失败,当前显示本次计划生成时的熟练度。') + } finally { + setReviewKnowledgeLoading(false) + } + } + + async function finish(answers = attempt.answers, sessionOverride?: ReviewSession) { + const activeSession = sessionOverride || session + if (!activeSession || !gamePackage) return + setBusy(true) + setError('') + try { + if (runtimeManifest?.reviewSessionsAvailable === false) { + const completedSession = { ...activeSession, status: 'COMPLETED' as const, completedAt: new Date().toISOString() } + setSession(completedSession) + updateWorkflow({ reviewSession: completedSession }) + setShellCompleted(true) + setProgress('C++/JS 基础壳已完成本地体验;本次结果未提交,掌握度不会更新。') + clearCompletedReview() + await loadReviewKnowledge(answers) + return + } + const completed = await api.submitReviewResult(activeSession.sessionId, { + expectedProgressVersion: activeSession.progressVersion, + idempotencyKey: resultKeyRef.current, + reviewPlanId: gamePackage.reviewPlanId, + snapshotVersion: gamePackage.snapshotVersion, + answerResults: answers, + durationSeconds: Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1_000)), + }) + setResult(completed) + const completedSession = { ...activeSession, status: 'COMPLETED' as const, completedAt: new Date().toISOString() } + setSession(completedSession) + updateWorkflow({ reviewSession: completedSession }) + setProgress(completed.status === 'DUPLICATE' ? '这次结果已经提交过。' : '本次复习结果已提交。') + clearCompletedReview() + await loadReviewKnowledge(answers) + } catch (reason) { + setError(errorMessage(reason, '结果提交失败。')) + } finally { + setBusy(false) + } + } + + async function finishCurrentScene() { + if (!scene) return + setBusy(true) + try { + const events = adapterRef.current?.dispatchInput({ type: 'ADVANCE' }) || [] + if (adapterRef.current?.engine === 'wasm' && !runtimeEvent(events, 'SESSION_COMPLETED')) { + const runtimeError = adapterRef.current.lastError?.() + throw new Error(runtimeError?.message || '渲染运行时无法结束当前场景。') + } + const savedSession = await saveSceneProgress(scene.sceneId, visitedSceneIds) + await finish(attempt.answers, savedSession) + } catch (reason) { + setError(errorMessage(reason, '进度保存失败。')) + setBusy(false) + } + } + + async function finishReviewEarly() { + if (!session || !gamePackage) return + await finish(attempt.answers, session) + } + + return { + gamePackage, setGamePackage, + session, setSession, + scene, sceneId, + visitedSceneIds, + attempt, + result, + shellCompleted, + choiceFeedback, + reviewKnowledge, reviewKnowledgeLoading, reviewKnowledgeError, + resultKeyRef, + enterScene, beginFreshRun, resetProgress, markRunStarted, + choose, continueAfterFeedback, finish, finishCurrentScene, finishReviewEarly, loadReviewKnowledge, + } +} diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css new file mode 100644 index 0000000..905dfb9 --- /dev/null +++ b/frontend/src/styles/admin.css @@ -0,0 +1,203 @@ +/* Refined management console. Kept fully scoped so the learner workspace is unaffected. */ +.admin-page { + --admin-blue: #0d72d9; + --admin-blue-deep: #075fb9; + --admin-blue-soft: #eaf4ff; + min-height: 100vh; + background: #f6f7f9; +} +.admin-page .admin-header { + min-height: 64px; + padding: 10px clamp(24px, 4vw, 56px); + border-bottom: 1px solid #e4e7eb; + background: rgba(255, 255, 255, .94); + box-shadow: none; + backdrop-filter: blur(14px); +} +.admin-brand { display: inline-flex; align-items: center; gap: 11px; color: #202124; text-decoration: none; } +.admin-page .admin-header .admin-brand .brand-mark { width: 34px; height: 24px; } +.admin-brand > span { display: flex; min-width: 0; flex-direction: column; gap: 0; } +.admin-brand strong { color: #202124; font-size: 20px; line-height: 1.2; letter-spacing: .08em; } +.admin-header__actions { gap: 8px !important; } +.admin-page .admin-header__actions .button { + min-height: 36px; + padding: 7px 11px; + border-color: transparent; + border-radius: 9px; + color: #5f6670; + background: transparent; + box-shadow: none; +} +.admin-page .admin-header__actions .button:hover:not(:disabled) { border-color: transparent; color: #202124; background: #f0f2f4; } +.admin-header__actions svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; } +.admin-page .admin-header__actions .admin-logout { color: #747b84; } + +.admin-page .admin-content { width: min(1280px, 100%); padding: 34px clamp(24px, 4vw, 52px) 64px; } +.admin-hero { display: flex; align-items: center; justify-content: space-between; gap: 32px; margin-bottom: 24px; } +.admin-hero__copy { min-width: 0; } +.admin-hero h1 { margin: 0; color: #202124; font-size: clamp(28px, 2.6vw, 36px); line-height: 1.15; letter-spacing: -.035em; } +.admin-page .admin-grid { grid-template-columns: minmax(0, 1.1fr) minmax(360px, .9fr); gap: 16px; align-items: stretch; } +.admin-page .admin-grid > .workspace-card { + display: flex; + height: clamp(540px, calc(100vh - 230px), 700px); + min-width: 0; + padding: 0; + flex-direction: column; + overflow: hidden; + border: 1px solid #e1e4e8; + border-radius: 16px; + background: #fff; + box-shadow: none; +} +.admin-page .admin-grid article > .admin-card__header { + display: flex; + min-height: 70px; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 0; + padding: 15px 18px; + border-bottom: 1px solid #eceef1; + background: #fff; +} +.admin-card__header > div { display: flex; min-width: 0; align-items: center; gap: 12px; } +.admin-card__header > div > span:last-child { display: grid; gap: 2px; min-width: 0; } +.admin-card__header h2 { margin: 0; color: #2a2d31; font-size: 15px; letter-spacing: -.01em; } +.admin-card__icon { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border-radius: 12px; color: var(--admin-blue); background: var(--admin-blue-soft); } + +.admin-page .admin-table, .admin-page .invitation-list { border-top: 0; } +.admin-page .admin-table, +.admin-page .invitation-list { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + scrollbar-color: #c6cbd1 transparent; + scrollbar-width: thin; +} +.admin-page .admin-table::-webkit-scrollbar, +.admin-page .invitation-list::-webkit-scrollbar { width: 7px; } +.admin-page .admin-table::-webkit-scrollbar-thumb, +.admin-page .invitation-list::-webkit-scrollbar-thumb { border-radius: 999px; background: #c6cbd1; } +.admin-page .admin-table::-webkit-scrollbar-track, +.admin-page .invitation-list::-webkit-scrollbar-track { background: transparent; } +.admin-page .admin-table > div { + grid-template-columns: minmax(0, 1fr) auto auto; + min-height: 68px; + gap: 14px; + padding: 10px 18px; + border-bottom-color: #eceef1; +} +.admin-page .admin-table > div:last-of-type { border-bottom: 0; } +.admin-page .admin-table > div:hover, .admin-page .invitation-list > div:hover { background: #f7f8fa; } +.admin-user { display: flex !important; min-width: 0; align-items: center; gap: 11px; } +.admin-user > b { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: 10px; color: #fff; background: #0d72d9; font-size: 13px; box-shadow: none; } +.admin-user > span { display: grid; min-width: 0; gap: 3px; } +.admin-user strong { color: #34383d; font-size: 13px; } +.admin-user small { color: #8a9199; font-size: 11px; } +.admin-user .admin-user__credits { color: #597793; font-size: 11px; } +.admin-user__credits b { color: var(--admin-blue); font-weight: 700; } +.admin-status { display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 999px; color: #80868e !important; background: #f0f2f4; font-size: 10px !important; font-weight: 700; } +.admin-status i { width: 6px; height: 6px; border-radius: 50%; background: #a8b0ba; } +.admin-status.is-active { color: #27835a !important; background: #e9f8f0; } +.admin-status.is-active i { background: #33a66f; box-shadow: 0 0 0 3px rgba(51, 166, 111, .12); } +.admin-row-actions { opacity: 1; } +.admin-page .admin-row-actions button:not(.danger), .admin-page .invitation-list button { + min-height: 30px; + padding: 5px 10px; + border: 1px solid #d8e1eb; + border-radius: 9px; + color: #627083; + background: #fff; + font-size: 10px; +} +.admin-page .admin-row-actions button:not(.danger):hover:not(:disabled) { border-color: #b9d7f5; color: var(--admin-blue); background: #f3f9ff; } +.admin-page .admin-row-actions button.danger { min-height: 30px; padding: 5px 9px; border: 1px solid transparent; border-radius: 9px; color: #d45c65; background: transparent; font-size: 10px; cursor: pointer; } +.admin-page .admin-row-actions button.danger:hover:not(:disabled), .admin-page .invitation-list button:hover:not(:disabled) { border-color: #f5d0d4; color: #c84853; background: #fff5f6; } + +.admin-page .admin-invitation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0; padding: 18px; border-bottom: 1px solid #eceef1; } +.admin-page .admin-invitation-form label { gap: 7px; color: #6f767f; font-size: 11px; font-weight: 600; } +.admin-page .admin-invitation-form input, .admin-page .admin-invitation-form select { min-height: 40px; padding: 8px 11px; border-color: #dde1e6; border-radius: 9px; color: #34383d; background: #f7f8fa; font-size: 12px; } +.admin-page .admin-invitation-form input:focus, .admin-page .admin-invitation-form select:focus { border-color: #9fc8f1; background: #fff; box-shadow: 0 0 0 3px rgba(13, 114, 217, .09); } +.admin-page .admin-invitation-form .button { min-height: 40px; align-self: end; gap: 5px; border-color: var(--admin-blue); border-radius: 9px; background: var(--admin-blue); font-size: 12px; font-weight: 700; box-shadow: none; } +.admin-page .admin-invitation-form .button:hover:not(:disabled) { border-color: var(--admin-blue-deep); color: #fff; background: var(--admin-blue-deep); } +.admin-page .admin-generated-codes { margin: 0; padding: 16px 18px; border-bottom: 1px solid #eceef1; font-size: 11px; } +.admin-generated-codes__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.admin-page .admin-generated-codes textarea { border-radius: 9px; background: #f7f8fa; font-size: 14px; } +.admin-page .invitation-list > div { min-height: 62px; padding: 10px 18px; border-bottom-color: #eceef1; } +.admin-code-line { display: flex; min-width: 0; align-items: center; gap: 8px; } +.admin-page .invitation-list strong { min-width: 0; color: #34383d; font-family: Consolas, "SFMono-Regular", monospace; font-size: 15px; letter-spacing: .035em; } +.admin-page .invitation-list small { color: #8a9199; font-size: 11px; } +.admin-page .invitation-list button { border-color: transparent; color: #d45c65; background: transparent; } +.admin-page .invitation-list button.admin-copy-code, +.admin-page .admin-generated-codes button.admin-copy-code { + min-height: 27px; + flex: 0 0 auto; + padding: 4px 8px; + border: 0; + border-radius: 7px; + color: var(--admin-blue); + background: transparent; + cursor: pointer; + font-size: 11px; + font-weight: 700; +} +.admin-page .invitation-list button.admin-copy-code:hover:not(:disabled), +.admin-page .admin-generated-codes button.admin-copy-code:hover:not(:disabled) { border-color: transparent; color: var(--admin-blue-deep); background: transparent; } +.admin-empty { display: grid; min-height: 130px; place-items: center; align-content: center; gap: 8px; margin: 0; color: #9298a0; font-size: 12px; } +.admin-empty span { color: #bfd5ea; font-size: 18px; letter-spacing: .3em; } +.admin-page .admin-content > .status-line { margin-top: 16px; border: 1px solid #dbe7f3; border-left: 3px solid var(--admin-blue); border-radius: 11px; color: #687586; background: rgba(245, 250, 255, .92); } +.admin-page .admin-content > .status-line--error { border-color: #f2c8cd; border-left-color: var(--danger); color: #c45656; background: #fff5f6; } +.admin-confirm-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(22, 27, 34, .42); backdrop-filter: blur(5px); } +.admin-confirm { width: min(420px, 100%); padding: 26px; border: 1px solid #e1e4e8; border-radius: 18px; background: #fff; box-shadow: 0 24px 70px -24px rgba(16, 24, 40, .42); } +.admin-confirm__eyebrow { display: block; margin-bottom: 8px; color: #c5535e; font-size: 11px; font-weight: 700; letter-spacing: .08em; } +.admin-confirm h2 { margin: 0; color: #25292e; font-size: 21px; } +.admin-confirm > p { margin: 12px 0 0; color: #6d747d; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } +.admin-confirm > p strong { color: #34383d; } +.admin-confirm .admin-confirm__error { padding: 9px 11px; border-radius: 9px; color: #b9434e; background: #fff2f3; } +.admin-password-reset { width: min(460px, 100%); } +.admin-password-reset label { display: grid; gap: 7px; margin-top: 16px; color: #4f5864; font-size: 12px; font-weight: 700; } +.admin-password-reset input { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid #d9e0e8; border-radius: 11px; color: #25292e; background: #f8fafc; outline: none; } +.admin-password-reset input:focus { border-color: var(--admin-blue); background: #fff; box-shadow: 0 0 0 3px rgba(13, 114, 217, .12); } +.admin-confirm__actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; } +.admin-confirm__actions .button { min-width: 0; min-height: 38px; flex: 1 1 0; border-radius: 10px; } +:root[data-theme="dark"] .admin-confirm { border-color: #424850; background: #292e35; } +:root[data-theme="dark"] .admin-confirm h2, :root[data-theme="dark"] .admin-confirm > p strong { color: #edf3f9; } +:root[data-theme="dark"] .admin-confirm > p { color: #b1bac5; } +:root[data-theme="dark"] .admin-confirm .admin-confirm__error { color: #ff9ca5; background: #432b30; } +:root[data-theme="dark"] .admin-password-reset label { color: #bac4cf; } +:root[data-theme="dark"] .admin-password-reset input { border-color: #444c57; color: #edf3f9; background: #242a31; } +:root[data-theme="dark"] .admin-password-reset input:focus { border-color: #4598e7; background: #20262d; } + +:root[data-theme="dark"] .admin-page { background: #20242a; } +:root[data-theme="dark"] .admin-page .admin-header { border-color: #363c45; background: rgba(31, 35, 41, .9); } +:root[data-theme="dark"] .admin-brand strong, :root[data-theme="dark"] .admin-hero h1 { color: #edf3f9; } +:root[data-theme="dark"] .admin-page .admin-header__actions .button { border-color: #414852; color: #b8c0ca; background: #292e35; } +:root[data-theme="dark"] .admin-page .admin-header__actions .admin-logout { background: transparent; } +:root[data-theme="dark"] .admin-page .admin-grid > .workspace-card { border-color: #373e48; background: rgba(42, 47, 55, .94); box-shadow: none; } +:root[data-theme="dark"] .admin-page .admin-grid article > .admin-card__header { border-color: #3a414a; background: linear-gradient(180deg, #2c323a, #292e35); } +:root[data-theme="dark"] .admin-card__header h2, :root[data-theme="dark"] .admin-user strong, :root[data-theme="dark"] .admin-page .invitation-list strong { color: #dce4ec; } +:root[data-theme="dark"] .admin-page .admin-table > div, :root[data-theme="dark"] .admin-page .admin-invitation-form, :root[data-theme="dark"] .admin-page .invitation-list > div { border-color: #383f48; } +:root[data-theme="dark"] .admin-page .admin-table > div:hover, :root[data-theme="dark"] .admin-page .invitation-list > div:hover { background: #303740; } +:root[data-theme="dark"] .admin-page .admin-row-actions button:not(.danger), :root[data-theme="dark"] .admin-page .admin-invitation-form input, :root[data-theme="dark"] .admin-page .admin-invitation-form select { border-color: #444c57; color: #c4ccd5; background: #242a31; } +:root[data-theme="dark"] .admin-page .admin-content > .status-line { border-color: #3b5066; border-left-color: #4598e7; color: #b9c7d5; background: #273441; } + +@media (max-width: 1040px) { + .admin-hero { align-items: stretch; flex-direction: column; } + .admin-page .admin-grid { grid-template-columns: 1fr; } + .admin-page .admin-grid > .workspace-card { height: auto; } + .admin-page .admin-table, .admin-page .invitation-list { max-height: 420px; } +} + +@media (max-width: 680px) { + .admin-page .admin-header { min-height: 64px; padding: 10px 16px; align-items: center; flex-direction: row; } + .admin-brand strong { font-size: 17px; } + .admin-page .admin-header__actions .button { min-height: 36px; padding: 6px 10px; font-size: 11px; } + .admin-page .admin-content { padding: 28px 16px 52px; } + .admin-hero { gap: 22px; margin-bottom: 20px; } + .admin-page .admin-grid > .workspace-card { border-radius: 16px; } + .admin-page .admin-table > div { grid-template-columns: minmax(0, 1fr) auto; padding: 12px 15px; } + .admin-row-actions { grid-column: 1 / -1; opacity: 1; } + .admin-page .admin-invitation-form { grid-template-columns: 1fr; padding: 17px 15px; } + .admin-page .invitation-list > div { padding-inline: 15px; } +} + diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css new file mode 100644 index 0000000..8e35a18 --- /dev/null +++ b/frontend/src/styles/auth.css @@ -0,0 +1,308 @@ + +/* Authentication entry design preserved from frontend1. */ +/* Login and registration use a dedicated, editorial two-column composition. + Other account routes keep the compact shared auth layout above. */ +.auth-page--entry { position: relative; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); overflow: hidden; background: #fff; font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; } +.auth-page--entry .auth-page__content { display: flex; grid-column: 2; align-items: center; justify-content: center; min-height: 100vh; padding: 52px clamp(54px, 9.7vw, 200px); background: #fff; } +.auth-page--entry .auth-form { width: min(100%, 626px); } +.auth-entry-home-link { position: absolute; z-index: 4; top: 24px; right: clamp(24px, 3.2vw, 52px); display: inline-flex; min-height: 34px; padding: 0 12px; align-items: center; gap: 7px; border: 1px solid #e1e3e6; border-radius: 999px; color: #7b8189; background: rgba(255,255,255,.76); font-size: 12px; font-weight: 600; text-decoration: none; backdrop-filter: blur(8px); transition: border-color 160ms ease, color 160ms ease, background-color 160ms ease; } +.auth-entry-home-link::before { content: "←"; color: #9a9fa6; font-size: 14px; font-weight: 400; transition: transform 160ms ease; } +.auth-entry-home-link:hover { border-color: #cfd3d8; color: #3f444b; background: #fff; } +.auth-entry-home-link:hover::before { transform: translateX(-2px); } +.auth-entry-home-link:focus-visible { outline: 2px solid rgba(13,114,217,.25); outline-offset: 3px; } +.auth-entry-story { position: relative; display: flex; min-width: 0; min-height: 100vh; flex-direction: column; padding: 68px clamp(52px, 8.2vw, 120px); overflow: hidden; border-right: 1px solid #e1e3e6; color: #1d2026; background: #f6f6f8; } +.auth-entry-story::after { display: none; } +.auth-entry-brand { display: flex; align-items: center; gap: 13px; color: #202124; font-size: 26px; font-weight: 800; letter-spacing: -.06em; } +.auth-entry-brand .brand-mark { width: 54px; height: 43px; color: #202124; } +.auth-entry-copy { position: relative; z-index: 1; max-width: 640px; margin: auto 0; transform: translateY(20px); } +.auth-entry-copy > p { margin: 0 0 24px; color: #68717d; font-size: 14px; font-weight: 800; letter-spacing: -.02em; } +.auth-entry-copy h1 { margin: 0; color: #1e222a; font-size: clamp(44px, 4.25vw, 66px); font-weight: 850; line-height: 1.1; letter-spacing: -.085em; white-space: nowrap; } +.auth-entry-copy > span { display: block; max-width: 590px; margin-top: 28px; color: #6e7784; font-size: 17px; line-height: 1.75; } +.auth-entry-card-stack { position: relative; z-index: 1; width: min(100%, 608px); height: 145px; margin-top: auto; perspective: 1200px; } +.auth-entry-card { --next-card-glass: rgb(255 255 255 / .72); position: absolute; inset: 0; display: grid; width: 100%; gap: 7px; padding: 18px 30px; overflow: hidden; border: 1px solid #dfe2e6; border-radius: 32px; background: #fff; box-shadow: 0 4px 12px rgb(20 29 45 / .05); transform-origin: 50% 100%; will-change: opacity, transform; } +.auth-entry-card::before { position: absolute; z-index: 2; inset: 0 auto 0 0; width: 4px; border-radius: 32px 0 0 32px; background: #202124; content: ""; } +.auth-entry-card::after { position: absolute; z-index: 0; right: 0; bottom: 0; left: 0; height: 42%; border-top: 1px solid rgb(255 255 255 / .28); background: var(--next-card-glass); backdrop-filter: blur(15px) saturate(128%); -webkit-backdrop-filter: blur(15px) saturate(128%); content: ""; } +.auth-entry-card > small, .auth-entry-card > strong, .auth-entry-card > span { position: relative; z-index: 1; } +.auth-entry-card--blue { background: #fff; } +.auth-entry-card--blue::before { background: #087ce6; } +.auth-entry-card--three { background: #fff; } +.auth-entry-card--three::before { background: #7067c9; } +.auth-entry-card small, .auth-entry-card span { color: #87909d; font-size: 13px; line-height: 1.45; } +.auth-entry-card strong { color: #252a33; font-family: Georgia, "Times New Roman", "Microsoft YaHei", serif; font-size: 20px; letter-spacing: -.02em; } +.auth-entry-card--one { animation: auth-card-one 9s infinite cubic-bezier(.65, 0, .35, 1); } +.auth-entry-card--two { animation: auth-card-two 9s infinite cubic-bezier(.65, 0, .35, 1); } +.auth-entry-card--three { animation: auth-card-three 9s infinite cubic-bezier(.65, 0, .35, 1); } +@keyframes auth-card-one { + 0%, 25% { z-index: 3; opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); } + 40%, 60% { z-index: 1; opacity: .9; transform: translate3d(22px, 18px, -30px) rotate(.7deg) scale(.952); } + 72%, 87% { z-index: 2; opacity: .96; transform: translate3d(11px, 9px, -15px) rotate(.3deg) scale(.976); } + 100% { z-index: 3; opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); } +} +@keyframes auth-card-two { + 0%, 25% { z-index: 2; opacity: .96; transform: translate3d(11px, 9px, -15px) rotate(.3deg) scale(.976); } + 40%, 60% { z-index: 3; opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); } + 72%, 87% { z-index: 1; opacity: .9; transform: translate3d(22px, 18px, -30px) rotate(.7deg) scale(.952); } + 100% { z-index: 2; opacity: .96; transform: translate3d(11px, 9px, -15px) rotate(.3deg) scale(.976); } +} +@keyframes auth-card-three { + 0%, 25% { z-index: 1; opacity: .9; transform: translate3d(22px, 18px, -30px) rotate(.7deg) scale(.952); } + 40%, 60% { z-index: 2; opacity: .96; transform: translate3d(11px, 9px, -15px) rotate(.3deg) scale(.976); } + 72%, 87% { z-index: 3; opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); } + 100% { z-index: 1; opacity: .9; transform: translate3d(22px, 18px, -30px) rotate(.7deg) scale(.952); } +} +.auth-entry-heading { margin: 0 0 42px; } +.auth-entry-heading p { min-height: 16px; margin: 0 0 15px; color: #6c7580; font-size: 14px; font-weight: 800; } +.auth-entry-heading p:empty { display: none; } +.auth-entry-heading h1 { margin: 0; color: #1e222a; font-size: clamp(35px, 3.2vw, 48px); font-weight: 850; line-height: 1.1; letter-spacing: -.075em; white-space: nowrap; } +.auth-entry-heading span { display: block; margin-top: 13px; color: #7a838f; font-size: 16px; line-height: 1.55; } +.auth-page--register .auth-entry-heading h1 { font-size: clamp(34px, 2.9vw, 44px); } +.auth-page--entry .form-field { min-height: 94px; } +.auth-page--entry .form-field--error { min-height: 112px; } +.auth-page--entry .form-field > label { margin-bottom: 8px; color: #31343a; font-size: 14px; font-weight: 800; } +.auth-page--entry .form-field__control { height: 52px; border: 1px solid #d8dade; border-radius: 11px; background: #fff; } +.auth-page--entry .form-field__control:focus-within { border-color: #202124; box-shadow: 0 0 0 3px rgb(32 33 36 / .08); background: #fff; } +.auth-page--entry .form-field__control input { padding-left: 16px; color: #202124; } +.auth-page--entry .form-field__icon, .auth-page--entry .form-field__decorative { right: 14px; color: #6f747c; } +.auth-page--entry .form-field__error { top: 85px; bottom: auto; line-height: 16px; } +.auth-page--entry .auth-form__actions { margin-top: 4px; } +.auth-page--entry .action-button { display: flex; align-items: center; justify-content: space-between; min-height: 54px; padding: 10px 28px; border: 0; border-radius: 999px; color: #fff; background: #0D72D9; box-shadow: none; font-size: 16px; } +.auth-page--entry .action-button b { font-size: 25px; font-weight: 400; line-height: 1; } +.auth-page--entry .action-button:hover:not(:disabled) { background: #0a61ba; transform: translateY(-1px); } +.auth-page--entry .auth-secondary-actions { justify-content: flex-start; gap: 22px; margin-top: 20px; } +.auth-page--entry .auth-secondary-actions button, .auth-page--entry .auth-form__back-link, .auth-page--entry .auth-entry-switch button { padding: 0; border: 0; color: #0D72DD; background: transparent; cursor: pointer; font-size: 14px; font-weight: 800; text-decoration: none; } +.auth-page--entry .auth-secondary-actions button:hover, .auth-page--entry .auth-form__back-link:hover, .auth-page--entry .auth-entry-switch button:hover { color: #0a61be; text-decoration: underline; text-underline-offset: 4px; } +.auth-page--entry .auth-form__back-link { float: none; margin-top: 20px; } +.auth-entry-field-help { margin: -8px 0 16px; color: #9098a3; font-size: 13px; line-height: 1.35; } +.form-field--error + .auth-entry-field-help { margin-top: -1px; } +.auth-entry-switch { margin: 27px 0 0; color: #7a838f; font-size: 15px; } +.auth-entry-switch button { margin-left: 7px; } +.auth-page--login .form-message--error, .auth-page--register .form-message--error { color: #bd525b; } + +@media (max-width: 800px) { + .auth-page--entry { display: grid; grid-template-rows: minmax(242px, 31svh) 1fr; grid-template-columns: 1fr; min-height: 100svh; overflow: auto; } + .auth-entry-story { grid-row: 1; min-height: 0; padding: 22px 24px 20px; border-right: 0; border-bottom: 1px solid #e4e5e7; } + .auth-entry-brand { gap: 9px; font-size: 20px; } + .auth-entry-brand .brand-mark { width: 38px; height: 29px; } + .auth-entry-copy { margin: auto 0 0; transform: none; } + .auth-entry-copy > p { margin-bottom: 10px; font-size: 12px; } + .auth-entry-copy h1 { font-size: clamp(32px, 9.4vw, 45px); } + .auth-entry-copy > span, .auth-entry-card-stack { display: none; } + .auth-entry-home-link { top: 16px; right: 18px; min-height: 32px; padding-inline: 11px; background: rgba(246,246,248,.8); } + .auth-page--entry .auth-page__content { grid-row: 2; grid-column: 1; align-items: start; min-height: 0; padding: 30px 24px 48px; } + .auth-entry-heading { margin-bottom: 29px; } + .auth-entry-heading h1 { font-size: 34px; white-space: normal; } + .auth-entry-heading span { font-size: 14px; } + .auth-page--entry .auth-heading { margin-bottom: 23px; } + .auth-page--entry .auth-heading h2 { font-size: 34px; } + .auth-page--entry .auth-heading p { font-size: 14px; } + .auth-page--entry .form-field { min-height: 82px; } +} + +/* Mobile-only account entry refinement. Desktop composition remains untouched. */ +@media (max-width: 800px) { + /* Keep the slogan visually centered inside the mobile story panel. */ + .auth-page--login .auth-entry-copy, + .auth-page--register .auth-entry-copy, + .auth-page--admin .auth-entry-copy, + .auth-page--forgot .auth-entry-copy { + position: absolute; + top: 50%; + right: 22px; + left: 22px; + margin: 0; + transform: translateY(-50%); + } + + /* Password and invitation hints need their own breathing room on narrow screens. */ + .auth-page--register .auth-entry-field-help { + margin: 6px 0 20px; + line-height: 1.45; + } + + /* Password recovery follows the same top-story / lower-form composition. */ + .auth-page--forgot { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 30svh auto; + min-height: 100svh; + padding: 0; + overflow: auto; + background: #fff; + } + .auth-page--forgot .auth-entry-story { + grid-row: 1; + min-height: 0; + padding: 18px 22px 16px; + } + .auth-page--forgot .auth-entry-brand { + font-size: 19px; + } + .auth-page--forgot .auth-entry-brand .brand-mark { + width: 36px; + height: 27px; + } + .auth-page--forgot .auth-entry-copy > p { + margin-bottom: 7px; + font-size: 11px; + } + .auth-page--forgot .auth-entry-copy h1 { + font-size: clamp(29px, 8.4vw, 38px); + line-height: 1.1; + } + .auth-page--forgot .auth-page__content { + grid-row: 2; + grid-column: 1; + display: block; + min-height: 0; + padding: 26px 22px max(30px, env(safe-area-inset-bottom)); + background: #fff; + } + .auth-page--forgot .auth-entry-heading { + margin-bottom: 23px; + } + .auth-page--forgot .auth-entry-heading h1 { + font-size: 31px; + } + .auth-page--forgot .auth-entry-heading span { + margin-top: 8px; + font-size: 14px; + } + .auth-page--forgot .form-field { + min-height: 80px; + } + .auth-page--forgot .form-field--error { + min-height: 102px; + } + .auth-page--forgot .form-field__control { + height: 50px; + } + .auth-page--forgot .form-field__error { + top: 75px; + } + .auth-page--forgot .action-button { + min-height: 52px; + } + + .auth-page--login, + .auth-page--admin, + .auth-page--register { + display: grid; + grid-template-columns: 1fr; + min-height: 100svh; + padding: 0; + background: #fff; + } + .auth-page--login, + .auth-page--admin { + grid-template-rows: 30svh minmax(0, 1fr); + overflow: hidden; + } + .auth-page--register { + grid-template-rows: 27svh auto; + overflow: auto; + } + .auth-page--login .auth-entry-story, + .auth-page--admin .auth-entry-story, + .auth-page--register .auth-entry-story { + grid-row: 1; + min-height: 0; + padding: 18px 22px 16px; + } + .auth-page--login .auth-entry-brand, + .auth-page--admin .auth-entry-brand, + .auth-page--register .auth-entry-brand { + font-size: 19px; + } + .auth-page--login .auth-entry-brand .brand-mark, + .auth-page--admin .auth-entry-brand .brand-mark, + .auth-page--register .auth-entry-brand .brand-mark { + width: 36px; + height: 27px; + } + .auth-page--login .auth-entry-copy, + .auth-page--admin .auth-entry-copy, + .auth-page--register .auth-entry-copy { + margin-top: auto; + } + .auth-page--login .auth-entry-copy > p, + .auth-page--admin .auth-entry-copy > p, + .auth-page--register .auth-entry-copy > p { + margin-bottom: 7px; + font-size: 11px; + } + .auth-page--login .auth-entry-copy h1, + .auth-page--admin .auth-entry-copy h1, + .auth-page--register .auth-entry-copy h1 { + font-size: clamp(29px, 8.4vw, 38px); + line-height: 1.1; + } + .auth-page--login .auth-page__content, + .auth-page--admin .auth-page__content, + .auth-page--register .auth-page__content { + grid-row: 2; + grid-column: 1; + display: block; + min-height: 0; + padding: 26px 22px max(30px, env(safe-area-inset-bottom)); + background: #fff; + } + .auth-page--login .auth-page__content, + .auth-page--admin .auth-page__content { + overflow-y: auto; + } + .auth-page--login .auth-entry-heading, + .auth-page--admin .auth-entry-heading, + .auth-page--register .auth-entry-heading { + margin-bottom: 23px; + } + .auth-page--login .auth-entry-heading h1, + .auth-page--admin .auth-entry-heading h1, + .auth-page--register .auth-entry-heading h1 { + font-size: 31px; + } + .auth-page--login .auth-entry-heading span, + .auth-page--admin .auth-entry-heading span, + .auth-page--register .auth-entry-heading span { + margin-top: 8px; + font-size: 14px; + } + .auth-page--login .form-field, + .auth-page--admin .form-field, + .auth-page--register .form-field { + min-height: 80px; + } + .auth-page--login .form-field--error, + .auth-page--admin .form-field--error, + .auth-page--register .form-field--error { + min-height: 102px; + } + .auth-page--login .form-field__control, + .auth-page--admin .form-field__control, + .auth-page--register .form-field__control { + height: 50px; + } + .auth-page--login .form-field__error, + .auth-page--admin .form-field__error, + .auth-page--register .form-field__error { + top: 75px; + } + .auth-page--login .action-button, + .auth-page--admin .action-button, + .auth-page--register .action-button { + min-height: 52px; + } + .auth-page--login .auth-entry-switch, + .auth-page--admin .auth-entry-switch, + .auth-page--register .auth-entry-switch { + margin-top: 22px; + } +} + +.auth-page--login .auth-entry-copy h1, +.auth-page--register .auth-entry-copy h1, +.auth-page--forgot .auth-entry-copy h1, +.auth-page--admin .auth-entry-copy h1 { + line-height: 1.16; + letter-spacing: -.025em; +} + diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css new file mode 100644 index 0000000..9df5a30 --- /dev/null +++ b/frontend/src/styles/base.css @@ -0,0 +1,711 @@ +:root { + font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; + color: #303236; + background: #f3f4f5; + font-synthesis: none; + --canvas: #f3f4f5; + --surface: #fbfbfc; + --surface-soft: #e8eaec; + --surface-strong: #858a90; + --accent-deep: #62676d; + --brand: #777c82; + --ink: #303236; + --muted: #70757a; + --rule: #c7cacf; + --field: #eff0f2; + --inverse: #ffffff; + --danger: #b64a58; + --radius-control: 18px; + --radius-group: 28px; + --radius-shell: 36px; + --ease-out: cubic-bezier(.22, 1, .36, 1); +} + +* { box-sizing: border-box; } +html { min-width: 320px; min-height: 100%; background: var(--canvas); } +body { min-width: 320px; min-height: 100vh; margin: 0; color: var(--ink); background: var(--canvas); font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; } +.brand-mark { display: block; flex: 0 0 auto; object-fit: contain; object-position: center; } +button, input, select, textarea { font: inherit; font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; } +button, a { -webkit-tap-highlight-color: transparent; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid color-mix(in srgb, var(--ink) 28%, transparent); outline-offset: 2px; } +input[type="password"]::-ms-reveal, input[type="password"]::-ms-clear { display: none; } +input[type="password"]::-webkit-credentials-auto-fill-button { visibility: hidden; display: none !important; } + +.route-transition { min-height: 100vh; animation: route-enter 260ms var(--ease-out); } +.route-transition--forward { --route-x: 12px; } +.route-transition--back { --route-x: -12px; } +.route-transition--neutral { --route-x: 0px; } +@keyframes route-enter { from { opacity: 0; transform: translateX(var(--route-x)); } to { opacity: 1; transform: none; } } +@keyframes route-fade { from { opacity: 0; } to { opacity: 1; } } + +.app-shell { min-height: 100vh; } +.app-rail { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; gap: clamp(18px, 3vw, 48px); min-height: 92px; padding: 16px clamp(28px, 5vw, 76px); border-bottom: 1px solid var(--rule); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(12px); } +.rail-brand { display: flex; align-items: center; gap: 11px; min-height: 54px; color: var(--ink); text-decoration: none; } +.rail-brand .brand-mark { width: 45px; height: 32px; color: var(--brand); } +.rail-brand > span, .rail-profile > span:last-child { display: flex; min-width: 0; flex-direction: column; } +.rail-brand strong { font-size: 16px; letter-spacing: .08em; } +.rail-brand small, .rail-profile small { color: var(--muted); font-size: 11px; } +.rail-nav { display: flex; gap: 4px; margin-left: auto; padding: 5px; border-radius: 999px; background: var(--surface-soft); } +.rail-nav a { display: inline-flex; align-items: center; gap: 6px; padding: 9px 14px; border-radius: 999px; color: var(--muted); text-decoration: none; transition: color 140ms ease, background-color 140ms ease; } +.rail-nav svg { width: 18px; height: 18px; flex: 0 0 auto; } +.rail-nav a:hover { color: var(--ink); background: color-mix(in srgb, var(--surface-soft) 45%, transparent); } +.rail-nav a.active { color: var(--ink); background: var(--surface); font-weight: 700; } +.rail-nav .mobile-nav-only { display: none; } +.rail-account { display: flex; align-items: center; gap: 12px; } +.rail-profile { display: flex; align-items: center; gap: 10px; color: var(--ink); text-decoration: none; } +.rail-avatar { display: grid; place-items: center; width: 37px; height: 37px; border-radius: 50%; color: var(--inverse); background: var(--surface-strong); font-weight: 700; } +.rail-profile strong { overflow: hidden; max-width: 120px; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } +.rail-actions { display: flex; gap: 7px; } +.rail-actions button, .rail-actions a { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 38px; padding: 7px 10px; border: 1px solid var(--rule); border-radius: 999px; color: var(--muted); background: var(--surface); cursor: pointer; text-decoration: none; } +.rail-actions svg { width: 17px; height: 17px; } +.rail-actions button:hover, .rail-actions a:hover { color: var(--ink); border-color: var(--muted); } +.app-shell__content { min-width: 0; } +.app-loading { display: grid; min-height: 100vh; place-items: center; color: var(--muted); background: var(--canvas); } +.app-loading span::after { content: "..."; } + +.page { width: min(1440px, 100%); min-height: calc(100vh - 92px); margin: 0 auto; padding: 46px clamp(28px, 5vw, 76px) 80px; } +.page-header { display: flex; align-items: end; justify-content: space-between; gap: 28px; margin-bottom: 34px; } +.page-header h1 { margin: 0; font-size: clamp(34px, 4.2vw, 58px); line-height: 1.05; letter-spacing: -.045em; } +.page-header p { max-width: 650px; margin: 10px 0 0; color: var(--muted); line-height: 1.6; } +.page-header__actions { display: flex; flex-wrap: wrap; gap: 8px; } +.button { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 9px 16px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: transparent; cursor: pointer; text-decoration: none; transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease, transform 140ms ease; } +.button:hover:not(:disabled) { border-color: var(--muted); transform: translateY(-1px); } +.button--primary { border-color: var(--surface-strong); color: var(--inverse); background: var(--surface-strong); } +.button--light { border-color: rgba(255,255,255,.48); color: #fff; } +.button--danger { border-color: var(--danger); color: var(--danger); } +.button:not(.button--danger), .primary-button, .inline-button { border-color: #0D72D9; color: #fff; background: #0D72D9; } +.button:not(.button--danger):hover:not(:disabled), .primary-button:hover:not(:disabled), .inline-button:hover:not(:disabled) { border-color: #0a61ba; color: #fff; background: #0a61ba; } +.app-shell .page-header__actions .button.button--quiet { min-height: auto; padding: 4px 2px; border: 0; border-radius: 0; color: #7a838e; background: transparent; font-size: 12px; font-weight: 600; } +.app-shell .page-header__actions .button.button--quiet:hover:not(:disabled) { border: 0; color: #3f4852; background: transparent; text-decoration: underline; text-underline-offset: 3px; transform: none; } +.quiet-action-divider { width: 1px; height: 14px; background: #d9dde2; } +.button:disabled { cursor: not-allowed; opacity: .56; } +.button[aria-busy="true"]:disabled { cursor: wait; } +.status-line { margin: 24px 0 0; padding: 13px 15px; border-left: 3px solid var(--surface-strong); color: var(--muted); background: color-mix(in srgb, var(--surface-soft) 62%, transparent); line-height: 1.5; } +.status-line--error { border-left-color: var(--danger); color: var(--danger); } +.empty-state { display: grid; justify-items: start; width: min(660px, 100%); margin-top: 12vh; padding: 34px 0; row-gap: 28px; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); } +.empty-state h2 { margin: 0; font-size: 30px; } +.empty-state p, .empty-row { color: var(--muted); line-height: 1.6; } +.data-strip { display: grid; grid-template-columns: repeat(3, 1fr); overflow: hidden; border: 1px solid var(--rule); border-radius: var(--radius-group); background: color-mix(in srgb, var(--surface) 68%, transparent); } +.data-strip div { display: grid; min-height: 92px; place-items: center; align-content: center; gap: 4px; padding: 16px 22px; border-right: 1px solid var(--rule); text-align: center; } +.data-strip div:last-child { border-right: 0; } +.data-strip span { color: var(--muted); font-size: 13px; } +.data-strip strong { font-size: 30px; letter-spacing: -.04em; } + +.home-workspace { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(260px, .65fr); gap: 18px; } +.resume-panel { display: grid; min-height: 290px; place-items: center; align-content: center; gap: 24px; padding: clamp(26px, 4vw, 48px); border-radius: var(--radius-shell); color: #fff; background: var(--surface-strong); text-align: center; } +.resume-panel > div { display: grid; justify-items: center; } +.resume-panel span { font-size: 12px; letter-spacing: .12em; } +.resume-panel h2 { max-width: 700px; margin: 10px 0 8px; font-size: clamp(31px, 4vw, 54px); line-height: 1.05; letter-spacing: -.04em; } +.resume-panel p { margin: 0; color: #eaf7fe; } +.study-snapshot { padding: 28px; border: 1px solid var(--rule); border-radius: var(--radius-group); } +.study-snapshot h2 { margin: 0; font-size: 20px; } +.study-snapshot dl { margin: 20px 0; } +.study-snapshot dl div { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--rule); } +.study-snapshot dt { color: var(--muted); } +.study-snapshot dd { margin: 0; font-weight: 700; } +.study-snapshot a { color: var(--ink); } +.home-quick-actions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-top: 48px; } +.home-quick-actions a { display: grid; min-height: 126px; grid-template-columns: auto minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 22px; border: 1px solid var(--rule); border-radius: var(--radius-group); color: var(--ink); background: var(--surface); text-decoration: none; transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease; } +.home-quick-actions a:hover { border-color: var(--surface-strong); background: var(--surface-soft); box-shadow: 0 10px 24px rgba(45,47,50,.1); } +.home-quick-actions a > span:not(.quick-action-icon) { display: grid; gap: 4px; min-width: 0; } +.home-quick-actions strong { font-size: 18px; } +.home-quick-actions small { display: -webkit-box; overflow: hidden; color: var(--muted); line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.home-quick-actions i { color: var(--muted); font-size: 20px; font-style: normal; } +.quick-action-icon { display: grid; width: 44px; height: 44px; place-items: center; border-radius: 12px; color: var(--surface-strong); background: var(--surface-soft); } +.quick-action-icon svg { width: 22px; height: 22px; } +.home-welcome { position: relative; padding: clamp(26px, 3vw, 38px); border: 1px solid var(--rule); border-radius: var(--radius-shell); background: var(--surface); box-shadow: 0 4px 12px rgba(40,40,44,.06); } +.home-welcome__intro { display: flex; align-items: end; justify-content: space-between; gap: 28px; } +.home-welcome__intro > div { min-width: 0; } +.home-welcome__intro span { color: var(--muted); font-size: 12px; } +.home-welcome__intro h2 { margin: 8px 0 10px; font-size: clamp(30px, 3vw, 42px); letter-spacing: -.025em; } +.home-welcome__intro p { margin: 0; color: var(--muted); } +.home-welcome .home-quick-actions { margin-top: 30px; } +.home-welcome .home-quick-actions a { min-height: 116px; box-shadow: none; } +.home-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 18px; } +.home-summary article { display: grid; min-height: 94px; padding: 18px 20px; align-content: center; gap: 7px; border: 1px solid var(--rule); border-radius: var(--radius-group); background: var(--surface); box-shadow: 0 4px 12px rgba(40,40,44,.05); } +.home-summary span { color: var(--muted); font-size: 12px; } +.home-summary strong { overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; } +.home-intelligence-flow { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(0, 1.3fr); gap: 28px; margin-top: 18px; padding: clamp(24px, 3vw, 34px); border: 1px solid var(--rule); border-radius: var(--radius-shell); background: var(--surface); box-shadow: 0 4px 12px rgba(40,40,44,.05); } +.home-intelligence-flow > header { align-self: center; } +.home-intelligence-flow > header > span { color: var(--muted); font-size: 12px; letter-spacing: .08em; } +.home-intelligence-flow h2 { margin: 8px 0 10px; font-size: clamp(24px, 2.5vw, 34px); letter-spacing: -.025em; } +.home-intelligence-flow > header p { margin: 0; color: var(--muted); line-height: 1.7; } +.home-intelligence-flow > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--rule); border-left: 1px solid var(--rule); } +.home-intelligence-flow article { padding: 20px; border-right: 1px solid var(--rule); border-bottom: 1px solid var(--rule); } +.home-intelligence-flow article > span { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.home-intelligence-flow h3 { margin: 8px 0 6px; font-size: 17px; } +.home-intelligence-flow article p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.65; } +.home-recent-projects { margin-top: 18px; } +.credit-balance { display: grid; gap: 6px; padding: 22px; border: 1px solid var(--rule); border-radius: var(--radius-group); background: var(--surface-soft); } +.credit-balance span, .credit-balance small { color: var(--muted); } +.credit-balance strong { font-size: clamp(28px, 4vw, 42px); font-variant-numeric: tabular-nums; } +.admin-generated-codes { display: grid; gap: 8px; margin-top: 16px; color: var(--muted); font-size: 12px; } +.admin-generated-codes textarea { width: 100%; padding: 12px; border: 1px solid var(--rule); border-radius: 12px; color: var(--ink); background: var(--field); font-family: Consolas, monospace; line-height: 1.6; resize: vertical; } + +.process-layout { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, .65fr); gap: 20px; align-items: start; } +.process-primary, .settings-main { display: grid; gap: 20px; } +.process-layout--materials { grid-template-columns: minmax(0, 1fr); } +.process-layout--scope { grid-template-columns: minmax(0, 860px); justify-content: center; } +.process-layout--scope .process-primary { display: none; } +.process-layout--scope .plan-panel { position: static; } +.process-layout--exit .process-primary, .process-layout--exit .plan-panel { animation: materials-flow-exit 220ms ease-in forwards; } +.process-layout--scope.process-layout--enter .plan-panel { animation: materials-flow-enter 300ms var(--ease-out) both; } +@keyframes materials-flow-exit { to { opacity: 0; transform: translateX(-7%); } } +@keyframes materials-flow-enter { from { opacity: 0; transform: translateX(8%); } to { opacity: 1; transform: translateX(0); } } +.form-section, .material-library, .plan-panel, .side-section, .filter-panel, .chapter-outline, .graph-board, .graph-inspector, .workspace-card { padding: 27px; border: 1px solid var(--rule); border-radius: var(--radius-group); background: color-mix(in srgb, var(--surface) 56%, transparent); } +.form-section > header, .material-library > header { margin-bottom: 23px; } +.upload-panel__header h2, .upload-panel__header p { margin: 0; } +.upload-panel__header p { margin-top: 7px; color: var(--muted); line-height: 1.55; } +.upload-panel__form { display: grid; } +.form-section h2, .material-library h2, .plan-panel h2, .side-section h2, .chapter-outline h2, .graph-board h2, .graph-inspector h2, .workspace-card h2 { margin: 0; font-size: 23px; } +.form-section header p, .material-library header p, .plan-panel > p, .side-section p, .workspace-card > p { margin: 7px 0 0; color: var(--muted); line-height: 1.55; } +.form-section > label, .upload-panel__form > label, .filter-panel label, .side-section label, .review-setup label { display: grid; gap: 7px; margin: 15px 0; color: var(--muted); font-size: 13px; } +.settings-language { display: grid; gap: 7px; margin: 15px 0; padding: 0; border: 0; border-radius: 0; color: var(--muted); background: transparent; font-size: 13px; } +.settings-language select { color: var(--ink); text-align: left; } +.form-section input:not([type="checkbox"]), .form-section select, .filter-panel input, .filter-panel select, .side-section input, .side-section select, .review-setup select, .material-library input, .graph-board input { width: 100%; min-height: 44px; padding: 9px 11px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--field); } +.form-section label small { color: var(--muted); } +.upload-panel__form input[type="file"]::file-selector-button { margin-right: 12px; padding: 8px 16px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--surface); cursor: pointer; transition: border-color 140ms ease, background-color 140ms ease; } +.upload-panel__form input[type="file"]::file-selector-button:hover { border-color: var(--muted); background: var(--surface-soft); } +.form-section > .button, .upload-panel__form > .button { margin-top: 7px; } +.option-row { display: grid; grid-template-columns: 1.2fr .8fr; gap: 12px; align-items: stretch; } +.inline-select-field { display: grid; min-height: 58px; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; padding: 9px 10px 9px 13px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--field); cursor: default; } +.inline-select-field > span { color: var(--ink); font-weight: 700; white-space: nowrap; } +.inline-select-field > select { min-width: 0; margin: 0; } +.inline-select-field.is-enabled, .inline-select-field.is-enabled > select { background: #fff; } +.toggle-field { display: grid !important; min-height: 58px; grid-template-columns: minmax(0, 1fr) 42px; gap: 14px; align-items: center; padding: 11px 13px; border: 1px solid var(--rule); border-radius: var(--radius-control); cursor: pointer; } +.toggle-field span { display: grid; min-width: 0; gap: 3px; } +.toggle-field strong { color: var(--ink); } +.toggle-field input[type="checkbox"] { position: relative; width: 40px; height: 22px; margin: 0; appearance: none; border: 1px solid var(--rule); border-radius: 999px; background: var(--field); cursor: pointer; transition: border-color .18s ease, background-color .18s ease; } +.toggle-field input[type="checkbox"]::before { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); content: ""; transition: background-color .18s ease, transform .18s ease; } +.toggle-field input[type="checkbox"]:checked { border-color: #0D72D9; background: #0D72D9; } +.toggle-field input[type="checkbox"]:checked::before { background: #fff; transform: translateX(18px); } +.toggle-field input[type="checkbox"]:focus-visible { outline: 3px solid color-mix(in srgb, #0D72D9 30%, transparent); outline-offset: 2px; } +.compact-toggle { margin-bottom: 18px !important; } +.material-library > header { display: flex; align-items: end; justify-content: space-between; gap: 20px; } +.material-library > header p { margin: 3px 0 0; } +.material-library > header input { max-width: 250px; } +.material-table { border-top: 1px solid var(--rule); } +.material-table article { display: grid; grid-template-columns: minmax(0, 1fr) auto; border-bottom: 1px solid var(--rule); } +.material-table article.selected { background: var(--surface-soft); } +.material-open { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-width: 0; padding: 15px 10px; border: 0; color: var(--ink); background: transparent; cursor: pointer; text-align: left; } +.material-open span { display: grid; min-width: 0; gap: 3px; } +.material-open strong, .material-open small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.material-open small, .material-open em { color: var(--muted); font-size: 11px; font-style: normal; } +.material-delete { align-self: center; padding: 7px 9px; border: 0; color: var(--danger); background: transparent; cursor: pointer; } +.material-actions { display: flex; align-items: center; gap: 2px; padding-right: 4px; } +.material-actions button { padding: 7px 8px; border: 0; color: var(--muted); background: transparent; cursor: pointer; } +.material-actions .material-delete { color: var(--danger); } +.plan-panel { position: sticky; top: 112px; } +.plan-panel > header > span, .section-label { color: var(--muted); font-size: 11px; letter-spacing: .12em; } +.flow-next-button { width: 100%; } +.flow-scope-intro { margin: 12px 0 0; color: var(--muted); line-height: 1.65; } +.flow-back-row { display: flex; justify-content: flex-end; margin-top: 16px; } +.app-shell .button.flow-back-button { min-height: auto; margin: 0; padding: 2px 0; border: 0; border-radius: 0; color: #7a838e; background: transparent; font-size: 12px; } +.app-shell .flow-back-row .button.flow-back-button:hover:not(:disabled) { border: 0; color: #4f5863; background: transparent; text-decoration: underline; text-underline-offset: 3px; transform: none; } +.plan-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 22px 0; } +.plan-summary div { display: grid; min-height: 64px; place-content: center; padding: 0 8px; border-right: 1px solid var(--rule); text-align: center; } +.plan-summary div:last-child { border: 0; } +.plan-summary dt { color: var(--muted); font-size: 11px; } +.plan-summary dd { margin: 4px 0 0; font-size: 21px; font-weight: 700; } +.segmented-control { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 4px; border-radius: 17px; background: var(--surface-soft); } +.segmented-control button { display: inline-flex; min-height: 36px; padding: 7px 10px; align-items: center; justify-content: center; border: 0; border-radius: 13px; color: var(--muted); background: transparent; cursor: pointer; } +.segmented-control button.active { color: var(--inverse); background: var(--surface-strong); } +.segmented-control.plan-type-switch { position: relative; gap: 0; padding: 0; overflow: hidden; border: 1px solid #cbdcf0; border-radius: 14px; background: #eff6ff; isolation: isolate; } +.segmented-control.plan-type-switch::before { position: absolute; z-index: 0; top: 0; bottom: 0; left: 0; width: 50%; border-radius: 12px; background: #0D72D9; box-shadow: 0 5px 13px -9px rgba(13,114,217,.95); content: ""; transition: transform 360ms var(--ease-out), background-color 240ms ease, box-shadow 240ms ease; } +.segmented-control.plan-type-switch--learning { border-color: #d9d2fb; background: #f4f1ff; } +.segmented-control.plan-type-switch--learning::before { background: #6B5DE8; box-shadow: 0 5px 13px -9px rgba(107,93,232,.95); transform: translateX(100%); } +.plan-type-switch .plan-type-button { position: relative; z-index: 1; min-height: 44px; border: 0; border-radius: 0; color: #0D72D9; background: transparent; font-weight: 700; transition: color 180ms ease; } +.plan-type-switch .plan-type-button--learning { color: #6B5DE8; } +.plan-type-switch .plan-type-button.is-active { color: #fff; background: transparent; } +.plan-type-switch .plan-type-button:hover:not(:disabled) { transform: none; } +.chapter-actions { display: flex; justify-content: flex-end; gap: 12px; margin: 18px 0 8px; } +.chapter-actions button { display: inline-flex; min-height: 32px; padding: 0 8px; align-items: center; justify-content: center; border: 0; border-radius: var(--radius-control); color: var(--muted); background: transparent; cursor: pointer; } +.chapter-picker { max-height: 300px; margin-bottom: 18px; overflow: auto; border-top: 1px solid var(--rule); } +.chapter-picker label { display: grid; min-height: 40px; grid-template-columns: 18px minmax(0, 1fr); gap: 10px; align-items: center; border-bottom: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); cursor: pointer; font-size: 13px; line-height: 1.4; } +.chapter-picker input[type="checkbox"] { width: 17px; height: 17px; margin: 0; accent-color: #0d72d9; cursor: pointer; } +.plan-options { margin: -3px 0 16px; padding: 11px 0; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); } +.plan-options summary { color: var(--muted); cursor: pointer; font-size: 12px; } +.plan-options__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding-top: 12px; } +.plan-options__grid label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; } +.plan-options__grid input { width: 100%; min-height: 38px; padding: 7px 9px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--field); } +.preview-overlay { position: fixed; z-index: 200; inset: 0; display: grid; place-items: center; padding: 24px; background: rgba(36,79,105,.2); backdrop-filter: blur(5px); } +.text-preview { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(960px, 100%); max-height: min(820px, calc(100vh - 48px)); overflow: hidden; border: 1px solid var(--rule); border-radius: var(--radius-shell); background: var(--surface); box-shadow: 0 24px 70px rgba(34,91,125,.2); } +.text-preview > header { display: flex; align-items: start; justify-content: space-between; gap: 20px; padding: 24px 28px; border-bottom: 1px solid var(--rule); } +.text-preview h2 { margin: 3px 0; } +.text-preview header span, .text-preview header p { color: var(--muted); font-size: 11px; } +.text-preview header p { margin: 0; } +.text-preview header button { border: 0; color: var(--muted); background: transparent; cursor: pointer; } +.text-preview pre { margin: 0; padding: 26px 28px 40px; overflow: auto; color: var(--ink); background: #fbfdff; font: 13px/1.85 "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; white-space: pre-wrap; word-break: break-word; } + +.settings-layout { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, .6fr); gap: 20px; align-items: start; } +.settings-side { display: grid; gap: 20px; } +.danger-section { border-color: color-mix(in srgb, var(--danger) 44%, var(--rule)); } + +.knowledge-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 20px; margin-top: 24px; align-items: start; } +.filter-panel { position: sticky; top: 112px; } +.filter-panel label:first-child { margin-top: 0; } +.filter-panel > p { margin: 20px 0 0; color: var(--muted); font-size: 12px; } +.knowledge-list { overflow: hidden; border: 1px solid var(--rule); border-radius: var(--radius-group); background: color-mix(in srgb, var(--surface) 58%, transparent); } +.knowledge-list article { border-bottom: 1px solid var(--rule); } +.knowledge-list article:last-child { border-bottom: 0; } +.knowledge-list article > button { display: grid; grid-template-columns: 58px minmax(0, 1fr) 54px; gap: 18px; align-items: center; width: 100%; min-height: 92px; padding: 18px 22px; border: 0; color: var(--ink); background: transparent; cursor: pointer; text-align: left; } +.knowledge-list article.expanded { background: color-mix(in srgb, var(--surface-soft) 48%, transparent); } +.mastery-value { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 50%; color: var(--accent-deep); background: var(--surface-soft); font-size: 20px; font-weight: 700; } +.point-copy { display: grid; gap: 3px; } +.point-copy small, .point-copy em, .row-action { color: var(--muted); font-size: 11px; font-style: normal; } +.row-action { justify-self: center; } +.point-copy strong { font-size: 16px; } +.point-detail { padding: 0 84px 20px; } +.point-detail > p { margin: 0 0 15px; color: var(--muted); line-height: 1.65; } +.point-detail dl { display: flex; flex-wrap: wrap; gap: 25px; margin: 0; } +.point-detail dl div { display: flex; gap: 7px; } +.point-detail dt { color: var(--muted); } +.point-detail dd { margin: 0; font-weight: 700; } +.source-references { margin-top: 18px; padding-top: 15px; border-top: 1px solid var(--rule); } +.source-references h3 { margin: 0 0 9px; font-size: 13px; } +.source-references blockquote { margin: 8px 0; padding: 10px 12px; border-left: 2px solid var(--surface-strong); background: color-mix(in srgb, var(--surface-soft) 55%, transparent); } +.source-references blockquote span { color: var(--muted); font-size: 10px; } +.source-references blockquote p { margin: 3px 0 0; line-height: 1.6; } + +.graph-workbench { display: grid; grid-template-columns: 220px minmax(360px, 1fr) minmax(310px, .72fr); gap: 18px; margin-top: 24px; align-items: start; } +.chapter-outline, .graph-inspector { position: sticky; top: 112px; max-height: calc(100vh - 132px); overflow: auto; } +.chapter-outline { padding: 19px 0; } +.chapter-outline header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 18px 13px; } +.chapter-outline header p { margin: 3px 0 0; color: var(--muted); font-size: 10px; } +.chapter-outline header button, .chapter-outline nav button { border: 0; color: var(--muted); background: transparent; cursor: pointer; } +.chapter-outline header button.active { color: var(--ink); font-weight: 700; } +.chapter-outline nav { display: grid; border-top: 1px solid var(--rule); } +.chapter-outline nav button { display: grid; gap: 3px; padding: 11px 18px; border-bottom: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); text-align: left; } +.chapter-outline nav button span { overflow: hidden; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; } +.chapter-outline nav button small { font-size: 10px; } +.chapter-outline nav button.active { color: var(--ink); background: var(--surface-soft); font-weight: 700; } +.graph-board > header { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding-bottom: 18px; border-bottom: 1px solid var(--rule); } +.graph-board header p { margin: 3px 0 0; color: var(--muted); font-size: 12px; } +.graph-board input { max-width: 230px; } +.graph-board-view-tools { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } +.graph-view-switch { position: relative; display: grid; min-width: 178px; padding: 3px; border: 1px solid color-mix(in srgb, var(--rule) 82%, transparent); border-radius: 999px; grid-template-columns: repeat(2, minmax(0, 1fr)); background: color-mix(in srgb, var(--surface-soft) 90%, transparent); box-shadow: inset 0 1px 2px rgba(32,45,58,.05); isolation: isolate; } +.graph-view-switch::before { position: absolute; z-index: 0; top: 3px; bottom: 3px; left: 3px; width: calc(50% - 3px); border-radius: 999px; background: var(--surface); box-shadow: 0 4px 12px -7px rgba(25,35,45,.55), 0 1px 3px rgba(25,35,45,.08); content: ""; transition: transform 340ms var(--ease-out), background-color 220ms ease, box-shadow 220ms ease; } +.graph-view-switch--relations::before { transform: translateX(100%); } +.graph-view-switch button { position: relative; z-index: 1; min-width: 0; min-height: 32px; padding: 4px 11px; border: 0; border-radius: 999px; color: var(--muted); background: transparent; cursor: pointer; font-size: 10px; font-weight: 750; white-space: nowrap; transition: color 220ms ease, transform 340ms var(--ease-out); } +.graph-view-switch button.active { color: var(--ink); transform: scale(1.015); } +.graph-view-switch button:hover:not(.active) { color: var(--ink); } +.graph-view-switch:focus-within { border-color: color-mix(in srgb, #1677d2 48%, var(--rule)); box-shadow: 0 0 0 3px color-mix(in srgb, #1677d2 12%, transparent); } +.graph-board { min-width: 0; overflow: hidden; } +.graph-version-control { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 5px 8px 5px 13px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--muted); font-size: 11px; } +.graph-version-control select { border: 0; color: var(--ink); background: transparent; } +.dag-shell { margin-top: 18px; } +.dag-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; } +.dag-legend, .dag-zoom-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; } +.dag-legend span { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; } +.dag-legend span::before { width: 9px; height: 9px; border-radius: 50%; content: ""; } +.dag-legend .available::before { background: #55a77b; } +.dag-legend .path::before { background: #d9aa3e; } +.dag-legend .target::before { background: #d95a67; } +.dag-legend .chapter::before { border-radius: 3px; background: #6d9fc7; } +.dag-legend .unlinked::before { border: 1px dashed #8997a5; background: transparent; } +.dag-zoom-controls { justify-content: flex-end; } +.dag-zoom-controls button { display: inline-flex; min-width: 32px; min-height: 32px; padding: 4px 9px; align-items: center; justify-content: center; border: 1px solid var(--rule); border-radius: 999px; color: var(--ink); background: var(--surface); cursor: pointer; line-height: 1; } +.dag-zoom-controls span { min-width: 42px; color: var(--muted); text-align: center; font-size: 11px; } +.dag-zoom-controls .dag-fullscreen-button { padding: 5px; border-color: transparent; background: transparent; } +.dag-fullscreen-button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.dag-zoom-controls .dag-fullscreen-button:hover { color: #0d72d9; background: rgba(13,114,217,.08); } +.dag-viewport { position: relative; height: min(620px, calc(100vh - 285px)); min-height: 480px; overflow: hidden; border: 1px solid var(--rule); border-radius: var(--radius-group); background-color: #f5f6f7; background-image: radial-gradient(circle, color-mix(in srgb, var(--rule) 62%, transparent) 1px, transparent 1px); background-size: 22px 22px; cursor: grab; touch-action: none; user-select: none; } +.dag-viewport.dragging { cursor: grabbing; } +.dag-shell:fullscreen { display: grid; width: 100vw; height: 100vh; margin: 0; padding: 12px; grid-template-rows: auto minmax(0, 1fr); background: var(--canvas); } +.dag-shell:fullscreen .dag-toolbar { margin-bottom: 8px; } +.dag-shell:fullscreen .dag-stage { width: 100%; height: 100%; min-height: 0; } +.dag-shell:fullscreen .dag-viewport { width: 100%; height: 100%; min-height: 0; } +.dag-canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; } +.dag-canvas svg { position: absolute; inset: 0; overflow: visible; pointer-events: none; } +.dag-canvas svg path { fill: none; stroke: #9ba0a6; stroke-width: 1.7; } +.dag-canvas svg path.highlighted { stroke: #d9aa3e; stroke-width: 3; } +.dag-canvas marker path { fill: #9ba0a6; stroke: none; } +.dag-canvas marker.highlighted path { fill: #d9aa3e; } +.dag-node { position: absolute; display: grid; align-content: center; gap: 5px; padding: 11px 14px; overflow: hidden; border: 2px solid #b7bbc0; border-radius: 18px; color: var(--ink); background: #fbfbfc; box-shadow: 0 8px 20px rgba(45,47,50,.08); cursor: pointer; text-align: left; transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease; } +.dag-node:hover { border-color: var(--surface-strong); } +.dag-node span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 700; } +.dag-node small { color: var(--muted); font-size: 10px; } +.dag-node--available { border-color: #55a77b; color: #1e5d43; background: #dcf4e7; } +.dag-node--path { border-color: #d9aa3e; color: #6e5317; background: #fff2c7; } +.dag-node--target { border-color: #d95a67; color: #792e38; background: #ffe1e5; } +.dag-node.selected { box-shadow: 0 0 0 4px rgba(255,255,255,.9), 0 0 0 7px currentColor, 0 12px 26px rgba(45,47,50,.15); } +.dag-empty { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--muted); } +.graph-inspector > header, .graph-inspector h3, .graph-inspector > p { text-align: center; } +.graph-inspector h3 { margin: 15px 0 7px; font-size: 24px; } +.graph-inspector > p { color: var(--muted); line-height: 1.6; } +.inspector-score { display: table; margin: 20px auto 0; padding: 6px 10px; border: 1px solid var(--rule); border-radius: 999px; color: var(--muted); font-size: 11px; } +.relation-filter { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 20px 0 11px; } +.relation-filter button { display: inline-flex; min-height: 32px; padding: 5px 8px; align-items: center; justify-content: center; border: 1px solid var(--rule); border-radius: 999px; color: var(--muted); background: transparent; cursor: pointer; font-size: 11px; line-height: 1; } +.relation-filter button.active { color: var(--inverse); background: var(--surface-strong); } +.relation-rows { border-top: 1px solid var(--rule); } +.relation-rows button { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 12px; align-items: center; width: 100%; min-height: 76px; padding: 12px 4px; border: 0; border-bottom: 1px solid var(--rule); color: var(--ink); background: transparent; cursor: pointer; text-align: left; } +.relation-rows span, .relation-rows small { color: var(--muted); font-size: 11px; } +.relation-rows small { line-height: 1.45; } +.relation-rows > p { text-align: center; } +.relation-copy { display: grid; gap: 4px; min-width: 0; } +.relation-copy strong { overflow: hidden; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } + +.review-setup { width: min(620px, 100%); margin: 6vh auto 0; } +.review-setup h2 { margin-top: 8px; } +.game-stage { position: relative; display: grid; align-content: end; min-height: min(710px, calc(100vh - 205px)); padding: clamp(24px, 5vw, 62px); overflow: hidden; border-radius: var(--radius-shell); color: #fff; background: #285675; } +.game-stage::before { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255,255,255,.08), transparent 42%), repeating-linear-gradient(90deg, transparent 0 79px, rgba(255,255,255,.025) 80px); content: ""; pointer-events: none; } +.game-stage__meta { position: absolute; z-index: 1; left: 26px; right: 26px; top: 21px; display: flex; justify-content: space-between; color: rgba(255,255,255,.67); font-size: 11px; } +.dialogue-panel, .choice-list, .game-stage > .primary-button { position: relative; z-index: 1; } +.dialogue-panel { padding: 25px; border: 1px solid rgba(255,255,255,.28); border-radius: var(--radius-group); background: rgba(15,48,70,.84); } +.dialogue-panel h2 { margin: 0 0 14px; } +.dialogue-line { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 18px; padding: 7px 0; } +.dialogue-line strong { color: #d8eefb; } +.dialogue-line p { margin: 0; line-height: 1.7; } +.choice-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 10px; } +.choice-list button { min-height: 56px; padding: 12px 16px; border: 1px solid rgba(255,255,255,.35); border-radius: var(--radius-control); color: #fff; background: rgba(255,255,255,.09); cursor: pointer; text-align: left; transition: background-color 140ms ease, transform 140ms ease; } +.choice-list button:hover:not(:disabled) { background: rgba(255,255,255,.18); transform: translateY(-1px); } +.primary-button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: var(--radius-control); color: var(--inverse); background: #0D72D9; cursor: pointer; } +.review-complete { width: min(920px, 100%); margin: 7vh auto 0; } +.review-complete > p:first-child { color: var(--muted); font-size: 12px; letter-spacing: .12em; } +.review-complete h2 { font-size: 36px; } +.review-summary { margin-top: 28px; padding-top: 22px; border-top: 1px solid var(--rule); } +.review-summary__heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } +.review-summary__heading h3 { margin: 0; font-size: 18px; } +.review-summary__heading span { color: var(--muted); font-size: 12px; } +.review-summary ul { display: grid; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; } +.review-summary li { padding: 14px 16px; border: 1px solid var(--rule); border-radius: var(--radius-control); background: var(--canvas); } +.review-summary li > div { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } +.review-summary li strong { font-size: 15px; } +.review-summary li span { flex: 0 0 auto; color: var(--accent); font-size: 13px; font-weight: 700; } +.review-summary li p { margin: 7px 0 0; color: var(--muted); font-size: 13px; } +.review-summary .review-summary__error { margin: 12px 0 0; color: var(--danger); font-size: 13px; } +.completion-actions { display: grid; margin-top: 30px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } +.completion-action { position: relative; display: grid; min-width: 0; min-height: 68px; padding: 8px 12px; overflow: hidden; border: 1px solid var(--rule); border-radius: 15px; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; color: var(--ink); background: var(--surface); box-shadow: 0 10px 30px -24px rgba(27,39,51,.6); cursor: pointer; text-align: left; text-decoration: none; transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms var(--ease-out); } +.completion-action::before { position: absolute; inset: auto -36px -54px auto; width: 130px; height: 130px; border-radius: 50%; background: currentColor; content: ""; opacity: .035; pointer-events: none; } +.completion-action:hover:not(:disabled) { border-color: color-mix(in srgb, currentColor 28%, var(--rule)); box-shadow: 0 18px 38px -24px rgba(27,39,51,.55); transform: translateY(-3px); } +.completion-action:focus-visible { outline: 3px solid color-mix(in srgb, #1677d2 32%, transparent); outline-offset: 3px; } +.completion-action:disabled { cursor: wait; opacity: .62; } +.completion-action__icon { display: grid; width: 30px; height: 30px; border-radius: 9px; place-items: center; color: #496477; background: var(--surface-soft); } +.completion-action__icon svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.completion-action__copy { display: grid; min-width: 0; align-self: center; gap: 3px; } +.completion-action__copy strong { font-size: 15px; letter-spacing: -.02em; } +.completion-action__copy small { color: var(--muted); font-size: 11px; line-height: 1.45; } +.completion-action--restart { border-color: transparent; color: #fff; background: linear-gradient(145deg, #1677d2, #0d579d); box-shadow: 0 18px 36px -22px rgba(13,87,157,.9); } +.completion-action--restart::before { opacity: .09; } +.completion-action--restart .completion-action__icon { color: #fff; background: rgba(255,255,255,.16); } +.completion-action--restart .completion-action__arrow, .completion-action--restart .completion-action__copy small { color: rgba(255,255,255,.74); } +.completion-action--restart:hover:not(:disabled) { border-color: transparent; box-shadow: 0 22px 42px -22px rgba(13,87,157,.95); } +.completion-action--graph { border-color: color-mix(in srgb, #6d9fc7 42%, var(--rule)); background: linear-gradient(145deg, color-mix(in srgb, #dcecf8 54%, var(--surface)), var(--surface)); } +.completion-action--graph .completion-action__icon { color: #176daa; background: color-mix(in srgb, #6d9fc7 17%, var(--surface)); } +.completion-action--plan .completion-action__icon { color: #665da0; background: color-mix(in srgb, #8b7fc5 14%, var(--surface)); } + +@media (max-width: 900px) { + .completion-actions { grid-template-columns: 1fr; } + .completion-action { min-height: 66px; } +} + +.auth-page { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 100vh; overflow: auto; background: var(--canvas); } +.auth-page__content { z-index: 1; display: grid; grid-column: 1; place-items: center; min-width: 0; padding: 64px clamp(36px, 7vw, 150px) 90px; } +.auth-page__mark { position: relative; display: grid; grid-column: 2; place-items: center; min-width: 0; color: var(--brand); } +.auth-page__mark .brand-mark { width: clamp(150px, 16vw, 300px); height: auto; } +.auth-page__footer { position: absolute; z-index: 1; left: 5%; right: 5%; bottom: 24px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; } +.auth-heading { margin-bottom: 30px; } +.auth-heading h1 { margin: 0 0 6px; color: var(--muted); font-size: 14px; letter-spacing: .24em; } +.auth-heading h2 { margin: 0; font-size: 44px; letter-spacing: -.05em; } +.auth-heading p { margin: 9px 0 0; color: var(--muted); line-height: 1.55; } +.auth-form { width: min(576px, 100%); } +.form-field { position: relative; min-height: 100px; } +.form-field > label { display: block; margin-bottom: 7px; color: var(--muted); font-size: 13px; } +.form-field__control { position: relative; display: flex; align-items: center; height: 58px; border: 2px solid var(--rule); border-radius: 30px; background: var(--field); transition: border-color 140ms ease, background-color 140ms ease; } +.form-field__control:focus-within { border-color: var(--muted); background: var(--surface); } +.form-field__control input { width: 100%; height: 100%; padding: 0 52px 0 14px; border: 0; outline: 0; color: var(--ink); background: transparent; } +.form-field__control input::placeholder { color: color-mix(in srgb, var(--muted) 70%, transparent); } +.form-field__icon, .form-field__decorative { position: absolute; right: 12px; width: 27px; height: 27px; } +.form-field__icon { display: grid; place-items: center; padding: 0; border: 0; color: var(--muted); background: transparent; cursor: pointer; } +.form-field__icon:disabled { cursor: default; opacity: .25; } +.form-field__icon svg, .form-field__decorative { width: 23px; } +.form-field__error { position: absolute; left: 2px; bottom: 6px; color: var(--danger); font-size: 11px; } +.form-field--error .form-field__control { border-color: var(--danger); } +.inline-button { position: absolute; right: 5px; min-width: 78px; height: 40px; border: 0; border-radius: 20px; color: var(--inverse); background: #0D72D9; cursor: pointer; } +.auth-form__actions { display: flex; margin-top: 5px; } +.action-button { width: 100%; min-height: 52px; padding: 10px 18px; border: 2px solid var(--rule); border-radius: 28px; color: var(--inverse); background: var(--surface-strong); cursor: pointer; font-weight: 700; transition: transform 140ms ease, opacity 140ms ease; } +.action-button:hover:not(:disabled) { transform: translateY(-1px); } +.action-button:disabled { cursor: wait; opacity: .55; } +.action-button--secondary { color: var(--ink); background: transparent; font-weight: 400; } +.auth-secondary-actions { display: flex; justify-content: space-between; margin-top: 15px; } +.auth-secondary-actions button, .auth-form__back-link { padding: 0; border: 0; color: var(--muted); background: transparent; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; } +.auth-form__back-link { float: right; margin-top: 15px; } +.form-message { clear: both; margin: 18px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; } + +.admin-page { min-height: 100vh; background: var(--canvas); } +.admin-header { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 78px; padding: 13px clamp(24px, 5vw, 70px); border-bottom: 1px solid var(--rule); background: color-mix(in srgb, var(--canvas) 94%, transparent); backdrop-filter: blur(12px); } +.admin-header > div { display: flex; align-items: center; gap: 10px; } +.admin-header .brand-mark { width: 44px; color: var(--brand); } +.admin-header span { display: grid; } +.admin-header small { color: var(--muted); } +.admin-content { width: min(1380px, 100%); margin: 0 auto; padding: 44px clamp(24px, 5vw, 70px) 80px; } +.admin-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(360px, .8fr); gap: 20px; align-items: start; } +.admin-grid article > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; } +.admin-grid article > header span { color: var(--muted); } +.admin-table, .invitation-list { border-top: 1px solid var(--rule); } +.admin-table > div { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 18px; align-items: center; min-height: 68px; padding: 10px 2px; border-bottom: 1px solid var(--rule); } +.admin-table > div > span:first-child, .invitation-list > div > span { display: grid; gap: 3px; min-width: 0; } +.admin-table strong, .admin-table small, .invitation-list strong, .invitation-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.admin-table small, .admin-table em, .invitation-list small { color: var(--muted); font-size: 11px; font-style: normal; } +.admin-row-actions { display: flex; gap: 7px; } +.admin-row-actions button:not(.danger), .invitation-list button { padding: 6px 8px; border: 1px solid #0D72D9; border-radius: 999px; color: #fff; background: #0D72D9; cursor: pointer; font-size: 11px; } +.admin-row-actions button:not(.danger):hover:not(:disabled), .invitation-list button:hover:not(:disabled) { border-color: #0a61ba; background: #0a61ba; } +.admin-row-actions button.danger { color: var(--danger); } +.admin-invitation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 22px; padding-bottom: 20px; border-bottom: 1px solid var(--rule); } +.admin-invitation-form label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; } +.admin-invitation-form input, .admin-invitation-form select { min-height: 42px; padding: 8px 10px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--field); } +.admin-invitation-form .button { align-self: end; } +.invitation-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 15px; align-items: center; min-height: 62px; border-bottom: 1px solid var(--rule); } + +.not-found { display: grid; min-height: 100vh; place-content: center; color: var(--ink); background: var(--canvas); text-align: center; } +.not-found span { color: var(--surface-strong); font-size: 88px; font-weight: 700; } +.not-found h1 { margin: 0; } +.not-found a { margin-top: 20px; color: var(--ink); } + +@media (max-width: 1120px) { + .app-rail { gap: 14px; } + .rail-brand > span { display: none; } + .rail-nav a { padding-right: 11px; padding-left: 11px; } + .rail-profile > span:last-child { display: none; } + .rail-actions span { display: none; } + .graph-workbench { grid-template-columns: 180px minmax(320px, 1fr); } + .graph-inspector { position: static; grid-column: 1 / -1; max-height: none; } + .process-layout, .settings-layout { grid-template-columns: 1fr; } + .plan-panel { position: static; } + .home-workspace { grid-template-columns: 1fr; } + .admin-grid { grid-template-columns: 1fr; } + .resume-panel { min-height: 240px; } +} + +@media (max-width: 760px) { + .app-shell { display: block; padding-bottom: calc(72px + env(safe-area-inset-bottom)); } + .app-rail { position: fixed; z-index: 100; inset: auto 0 0; display: block; width: 100%; min-height: 0; height: calc(72px + env(safe-area-inset-bottom)); padding: 8px 9px max(8px, env(safe-area-inset-bottom)); border-top: 1px solid var(--rule); border-right: 0; background: color-mix(in srgb, var(--canvas) 96%, transparent); box-shadow: 0 -8px 24px rgba(45,47,50,.1); } + .rail-brand, .rail-account { display: none; } + .rail-nav { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 2px; margin: 0; padding: 0; background: transparent; } + .rail-nav .mobile-nav-only { display: flex; } + .rail-nav a { flex-direction: column; gap: 1px; padding: 5px 2px; border: 0; border-radius: var(--radius-control); text-align: center; font-size: 10px; } + .rail-nav svg { width: 17px; height: 17px; } + .rail-nav a.active { border: 0; } + .page { min-height: calc(100vh - 72px); padding: 30px 18px 42px; } + .page-header { align-items: flex-start; flex-direction: column; margin-bottom: 26px; } + .page-header h1 { font-size: 38px; } + .page-header__actions { width: 100%; } + .page-header__actions .button { flex: 1; } + .data-strip { grid-template-columns: 1fr; } + .data-strip div { border-right: 0; border-bottom: 1px solid var(--rule); } + .data-strip div:last-child { border-bottom: 0; } + .resume-panel { min-height: 270px; place-items: center; text-align: center; } + .home-quick-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .home-welcome { padding: 22px; } + .home-welcome__intro { align-items: flex-start; flex-direction: column; gap: 18px; } + .home-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .material-library > header, .graph-board > header { align-items: stretch; flex-direction: column; } + .material-library > header input, .graph-board input { max-width: none; } + .option-row, .knowledge-layout, .graph-workbench { grid-template-columns: 1fr; } + .plan-options__grid, .admin-invitation-form { grid-template-columns: 1fr; } + .filter-panel, .chapter-outline { position: static; max-height: none; } + .dag-toolbar { align-items: flex-start; flex-direction: column; } + .dag-zoom-controls { width: 100%; justify-content: flex-start; } + .dag-viewport { height: 520px; min-height: 520px; } + .point-detail { padding-right: 12px; padding-left: 12px; } + .game-stage { min-height: 650px; padding: 72px 16px 20px; } + .dialogue-line, .choice-list { grid-template-columns: 1fr; } + .auth-page { display: flex; min-height: 100svh; flex-direction: column-reverse; justify-content: flex-end; padding: 0 20px 84px; overflow: auto; } + .auth-page__content { display: block; width: 100%; padding: 28px 0 0; } + .auth-page__mark { display: grid; width: 100%; min-height: 118px; place-items: end; padding: 24px 0 0; } + .auth-page__mark .brand-mark { width: 82px; height: 48px; } + .auth-page__footer { left: 20px; right: 20px; gap: 10px; } + .auth-heading h2 { font-size: 38px; } + .admin-header { align-items: flex-start; flex-direction: column; } + .admin-content { padding-top: 30px; } + .admin-table > div { grid-template-columns: minmax(0, 1fr) auto; } + .admin-row-actions { grid-column: 1 / -1; } +} + +@media (prefers-reduced-motion: reduce) { + .process-layout--exit .process-primary, .process-layout--exit .plan-panel, + .process-layout--scope.process-layout--enter .plan-panel { animation: none; } +} + +/* Persisted dark appearance. */ +:root[data-theme="dark"] { + color-scheme: dark; + --canvas: #2b2d31; + --surface: #2f3136; + --surface-soft: #393c42; + --surface-strong: #a3a7ad; + --accent-deep: #c1c4c8; + --brand: #b6b9bd; + --ink: #e3e5e8; + --muted: rgba(227, 229, 232, .68); + --rule: #45484d; + --field: #292b2f; + --inverse: #202124; + --danger: #ff7b86; +} + +:root[data-theme="dark"] .app-shell, +:root[data-theme="dark"] .admin-page { + --canvas: #2b2d31; + --surface: #2f3136; + --surface-soft: #393c42; + --surface-strong: #a3a7ad; + --accent-deep: #c1c4c8; + --brand: #b6b9bd; + --ink: #e3e5e8; + --muted: rgba(227, 229, 232, .68); + --rule: #45484d; + --field: #292b2f; + --danger: #ff7b86; +} + +:root[data-theme="dark"] .app-rail, +:root[data-theme="dark"] .admin-header { background: #1f2024; } +:root[data-theme="dark"] .rail-nav a, +:root[data-theme="dark"] .rail-profile, +:root[data-theme="dark"] .rail-logout { color: var(--muted); } +:root[data-theme="dark"] .rail-nav a:hover, +:root[data-theme="dark"] .rail-profile:hover, +:root[data-theme="dark"] .rail-logout:hover { background: rgba(255,255,255,.06); } +:root[data-theme="dark"] .rail-nav a::after, +:root[data-theme="dark"] .rail-profile::after, +:root[data-theme="dark"] .rail-logout::after { background: rgba(47,49,54,.98); box-shadow: 0 4px 14px rgba(0,0,0,.35); } +:root[data-theme="dark"] .app-statusbar { background: #35383e; } +:root[data-theme="dark"] .app-shell .page-header, +:root[data-theme="dark"] .admin-content > .page-header, +:root[data-theme="dark"] .mobile-shell-head { background: rgba(53,56,62,.96); } +:root[data-theme="dark"] .form-section input:focus, +:root[data-theme="dark"] .form-section select:focus, +:root[data-theme="dark"] .filter-panel input:focus, +:root[data-theme="dark"] .filter-panel select:focus, +:root[data-theme="dark"] .side-section input:focus, +:root[data-theme="dark"] .side-section select:focus, +:root[data-theme="dark"] .material-library input:focus, +:root[data-theme="dark"] .graph-board input:focus, +:root[data-theme="dark"] .admin-invitation-form input:focus, +:root[data-theme="dark"] .admin-invitation-form select:focus { background: #35383e; } +:root[data-theme="dark"] .toggle-field, +:root[data-theme="dark"] .inline-select-field { background: #292b2f; } +:root[data-theme="dark"] .inline-select-field.is-enabled, :root[data-theme="dark"] .inline-select-field.is-enabled > select { background: var(--surface); } +:root[data-theme="dark"] .status-line { border-color: var(--rule); color: #d2d5d8; background: #36383d; } +:root[data-theme="dark"] .status-line--error { border-color: var(--rule); color: #ff9aa3; background: #321d24; } + +:root[data-theme="dark"] .home-welcome, +:root[data-theme="dark"] .home-summary article { background: #2f3136; box-shadow: none; } +:root[data-theme="dark"] .home-intelligence-flow { background: #2f3136; box-shadow: none; } +:root[data-theme="dark"] .home-quick-actions a { background: var(--surface); box-shadow: 0 5px 14px -8px rgba(0,0,0,.8); } +:root[data-theme="dark"] .home-quick-actions a:hover { background: var(--surface-soft); } +:root[data-theme="dark"] .quick-action-icon { color: #d8dbe0; background: #383b40; } + +:root[data-theme="dark"] .segmented-control { background: #25272b; } +:root[data-theme="dark"] .chapter-picker label:hover, +:root[data-theme="dark"] .material-table article:hover, +:root[data-theme="dark"] .admin-table > div:hover, +:root[data-theme="dark"] .invitation-list > div:hover, +:root[data-theme="dark"] .knowledge-list article:hover, +:root[data-theme="dark"] .relation-rows button:hover, +:root[data-theme="dark"] .chapter-outline nav button:hover { background: #383b40; } +:root[data-theme="dark"] .material-table article.selected, +:root[data-theme="dark"] .knowledge-list article.expanded, +:root[data-theme="dark"] .chapter-outline nav button.active, +:root[data-theme="dark"] .settings-index button:hover, +:root[data-theme="dark"] .settings-index button.active { background: #3a3d43; } +:root[data-theme="dark"] .text-preview pre { background: #27292d; } + +:root[data-theme="dark"] .knowledge-layout, +:root[data-theme="dark"] .graph-workbench { background: var(--surface); } +:root[data-theme="dark"] .filter-panel, +:root[data-theme="dark"] .settings-index, +:root[data-theme="dark"] .chapter-outline, +:root[data-theme="dark"] .graph-inspector { background: #282a2e; } +:root[data-theme="dark"] .mastery-value { color: #d2d5d8; background: #393c42; } +:root[data-theme="dark"] .graph-version-control { background: var(--surface); } +:root[data-theme="dark"] .dag-viewport { border-color: var(--rule); background-color: #27292d; background-image: radial-gradient(circle, rgba(188,191,197,.2) 1px, transparent 1px); } +:root[data-theme="dark"] .dag-canvas svg path { stroke: #71849a; } +:root[data-theme="dark"] .dag-canvas marker path { fill: #71849a; } +:root[data-theme="dark"] .dag-node { background: rgba(47,49,54,.94); } +:root[data-theme="dark"] .dag-node:hover { background: #3a3d43; } +:root[data-theme="dark"] .dag-node::before { border-color: var(--rule); } +:root[data-theme="dark"] .dag-node--available { color: #9bdfad; background: rgba(29,70,50,.95); } +:root[data-theme="dark"] .dag-node--path { color: #f2cf77; background: rgba(79,60,21,.95); } +:root[data-theme="dark"] .dag-node--target { color: #ff9aa3; background: rgba(83,35,43,.95); } + +:root[data-theme="dark"] .game-stage { border-color: var(--rule); background: linear-gradient(150deg, #303238 0%, #27292d 100%); box-shadow: 0 5px 14px -8px rgba(0,0,0,.8); } +:root[data-theme="dark"] .dialogue-panel { border-color: var(--rule); background: rgba(39,41,45,.94); box-shadow: 0 8px 24px rgba(0,0,0,.25); } +:root[data-theme="dark"] .choice-list button { border-color: var(--rule); background: rgba(57,60,66,.82); } +:root[data-theme="dark"] .choice-list button:hover:not(:disabled) { background: #44474e; } +:root[data-theme="dark"] .admin-header .button:not(.button--danger):hover:not(:disabled) { border-color: #0a61ba; color: #fff; background: #0a61ba; } +:root[data-theme="dark"] button, +:root[data-theme="dark"] .button, +:root[data-theme="dark"] input, +:root[data-theme="dark"] select, +:root[data-theme="dark"] textarea { border-color: var(--rule); } +:root[data-theme="dark"] input::file-selector-button { border-color: var(--rule); } +:root[data-theme="dark"] .toggle-field input[type="checkbox"] { border-color: var(--rule); } +:root[data-theme="dark"] .toggle-field input[type="checkbox"]:checked { border-color: #0D72D9; background: #0D72D9; } +:root[data-theme="dark"] .danger-section { border-color: var(--rule); } +:root[data-theme="dark"] .app-shell .button:hover:not(:disabled), +:root[data-theme="dark"] .admin-page .button:hover:not(:disabled) { border-color: var(--rule); } +:root[data-theme="dark"] button:focus-visible, +:root[data-theme="dark"] a:focus-visible, +:root[data-theme="dark"] input:focus-visible, +:root[data-theme="dark"] select:focus-visible { outline-color: var(--rule); } + +@media (max-width: 760px) { + :root[data-theme="dark"] .app-rail { background: rgba(31,32,36,.98); box-shadow: 0 -4px 14px rgba(0,0,0,.28); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } +} +:root[data-reduced-motion="true"] *, :root[data-reduced-motion="true"] *::before, :root[data-reduced-motion="true"] *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } + +/* Shared loading transition: quiet enough for short waits, visible enough for long generation jobs. */ +.loading-indicator { + display: grid; + min-height: 150px; + align-content: center; + justify-items: center; + gap: 12px; + color: var(--muted); + animation: loading-transition-in 280ms var(--ease-out) both; +} +.loading-indicator--compact { + grid-template-columns: auto minmax(0, 1fr); + min-height: 0; + align-items: center; + justify-items: start; + gap: 11px; +} +.loading-indicator__buffer { display: inline-flex; align-items: center; gap: 4px; min-width: 31px; } +.loading-indicator__buffer i { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + animation: loading-buffer-dot 1.15s ease-in-out infinite; +} +.loading-indicator__buffer i:nth-child(2) { animation-delay: 140ms; } +.loading-indicator__buffer i:nth-child(3) { animation-delay: 280ms; } +.loading-indicator__label { font-size: 13px; line-height: 1.5; letter-spacing: .025em; } +.app-loading span::after { content: none; } +.app-loading .loading-indicator { min-height: 0; } +.page-loading-transition { + margin-top: 16px; + padding: 11px 14px; + border: 1px solid var(--rule); + border-radius: 14px; + background: color-mix(in srgb, var(--surface) 72%, transparent); +} +.review-page--setup > .page-loading-transition { width: min(860px, 100%); margin-inline: auto; } +:root[data-theme="dark"] .page-loading-transition { background: color-mix(in srgb, var(--surface) 76%, transparent); } + +@keyframes loading-transition-in { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: none; } +} +@keyframes loading-buffer-dot { + 0%, 70%, 100% { opacity: .28; transform: translateY(0) scale(.82); } + 35% { opacity: 1; transform: translateY(-3px) scale(1); } +} + diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index b6fd6e6..27578cd 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -1,3354 +1,20 @@ -:root { - font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; - color: #303236; - background: #f3f4f5; - font-synthesis: none; - --canvas: #f3f4f5; - --surface: #fbfbfc; - --surface-soft: #e8eaec; - --surface-strong: #858a90; - --accent-deep: #62676d; - --brand: #777c82; - --ink: #303236; - --muted: #70757a; - --rule: #c7cacf; - --field: #eff0f2; - --inverse: #ffffff; - --danger: #b64a58; - --radius-control: 18px; - --radius-group: 28px; - --radius-shell: 36px; - --ease-out: cubic-bezier(.22, 1, .36, 1); -} - -* { box-sizing: border-box; } -html { min-width: 320px; min-height: 100%; background: var(--canvas); } -body { min-width: 320px; min-height: 100vh; margin: 0; color: var(--ink); background: var(--canvas); font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; } -.brand-mark { display: block; flex: 0 0 auto; object-fit: contain; object-position: center; } -button, input, select, textarea { font: inherit; font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; } -button, a { -webkit-tap-highlight-color: transparent; } -button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid color-mix(in srgb, var(--ink) 28%, transparent); outline-offset: 2px; } -input[type="password"]::-ms-reveal, input[type="password"]::-ms-clear { display: none; } -input[type="password"]::-webkit-credentials-auto-fill-button { visibility: hidden; display: none !important; } - -.route-transition { min-height: 100vh; animation: route-enter 260ms var(--ease-out); } -.route-transition--forward { --route-x: 12px; } -.route-transition--back { --route-x: -12px; } -.route-transition--neutral { --route-x: 0px; } -@keyframes route-enter { from { opacity: 0; transform: translateX(var(--route-x)); } to { opacity: 1; transform: none; } } -@keyframes route-fade { from { opacity: 0; } to { opacity: 1; } } - -.app-shell { min-height: 100vh; } -.app-rail { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; gap: clamp(18px, 3vw, 48px); min-height: 92px; padding: 16px clamp(28px, 5vw, 76px); border-bottom: 1px solid var(--rule); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(12px); } -.rail-brand { display: flex; align-items: center; gap: 11px; min-height: 54px; color: var(--ink); text-decoration: none; } -.rail-brand .brand-mark { width: 45px; height: 32px; color: var(--brand); } -.rail-brand > span, .rail-profile > span:last-child { display: flex; min-width: 0; flex-direction: column; } -.rail-brand strong { font-size: 16px; letter-spacing: .08em; } -.rail-brand small, .rail-profile small { color: var(--muted); font-size: 11px; } -.rail-nav { display: flex; gap: 4px; margin-left: auto; padding: 5px; border-radius: 999px; background: var(--surface-soft); } -.rail-nav a { display: inline-flex; align-items: center; gap: 6px; padding: 9px 14px; border-radius: 999px; color: var(--muted); text-decoration: none; transition: color 140ms ease, background-color 140ms ease; } -.rail-nav svg { width: 18px; height: 18px; flex: 0 0 auto; } -.rail-nav a:hover { color: var(--ink); background: color-mix(in srgb, var(--surface-soft) 45%, transparent); } -.rail-nav a.active { color: var(--ink); background: var(--surface); font-weight: 700; } -.rail-nav .mobile-nav-only { display: none; } -.rail-account { display: flex; align-items: center; gap: 12px; } -.rail-profile { display: flex; align-items: center; gap: 10px; color: var(--ink); text-decoration: none; } -.rail-avatar { display: grid; place-items: center; width: 37px; height: 37px; border-radius: 50%; color: var(--inverse); background: var(--surface-strong); font-weight: 700; } -.rail-profile strong { overflow: hidden; max-width: 120px; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } -.rail-actions { display: flex; gap: 7px; } -.rail-actions button, .rail-actions a { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 38px; padding: 7px 10px; border: 1px solid var(--rule); border-radius: 999px; color: var(--muted); background: var(--surface); cursor: pointer; text-decoration: none; } -.rail-actions svg { width: 17px; height: 17px; } -.rail-actions button:hover, .rail-actions a:hover { color: var(--ink); border-color: var(--muted); } -.app-shell__content { min-width: 0; } -.app-loading { display: grid; min-height: 100vh; place-items: center; color: var(--muted); background: var(--canvas); } -.app-loading span::after { content: "..."; } - -.page { width: min(1440px, 100%); min-height: calc(100vh - 92px); margin: 0 auto; padding: 46px clamp(28px, 5vw, 76px) 80px; } -.page-header { display: flex; align-items: end; justify-content: space-between; gap: 28px; margin-bottom: 34px; } -.page-header h1 { margin: 0; font-size: clamp(34px, 4.2vw, 58px); line-height: 1.05; letter-spacing: -.045em; } -.page-header p { max-width: 650px; margin: 10px 0 0; color: var(--muted); line-height: 1.6; } -.page-header__actions { display: flex; flex-wrap: wrap; gap: 8px; } -.button { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 9px 16px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: transparent; cursor: pointer; text-decoration: none; transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease, transform 140ms ease; } -.button:hover:not(:disabled) { border-color: var(--muted); transform: translateY(-1px); } -.button--primary { border-color: var(--surface-strong); color: var(--inverse); background: var(--surface-strong); } -.button--light { border-color: rgba(255,255,255,.48); color: #fff; } -.button--danger { border-color: var(--danger); color: var(--danger); } -.button:not(.button--danger), .primary-button, .inline-button { border-color: #0D72D9; color: #fff; background: #0D72D9; } -.button:not(.button--danger):hover:not(:disabled), .primary-button:hover:not(:disabled), .inline-button:hover:not(:disabled) { border-color: #0a61ba; color: #fff; background: #0a61ba; } -.app-shell .page-header__actions .button.button--quiet { min-height: auto; padding: 4px 2px; border: 0; border-radius: 0; color: #7a838e; background: transparent; font-size: 12px; font-weight: 600; } -.app-shell .page-header__actions .button.button--quiet:hover:not(:disabled) { border: 0; color: #3f4852; background: transparent; text-decoration: underline; text-underline-offset: 3px; transform: none; } -.quiet-action-divider { width: 1px; height: 14px; background: #d9dde2; } -.button:disabled { cursor: not-allowed; opacity: .56; } -.button[aria-busy="true"]:disabled { cursor: wait; } -.status-line { margin: 24px 0 0; padding: 13px 15px; border-left: 3px solid var(--surface-strong); color: var(--muted); background: color-mix(in srgb, var(--surface-soft) 62%, transparent); line-height: 1.5; } -.status-line--error { border-left-color: var(--danger); color: var(--danger); } -.empty-state { display: grid; justify-items: start; width: min(660px, 100%); margin-top: 12vh; padding: 34px 0; row-gap: 28px; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); } -.empty-state h2 { margin: 0; font-size: 30px; } -.empty-state p, .empty-row { color: var(--muted); line-height: 1.6; } -.data-strip { display: grid; grid-template-columns: repeat(3, 1fr); overflow: hidden; border: 1px solid var(--rule); border-radius: var(--radius-group); background: color-mix(in srgb, var(--surface) 68%, transparent); } -.data-strip div { display: grid; min-height: 92px; place-items: center; align-content: center; gap: 4px; padding: 16px 22px; border-right: 1px solid var(--rule); text-align: center; } -.data-strip div:last-child { border-right: 0; } -.data-strip span { color: var(--muted); font-size: 13px; } -.data-strip strong { font-size: 30px; letter-spacing: -.04em; } - -.home-workspace { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(260px, .65fr); gap: 18px; } -.resume-panel { display: grid; min-height: 290px; place-items: center; align-content: center; gap: 24px; padding: clamp(26px, 4vw, 48px); border-radius: var(--radius-shell); color: #fff; background: var(--surface-strong); text-align: center; } -.resume-panel > div { display: grid; justify-items: center; } -.resume-panel span { font-size: 12px; letter-spacing: .12em; } -.resume-panel h2 { max-width: 700px; margin: 10px 0 8px; font-size: clamp(31px, 4vw, 54px); line-height: 1.05; letter-spacing: -.04em; } -.resume-panel p { margin: 0; color: #eaf7fe; } -.study-snapshot { padding: 28px; border: 1px solid var(--rule); border-radius: var(--radius-group); } -.study-snapshot h2 { margin: 0; font-size: 20px; } -.study-snapshot dl { margin: 20px 0; } -.study-snapshot dl div { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--rule); } -.study-snapshot dt { color: var(--muted); } -.study-snapshot dd { margin: 0; font-weight: 700; } -.study-snapshot a { color: var(--ink); } -.home-quick-actions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-top: 48px; } -.home-quick-actions a { display: grid; min-height: 126px; grid-template-columns: auto minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 22px; border: 1px solid var(--rule); border-radius: var(--radius-group); color: var(--ink); background: var(--surface); text-decoration: none; transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease; } -.home-quick-actions a:hover { border-color: var(--surface-strong); background: var(--surface-soft); box-shadow: 0 10px 24px rgba(45,47,50,.1); } -.home-quick-actions a > span:not(.quick-action-icon) { display: grid; gap: 4px; min-width: 0; } -.home-quick-actions strong { font-size: 18px; } -.home-quick-actions small { display: -webkit-box; overflow: hidden; color: var(--muted); line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } -.home-quick-actions i { color: var(--muted); font-size: 20px; font-style: normal; } -.quick-action-icon { display: grid; width: 44px; height: 44px; place-items: center; border-radius: 12px; color: var(--surface-strong); background: var(--surface-soft); } -.quick-action-icon svg { width: 22px; height: 22px; } -.home-welcome { position: relative; padding: clamp(26px, 3vw, 38px); border: 1px solid var(--rule); border-radius: var(--radius-shell); background: var(--surface); box-shadow: 0 4px 12px rgba(40,40,44,.06); } -.home-welcome__intro { display: flex; align-items: end; justify-content: space-between; gap: 28px; } -.home-welcome__intro > div { min-width: 0; } -.home-welcome__intro span { color: var(--muted); font-size: 12px; } -.home-welcome__intro h2 { margin: 8px 0 10px; font-size: clamp(30px, 3vw, 42px); letter-spacing: -.025em; } -.home-welcome__intro p { margin: 0; color: var(--muted); } -.home-welcome .home-quick-actions { margin-top: 30px; } -.home-welcome .home-quick-actions a { min-height: 116px; box-shadow: none; } -.home-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 18px; } -.home-summary article { display: grid; min-height: 94px; padding: 18px 20px; align-content: center; gap: 7px; border: 1px solid var(--rule); border-radius: var(--radius-group); background: var(--surface); box-shadow: 0 4px 12px rgba(40,40,44,.05); } -.home-summary span { color: var(--muted); font-size: 12px; } -.home-summary strong { overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; } -.home-intelligence-flow { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(0, 1.3fr); gap: 28px; margin-top: 18px; padding: clamp(24px, 3vw, 34px); border: 1px solid var(--rule); border-radius: var(--radius-shell); background: var(--surface); box-shadow: 0 4px 12px rgba(40,40,44,.05); } -.home-intelligence-flow > header { align-self: center; } -.home-intelligence-flow > header > span { color: var(--muted); font-size: 12px; letter-spacing: .08em; } -.home-intelligence-flow h2 { margin: 8px 0 10px; font-size: clamp(24px, 2.5vw, 34px); letter-spacing: -.025em; } -.home-intelligence-flow > header p { margin: 0; color: var(--muted); line-height: 1.7; } -.home-intelligence-flow > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--rule); border-left: 1px solid var(--rule); } -.home-intelligence-flow article { padding: 20px; border-right: 1px solid var(--rule); border-bottom: 1px solid var(--rule); } -.home-intelligence-flow article > span { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } -.home-intelligence-flow h3 { margin: 8px 0 6px; font-size: 17px; } -.home-intelligence-flow article p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.65; } -.home-recent-projects { margin-top: 18px; } -.credit-balance { display: grid; gap: 6px; padding: 22px; border: 1px solid var(--rule); border-radius: var(--radius-group); background: var(--surface-soft); } -.credit-balance span, .credit-balance small { color: var(--muted); } -.credit-balance strong { font-size: clamp(28px, 4vw, 42px); font-variant-numeric: tabular-nums; } -.admin-generated-codes { display: grid; gap: 8px; margin-top: 16px; color: var(--muted); font-size: 12px; } -.admin-generated-codes textarea { width: 100%; padding: 12px; border: 1px solid var(--rule); border-radius: 12px; color: var(--ink); background: var(--field); font-family: Consolas, monospace; line-height: 1.6; resize: vertical; } - -.process-layout { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(300px, .65fr); gap: 20px; align-items: start; } -.process-primary, .settings-main { display: grid; gap: 20px; } -.process-layout--materials { grid-template-columns: minmax(0, 1fr); } -.process-layout--scope { grid-template-columns: minmax(0, 860px); justify-content: center; } -.process-layout--scope .process-primary { display: none; } -.process-layout--scope .plan-panel { position: static; } -.process-layout--exit .process-primary, .process-layout--exit .plan-panel { animation: materials-flow-exit 220ms ease-in forwards; } -.process-layout--scope.process-layout--enter .plan-panel { animation: materials-flow-enter 300ms var(--ease-out) both; } -@keyframes materials-flow-exit { to { opacity: 0; transform: translateX(-7%); } } -@keyframes materials-flow-enter { from { opacity: 0; transform: translateX(8%); } to { opacity: 1; transform: translateX(0); } } -.form-section, .material-library, .plan-panel, .side-section, .filter-panel, .chapter-outline, .graph-board, .graph-inspector, .workspace-card { padding: 27px; border: 1px solid var(--rule); border-radius: var(--radius-group); background: color-mix(in srgb, var(--surface) 56%, transparent); } -.form-section > header, .material-library > header { margin-bottom: 23px; } -.upload-panel__header h2, .upload-panel__header p { margin: 0; } -.upload-panel__header p { margin-top: 7px; color: var(--muted); line-height: 1.55; } -.upload-panel__form { display: grid; } -.form-section h2, .material-library h2, .plan-panel h2, .side-section h2, .chapter-outline h2, .graph-board h2, .graph-inspector h2, .workspace-card h2 { margin: 0; font-size: 23px; } -.form-section header p, .material-library header p, .plan-panel > p, .side-section p, .workspace-card > p { margin: 7px 0 0; color: var(--muted); line-height: 1.55; } -.form-section > label, .upload-panel__form > label, .filter-panel label, .side-section label, .review-setup label { display: grid; gap: 7px; margin: 15px 0; color: var(--muted); font-size: 13px; } -.settings-language { display: grid; gap: 7px; margin: 15px 0; padding: 0; border: 0; border-radius: 0; color: var(--muted); background: transparent; font-size: 13px; } -.settings-language select { color: var(--ink); text-align: left; } -.form-section input:not([type="checkbox"]), .form-section select, .filter-panel input, .filter-panel select, .side-section input, .side-section select, .review-setup select, .material-library input, .graph-board input { width: 100%; min-height: 44px; padding: 9px 11px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--field); } -.form-section label small { color: var(--muted); } -.upload-panel__form input[type="file"]::file-selector-button { margin-right: 12px; padding: 8px 16px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--surface); cursor: pointer; transition: border-color 140ms ease, background-color 140ms ease; } -.upload-panel__form input[type="file"]::file-selector-button:hover { border-color: var(--muted); background: var(--surface-soft); } -.form-section > .button, .upload-panel__form > .button { margin-top: 7px; } -.option-row { display: grid; grid-template-columns: 1.2fr .8fr; gap: 12px; align-items: stretch; } -.inline-select-field { display: grid; min-height: 58px; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; padding: 9px 10px 9px 13px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--field); cursor: default; } -.inline-select-field > span { color: var(--ink); font-weight: 700; white-space: nowrap; } -.inline-select-field > select { min-width: 0; margin: 0; } -.inline-select-field.is-enabled, .inline-select-field.is-enabled > select { background: #fff; } -.toggle-field { display: grid !important; min-height: 58px; grid-template-columns: minmax(0, 1fr) 42px; gap: 14px; align-items: center; padding: 11px 13px; border: 1px solid var(--rule); border-radius: var(--radius-control); cursor: pointer; } -.toggle-field span { display: grid; min-width: 0; gap: 3px; } -.toggle-field strong { color: var(--ink); } -.toggle-field input[type="checkbox"] { position: relative; width: 40px; height: 22px; margin: 0; appearance: none; border: 1px solid var(--rule); border-radius: 999px; background: var(--field); cursor: pointer; transition: border-color .18s ease, background-color .18s ease; } -.toggle-field input[type="checkbox"]::before { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); content: ""; transition: background-color .18s ease, transform .18s ease; } -.toggle-field input[type="checkbox"]:checked { border-color: #0D72D9; background: #0D72D9; } -.toggle-field input[type="checkbox"]:checked::before { background: #fff; transform: translateX(18px); } -.toggle-field input[type="checkbox"]:focus-visible { outline: 3px solid color-mix(in srgb, #0D72D9 30%, transparent); outline-offset: 2px; } -.compact-toggle { margin-bottom: 18px !important; } -.material-library > header { display: flex; align-items: end; justify-content: space-between; gap: 20px; } -.material-library > header p { margin: 3px 0 0; } -.material-library > header input { max-width: 250px; } -.material-table { border-top: 1px solid var(--rule); } -.material-table article { display: grid; grid-template-columns: minmax(0, 1fr) auto; border-bottom: 1px solid var(--rule); } -.material-table article.selected { background: var(--surface-soft); } -.material-open { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-width: 0; padding: 15px 10px; border: 0; color: var(--ink); background: transparent; cursor: pointer; text-align: left; } -.material-open span { display: grid; min-width: 0; gap: 3px; } -.material-open strong, .material-open small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.material-open small, .material-open em { color: var(--muted); font-size: 11px; font-style: normal; } -.material-delete { align-self: center; padding: 7px 9px; border: 0; color: var(--danger); background: transparent; cursor: pointer; } -.material-actions { display: flex; align-items: center; gap: 2px; padding-right: 4px; } -.material-actions button { padding: 7px 8px; border: 0; color: var(--muted); background: transparent; cursor: pointer; } -.material-actions .material-delete { color: var(--danger); } -.plan-panel { position: sticky; top: 112px; } -.plan-panel > header > span, .section-label { color: var(--muted); font-size: 11px; letter-spacing: .12em; } -.flow-next-button { width: 100%; } -.flow-scope-intro { margin: 12px 0 0; color: var(--muted); line-height: 1.65; } -.flow-back-row { display: flex; justify-content: flex-end; margin-top: 16px; } -.app-shell .button.flow-back-button { min-height: auto; margin: 0; padding: 2px 0; border: 0; border-radius: 0; color: #7a838e; background: transparent; font-size: 12px; } -.app-shell .flow-back-row .button.flow-back-button:hover:not(:disabled) { border: 0; color: #4f5863; background: transparent; text-decoration: underline; text-underline-offset: 3px; transform: none; } -.plan-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 22px 0; } -.plan-summary div { display: grid; min-height: 64px; place-content: center; padding: 0 8px; border-right: 1px solid var(--rule); text-align: center; } -.plan-summary div:last-child { border: 0; } -.plan-summary dt { color: var(--muted); font-size: 11px; } -.plan-summary dd { margin: 4px 0 0; font-size: 21px; font-weight: 700; } -.segmented-control { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 4px; border-radius: 17px; background: var(--surface-soft); } -.segmented-control button { display: inline-flex; min-height: 36px; padding: 7px 10px; align-items: center; justify-content: center; border: 0; border-radius: 13px; color: var(--muted); background: transparent; cursor: pointer; } -.segmented-control button.active { color: var(--inverse); background: var(--surface-strong); } -.segmented-control.plan-type-switch { position: relative; gap: 0; padding: 0; overflow: hidden; border: 1px solid #cbdcf0; border-radius: 14px; background: #eff6ff; isolation: isolate; } -.segmented-control.plan-type-switch::before { position: absolute; z-index: 0; top: 0; bottom: 0; left: 0; width: 50%; border-radius: 12px; background: #0D72D9; box-shadow: 0 5px 13px -9px rgba(13,114,217,.95); content: ""; transition: transform 360ms var(--ease-out), background-color 240ms ease, box-shadow 240ms ease; } -.segmented-control.plan-type-switch--learning { border-color: #d9d2fb; background: #f4f1ff; } -.segmented-control.plan-type-switch--learning::before { background: #6B5DE8; box-shadow: 0 5px 13px -9px rgba(107,93,232,.95); transform: translateX(100%); } -.plan-type-switch .plan-type-button { position: relative; z-index: 1; min-height: 44px; border: 0; border-radius: 0; color: #0D72D9; background: transparent; font-weight: 700; transition: color 180ms ease; } -.plan-type-switch .plan-type-button--learning { color: #6B5DE8; } -.plan-type-switch .plan-type-button.is-active { color: #fff; background: transparent; } -.plan-type-switch .plan-type-button:hover:not(:disabled) { transform: none; } -.chapter-actions { display: flex; justify-content: flex-end; gap: 12px; margin: 18px 0 8px; } -.chapter-actions button { display: inline-flex; min-height: 32px; padding: 0 8px; align-items: center; justify-content: center; border: 0; border-radius: var(--radius-control); color: var(--muted); background: transparent; cursor: pointer; } -.chapter-picker { max-height: 300px; margin-bottom: 18px; overflow: auto; border-top: 1px solid var(--rule); } -.chapter-picker label { display: grid; min-height: 40px; grid-template-columns: 18px minmax(0, 1fr); gap: 10px; align-items: center; border-bottom: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); cursor: pointer; font-size: 13px; line-height: 1.4; } -.chapter-picker input[type="checkbox"] { width: 17px; height: 17px; margin: 0; accent-color: #0d72d9; cursor: pointer; } -.plan-options { margin: -3px 0 16px; padding: 11px 0; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); } -.plan-options summary { color: var(--muted); cursor: pointer; font-size: 12px; } -.plan-options__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding-top: 12px; } -.plan-options__grid label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; } -.plan-options__grid input { width: 100%; min-height: 38px; padding: 7px 9px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--field); } -.preview-overlay { position: fixed; z-index: 200; inset: 0; display: grid; place-items: center; padding: 24px; background: rgba(36,79,105,.2); backdrop-filter: blur(5px); } -.text-preview { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(960px, 100%); max-height: min(820px, calc(100vh - 48px)); overflow: hidden; border: 1px solid var(--rule); border-radius: var(--radius-shell); background: var(--surface); box-shadow: 0 24px 70px rgba(34,91,125,.2); } -.text-preview > header { display: flex; align-items: start; justify-content: space-between; gap: 20px; padding: 24px 28px; border-bottom: 1px solid var(--rule); } -.text-preview h2 { margin: 3px 0; } -.text-preview header span, .text-preview header p { color: var(--muted); font-size: 11px; } -.text-preview header p { margin: 0; } -.text-preview header button { border: 0; color: var(--muted); background: transparent; cursor: pointer; } -.text-preview pre { margin: 0; padding: 26px 28px 40px; overflow: auto; color: var(--ink); background: #fbfdff; font: 13px/1.85 "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; white-space: pre-wrap; word-break: break-word; } - -.settings-layout { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(280px, .6fr); gap: 20px; align-items: start; } -.settings-side { display: grid; gap: 20px; } -.danger-section { border-color: color-mix(in srgb, var(--danger) 44%, var(--rule)); } - -.knowledge-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 20px; margin-top: 24px; align-items: start; } -.filter-panel { position: sticky; top: 112px; } -.filter-panel label:first-child { margin-top: 0; } -.filter-panel > p { margin: 20px 0 0; color: var(--muted); font-size: 12px; } -.knowledge-list { overflow: hidden; border: 1px solid var(--rule); border-radius: var(--radius-group); background: color-mix(in srgb, var(--surface) 58%, transparent); } -.knowledge-list article { border-bottom: 1px solid var(--rule); } -.knowledge-list article:last-child { border-bottom: 0; } -.knowledge-list article > button { display: grid; grid-template-columns: 58px minmax(0, 1fr) 54px; gap: 18px; align-items: center; width: 100%; min-height: 92px; padding: 18px 22px; border: 0; color: var(--ink); background: transparent; cursor: pointer; text-align: left; } -.knowledge-list article.expanded { background: color-mix(in srgb, var(--surface-soft) 48%, transparent); } -.mastery-value { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 50%; color: var(--accent-deep); background: var(--surface-soft); font-size: 20px; font-weight: 700; } -.point-copy { display: grid; gap: 3px; } -.point-copy small, .point-copy em, .row-action { color: var(--muted); font-size: 11px; font-style: normal; } -.row-action { justify-self: center; } -.point-copy strong { font-size: 16px; } -.point-detail { padding: 0 84px 20px; } -.point-detail > p { margin: 0 0 15px; color: var(--muted); line-height: 1.65; } -.point-detail dl { display: flex; flex-wrap: wrap; gap: 25px; margin: 0; } -.point-detail dl div { display: flex; gap: 7px; } -.point-detail dt { color: var(--muted); } -.point-detail dd { margin: 0; font-weight: 700; } -.source-references { margin-top: 18px; padding-top: 15px; border-top: 1px solid var(--rule); } -.source-references h3 { margin: 0 0 9px; font-size: 13px; } -.source-references blockquote { margin: 8px 0; padding: 10px 12px; border-left: 2px solid var(--surface-strong); background: color-mix(in srgb, var(--surface-soft) 55%, transparent); } -.source-references blockquote span { color: var(--muted); font-size: 10px; } -.source-references blockquote p { margin: 3px 0 0; line-height: 1.6; } - -.graph-workbench { display: grid; grid-template-columns: 220px minmax(360px, 1fr) minmax(310px, .72fr); gap: 18px; margin-top: 24px; align-items: start; } -.chapter-outline, .graph-inspector { position: sticky; top: 112px; max-height: calc(100vh - 132px); overflow: auto; } -.chapter-outline { padding: 19px 0; } -.chapter-outline header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 18px 13px; } -.chapter-outline header p { margin: 3px 0 0; color: var(--muted); font-size: 10px; } -.chapter-outline header button, .chapter-outline nav button { border: 0; color: var(--muted); background: transparent; cursor: pointer; } -.chapter-outline header button.active { color: var(--ink); font-weight: 700; } -.chapter-outline nav { display: grid; border-top: 1px solid var(--rule); } -.chapter-outline nav button { display: grid; gap: 3px; padding: 11px 18px; border-bottom: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); text-align: left; } -.chapter-outline nav button span { overflow: hidden; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; } -.chapter-outline nav button small { font-size: 10px; } -.chapter-outline nav button.active { color: var(--ink); background: var(--surface-soft); font-weight: 700; } -.graph-board > header { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding-bottom: 18px; border-bottom: 1px solid var(--rule); } -.graph-board header p { margin: 3px 0 0; color: var(--muted); font-size: 12px; } -.graph-board input { max-width: 230px; } -.graph-board-view-tools { display: flex; align-items: center; justify-content: flex-end; gap: 10px; } -.graph-view-switch { position: relative; display: grid; min-width: 178px; padding: 3px; border: 1px solid color-mix(in srgb, var(--rule) 82%, transparent); border-radius: 999px; grid-template-columns: repeat(2, minmax(0, 1fr)); background: color-mix(in srgb, var(--surface-soft) 90%, transparent); box-shadow: inset 0 1px 2px rgba(32,45,58,.05); isolation: isolate; } -.graph-view-switch::before { position: absolute; z-index: 0; top: 3px; bottom: 3px; left: 3px; width: calc(50% - 3px); border-radius: 999px; background: var(--surface); box-shadow: 0 4px 12px -7px rgba(25,35,45,.55), 0 1px 3px rgba(25,35,45,.08); content: ""; transition: transform 340ms var(--ease-out), background-color 220ms ease, box-shadow 220ms ease; } -.graph-view-switch--relations::before { transform: translateX(100%); } -.graph-view-switch button { position: relative; z-index: 1; min-width: 0; min-height: 32px; padding: 4px 11px; border: 0; border-radius: 999px; color: var(--muted); background: transparent; cursor: pointer; font-size: 10px; font-weight: 750; white-space: nowrap; transition: color 220ms ease, transform 340ms var(--ease-out); } -.graph-view-switch button.active { color: var(--ink); transform: scale(1.015); } -.graph-view-switch button:hover:not(.active) { color: var(--ink); } -.graph-view-switch:focus-within { border-color: color-mix(in srgb, #1677d2 48%, var(--rule)); box-shadow: 0 0 0 3px color-mix(in srgb, #1677d2 12%, transparent); } -.graph-board { min-width: 0; overflow: hidden; } -.graph-version-control { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 5px 8px 5px 13px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--muted); font-size: 11px; } -.graph-version-control select { border: 0; color: var(--ink); background: transparent; } -.dag-shell { margin-top: 18px; } -.dag-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; } -.dag-legend, .dag-zoom-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; } -.dag-legend span { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; } -.dag-legend span::before { width: 9px; height: 9px; border-radius: 50%; content: ""; } -.dag-legend .available::before { background: #55a77b; } -.dag-legend .path::before { background: #d9aa3e; } -.dag-legend .target::before { background: #d95a67; } -.dag-legend .chapter::before { border-radius: 3px; background: #6d9fc7; } -.dag-legend .unlinked::before { border: 1px dashed #8997a5; background: transparent; } -.dag-zoom-controls { justify-content: flex-end; } -.dag-zoom-controls button { display: inline-flex; min-width: 32px; min-height: 32px; padding: 4px 9px; align-items: center; justify-content: center; border: 1px solid var(--rule); border-radius: 999px; color: var(--ink); background: var(--surface); cursor: pointer; line-height: 1; } -.dag-zoom-controls span { min-width: 42px; color: var(--muted); text-align: center; font-size: 11px; } -.dag-zoom-controls .dag-fullscreen-button { padding: 5px; border-color: transparent; background: transparent; } -.dag-fullscreen-button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } -.dag-zoom-controls .dag-fullscreen-button:hover { color: #0d72d9; background: rgba(13,114,217,.08); } -.dag-viewport { position: relative; height: min(620px, calc(100vh - 285px)); min-height: 480px; overflow: hidden; border: 1px solid var(--rule); border-radius: var(--radius-group); background-color: #f5f6f7; background-image: radial-gradient(circle, color-mix(in srgb, var(--rule) 62%, transparent) 1px, transparent 1px); background-size: 22px 22px; cursor: grab; touch-action: none; user-select: none; } -.dag-viewport.dragging { cursor: grabbing; } -.dag-shell:fullscreen { display: grid; width: 100vw; height: 100vh; margin: 0; padding: 12px; grid-template-rows: auto minmax(0, 1fr); background: var(--canvas); } -.dag-shell:fullscreen .dag-toolbar { margin-bottom: 8px; } -.dag-shell:fullscreen .dag-stage { width: 100%; height: 100%; min-height: 0; } -.dag-shell:fullscreen .dag-viewport { width: 100%; height: 100%; min-height: 0; } -.dag-canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; } -.dag-canvas svg { position: absolute; inset: 0; overflow: visible; pointer-events: none; } -.dag-canvas svg path { fill: none; stroke: #9ba0a6; stroke-width: 1.7; } -.dag-canvas svg path.highlighted { stroke: #d9aa3e; stroke-width: 3; } -.dag-canvas marker path { fill: #9ba0a6; stroke: none; } -.dag-canvas marker.highlighted path { fill: #d9aa3e; } -.dag-node { position: absolute; display: grid; align-content: center; gap: 5px; padding: 11px 14px; overflow: hidden; border: 2px solid #b7bbc0; border-radius: 18px; color: var(--ink); background: #fbfbfc; box-shadow: 0 8px 20px rgba(45,47,50,.08); cursor: pointer; text-align: left; transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease; } -.dag-node:hover { border-color: var(--surface-strong); } -.dag-node span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 700; } -.dag-node small { color: var(--muted); font-size: 10px; } -.dag-node--available { border-color: #55a77b; color: #1e5d43; background: #dcf4e7; } -.dag-node--path { border-color: #d9aa3e; color: #6e5317; background: #fff2c7; } -.dag-node--target { border-color: #d95a67; color: #792e38; background: #ffe1e5; } -.dag-node.selected { box-shadow: 0 0 0 4px rgba(255,255,255,.9), 0 0 0 7px currentColor, 0 12px 26px rgba(45,47,50,.15); } -.dag-empty { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--muted); } -.graph-inspector > header, .graph-inspector h3, .graph-inspector > p { text-align: center; } -.graph-inspector h3 { margin: 15px 0 7px; font-size: 24px; } -.graph-inspector > p { color: var(--muted); line-height: 1.6; } -.inspector-score { display: table; margin: 20px auto 0; padding: 6px 10px; border: 1px solid var(--rule); border-radius: 999px; color: var(--muted); font-size: 11px; } -.relation-filter { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 20px 0 11px; } -.relation-filter button { display: inline-flex; min-height: 32px; padding: 5px 8px; align-items: center; justify-content: center; border: 1px solid var(--rule); border-radius: 999px; color: var(--muted); background: transparent; cursor: pointer; font-size: 11px; line-height: 1; } -.relation-filter button.active { color: var(--inverse); background: var(--surface-strong); } -.relation-rows { border-top: 1px solid var(--rule); } -.relation-rows button { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 12px; align-items: center; width: 100%; min-height: 76px; padding: 12px 4px; border: 0; border-bottom: 1px solid var(--rule); color: var(--ink); background: transparent; cursor: pointer; text-align: left; } -.relation-rows span, .relation-rows small { color: var(--muted); font-size: 11px; } -.relation-rows small { line-height: 1.45; } -.relation-rows > p { text-align: center; } -.relation-copy { display: grid; gap: 4px; min-width: 0; } -.relation-copy strong { overflow: hidden; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } - -.review-setup { width: min(620px, 100%); margin: 6vh auto 0; } -.review-setup h2 { margin-top: 8px; } -.game-stage { position: relative; display: grid; align-content: end; min-height: min(710px, calc(100vh - 205px)); padding: clamp(24px, 5vw, 62px); overflow: hidden; border-radius: var(--radius-shell); color: #fff; background: #285675; } -.game-stage::before { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255,255,255,.08), transparent 42%), repeating-linear-gradient(90deg, transparent 0 79px, rgba(255,255,255,.025) 80px); content: ""; pointer-events: none; } -.game-stage__meta { position: absolute; z-index: 1; left: 26px; right: 26px; top: 21px; display: flex; justify-content: space-between; color: rgba(255,255,255,.67); font-size: 11px; } -.dialogue-panel, .choice-list, .game-stage > .primary-button { position: relative; z-index: 1; } -.dialogue-panel { padding: 25px; border: 1px solid rgba(255,255,255,.28); border-radius: var(--radius-group); background: rgba(15,48,70,.84); } -.dialogue-panel h2 { margin: 0 0 14px; } -.dialogue-line { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 18px; padding: 7px 0; } -.dialogue-line strong { color: #d8eefb; } -.dialogue-line p { margin: 0; line-height: 1.7; } -.choice-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 10px; } -.choice-list button { min-height: 56px; padding: 12px 16px; border: 1px solid rgba(255,255,255,.35); border-radius: var(--radius-control); color: #fff; background: rgba(255,255,255,.09); cursor: pointer; text-align: left; transition: background-color 140ms ease, transform 140ms ease; } -.choice-list button:hover:not(:disabled) { background: rgba(255,255,255,.18); transform: translateY(-1px); } -.primary-button { min-height: 44px; padding: 9px 16px; border: 0; border-radius: var(--radius-control); color: var(--inverse); background: #0D72D9; cursor: pointer; } -.review-complete { width: min(920px, 100%); margin: 7vh auto 0; } -.review-complete > p:first-child { color: var(--muted); font-size: 12px; letter-spacing: .12em; } -.review-complete h2 { font-size: 36px; } -.review-summary { margin-top: 28px; padding-top: 22px; border-top: 1px solid var(--rule); } -.review-summary__heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } -.review-summary__heading h3 { margin: 0; font-size: 18px; } -.review-summary__heading span { color: var(--muted); font-size: 12px; } -.review-summary ul { display: grid; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; } -.review-summary li { padding: 14px 16px; border: 1px solid var(--rule); border-radius: var(--radius-control); background: var(--canvas); } -.review-summary li > div { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } -.review-summary li strong { font-size: 15px; } -.review-summary li span { flex: 0 0 auto; color: var(--accent); font-size: 13px; font-weight: 700; } -.review-summary li p { margin: 7px 0 0; color: var(--muted); font-size: 13px; } -.review-summary .review-summary__error { margin: 12px 0 0; color: var(--danger); font-size: 13px; } -.completion-actions { display: grid; margin-top: 30px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } -.completion-action { position: relative; display: grid; min-width: 0; min-height: 68px; padding: 8px 12px; overflow: hidden; border: 1px solid var(--rule); border-radius: 15px; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; color: var(--ink); background: var(--surface); box-shadow: 0 10px 30px -24px rgba(27,39,51,.6); cursor: pointer; text-align: left; text-decoration: none; transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms var(--ease-out); } -.completion-action::before { position: absolute; inset: auto -36px -54px auto; width: 130px; height: 130px; border-radius: 50%; background: currentColor; content: ""; opacity: .035; pointer-events: none; } -.completion-action:hover:not(:disabled) { border-color: color-mix(in srgb, currentColor 28%, var(--rule)); box-shadow: 0 18px 38px -24px rgba(27,39,51,.55); transform: translateY(-3px); } -.completion-action:focus-visible { outline: 3px solid color-mix(in srgb, #1677d2 32%, transparent); outline-offset: 3px; } -.completion-action:disabled { cursor: wait; opacity: .62; } -.completion-action__icon { display: grid; width: 30px; height: 30px; border-radius: 9px; place-items: center; color: #496477; background: var(--surface-soft); } -.completion-action__icon svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } -.completion-action__copy { display: grid; min-width: 0; align-self: center; gap: 3px; } -.completion-action__copy strong { font-size: 15px; letter-spacing: -.02em; } -.completion-action__copy small { color: var(--muted); font-size: 11px; line-height: 1.45; } -.completion-action--restart { border-color: transparent; color: #fff; background: linear-gradient(145deg, #1677d2, #0d579d); box-shadow: 0 18px 36px -22px rgba(13,87,157,.9); } -.completion-action--restart::before { opacity: .09; } -.completion-action--restart .completion-action__icon { color: #fff; background: rgba(255,255,255,.16); } -.completion-action--restart .completion-action__arrow, .completion-action--restart .completion-action__copy small { color: rgba(255,255,255,.74); } -.completion-action--restart:hover:not(:disabled) { border-color: transparent; box-shadow: 0 22px 42px -22px rgba(13,87,157,.95); } -.completion-action--graph { border-color: color-mix(in srgb, #6d9fc7 42%, var(--rule)); background: linear-gradient(145deg, color-mix(in srgb, #dcecf8 54%, var(--surface)), var(--surface)); } -.completion-action--graph .completion-action__icon { color: #176daa; background: color-mix(in srgb, #6d9fc7 17%, var(--surface)); } -.completion-action--plan .completion-action__icon { color: #665da0; background: color-mix(in srgb, #8b7fc5 14%, var(--surface)); } - -@media (max-width: 900px) { - .completion-actions { grid-template-columns: 1fr; } - .completion-action { min-height: 66px; } -} - -.auth-page { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 100vh; overflow: auto; background: var(--canvas); } -.auth-page__content { z-index: 1; display: grid; grid-column: 1; place-items: center; min-width: 0; padding: 64px clamp(36px, 7vw, 150px) 90px; } -.auth-page__mark { position: relative; display: grid; grid-column: 2; place-items: center; min-width: 0; color: var(--brand); } -.auth-page__mark .brand-mark { width: clamp(150px, 16vw, 300px); height: auto; } -.auth-page__footer { position: absolute; z-index: 1; left: 5%; right: 5%; bottom: 24px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; } -.auth-heading { margin-bottom: 30px; } -.auth-heading h1 { margin: 0 0 6px; color: var(--muted); font-size: 14px; letter-spacing: .24em; } -.auth-heading h2 { margin: 0; font-size: 44px; letter-spacing: -.05em; } -.auth-heading p { margin: 9px 0 0; color: var(--muted); line-height: 1.55; } -.auth-form { width: min(576px, 100%); } -.form-field { position: relative; min-height: 100px; } -.form-field > label { display: block; margin-bottom: 7px; color: var(--muted); font-size: 13px; } -.form-field__control { position: relative; display: flex; align-items: center; height: 58px; border: 2px solid var(--rule); border-radius: 30px; background: var(--field); transition: border-color 140ms ease, background-color 140ms ease; } -.form-field__control:focus-within { border-color: var(--muted); background: var(--surface); } -.form-field__control input { width: 100%; height: 100%; padding: 0 52px 0 14px; border: 0; outline: 0; color: var(--ink); background: transparent; } -.form-field__control input::placeholder { color: color-mix(in srgb, var(--muted) 70%, transparent); } -.form-field__icon, .form-field__decorative { position: absolute; right: 12px; width: 27px; height: 27px; } -.form-field__icon { display: grid; place-items: center; padding: 0; border: 0; color: var(--muted); background: transparent; cursor: pointer; } -.form-field__icon:disabled { cursor: default; opacity: .25; } -.form-field__icon svg, .form-field__decorative { width: 23px; } -.form-field__error { position: absolute; left: 2px; bottom: 6px; color: var(--danger); font-size: 11px; } -.form-field--error .form-field__control { border-color: var(--danger); } -.inline-button { position: absolute; right: 5px; min-width: 78px; height: 40px; border: 0; border-radius: 20px; color: var(--inverse); background: #0D72D9; cursor: pointer; } -.auth-form__actions { display: flex; margin-top: 5px; } -.action-button { width: 100%; min-height: 52px; padding: 10px 18px; border: 2px solid var(--rule); border-radius: 28px; color: var(--inverse); background: var(--surface-strong); cursor: pointer; font-weight: 700; transition: transform 140ms ease, opacity 140ms ease; } -.action-button:hover:not(:disabled) { transform: translateY(-1px); } -.action-button:disabled { cursor: wait; opacity: .55; } -.action-button--secondary { color: var(--ink); background: transparent; font-weight: 400; } -.auth-secondary-actions { display: flex; justify-content: space-between; margin-top: 15px; } -.auth-secondary-actions button, .auth-form__back-link { padding: 0; border: 0; color: var(--muted); background: transparent; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; } -.auth-form__back-link { float: right; margin-top: 15px; } -.form-message { clear: both; margin: 18px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; } - -.admin-page { min-height: 100vh; background: var(--canvas); } -.admin-header { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 78px; padding: 13px clamp(24px, 5vw, 70px); border-bottom: 1px solid var(--rule); background: color-mix(in srgb, var(--canvas) 94%, transparent); backdrop-filter: blur(12px); } -.admin-header > div { display: flex; align-items: center; gap: 10px; } -.admin-header .brand-mark { width: 44px; color: var(--brand); } -.admin-header span { display: grid; } -.admin-header small { color: var(--muted); } -.admin-content { width: min(1380px, 100%); margin: 0 auto; padding: 44px clamp(24px, 5vw, 70px) 80px; } -.admin-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(360px, .8fr); gap: 20px; align-items: start; } -.admin-grid article > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; } -.admin-grid article > header span { color: var(--muted); } -.admin-table, .invitation-list { border-top: 1px solid var(--rule); } -.admin-table > div { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 18px; align-items: center; min-height: 68px; padding: 10px 2px; border-bottom: 1px solid var(--rule); } -.admin-table > div > span:first-child, .invitation-list > div > span { display: grid; gap: 3px; min-width: 0; } -.admin-table strong, .admin-table small, .invitation-list strong, .invitation-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.admin-table small, .admin-table em, .invitation-list small { color: var(--muted); font-size: 11px; font-style: normal; } -.admin-row-actions { display: flex; gap: 7px; } -.admin-row-actions button:not(.danger), .invitation-list button { padding: 6px 8px; border: 1px solid #0D72D9; border-radius: 999px; color: #fff; background: #0D72D9; cursor: pointer; font-size: 11px; } -.admin-row-actions button:not(.danger):hover:not(:disabled), .invitation-list button:hover:not(:disabled) { border-color: #0a61ba; background: #0a61ba; } -.admin-row-actions button.danger { color: var(--danger); } -.admin-invitation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 22px; padding-bottom: 20px; border-bottom: 1px solid var(--rule); } -.admin-invitation-form label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; } -.admin-invitation-form input, .admin-invitation-form select { min-height: 42px; padding: 8px 10px; border: 1px solid var(--rule); border-radius: var(--radius-control); color: var(--ink); background: var(--field); } -.admin-invitation-form .button { align-self: end; } -.invitation-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 15px; align-items: center; min-height: 62px; border-bottom: 1px solid var(--rule); } - -.not-found { display: grid; min-height: 100vh; place-content: center; color: var(--ink); background: var(--canvas); text-align: center; } -.not-found span { color: var(--surface-strong); font-size: 88px; font-weight: 700; } -.not-found h1 { margin: 0; } -.not-found a { margin-top: 20px; color: var(--ink); } - -@media (max-width: 1120px) { - .app-rail { gap: 14px; } - .rail-brand > span { display: none; } - .rail-nav a { padding-right: 11px; padding-left: 11px; } - .rail-profile > span:last-child { display: none; } - .rail-actions span { display: none; } - .graph-workbench { grid-template-columns: 180px minmax(320px, 1fr); } - .graph-inspector { position: static; grid-column: 1 / -1; max-height: none; } - .process-layout, .settings-layout { grid-template-columns: 1fr; } - .plan-panel { position: static; } - .home-workspace { grid-template-columns: 1fr; } - .admin-grid { grid-template-columns: 1fr; } - .resume-panel { min-height: 240px; } -} - -@media (max-width: 760px) { - .app-shell { display: block; padding-bottom: calc(72px + env(safe-area-inset-bottom)); } - .app-rail { position: fixed; z-index: 100; inset: auto 0 0; display: block; width: 100%; min-height: 0; height: calc(72px + env(safe-area-inset-bottom)); padding: 8px 9px max(8px, env(safe-area-inset-bottom)); border-top: 1px solid var(--rule); border-right: 0; background: color-mix(in srgb, var(--canvas) 96%, transparent); box-shadow: 0 -8px 24px rgba(45,47,50,.1); } - .rail-brand, .rail-account { display: none; } - .rail-nav { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 2px; margin: 0; padding: 0; background: transparent; } - .rail-nav .mobile-nav-only { display: flex; } - .rail-nav a { flex-direction: column; gap: 1px; padding: 5px 2px; border: 0; border-radius: var(--radius-control); text-align: center; font-size: 10px; } - .rail-nav svg { width: 17px; height: 17px; } - .rail-nav a.active { border: 0; } - .page { min-height: calc(100vh - 72px); padding: 30px 18px 42px; } - .page-header { align-items: flex-start; flex-direction: column; margin-bottom: 26px; } - .page-header h1 { font-size: 38px; } - .page-header__actions { width: 100%; } - .page-header__actions .button { flex: 1; } - .data-strip { grid-template-columns: 1fr; } - .data-strip div { border-right: 0; border-bottom: 1px solid var(--rule); } - .data-strip div:last-child { border-bottom: 0; } - .resume-panel { min-height: 270px; place-items: center; text-align: center; } - .home-quick-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .home-welcome { padding: 22px; } - .home-welcome__intro { align-items: flex-start; flex-direction: column; gap: 18px; } - .home-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .material-library > header, .graph-board > header { align-items: stretch; flex-direction: column; } - .material-library > header input, .graph-board input { max-width: none; } - .option-row, .knowledge-layout, .graph-workbench { grid-template-columns: 1fr; } - .plan-options__grid, .admin-invitation-form { grid-template-columns: 1fr; } - .filter-panel, .chapter-outline { position: static; max-height: none; } - .dag-toolbar { align-items: flex-start; flex-direction: column; } - .dag-zoom-controls { width: 100%; justify-content: flex-start; } - .dag-viewport { height: 520px; min-height: 520px; } - .point-detail { padding-right: 12px; padding-left: 12px; } - .game-stage { min-height: 650px; padding: 72px 16px 20px; } - .dialogue-line, .choice-list { grid-template-columns: 1fr; } - .auth-page { display: flex; min-height: 100svh; flex-direction: column-reverse; justify-content: flex-end; padding: 0 20px 84px; overflow: auto; } - .auth-page__content { display: block; width: 100%; padding: 28px 0 0; } - .auth-page__mark { display: grid; width: 100%; min-height: 118px; place-items: end; padding: 24px 0 0; } - .auth-page__mark .brand-mark { width: 82px; height: 48px; } - .auth-page__footer { left: 20px; right: 20px; gap: 10px; } - .auth-heading h2 { font-size: 38px; } - .admin-header { align-items: flex-start; flex-direction: column; } - .admin-content { padding-top: 30px; } - .admin-table > div { grid-template-columns: minmax(0, 1fr) auto; } - .admin-row-actions { grid-column: 1 / -1; } -} - -@media (prefers-reduced-motion: reduce) { - .process-layout--exit .process-primary, .process-layout--exit .plan-panel, - .process-layout--scope.process-layout--enter .plan-panel { animation: none; } -} - -/* Persisted dark appearance. */ -:root[data-theme="dark"] { - color-scheme: dark; - --canvas: #2b2d31; - --surface: #2f3136; - --surface-soft: #393c42; - --surface-strong: #a3a7ad; - --accent-deep: #c1c4c8; - --brand: #b6b9bd; - --ink: #e3e5e8; - --muted: rgba(227, 229, 232, .68); - --rule: #45484d; - --field: #292b2f; - --inverse: #202124; - --danger: #ff7b86; -} - -:root[data-theme="dark"] .app-shell, -:root[data-theme="dark"] .admin-page { - --canvas: #2b2d31; - --surface: #2f3136; - --surface-soft: #393c42; - --surface-strong: #a3a7ad; - --accent-deep: #c1c4c8; - --brand: #b6b9bd; - --ink: #e3e5e8; - --muted: rgba(227, 229, 232, .68); - --rule: #45484d; - --field: #292b2f; - --danger: #ff7b86; -} - -:root[data-theme="dark"] .app-rail, -:root[data-theme="dark"] .admin-header { background: #1f2024; } -:root[data-theme="dark"] .rail-nav a, -:root[data-theme="dark"] .rail-profile, -:root[data-theme="dark"] .rail-logout { color: var(--muted); } -:root[data-theme="dark"] .rail-nav a:hover, -:root[data-theme="dark"] .rail-profile:hover, -:root[data-theme="dark"] .rail-logout:hover { background: rgba(255,255,255,.06); } -:root[data-theme="dark"] .rail-nav a::after, -:root[data-theme="dark"] .rail-profile::after, -:root[data-theme="dark"] .rail-logout::after { background: rgba(47,49,54,.98); box-shadow: 0 4px 14px rgba(0,0,0,.35); } -:root[data-theme="dark"] .app-statusbar { background: #35383e; } -:root[data-theme="dark"] .app-shell .page-header, -:root[data-theme="dark"] .admin-content > .page-header, -:root[data-theme="dark"] .mobile-shell-head { background: rgba(53,56,62,.96); } -:root[data-theme="dark"] .form-section input:focus, -:root[data-theme="dark"] .form-section select:focus, -:root[data-theme="dark"] .filter-panel input:focus, -:root[data-theme="dark"] .filter-panel select:focus, -:root[data-theme="dark"] .side-section input:focus, -:root[data-theme="dark"] .side-section select:focus, -:root[data-theme="dark"] .material-library input:focus, -:root[data-theme="dark"] .graph-board input:focus, -:root[data-theme="dark"] .admin-invitation-form input:focus, -:root[data-theme="dark"] .admin-invitation-form select:focus { background: #35383e; } -:root[data-theme="dark"] .toggle-field, -:root[data-theme="dark"] .inline-select-field { background: #292b2f; } -:root[data-theme="dark"] .inline-select-field.is-enabled, :root[data-theme="dark"] .inline-select-field.is-enabled > select { background: var(--surface); } -:root[data-theme="dark"] .status-line { border-color: var(--rule); color: #d2d5d8; background: #36383d; } -:root[data-theme="dark"] .status-line--error { border-color: var(--rule); color: #ff9aa3; background: #321d24; } - -:root[data-theme="dark"] .home-welcome, -:root[data-theme="dark"] .home-summary article { background: #2f3136; box-shadow: none; } -:root[data-theme="dark"] .home-intelligence-flow { background: #2f3136; box-shadow: none; } -:root[data-theme="dark"] .home-quick-actions a { background: var(--surface); box-shadow: 0 5px 14px -8px rgba(0,0,0,.8); } -:root[data-theme="dark"] .home-quick-actions a:hover { background: var(--surface-soft); } -:root[data-theme="dark"] .quick-action-icon { color: #d8dbe0; background: #383b40; } - -:root[data-theme="dark"] .segmented-control { background: #25272b; } -:root[data-theme="dark"] .chapter-picker label:hover, -:root[data-theme="dark"] .material-table article:hover, -:root[data-theme="dark"] .admin-table > div:hover, -:root[data-theme="dark"] .invitation-list > div:hover, -:root[data-theme="dark"] .knowledge-list article:hover, -:root[data-theme="dark"] .relation-rows button:hover, -:root[data-theme="dark"] .chapter-outline nav button:hover { background: #383b40; } -:root[data-theme="dark"] .material-table article.selected, -:root[data-theme="dark"] .knowledge-list article.expanded, -:root[data-theme="dark"] .chapter-outline nav button.active, -:root[data-theme="dark"] .settings-index button:hover, -:root[data-theme="dark"] .settings-index button.active { background: #3a3d43; } -:root[data-theme="dark"] .text-preview pre { background: #27292d; } - -:root[data-theme="dark"] .knowledge-layout, -:root[data-theme="dark"] .graph-workbench { background: var(--surface); } -:root[data-theme="dark"] .filter-panel, -:root[data-theme="dark"] .settings-index, -:root[data-theme="dark"] .chapter-outline, -:root[data-theme="dark"] .graph-inspector { background: #282a2e; } -:root[data-theme="dark"] .mastery-value { color: #d2d5d8; background: #393c42; } -:root[data-theme="dark"] .graph-version-control { background: var(--surface); } -:root[data-theme="dark"] .dag-viewport { border-color: var(--rule); background-color: #27292d; background-image: radial-gradient(circle, rgba(188,191,197,.2) 1px, transparent 1px); } -:root[data-theme="dark"] .dag-canvas svg path { stroke: #71849a; } -:root[data-theme="dark"] .dag-canvas marker path { fill: #71849a; } -:root[data-theme="dark"] .dag-node { background: rgba(47,49,54,.94); } -:root[data-theme="dark"] .dag-node:hover { background: #3a3d43; } -:root[data-theme="dark"] .dag-node::before { border-color: var(--rule); } -:root[data-theme="dark"] .dag-node--available { color: #9bdfad; background: rgba(29,70,50,.95); } -:root[data-theme="dark"] .dag-node--path { color: #f2cf77; background: rgba(79,60,21,.95); } -:root[data-theme="dark"] .dag-node--target { color: #ff9aa3; background: rgba(83,35,43,.95); } - -:root[data-theme="dark"] .game-stage { border-color: var(--rule); background: linear-gradient(150deg, #303238 0%, #27292d 100%); box-shadow: 0 5px 14px -8px rgba(0,0,0,.8); } -:root[data-theme="dark"] .dialogue-panel { border-color: var(--rule); background: rgba(39,41,45,.94); box-shadow: 0 8px 24px rgba(0,0,0,.25); } -:root[data-theme="dark"] .choice-list button { border-color: var(--rule); background: rgba(57,60,66,.82); } -:root[data-theme="dark"] .choice-list button:hover:not(:disabled) { background: #44474e; } -:root[data-theme="dark"] .admin-header .button:not(.button--danger):hover:not(:disabled) { border-color: #0a61ba; color: #fff; background: #0a61ba; } -:root[data-theme="dark"] button, -:root[data-theme="dark"] .button, -:root[data-theme="dark"] input, -:root[data-theme="dark"] select, -:root[data-theme="dark"] textarea { border-color: var(--rule); } -:root[data-theme="dark"] input::file-selector-button { border-color: var(--rule); } -:root[data-theme="dark"] .toggle-field input[type="checkbox"] { border-color: var(--rule); } -:root[data-theme="dark"] .toggle-field input[type="checkbox"]:checked { border-color: #0D72D9; background: #0D72D9; } -:root[data-theme="dark"] .danger-section { border-color: var(--rule); } -:root[data-theme="dark"] .app-shell .button:hover:not(:disabled), -:root[data-theme="dark"] .admin-page .button:hover:not(:disabled) { border-color: var(--rule); } -:root[data-theme="dark"] button:focus-visible, -:root[data-theme="dark"] a:focus-visible, -:root[data-theme="dark"] input:focus-visible, -:root[data-theme="dark"] select:focus-visible { outline-color: var(--rule); } - -@media (max-width: 760px) { - :root[data-theme="dark"] .app-rail { background: rgba(31,32,36,.98); box-shadow: 0 -4px 14px rgba(0,0,0,.28); } -} - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } -} -:root[data-reduced-motion="true"] *, :root[data-reduced-motion="true"] *::before, :root[data-reduced-motion="true"] *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } - -/* Shared loading transition: quiet enough for short waits, visible enough for long generation jobs. */ -.loading-indicator { - display: grid; - min-height: 150px; - align-content: center; - justify-items: center; - gap: 12px; - color: var(--muted); - animation: loading-transition-in 280ms var(--ease-out) both; -} -.loading-indicator--compact { - grid-template-columns: auto minmax(0, 1fr); - min-height: 0; - align-items: center; - justify-items: start; - gap: 11px; -} -.loading-indicator__buffer { display: inline-flex; align-items: center; gap: 4px; min-width: 31px; } -.loading-indicator__buffer i { - width: 7px; - height: 7px; - border-radius: 50%; - background: currentColor; - animation: loading-buffer-dot 1.15s ease-in-out infinite; -} -.loading-indicator__buffer i:nth-child(2) { animation-delay: 140ms; } -.loading-indicator__buffer i:nth-child(3) { animation-delay: 280ms; } -.loading-indicator__label { font-size: 13px; line-height: 1.5; letter-spacing: .025em; } -.app-loading span::after { content: none; } -.app-loading .loading-indicator { min-height: 0; } -.page-loading-transition { - margin-top: 16px; - padding: 11px 14px; - border: 1px solid var(--rule); - border-radius: 14px; - background: color-mix(in srgb, var(--surface) 72%, transparent); -} -.review-page--setup > .page-loading-transition { width: min(860px, 100%); margin-inline: auto; } -:root[data-theme="dark"] .page-loading-transition { background: color-mix(in srgb, var(--surface) 76%, transparent); } - -@keyframes loading-transition-in { - from { opacity: 0; transform: translateY(5px); } - to { opacity: 1; transform: none; } -} -@keyframes loading-buffer-dot { - 0%, 70%, 100% { opacity: .28; transform: translateY(0) scale(.82); } - 35% { opacity: 1; transform: translateY(-3px) scale(1); } -} - -/* Koishi Console inspired workspace. Authentication pages intentionally keep their own layout. */ -.app-shell, .admin-page { - --canvas: #f2f3f4; - --surface: #ffffff; - --surface-soft: #eceeef; - --surface-strong: #858a90; - --accent-deep: #62676d; - --brand: #777c82; - --ink: #2d2f32; - --muted: rgba(45, 47, 50, .66); - --rule: #d0d2d5; - --field: #f5f5f6; - --danger: #f56c6c; - --radius-control: 12px; - --radius-group: 16px; - --radius-shell: 18px; - --koishi-activity-width: 64px; - --koishi-status-height: 28px; - color: var(--ink); - background: var(--canvas); -} - -.app-shell { - position: relative; - min-height: 100vh; - padding: 0 0 var(--koishi-status-height) var(--koishi-activity-width); -} - -.app-rail { - position: fixed; - z-index: 120; - inset: 0 auto 0 0; - display: flex; - width: var(--koishi-activity-width); - min-height: 0; - padding: 0; - flex-direction: column; - gap: 0; - border: 0; - border-right: 1px solid var(--rule); - background: #eceeef; - box-shadow: none; - backdrop-filter: none; -} - -.rail-brand { - position: relative; - display: grid; - width: 100%; - min-height: 64px; - place-items: center; - border-bottom: 1px solid var(--rule); -} - -.rail-brand .brand-mark { width: 34px; height: 24px; color: var(--brand); } -.rail-brand > span { display: none; } - -.rail-nav { - display: flex; - width: 100%; - margin: 0; - padding: 7px 0; - flex: 1 1 auto; - flex-direction: column; - gap: 2px; - border-radius: 0; - background: transparent; -} - -.rail-nav a, -.rail-profile, -.rail-logout { - position: relative; - display: grid; - width: 100%; - min-height: 54px; - padding: 0; - place-items: center; - border: 0; - border-radius: 0; - color: rgba(40, 40, 44, .56); - background: transparent; - cursor: pointer; - text-decoration: none; - transition: color .2s ease, background-color .2s ease; -} - -.rail-nav a::before, -.rail-profile::before { - position: absolute; - top: 50%; - left: 0; - width: 4px; - height: 28px; - border-radius: 0 4px 4px 0; - background: var(--surface-strong); - content: ""; - opacity: 0; - transform: translate(-100%, -50%); - transition: opacity .2s ease, transform .2s ease; -} - -.rail-nav a:hover, -.rail-profile:hover, -.rail-logout:hover { color: var(--ink); background: rgba(64, 158, 255, .08); } -.rail-nav a.active { color: var(--surface-strong); background: transparent; font-weight: 400; } -.rail-nav a.active::before { opacity: 1; transform: translate(0, -50%); } -.rail-nav svg, .rail-logout svg { width: 24px; height: 24px; } -.rail-nav a > span, .rail-profile > span:last-child, .rail-logout > span { display: none; } - -.rail-nav a::after, -.rail-profile::after, -.rail-logout::after { - position: absolute; - z-index: 150; - top: 50%; - left: calc(100% + 8px); - padding: 6px 9px; - border: 1px solid var(--rule); - border-radius: 10px; - color: var(--ink); - background: rgba(255, 255, 255, .98); - box-shadow: 0 4px 12px rgba(40, 40, 44, .12); - content: attr(data-label); - font-size: 12px; - opacity: 0; - pointer-events: none; - transform: translate(-4px, -50%); - transition: opacity .15s ease, transform .15s ease; - white-space: nowrap; -} - -.rail-nav a:hover::after, -.rail-profile:hover::after, -.rail-logout:hover::after { opacity: 1; transform: translate(0, -50%); } - -.rail-account { - display: flex; - width: 100%; - padding: 6px 0 8px; - flex-direction: column; - gap: 0; - border-top: 1px solid var(--rule); -} - -.rail-avatar { - display: grid; - width: 30px; - height: 30px; - place-items: center; - border: 1px solid #c9ced4; - border-radius: 50%; - color: #3f4650; - background: #fff; - font-size: 12px; - font-weight: 700; -} - -.rail-logout { font: inherit; } -.mobile-shell-head { display: none; } - -.app-shell__content { - min-width: 0; - min-height: calc(100vh - var(--koishi-status-height)); - background: var(--canvas); -} - -.app-statusbar { - position: fixed; - z-index: 110; - right: 0; - bottom: 0; - left: var(--koishi-activity-width); - display: flex; - height: var(--koishi-status-height); - padding: 0 12px; - align-items: center; - justify-content: space-between; - border-top: 1px solid var(--rule); - color: var(--muted); - background: #f4f5f6; - font-size: 12px; -} - -.app-statusbar span { display: inline-flex; align-items: center; gap: 7px; } -.app-statusbar i { width: 7px; height: 7px; border-radius: 50%; background: #67c23a; box-shadow: 0 0 0 3px rgba(103,194,58,.12); } - -.app-shell .page { - width: 100%; - max-width: 1600px; - min-height: calc(100vh - var(--koishi-status-height)); - margin: 0 auto; - padding: 26px 32px 46px; -} - -.app-shell .page-header { - min-height: 54px; - margin: -26px -32px 24px; - padding: 0 20px; - align-items: center; - gap: 18px; - border-bottom: 1px solid var(--rule); - background: rgba(255,255,255,.92); - backdrop-filter: blur(10px); -} - -.page-header__copy { display: flex; min-width: 0; align-items: center; gap: 11px; } -.page-header__icon { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 10px; color: var(--surface-strong); background: rgba(133,138,144,.12); } -.page-header__icon svg { width: 18px; height: 18px; } -.app-shell .page-header h1, .admin-page .page-header h1 { margin: 0; font-size: 18px; line-height: 1.25; letter-spacing: 0; } -.app-shell .page-header p, .admin-page .page-header p { overflow: hidden; max-width: 720px; margin: 2px 0 0; color: var(--muted); font-size: 12px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; } -.page-header__actions { align-items: center; gap: 7px; } - -.app-shell .button, .admin-page .button, .primary-button { - min-height: 34px; - padding: 6px 13px; - border-radius: 12px; - font-size: 13px; -} - -.app-shell .button:not(.button--danger):hover:not(:disabled), .admin-page .button:not(.button--danger):hover:not(:disabled) { border-color: #0a61ba; color: #fff; background: #0a61ba; transform: none; } -.app-shell .button.graph-create-plan-button { min-height: auto; padding: 0; border: 0; border-radius: 0; color: #7a838e; background: transparent; font-weight: 700; } -.app-shell .button.graph-create-plan-button:hover:not(:disabled) { border: 0; color: #525b66; background: transparent; text-decoration: underline; text-underline-offset: 4px; } - -.form-section, -.material-library, -.plan-panel, -.side-section, -.filter-panel, -.chapter-outline, -.graph-board, -.graph-inspector, -.workspace-card, -.study-snapshot, -.knowledge-list, -.data-strip { - border: 1px solid var(--rule); - border-radius: 16px; - background: var(--surface); - box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); -} - -.form-section, .material-library, .plan-panel, .side-section, .filter-panel, .graph-board, .graph-inspector, .workspace-card { padding: 22px; } -.form-section h2, .material-library h2, .plan-panel h2, .side-section h2, .chapter-outline h2, .graph-board h2, .graph-inspector h2, .workspace-card h2 { font-size: 18px; } -.form-section header p, .material-library header p, .plan-panel > p, .side-section p, .workspace-card > p { font-size: 13px; } - -.form-section input:not([type="checkbox"]), -.form-section select, -.filter-panel input, -.filter-panel select, -.side-section input, -.side-section select, -.review-setup select, -.material-library input, -.graph-board input, -.admin-invitation-form input, -.admin-invitation-form select, -.plan-options__grid input { - min-height: 38px; - border-color: var(--rule); - border-radius: 12px; - background: var(--field); - outline: none; - transition: border-color .2s ease, box-shadow .2s ease, background-color .2s ease; -} - -.form-section input:focus, .form-section select:focus, .filter-panel input:focus, .filter-panel select:focus, .side-section input:focus, .side-section select:focus, .material-library input:focus, .graph-board input:focus, .admin-invitation-form input:focus, .admin-invitation-form select:focus { border-color: var(--surface-strong); background: #fff; box-shadow: 0 0 0 2px rgba(133,138,144,.16); } -.toggle-field { border-radius: 12px; background: #fafcfe; } -.home-theme-toggle { display: inline-flex !important; min-height: 36px; margin: 0; padding: 6px 8px; align-items: center; gap: 10px; border: 0; border-radius: 999px; background: transparent; font-size: 12px; transition: background-color .18s ease; } -.home-welcome > .home-theme-toggle { position: absolute; z-index: 2; top: 20px; right: 22px; } -.home-theme-toggle > span strong { color: var(--muted); font-weight: 600; } -.home-theme-toggle input[type="checkbox"] { flex: 0 0 auto; border: 1px solid var(--rule); } -:root[data-theme="dark"] .home-theme-toggle { border: 0; background: transparent; } -:root[data-theme="dark"] .home-theme-toggle:hover { background: rgba(255,255,255,.05); } -:root[data-theme="dark"] .home-theme-toggle > span strong { color: rgba(227,229,232,.72); } -:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"] { border-color: #5a5f66; background: #484c52; box-shadow: inset 0 1px 2px rgba(0,0,0,.24); } -:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"]::before { background: #c5c9ce; } -:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"]:checked { border-color: #6683a0; background: #58789a; } -:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"]:checked::before { background: #e2e5e9; } -:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"]:focus-visible { outline-color: rgba(126,162,198,.42); } - -.status-line { - margin-top: 16px; - padding: 9px 12px; - border: 1px solid var(--rule); - border-left-width: 3px; - border-radius: 12px; - color: #62676d; - background: #eceeef; - font-size: 12px; -} -.status-line--error { border-color: #fab6b6; color: #c45656; background: #fef0f0; } - -.data-strip { overflow: hidden; box-shadow: none; } -.data-strip div { min-height: 72px; padding: 10px 18px; } -.data-strip span { font-size: 12px; } -.data-strip strong { font-size: 24px; } - -.home-workspace { grid-template-columns: minmax(0, 1.45fr) minmax(280px, .55fr); gap: 16px; } -.resume-panel { - position: relative; - min-height: 232px; - padding: 32px; - place-items: start; - align-content: center; - justify-items: start; - overflow: hidden; - border: 1px solid var(--rule); - border-radius: 18px; - color: var(--ink); - background: linear-gradient(135deg, #fff 0%, #f3f4f5 68%, #e8eaec 100%); - box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); - text-align: left; -} -.resume-panel::after { position: absolute; right: -62px; bottom: -82px; width: 230px; height: 230px; border: 38px solid rgba(133,138,144,.09); border-radius: 50%; content: ""; } -.resume-panel > div { z-index: 1; justify-items: start; } -.resume-panel span { color: var(--surface-strong); font-size: 11px; } -.resume-panel h2 { margin: 8px 0 6px; font-size: clamp(28px, 3vw, 40px); } -.resume-panel p { color: var(--muted); } -.resume-panel .button--light { z-index: 1; border-color: #0D72D9; color: #fff; background: #0D72D9; } -.study-snapshot { padding: 22px; } -.study-snapshot h2 { font-size: 18px; } -.home-quick-actions { gap: 12px; margin-top: 18px; } -.home-quick-actions > a > i { display: none; } -.home-quick-actions a { min-height: 108px; padding: 18px; border-radius: 16px; background: #fff; box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); } -.home-quick-actions a:hover { background: #f0f1f2; box-shadow: 0 8px 18px -10px rgba(40,40,44,.3); } -.quick-action-icon { border-radius: 12px; background: #eceeef; } -.quick-action-icon--materials { color: #1677d2; background: #e7f3ff; } -.quick-action-icon--knowledge { color: #27945a; background: #e7f7ed; } -.quick-action-icon--graph { color: #7657c8; background: #f0ebff; } -.quick-action-icon--review { color: #d77918; background: #fff1df; } -:root[data-theme="dark"] .quick-action-icon--materials { color: #67b6ff; background: #183b5d; } -:root[data-theme="dark"] .quick-action-icon--knowledge { color: #67d695; background: #183f2c; } -:root[data-theme="dark"] .quick-action-icon--graph { color: #b79cff; background: #352b57; } -:root[data-theme="dark"] .quick-action-icon--review { color: #ffb55e; background: #513518; } - -.process-layout { gap: 16px; } -.process-primary, .settings-main { gap: 16px; } -.app-shell .materials-page { display: flex; flex-direction: column; } -.materials-page .process-layout--materials { flex: 1 1 auto; align-items: stretch; } -.process-layout--materials .process-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; } -.process-layout--materials .process-primary { min-height: 100%; } -.process-layout--materials .upload-panel, .process-layout--materials .material-library { min-width: 0; height: 100%; } -.process-layout--materials .upload-panel { display: flex; flex-direction: column; } -.process-layout--materials .upload-panel__form { display: flex; flex: 1 1 auto; flex-direction: column; gap: 18px; } -.process-layout--materials .upload-panel__form > label { margin: 0; } -.process-layout--materials .upload-panel__form > .compact-toggle { margin: 0 !important; } -.process-layout--materials .upload-panel__form > .button { margin-top: auto; } -.process-layout--materials .material-library { display: flex; flex-direction: column; } -.process-layout--materials .material-table { flex: 1 1 auto; grid-auto-rows: max-content; align-content: start; } -.upload-panel__form > .button, .material-library > .flow-next-button { min-height: 44px; border-radius: 18px; } -.material-library > .flow-next-button { margin-top: 20px; } -.plan-panel { top: 16px; } -.segmented-control { border-radius: 14px; background: #e7e9eb; } -.segmented-control button { border-radius: 10px; } -.chapter-picker label:hover { background: #f0f1f2; } -.material-table article, .admin-table > div, .invitation-list > div { transition: background-color .2s ease; } -.material-table article:hover, .admin-table > div:hover, .invitation-list > div:hover { background: #f4f5f6; } -.material-table article.selected { box-shadow: inset 3px 0 var(--surface-strong); background: #eceeef; } -.upload-panel .option-row .toggle-field { font-size: 13px; } -.material-library { border-color: #e2e6eb; background: #fff; box-shadow: 0 10px 30px -22px rgba(31, 45, 61, .32); } -.material-library > header { align-items: center; } -.material-library > header input { border-color: #dce2e8; background: #fff; box-shadow: 0 2px 8px rgba(31, 45, 61, .05); } -.material-table { display: grid; gap: 8px; border-top: 0; } -.material-table article { overflow: hidden; border: 1px solid #e5e9ee; border-radius: 12px; background: #fff; transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; } -.material-table article:hover { border-color: #b9d9f8; background: #fff; box-shadow: 0 7px 18px -14px rgba(13, 114, 217, .55); transform: translateY(-1px); } -.material-table article.selected { border-color: #86bff2; background: #f5faff; box-shadow: inset 3px 0 #0d72d9; } -.material-open { padding: 14px 16px; } -.material-open strong { font-size: 14px; } -.material-open em { padding: 4px 8px; border-radius: 999px; color: #1677d2; background: #eaf4ff; font-size: 10px; font-weight: 700; letter-spacing: .04em; } -.material-open .material-ocr-badge { width: fit-content; padding: 0; border-radius: 0; background: transparent; letter-spacing: .02em; } -.material-open .material-status--ready { color: #27945a; background: transparent; } -.material-open .material-status--failed { color: #d92d20; background: transparent; } -.material-actions { padding-right: 10px; } -.material-actions button { border-radius: 8px; } -.material-actions button:hover:not(:disabled) { background: #edf6ff; } -.material-actions .material-delete:hover:not(:disabled) { background: #fff0f0; } -.material-table > .empty-row { margin: 0; padding: 24px 16px; border: 1px dashed #dce2e8; border-radius: 12px; background: #fff; text-align: center; } -:root[data-theme="dark"] .material-library, -:root[data-theme="dark"] .material-library > header input, -:root[data-theme="dark"] .material-table article, -:root[data-theme="dark"] .material-table > .empty-row { border-color: var(--rule); background: var(--surface); } -:root[data-theme="dark"] .material-table article:hover { border-color: #356f9f; background: #35383e; } -:root[data-theme="dark"] .material-table article.selected { border-color: #4388c4; background: #25384a; box-shadow: inset 3px 0 #409eff; } -:root[data-theme="dark"] .material-open em { color: #78bdff; background: #193c5c; } -:root[data-theme="dark"] .material-open .material-ocr-badge { background: transparent; } -:root[data-theme="dark"] .material-open .material-status--ready { color: #67d695; background: transparent; } -:root[data-theme="dark"] .material-open .material-status--failed { color: #ff8f87; background: transparent; } -.text-preview { border-radius: 18px; box-shadow: 0 18px 52px rgba(40,40,44,.18); } - -.knowledge-layout { grid-template-columns: 240px minmax(0, 1fr); gap: 0; overflow: hidden; border: 1px solid var(--rule); border-radius: 16px; background: #fff; } -.filter-panel { top: 16px; height: 100%; border: 0; border-right: 1px solid var(--rule); border-radius: 0; box-shadow: none; background: #f5f6f7; } -.knowledge-list { border: 0; border-radius: 0; box-shadow: none; } -.knowledge-list__status { padding-inline: 18px; } -.knowledge-list article > button { min-height: 76px; padding: 13px 18px; } -.knowledge-list article:hover { background: #f4f5f6; } -.knowledge-list article.expanded { background: #eff0f1; } -.mastery-value { width: 42px; height: 42px; color: #62676d; background: #eceeef; font-size: 17px; } -.point-detail { padding-bottom: 18px; } - -.settings-layout { width: min(1120px, 100%); min-height: calc(100vh - 180px); margin: 0 auto; grid-template-columns: 250px minmax(0, 852px); justify-content: center; align-items: center; gap: 18px; } -.settings-index { position: sticky; top: 16px; display: flex; min-height: 440px; grid-column: 1; grid-row: 1; overflow: hidden; border: 1px solid var(--rule); border-radius: 16px; flex-direction: column; background: #f5f6f7; box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); } -.settings-index h2 { margin: 0; padding: 18px; border-bottom: 1px solid var(--rule); font-size: 16px; } -.settings-index nav { display: grid; padding: 7px 0; flex: 1; grid-template-rows: repeat(5, minmax(0, 1fr)); } -.settings-index button { position: relative; width: 100%; min-height: 58px; padding: 10px 18px; border: 0; color: var(--muted); background: transparent; cursor: pointer; text-align: left; font-size: 13px; } -.settings-index button:hover, .settings-index button.active { color: var(--accent-deep); background: #eceeef; } -.settings-index button::before { position: absolute; top: 50%; left: 0; width: 3px; height: 20px; border-radius: 0 3px 3px 0; background: #0d72d9; content: ""; opacity: 0; transform: translateY(-50%); } -.settings-index button:hover::before, .settings-index button.active::before { opacity: 1; } -.settings-main { grid-column: 2; grid-row: 1; } -.settings-main > .form-section, .settings-main > .side-section { display: flex; min-height: 440px; padding: 36px 40px; flex-direction: column; justify-content: center; } -.settings-main input:not([type="checkbox"]), .settings-main select { background: #fff; } -:root[data-theme="dark"] .settings-main input:not([type="checkbox"]), :root[data-theme="dark"] .settings-main select { background: var(--field); } -.settings-main > #credits-settings { justify-content: flex-start; gap: 22px; } -#credits-settings > .credits-settings__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin: 0; } -.credits-settings__header > div { min-width: 0; } -.credits-settings__header p { max-width: 520px; } -#credits-settings .credit-balance { - display: grid; - min-height: 94px; - grid-template-columns: minmax(0, 1fr) auto; - padding: 16px 18px; - align-items: center; - gap: 5px 20px; - border: 1px solid #d8e8f5; - border-radius: 14px; - background: linear-gradient(135deg, #f0f8ff 0%, #f9fcff 75%); - box-shadow: none; -} -#credits-settings .credit-balance__label { grid-column: 1; color: #5f7182; font-size: 14px; font-weight: 700; letter-spacing: .03em; } -.credit-balance__value { display: flex; grid-column: 2; grid-row: 1 / 3; align-items: baseline; justify-self: end; gap: 7px; } -#credits-settings .credit-balance__value strong { color: #173d60; font-size: clamp(26px, 3.4vw, 32px); line-height: 1; letter-spacing: -.035em; } -#credits-settings .credit-balance__value span { color: #57728a; font-size: 11px; font-weight: 700; } -#credits-settings .credit-balance > small { grid-column: 1; color: #75889a; font-size: 13px; } -.credits-redeem { display: grid; gap: 17px; padding-top: 21px; border-top: 1px solid var(--rule); } -.credits-redeem__copy h3 { margin: 0; font-size: 16px; } -.credits-redeem__copy p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; } -.credits-redeem__controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; } -.credits-redeem__controls > label { display: grid; min-width: 0; gap: 7px; margin: 0; color: var(--muted); font-size: 12px; } -.credits-redeem__controls input { min-height: 42px !important; border-radius: 11px !important; font-family: Consolas, "SFMono-Regular", monospace; letter-spacing: .04em; } -.credits-redeem__actions { display: flex; gap: 8px; } -.credits-redeem__actions .button { min-height: 42px; padding-inline: 16px; border-radius: 999px; white-space: nowrap; } -:root[data-theme="dark"] #credits-settings .credit-balance { border-color: #354d62; background: linear-gradient(135deg, #24384a, #29333d); } -:root[data-theme="dark"] #credits-settings .credit-balance__label, -:root[data-theme="dark"] #credits-settings .credit-balance__value span, -:root[data-theme="dark"] #credits-settings .credit-balance > small { color: #a8bac9; } -:root[data-theme="dark"] #credits-settings .credit-balance__value strong { color: #e7f3ff; } -.settings-side { grid-column: 2; grid-row: 2; } -.danger-section { border-color: #fab6b6; box-shadow: none; } - -.graph-page { max-width: none !important; } -.graph-workbench { - display: grid; - grid-template-columns: 200px minmax(480px, 1fr) 280px; - gap: 0; - min-height: 620px; - margin-top: 16px; - overflow: hidden; - border: 1px solid var(--rule); - border-radius: 16px; - background: #fff; - box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); - transition: grid-template-columns 360ms var(--ease-out); -} -.graph-workbench--inspector-collapsed { grid-template-columns: 200px minmax(480px, 1fr) 48px; } -.chapter-outline, .graph-inspector { position: relative; top: auto; max-height: none; border: 0; border-radius: 0; box-shadow: none; background: #f5f6f7; } -.chapter-outline { padding: 16px 0; border-right: 1px solid var(--rule); } -.chapter-outline nav button { min-height: 44px; } -.chapter-outline nav button:hover { color: var(--ink); background: #eceeef; } -.chapter-outline nav button.active { color: var(--accent-deep); background: #eceeef; box-shadow: inset 3px 0 var(--surface-strong); } -.graph-board { padding: 18px; border: 0; border-radius: 0; box-shadow: none; } -.graph-board > header { padding-bottom: 14px; } -.graph-inspector { min-width: 0; padding: 18px; overflow: auto; border-left: 1px solid var(--rule); transition: padding 360ms var(--ease-out); } -.graph-inspector__header { display: flex; min-width: 0; align-items: center; gap: 10px; } -.graph-inspector__header h2 { flex: 1; overflow: hidden; white-space: nowrap; transition: width 260ms var(--ease-out), flex-grow 260ms var(--ease-out), opacity 180ms ease, transform 260ms var(--ease-out); } -.graph-inspector__toggle { display: grid; width: 32px; height: 32px; padding: 0; flex: 0 0 32px; place-items: center; border: 1px solid var(--rule); border-radius: 10px; color: var(--muted); background: var(--surface); cursor: pointer; transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease, transform 360ms var(--ease-out); } -.graph-inspector__toggle:hover { border-color: color-mix(in srgb, #1677d2 48%, var(--rule)); color: #1677d2; background: color-mix(in srgb, #1677d2 7%, var(--surface)); box-shadow: 0 4px 12px -8px rgba(22,119,210,.75); } -.graph-inspector__toggle svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform 360ms var(--ease-out); } -.graph-inspector__content { min-width: 243px; opacity: 1; transform: translateX(0); transition: opacity 180ms ease 100ms, transform 360ms var(--ease-out), visibility 0s linear; } -.graph-workbench--inspector-collapsed .graph-inspector { padding-right: 7px; padding-left: 7px; overflow: hidden; } -.graph-workbench--inspector-collapsed .graph-inspector__header { justify-content: center; } -.graph-workbench--inspector-collapsed .graph-inspector__header h2 { width: 0; flex-grow: 0; opacity: 0; transform: translateX(12px); } -.graph-workbench--inspector-collapsed .graph-inspector__toggle { transform: translateX(-6px); } -.graph-workbench--inspector-collapsed .graph-inspector__toggle svg { transform: rotate(180deg); } -.graph-workbench--inspector-collapsed .graph-inspector__content { visibility: hidden; opacity: 0; pointer-events: none; transform: translateX(18px); transition: opacity 120ms ease, transform 300ms var(--ease-out), visibility 0s linear 300ms; } -.graph-version-control { min-height: 34px; border-radius: 12px; background: #fff; } -.relation-filter { position: relative; display: grid; padding: 0; overflow: hidden; border: 1px solid #cbdcf0; border-radius: 16px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; background: #eff6ff; isolation: isolate; } -.relation-filter::before { position: absolute; z-index: 0; inset: 0 auto 0 0; width: 33.3334%; border-radius: 14px; background: #1677d2; box-shadow: 0 5px 13px -9px rgba(22,119,210,.95); content: ""; transition: transform 360ms var(--ease-out), background-color 240ms ease, box-shadow 240ms ease; } -.relation-filter--prerequisite { border-color: #d9d2fb; background: #f4f1ff; } -.relation-filter--prerequisite::before { background: #7657c8; box-shadow: 0 5px 13px -9px rgba(118,87,200,.95); transform: translateX(100%); } -.relation-filter--related { border-color: #c9e8d5; background: #effaf3; } -.relation-filter--related::before { background: #27945a; box-shadow: 0 5px 13px -9px rgba(39,148,90,.95); transform: translateX(200%); } -.relation-filter .relation-filter__button { position: relative; z-index: 1; min-height: 40px; border: 0; border-radius: 0; background: transparent; font-weight: 700; transition: color 180ms ease; } -.relation-filter__button--all { color: #1677d2; } -.relation-filter__button--prerequisite { color: #7657c8; } -.relation-filter__button--related { color: #27945a; } -.relation-filter .relation-filter__button.is-active { color: #fff; background: transparent; } -.relation-filter .relation-filter__button:hover { transform: none; } -:root[data-theme="dark"] .relation-filter { border-color: #315675; background: #19334a; } -:root[data-theme="dark"] .relation-filter--prerequisite { border-color: #514878; background: #2f2948; } -:root[data-theme="dark"] .relation-filter--related { border-color: #37644a; background: #203b2b; } -:root[data-theme="dark"] .relation-filter__button--all { color: #77bdff; } -:root[data-theme="dark"] .relation-filter__button--prerequisite { color: #b9a3ff; } -:root[data-theme="dark"] .relation-filter__button--related { color: #70d99b; } -:root[data-theme="dark"] .relation-filter .relation-filter__button.is-active { color: #fff; } -.relation-rows button:hover { background: #eceeef; } - -.dag-shell { margin-top: 12px; } -.dag-toolbar { min-height: 34px; margin-bottom: 8px; } -.dag-zoom-controls button { border-radius: 10px; } -.dag-viewport { - height: min(650px, calc(100vh - 270px)); - min-height: 500px; - border-color: var(--rule); - border-radius: 14px; - background-color: #f5f6f7; - background-image: radial-gradient(circle, rgba(96,112,128,.18) 1px, transparent 1px); - background-size: 20px 20px; -} -.dag-canvas svg path { stroke: #8997a5; stroke-width: 1.5; opacity: .46; transition: opacity .2s ease, stroke .2s ease, stroke-width .2s ease; } -.dag-canvas svg path.highlighted { stroke: #e6a23c; stroke-width: 2.5; opacity: 1; } -.dag-canvas marker path { fill: #8997a5; } -.dag-canvas marker.highlighted path { fill: #e6a23c; } -.dag-node { - position: absolute; - display: grid; - padding: 3px 8px 3px 28px; - align-content: center; - gap: 1px; - overflow: visible; - border: 0; - border-radius: 12px; - color: var(--ink); - background: rgba(255,255,255,.84); - box-shadow: none; - cursor: pointer; - text-align: left; - transition: color .2s ease, opacity .2s ease, background-color .2s ease; -} -.dag-node::before { - position: absolute; - top: 50%; - left: 5px; - width: 16px; - height: 16px; - border: 2px solid #fff; - border-radius: 50%; - background: #7f8b96; - box-shadow: 0 0 0 2px #7f8b96; - content: ""; - transform: translateY(-50%); - transition: width .2s ease, height .2s ease, background-color .2s ease, box-shadow .2s ease; -} -.dag-node:hover { color: var(--surface-strong); background: #fff; } -.dag-node:hover::before { background: var(--surface-strong); box-shadow: 0 0 0 2px var(--surface-strong); } -.dag-node span { font-size: 12px; } -.dag-node small { font-size: 9px; } -.dag-node--available { color: #3f7652; background: rgba(240,249,235,.94); } -.dag-node--available::before { background: #67c23a; box-shadow: 0 0 0 2px #67c23a; } -.dag-node--path { color: #8a641b; background: rgba(253,246,236,.94); } -.dag-node--path::before { background: #e6a23c; box-shadow: 0 0 0 2px #e6a23c; } -.dag-node--target { color: #b64040; background: rgba(254,240,240,.94); } -.dag-node--target::before { background: #f56c6c; box-shadow: 0 0 0 2px #f56c6c; } -.dag-node.selected { box-shadow: none; font-weight: 700; } -.dag-node.selected::before { width: 22px; height: 22px; left: 2px; border-width: 3px; } -.dag-shell.has-selection .dag-node--default:not(.selected) { opacity: .28; } -.dag-shell.has-selection .dag-canvas svg > path:not(.highlighted) { opacity: .12; } - -.review-setup { margin-top: 5vh; } -.game-stage { - min-height: min(700px, calc(100vh - 150px)); - border: 1px solid var(--rule); - border-radius: 18px; - color: var(--ink); - background: linear-gradient(150deg, rgb(248 251 255 / .16), rgb(232 244 255 / .24)), var(--scene-background, url('/bg.png')) 50% 50% / 114% auto no-repeat; - box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); -} -.dag-viewport--g6 { cursor: grab; } -.dag-viewport--g6:active { cursor: grabbing; } -.dag-viewport--g6 canvas { outline: none; } -.dag-viewport--g6 .dag-empty { z-index: 2; pointer-events: none; } -.dag-empty--error { padding: 24px; color: var(--danger); text-align: center; } -.dag-stage { position: relative; } -.dag-zoom-controls .dag-unlinked-toggle { min-width: auto; padding-inline: 11px; border-color: #aab7c3; border-style: dashed; } -.dag-zoom-controls .dag-unlinked-toggle.is-active { border-style: solid; color: #fff; background: #667b8d; } -.dag-zoom-controls button:disabled { cursor: not-allowed; opacity: .42; } -.dag-unlinked-panel { position: absolute; z-index: 4; top: 10px; right: 10px; bottom: 10px; display: grid; width: min(360px, calc(100% - 20px)); overflow: hidden; border: 1px solid var(--rule); border-radius: 14px; grid-template-rows: auto minmax(0, 1fr); background: color-mix(in srgb, var(--surface) 96%, transparent); box-shadow: 0 18px 48px rgba(25,35,45,.2); backdrop-filter: blur(12px); } -.dag-unlinked-panel > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 14px; border-bottom: 1px solid var(--rule); } -.dag-unlinked-panel > header div { display: grid; gap: 2px; } -.dag-unlinked-panel > header small { color: var(--muted); font-size: 10px; } -.dag-unlinked-panel > header button { width: 30px; height: 30px; border: 0; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: 20px; } -.dag-unlinked-panel > header button:hover { color: var(--ink); background: var(--surface-soft); } -.dag-unlinked-grid { display: grid; padding: 10px; overflow: auto; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; } -.dag-unlinked-grid > button { display: grid; min-width: 0; min-height: 54px; padding: 8px 9px; border: 1px solid var(--rule); border-radius: 10px; align-content: center; gap: 3px; color: var(--ink); background: var(--surface); cursor: pointer; text-align: left; } -.dag-unlinked-grid > button:hover { border-color: #6d9fc7; background: var(--surface-soft); } -.dag-unlinked-grid > button.selected { border-color: #f56c6c; background: color-mix(in srgb, #f56c6c 10%, var(--surface)); box-shadow: inset 3px 0 #f56c6c; } -.dag-unlinked-grid span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: 700; } -.dag-unlinked-grid small { color: var(--muted); font-size: 9px; } -.game-stage::before { background: linear-gradient(180deg, rgb(9 22 38 / .06), rgb(9 22 38 / .22)); } -.game-stage--backgrounds-loading { - background-color: #dfeaf0; -} -.game-stage--backgrounds-loading > :not(.game-stage__background-loading) { - opacity: 0; -} -.game-stage__background-loading { - position: absolute; - inset: 0; - display: grid; - place-items: center; - z-index: 3; - color: #436276; - font-size: 14px; - letter-spacing: .08em; - background: rgb(223 234 240 / .9); -} -.game-stage--scene-001 { --scene-background: url('/bg.png'); } -.game-stage--scene-002, .game-stage--scene-004 { --scene-background: url('/bg_1.png'); } -.game-stage--scene-003, .game-stage--scene-007 { --scene-background: url('/bg2.png'); } -.game-stage--scene-005 { --scene-background: url('/bg3.png'); } -.game-stage--scene-006, .game-stage--scene-008 { --scene-background: url('/bg4.png'); } -.game-stage::after { - position: absolute; - z-index: 0; - inset: 0; - background: repeating-linear-gradient(106deg, transparent 0 42px, rgb(255 224 163 / .07) 43px 46px, transparent 47px 90px); - content: ""; - opacity: .72; - pointer-events: none; - mix-blend-mode: soft-light; -} -.game-stage__meta { color: rgb(255 238 214 / .9); text-shadow: 0 1px 8px rgb(31 20 31 / .45); } -.game-stage .game-stage__meta { right: 230px; } -.game-stage__actions { position: absolute; z-index: 2; top: 16px; right: 18px; display: flex; align-items: center; gap: 8px; } -.game-stage__end, .game-stage__voice, .game-stage__fullscreen { border: 1px solid rgb(255 223 181 / .5); color: #fff6e7; background: rgb(47 43 59 / .42); box-shadow: 0 5px 16px rgb(28 17 23 / .22); cursor: pointer; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } -.game-stage__end { height: 38px; padding: 0 14px; border-radius: 999px; font: inherit; font-size: 13px; font-weight: 700; } -.game-stage__voice, .game-stage__fullscreen { display: grid; width: 38px; height: 38px; padding: 0; border-radius: 12px; place-items: center; } -.game-stage__voice.is-playing { border-color: rgb(169 247 201 / .82); color: #baf9d3; background: rgb(34 87 73 / .64); } -.game-stage__end:hover:not(:disabled), .game-stage__voice:hover, .game-stage__fullscreen:hover { border-color: rgb(255 232 195 / .85); background: rgb(72 50 61 / .62); } -.game-stage__end:disabled { opacity: .58; cursor: wait; } -.game-stage__voice svg, .game-stage__fullscreen svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; } -.game-stage:fullscreen { width: 100vw; max-width: none; min-height: 100vh; height: 100vh; border: 0; border-radius: 0; } -.game-stage .dialogue-panel { position: absolute; bottom: 94px; left: clamp(24px, 5vw, 62px); width: fit-content; max-width: min(760px, 86%); margin: 0; border-color: rgb(255 205 151 / .38); border-radius: 16px; color: #fff8ed; background: linear-gradient(125deg, rgb(55 42 60 / .53), rgb(30 49 65 / .48)); box-shadow: 0 12px 30px rgb(35 21 25 / .22); backdrop-filter: blur(11px) saturate(116%); -webkit-backdrop-filter: blur(11px) saturate(116%); } -.game-stage .dialogue-panel--advance { cursor: pointer; } -.game-stage .dialogue-panel--advance:hover { border-color: rgb(255 224 174 / .72); } -.game-stage .dialogue-line strong { color: #ffd0a0; } -.game-stage .dialogue-line strong { font-size: 17px; } -.game-stage .dialogue-line p { max-width: 590px; font-size: clamp(17px, 1.35vw, 20px); line-height: 1.78; } -.game-stage .dialogue-line__text { display: grid; min-width: 0; } -.game-stage .dialogue-line__measure, .game-stage .dialogue-line__typed { grid-area: 1 / 1; } -.game-stage .dialogue-line__measure { visibility: hidden; pointer-events: none; } -.game-stage .dialogue-line__typed { margin: 0; overflow-wrap: anywhere; } -.game-stage .dialogue-line--narration { grid-template-columns: 1fr; } -.game-stage .dialogue-panel { bottom: 138px; } -.game-stage .choice-list { position: absolute; right: clamp(24px, 5vw, 62px); bottom: 28px; display: grid; width: min(440px, calc(100% - clamp(48px, 10vw, 124px))); grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 0; } -.game-stage .choice-list button { min-height: 43px; padding: 9px 12px; font-size: 13px; line-height: 1.35; }.game-stage .choice-list button { border-color: rgb(255 207 151 / .4); border-radius: 12px; color: #fff7e9; background: linear-gradient(115deg, rgb(117 65 49 / .76), rgb(78 50 65 / .76)); box-shadow: 0 5px 14px rgb(46 24 19 / .2); backdrop-filter: blur(10px) saturate(118%); -webkit-backdrop-filter: blur(10px) saturate(118%); } -.game-stage .choice-list button:hover:not(:disabled) { border-color: #ffe0ae; color: #fffaf1; background: linear-gradient(115deg, rgb(157 83 54 / .9), rgb(105 64 83 / .9)); transform: none; } -.game-stage .dialogue-panel--feedback { border-width: 2px; } -.game-stage .dialogue-panel--feedback-correct { border-color: rgb(150 245 190 / .76); background: linear-gradient(125deg, rgb(27 82 69 / .76), rgb(29 57 68 / .74)); } -.game-stage .dialogue-panel--feedback-incorrect { border-color: rgb(255 173 160 / .78); background: linear-gradient(125deg, rgb(105 48 55 / .78), rgb(54 43 65 / .76)); } -.game-stage .dialogue-panel--feedback-correct .dialogue-line strong { color: #a9f7c9; } -.game-stage .dialogue-panel--feedback-incorrect .dialogue-line strong { color: #ffc0b5; } -.game-stage .dialogue-line--feedback { grid-template-columns: minmax(92px, auto) minmax(0, 1fr); } -.game-stage .choice-list--feedback { display: block; width: auto; } -.game-stage > .primary-button { justify-self: end; margin-top: 14px; padding: 12px 22px; border: 1px solid rgb(255 218 172 / .5); border-radius: 999px; color: #fff; background: linear-gradient(115deg, #cf714c, #8f596c); box-shadow: 0 8px 18px rgb(59 30 34 / .28); font-size: 14px; font-weight: 750; } -.game-stage > .primary-button:hover:not(:disabled) { background: linear-gradient(115deg, #df8054, #a4647a); transform: translateY(-1px); } -.game-stage .choice-list:has(> button:only-child) { display: block; width: auto; justify-self: end; margin-top: 14px; } -.game-stage .choice-list:has(> button:only-child) button { min-height: 0; padding: 12px 22px; border: 1px solid rgb(255 218 172 / .5); border-radius: 999px; color: #fff; background: linear-gradient(115deg, #cf714c, #8f596c); box-shadow: 0 8px 18px rgb(59 30 34 / .28); font-size: 14px; font-weight: 750; } -.game-stage .choice-list:has(> button:only-child) button:hover:not(:disabled) { border-color: #ffe0ae; color: #fff; background: linear-gradient(115deg, #df8054, #a4647a); transform: translateY(-1px); } - -.review-page--setup { - position: relative; - isolation: isolate; - overflow: hidden; - background: - radial-gradient(circle at 18% 28%, rgba(60, 167, 255, .18), transparent 30%), - radial-gradient(circle at 82% 24%, rgba(160, 105, 255, .16), transparent 32%), - radial-gradient(circle at 68% 82%, rgba(255, 126, 179, .13), transparent 34%), - var(--canvas); -} -.review-page--setup::before { - position: absolute; - z-index: -1; - inset: 54px 0 0; - opacity: .45; - background-image: radial-gradient(circle, rgba(71, 104, 146, .16) 1px, transparent 1px); - background-size: 26px 26px; - mask-image: linear-gradient(to bottom, transparent, #000 18%, #000 72%, transparent); - content: ""; - pointer-events: none; -} -.review-page--setup .review-setup { - --review-glow-one: rgba(64, 160, 255, .66); - --review-glow-two: rgba(255, 105, 174, .52); - position: relative; - display: grid; - width: min(860px, 100%); - min-height: 520px; - margin: clamp(34px, 7vh, 78px) auto 0; - padding: clamp(34px, 5vw, 54px); - align-content: center; - gap: 26px; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, .72); - border-radius: 32px; - background: linear-gradient(145deg, rgba(255, 255, 255, .7), rgba(255, 255, 255, .38)); - box-shadow: 0 32px 80px -40px rgba(43, 68, 110, .52), inset 0 1px 0 rgba(255, 255, 255, .86); - backdrop-filter: blur(28px) saturate(155%); - -webkit-backdrop-filter: blur(28px) saturate(155%); -} -.review-page--setup .review-setup[data-style="fantasy"] { --review-glow-one: rgba(137, 91, 255, .66); --review-glow-two: rgba(255, 91, 164, .56); } -.review-page--setup .review-setup[data-style="science"] { --review-glow-one: rgba(34, 139, 255, .68); --review-glow-two: rgba(34, 222, 203, .5); } -.review-setup__orb { position: absolute; border-radius: 50%; filter: blur(7px); opacity: .82; pointer-events: none; will-change: transform, opacity; } -.review-setup__orb--one { top: -130px; right: -90px; width: 360px; height: 360px; background: radial-gradient(circle at 36% 36%, var(--review-glow-one), transparent 68%); animation: review-orb-one 8s ease-in-out infinite alternate; } -.review-setup__orb--two { bottom: -170px; left: -110px; width: 420px; height: 420px; background: radial-gradient(circle at 58% 42%, var(--review-glow-two), transparent 68%); animation: review-orb-two 10s ease-in-out infinite alternate; } -@keyframes review-orb-one { from { opacity: .62; transform: translate3d(0, 0, 0) scale(.94); } to { opacity: .92; transform: translate3d(-72px, 58px, 0) scale(1.12); } } -@keyframes review-orb-two { from { opacity: .58; transform: translate3d(0, 0, 0) scale(.92); } to { opacity: .88; transform: translate3d(88px, -54px, 0) scale(1.1); } } -.review-setup > :not(.review-setup__orb) { position: relative; z-index: 1; } -.review-setup__heading { max-width: 650px; } -.review-setup__heading .section-label { display: inline-flex; padding: 6px 10px; border: 1px solid rgba(255,255,255,.78); border-radius: 999px; color: #47627f; background: rgba(255,255,255,.42); font-weight: 700; letter-spacing: .1em; backdrop-filter: blur(12px); } -.review-setup__heading h2 { margin: 14px 0 10px; font-size: clamp(34px, 4vw, 48px); line-height: 1.05; letter-spacing: -.045em; } -.review-setup__heading p { max-width: 590px; margin: 0; color: rgba(45, 58, 78, .68); font-size: 14px; line-height: 1.7; } -.review-setup__summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } -.review-setup__summary > div { display: flex; min-height: 76px; padding: 16px 18px; align-items: center; gap: 7px; border: 1px solid rgba(255,255,255,.66); border-radius: 18px; background: rgba(255,255,255,.34); box-shadow: inset 0 1px 0 rgba(255,255,255,.7); backdrop-filter: blur(16px); } -.review-setup__summary span { margin-right: auto; color: rgba(52, 68, 90, .62); font-size: 14px; font-weight: 700; } -.review-setup__summary strong { color: #25364b; font-size: 34px; letter-spacing: -.04em; } -.review-setup__summary small { color: rgba(52, 68, 90, .62); font-size: 13px; } -.review-setup__controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } -.review-page--setup .review-setup__controls label { display: grid; gap: 8px; margin: 0; color: #40536c; font-size: 12px; font-weight: 700; } -.review-page--setup .review-setup__controls select { min-height: 48px; padding: 10px 14px; border: 1px solid rgba(255,255,255,.82); border-radius: 15px; color: #273b54; background: rgba(255,255,255,.5); box-shadow: inset 0 1px 0 rgba(255,255,255,.72); backdrop-filter: blur(14px); } -.review-page--setup .review-setup__controls select:focus { border-color: rgba(51, 132, 220, .62); background: rgba(255,255,255,.72); box-shadow: 0 0 0 3px rgba(42, 135, 228, .12); } -.review-page--setup .review-setup > .primary-button { width: 100%; min-height: 50px; border-radius: 16px; background: linear-gradient(135deg, #1677d2, #7858d7); box-shadow: 0 14px 28px -16px rgba(52, 85, 190, .7); font-weight: 700; transition: transform .2s ease, box-shadow .2s ease, opacity .2s ease; } -.review-page--setup .review-setup > .primary-button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 18px 34px -17px rgba(52, 85, 190, .82); } -.review-page--setup > .status-line { width: min(860px, 100%); margin: 16px auto 0; border-color: rgba(255,255,255,.62); border-radius: 14px; background: rgba(255,255,255,.42); backdrop-filter: blur(16px); } -:root[data-theme="dark"] .review-page--setup { background: radial-gradient(circle at 18% 28%, rgba(35, 126, 207, .24), transparent 30%), radial-gradient(circle at 82% 24%, rgba(119, 72, 204, .23), transparent 32%), radial-gradient(circle at 68% 82%, rgba(194, 69, 125, .17), transparent 34%), var(--canvas); } -:root[data-theme="dark"] .review-page--setup .review-setup { border-color: rgba(255,255,255,.14); background: linear-gradient(145deg, rgba(47, 52, 65, .72), rgba(34, 38, 49, .46)); box-shadow: 0 34px 90px -42px rgba(0,0,0,.8), inset 0 1px 0 rgba(255,255,255,.12); } -:root[data-theme="dark"] .review-setup__heading .section-label { border-color: rgba(255,255,255,.14); color: #a9caeb; background: rgba(255,255,255,.07); } -:root[data-theme="dark"] .review-setup__heading p, -:root[data-theme="dark"] .review-setup__summary span, -:root[data-theme="dark"] .review-setup__summary small { color: rgba(225,232,242,.64); } -:root[data-theme="dark"] .review-setup__summary > div { border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.07); box-shadow: inset 0 1px 0 rgba(255,255,255,.1); } -:root[data-theme="dark"] .review-setup__summary strong { color: #f1f6fc; } -:root[data-theme="dark"] .review-page--setup .review-setup__controls label { color: #bed0e4; } -:root[data-theme="dark"] .review-page--setup .review-setup__controls select { border-color: rgba(255,255,255,.14); color: #edf4fb; background: rgba(255,255,255,.08); } -:root[data-theme="dark"] .review-page--setup > .status-line { border-color: rgba(255,255,255,.1); background: rgba(37,41,51,.52); } - -@media (max-width: 760px) { - .review-page--setup .review-setup { min-height: 0; margin-top: 18px; padding: 28px 20px; gap: 20px; border-radius: 24px; } - .review-setup__heading h2 { font-size: 34px; } - .review-setup__summary > div { min-height: 66px; padding: 13px 14px; } - .review-setup__summary strong { font-size: 30px; } - .review-setup__controls { grid-template-columns: 1fr; } - .review-page--setup .review-setup__controls select { min-height: 46px; } - .review-page--setup > .status-line { margin-top: 12px; } -} - -@media (max-width: 1180px) { - .graph-workbench { grid-template-columns: 210px minmax(420px, 1fr); } - .graph-inspector { grid-column: 1 / -1; min-height: 240px; border-top: 1px solid var(--rule); border-left: 0; } - .graph-workbench--inspector-collapsed .graph-inspector { min-height: 48px; padding: 8px 12px; } - .graph-workbench--inspector-collapsed .graph-inspector__header { justify-content: flex-end; } - .home-workspace { grid-template-columns: 1fr; } - .process-layout { grid-template-columns: 1fr; } - .plan-panel { position: static; } - .admin-grid { grid-template-columns: 1fr; } -} - -@media (max-width: 760px) { - .route-transition { animation-name: route-fade; } - .app-shell { - padding: 48px 0 calc(64px + env(safe-area-inset-bottom)); - --koishi-activity-width: 0px; - --koishi-status-height: 0px; - } - - .mobile-shell-head { - position: fixed; - z-index: 115; - inset: 0 0 auto; - display: grid; - height: 48px; - padding: 0 12px; - grid-template-columns: 1fr auto 1fr; - align-items: center; - border-bottom: 1px solid var(--rule); - background: rgba(255,255,255,.96); - backdrop-filter: blur(10px); - } - .mobile-shell-head > a { display: inline-flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; } - .mobile-shell-head .brand-mark { width: 25px; height: 18px; color: var(--brand); } - .mobile-shell-head strong { font-size: 13px; } - .mobile-shell-head > span { color: var(--muted); font-size: 12px; } - .mobile-shell-head > button { display: grid; justify-self: end; width: 34px; height: 34px; padding: 0; place-items: center; border: 0; color: var(--muted); background: transparent; } - .mobile-shell-head > button svg { width: 20px; height: 20px; } - - .app-rail { - inset: auto 0 0; - display: block; - width: 100%; - height: calc(64px + env(safe-area-inset-bottom)); - padding: 4px 6px max(4px, env(safe-area-inset-bottom)); - border-top: 1px solid var(--rule); - border-right: 0; - background: rgba(248,250,252,.98); - box-shadow: 0 -4px 14px rgba(40,40,44,.08); - } - .rail-brand, .rail-account { display: none; } - .rail-nav { display: grid; height: 56px; padding: 0; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; } - .rail-nav a { min-height: 56px; border-radius: 10px; } - .rail-nav a::before { top: auto; bottom: 0; left: 50%; width: 22px; height: 3px; border-radius: 3px 3px 0 0; transform: translate(-50%, 100%); } - .rail-nav a.active::before { transform: translate(-50%, 0); } - .rail-nav a::after { display: none; } - .rail-nav a > span { display: block; margin-top: -5px; font-size: 9px; } - .rail-nav svg { width: 20px; height: 20px; } - - .app-shell__content { min-height: calc(100svh - 112px); } - .app-statusbar { display: none; } - .app-shell .page { min-height: calc(100svh - 112px); padding: 16px 14px 30px; } - .app-shell .page-header { min-height: auto; margin: -16px -14px 16px; padding: 12px 14px; align-items: flex-start; flex-direction: column; gap: 10px; } - .app-shell .page-header:not(.page-header--has-description):not(.page-header--has-actions) { display: none; } - .app-shell .page-header h1 { display: none; } - .app-shell .page-header__copy { display: none; } - .app-shell .page-header--has-description .page-header__copy { display: flex; } - .app-shell .page-header--has-description p { margin: 0; color: var(--ink); font-size: 14px; font-weight: 700; white-space: normal; } - .page-header__actions { width: 100%; } - .page-header__actions > :not(.quiet-action-divider) { flex: 1 1 auto; } - .page-header__actions > .quiet-action-divider { width: 1px; height: 14px; flex: 0 0 1px; align-self: center; } - .home-dashboard .page-header { align-items: center; flex-direction: row; } - .home-dashboard .page-header__actions { width: auto; margin-left: auto; } - .home-dashboard .page-header__actions > * { flex: 0 0 auto; } - .home-theme-toggle { min-height: 32px; padding: 4px 6px; gap: 8px; } - .home-welcome > .home-theme-toggle { top: 14px; right: 14px; } - .home-welcome__intro > div { padding-right: 104px; } - .home-theme-toggle input[type="checkbox"] { width: 36px; height: 20px; } - .home-theme-toggle input[type="checkbox"]::before { width: 14px; height: 14px; } - .home-theme-toggle input[type="checkbox"]:checked::before { transform: translateX(16px); } - .home-workspace { grid-template-columns: 1fr; } - .resume-panel { min-height: 220px; padding: 24px; } - .study-snapshot { padding: 18px; } - .home-quick-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } - .home-quick-actions a { min-height: 94px; gap: 10px; padding: 14px; } - .home-welcome .home-quick-actions { grid-template-columns: 1fr; } - .home-welcome .home-quick-actions a { min-height: 78px; } - .home-summary { display: none; } - .quick-action-icon { width: 38px; height: 38px; } - .data-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .data-strip div { min-height: 64px; padding: 8px; border-right: 1px solid var(--rule); border-bottom: 0; } - .data-strip div:last-child { border-right: 0; } - .data-strip strong { font-size: 19px; } - .knowledge-layout { grid-template-columns: 1fr; } - .filter-panel { position: static; border-right: 0; border-bottom: 1px solid var(--rule); } - .settings-layout { grid-template-columns: 1fr; } - .settings-index, .settings-main, .settings-side { position: static; grid-column: 1; grid-row: auto; } - .settings-index nav { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .settings-layout { min-height: 0; align-items: stretch; } - .settings-index { min-height: 0; } - .settings-index nav { grid-template-rows: none; } - .settings-main > .form-section, .settings-main > .side-section { min-height: 0; padding: 24px 22px; } - #credits-settings > .credits-settings__header { flex-direction: column; gap: 12px; } - #credits-settings .credit-balance { min-height: 88px; padding: 15px 16px; gap: 4px 12px; } - .credits-redeem__controls { grid-template-columns: 1fr; } - .credits-redeem__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } - .credits-redeem__actions .button { width: 100%; } - .graph-workbench { grid-template-columns: 1fr; min-height: 0; overflow: visible; } - .chapter-outline, .graph-inspector { grid-column: 1; } - .chapter-outline { max-height: 260px; border-right: 0; border-bottom: 1px solid var(--rule); } - .graph-board { padding: 14px; } - .graph-board > header, .graph-board-view-tools { align-items: stretch; flex-direction: column; } - .graph-board-view-tools { width: 100%; } - .graph-view-switch { width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); } - .graph-board input { width: 100%; max-width: none; } - .graph-inspector { border-top: 1px solid var(--rule); } - .dag-viewport { height: 520px; min-height: 520px; } - .app-shell .materials-page { display: block; } - .materials-page .process-layout--materials, .process-layout--materials .process-primary { min-height: 0; } - .process-layout--materials .process-primary { grid-template-columns: minmax(0, 1fr); } - .process-layout--materials .upload-panel, .process-layout--materials .material-library { height: auto; } - .materials-page .form-section, .materials-page .material-library { padding: 18px; } - .dialogue-line, .choice-list { grid-template-columns: 1fr; } - .game-stage .dialogue-line--feedback { grid-template-columns: 1fr; } - .game-stage { min-height: 620px; padding: 68px 16px 20px; } - .review-summary__heading, .review-summary li > div { align-items: flex-start; flex-direction: column; gap: 6px; } - -} - -/* Authentication entry design preserved from frontend1. */ -/* Login and registration use a dedicated, editorial two-column composition. - Other account routes keep the compact shared auth layout above. */ -.auth-page--entry { position: relative; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); overflow: hidden; background: #fff; font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; } -.auth-page--entry .auth-page__content { display: flex; grid-column: 2; align-items: center; justify-content: center; min-height: 100vh; padding: 52px clamp(54px, 9.7vw, 200px); background: #fff; } -.auth-page--entry .auth-form { width: min(100%, 626px); } -.auth-entry-home-link { position: absolute; z-index: 4; top: 24px; right: clamp(24px, 3.2vw, 52px); display: inline-flex; min-height: 34px; padding: 0 12px; align-items: center; gap: 7px; border: 1px solid #e1e3e6; border-radius: 999px; color: #7b8189; background: rgba(255,255,255,.76); font-size: 12px; font-weight: 600; text-decoration: none; backdrop-filter: blur(8px); transition: border-color 160ms ease, color 160ms ease, background-color 160ms ease; } -.auth-entry-home-link::before { content: "←"; color: #9a9fa6; font-size: 14px; font-weight: 400; transition: transform 160ms ease; } -.auth-entry-home-link:hover { border-color: #cfd3d8; color: #3f444b; background: #fff; } -.auth-entry-home-link:hover::before { transform: translateX(-2px); } -.auth-entry-home-link:focus-visible { outline: 2px solid rgba(13,114,217,.25); outline-offset: 3px; } -.auth-entry-story { position: relative; display: flex; min-width: 0; min-height: 100vh; flex-direction: column; padding: 68px clamp(52px, 8.2vw, 120px); overflow: hidden; border-right: 1px solid #e1e3e6; color: #1d2026; background: #f6f6f8; } -.auth-entry-story::after { display: none; } -.auth-entry-brand { display: flex; align-items: center; gap: 13px; color: #202124; font-size: 26px; font-weight: 800; letter-spacing: -.06em; } -.auth-entry-brand .brand-mark { width: 54px; height: 43px; color: #202124; } -.auth-entry-copy { position: relative; z-index: 1; max-width: 640px; margin: auto 0; transform: translateY(20px); } -.auth-entry-copy > p { margin: 0 0 24px; color: #68717d; font-size: 14px; font-weight: 800; letter-spacing: -.02em; } -.auth-entry-copy h1 { margin: 0; color: #1e222a; font-size: clamp(44px, 4.25vw, 66px); font-weight: 850; line-height: 1.1; letter-spacing: -.085em; white-space: nowrap; } -.auth-entry-copy > span { display: block; max-width: 590px; margin-top: 28px; color: #6e7784; font-size: 17px; line-height: 1.75; } -.auth-entry-card-stack { position: relative; z-index: 1; width: min(100%, 608px); height: 145px; margin-top: auto; perspective: 1200px; } -.auth-entry-card { --next-card-glass: rgb(255 255 255 / .72); position: absolute; inset: 0; display: grid; width: 100%; gap: 7px; padding: 18px 30px; overflow: hidden; border: 1px solid #dfe2e6; border-radius: 32px; background: #fff; box-shadow: 0 4px 12px rgb(20 29 45 / .05); transform-origin: 50% 100%; will-change: opacity, transform; } -.auth-entry-card::before { position: absolute; z-index: 2; inset: 0 auto 0 0; width: 4px; border-radius: 32px 0 0 32px; background: #202124; content: ""; } -.auth-entry-card::after { position: absolute; z-index: 0; right: 0; bottom: 0; left: 0; height: 42%; border-top: 1px solid rgb(255 255 255 / .28); background: var(--next-card-glass); backdrop-filter: blur(15px) saturate(128%); -webkit-backdrop-filter: blur(15px) saturate(128%); content: ""; } -.auth-entry-card > small, .auth-entry-card > strong, .auth-entry-card > span { position: relative; z-index: 1; } -.auth-entry-card--blue { background: #fff; } -.auth-entry-card--blue::before { background: #087ce6; } -.auth-entry-card--three { background: #fff; } -.auth-entry-card--three::before { background: #7067c9; } -.auth-entry-card small, .auth-entry-card span { color: #87909d; font-size: 13px; line-height: 1.45; } -.auth-entry-card strong { color: #252a33; font-family: Georgia, "Times New Roman", "Microsoft YaHei", serif; font-size: 20px; letter-spacing: -.02em; } -.auth-entry-card--one { animation: auth-card-one 9s infinite cubic-bezier(.65, 0, .35, 1); } -.auth-entry-card--two { animation: auth-card-two 9s infinite cubic-bezier(.65, 0, .35, 1); } -.auth-entry-card--three { animation: auth-card-three 9s infinite cubic-bezier(.65, 0, .35, 1); } -@keyframes auth-card-one { - 0%, 25% { z-index: 3; opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); } - 40%, 60% { z-index: 1; opacity: .9; transform: translate3d(22px, 18px, -30px) rotate(.7deg) scale(.952); } - 72%, 87% { z-index: 2; opacity: .96; transform: translate3d(11px, 9px, -15px) rotate(.3deg) scale(.976); } - 100% { z-index: 3; opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); } -} -@keyframes auth-card-two { - 0%, 25% { z-index: 2; opacity: .96; transform: translate3d(11px, 9px, -15px) rotate(.3deg) scale(.976); } - 40%, 60% { z-index: 3; opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); } - 72%, 87% { z-index: 1; opacity: .9; transform: translate3d(22px, 18px, -30px) rotate(.7deg) scale(.952); } - 100% { z-index: 2; opacity: .96; transform: translate3d(11px, 9px, -15px) rotate(.3deg) scale(.976); } -} -@keyframes auth-card-three { - 0%, 25% { z-index: 1; opacity: .9; transform: translate3d(22px, 18px, -30px) rotate(.7deg) scale(.952); } - 40%, 60% { z-index: 2; opacity: .96; transform: translate3d(11px, 9px, -15px) rotate(.3deg) scale(.976); } - 72%, 87% { z-index: 3; opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg) scale(1); } - 100% { z-index: 1; opacity: .9; transform: translate3d(22px, 18px, -30px) rotate(.7deg) scale(.952); } -} -.auth-entry-heading { margin: 0 0 42px; } -.auth-entry-heading p { min-height: 16px; margin: 0 0 15px; color: #6c7580; font-size: 14px; font-weight: 800; } -.auth-entry-heading p:empty { display: none; } -.auth-entry-heading h1 { margin: 0; color: #1e222a; font-size: clamp(35px, 3.2vw, 48px); font-weight: 850; line-height: 1.1; letter-spacing: -.075em; white-space: nowrap; } -.auth-entry-heading span { display: block; margin-top: 13px; color: #7a838f; font-size: 16px; line-height: 1.55; } -.auth-page--register .auth-entry-heading h1 { font-size: clamp(34px, 2.9vw, 44px); } -.auth-page--entry .form-field { min-height: 94px; } -.auth-page--entry .form-field--error { min-height: 112px; } -.auth-page--entry .form-field > label { margin-bottom: 8px; color: #31343a; font-size: 14px; font-weight: 800; } -.auth-page--entry .form-field__control { height: 52px; border: 1px solid #d8dade; border-radius: 11px; background: #fff; } -.auth-page--entry .form-field__control:focus-within { border-color: #202124; box-shadow: 0 0 0 3px rgb(32 33 36 / .08); background: #fff; } -.auth-page--entry .form-field__control input { padding-left: 16px; color: #202124; } -.auth-page--entry .form-field__icon, .auth-page--entry .form-field__decorative { right: 14px; color: #6f747c; } -.auth-page--entry .form-field__error { top: 85px; bottom: auto; line-height: 16px; } -.auth-page--entry .auth-form__actions { margin-top: 4px; } -.auth-page--entry .action-button { display: flex; align-items: center; justify-content: space-between; min-height: 54px; padding: 10px 28px; border: 0; border-radius: 999px; color: #fff; background: #0D72D9; box-shadow: none; font-size: 16px; } -.auth-page--entry .action-button b { font-size: 25px; font-weight: 400; line-height: 1; } -.auth-page--entry .action-button:hover:not(:disabled) { background: #0a61ba; transform: translateY(-1px); } -.auth-page--entry .auth-secondary-actions { justify-content: flex-start; gap: 22px; margin-top: 20px; } -.auth-page--entry .auth-secondary-actions button, .auth-page--entry .auth-form__back-link, .auth-page--entry .auth-entry-switch button { padding: 0; border: 0; color: #0D72DD; background: transparent; cursor: pointer; font-size: 14px; font-weight: 800; text-decoration: none; } -.auth-page--entry .auth-secondary-actions button:hover, .auth-page--entry .auth-form__back-link:hover, .auth-page--entry .auth-entry-switch button:hover { color: #0a61be; text-decoration: underline; text-underline-offset: 4px; } -.auth-page--entry .auth-form__back-link { float: none; margin-top: 20px; } -.auth-entry-field-help { margin: -8px 0 16px; color: #9098a3; font-size: 13px; line-height: 1.35; } -.form-field--error + .auth-entry-field-help { margin-top: -1px; } -.auth-entry-switch { margin: 27px 0 0; color: #7a838f; font-size: 15px; } -.auth-entry-switch button { margin-left: 7px; } -.auth-page--login .form-message--error, .auth-page--register .form-message--error { color: #bd525b; } - -@media (max-width: 800px) { - .auth-page--entry { display: grid; grid-template-rows: minmax(242px, 31svh) 1fr; grid-template-columns: 1fr; min-height: 100svh; overflow: auto; } - .auth-entry-story { grid-row: 1; min-height: 0; padding: 22px 24px 20px; border-right: 0; border-bottom: 1px solid #e4e5e7; } - .auth-entry-brand { gap: 9px; font-size: 20px; } - .auth-entry-brand .brand-mark { width: 38px; height: 29px; } - .auth-entry-copy { margin: auto 0 0; transform: none; } - .auth-entry-copy > p { margin-bottom: 10px; font-size: 12px; } - .auth-entry-copy h1 { font-size: clamp(32px, 9.4vw, 45px); } - .auth-entry-copy > span, .auth-entry-card-stack { display: none; } - .auth-entry-home-link { top: 16px; right: 18px; min-height: 32px; padding-inline: 11px; background: rgba(246,246,248,.8); } - .auth-page--entry .auth-page__content { grid-row: 2; grid-column: 1; align-items: start; min-height: 0; padding: 30px 24px 48px; } - .auth-entry-heading { margin-bottom: 29px; } - .auth-entry-heading h1 { font-size: 34px; white-space: normal; } - .auth-entry-heading span { font-size: 14px; } - .auth-page--entry .auth-heading { margin-bottom: 23px; } - .auth-page--entry .auth-heading h2 { font-size: 34px; } - .auth-page--entry .auth-heading p { font-size: 14px; } - .auth-page--entry .form-field { min-height: 82px; } -} - -/* Mobile-only account entry refinement. Desktop composition remains untouched. */ -@media (max-width: 800px) { - /* Keep the slogan visually centered inside the mobile story panel. */ - .auth-page--login .auth-entry-copy, - .auth-page--register .auth-entry-copy, - .auth-page--admin .auth-entry-copy, - .auth-page--forgot .auth-entry-copy { - position: absolute; - top: 50%; - right: 22px; - left: 22px; - margin: 0; - transform: translateY(-50%); - } - - /* Password and invitation hints need their own breathing room on narrow screens. */ - .auth-page--register .auth-entry-field-help { - margin: 6px 0 20px; - line-height: 1.45; - } - - /* Password recovery follows the same top-story / lower-form composition. */ - .auth-page--forgot { - display: grid; - grid-template-columns: 1fr; - grid-template-rows: 30svh auto; - min-height: 100svh; - padding: 0; - overflow: auto; - background: #fff; - } - .auth-page--forgot .auth-entry-story { - grid-row: 1; - min-height: 0; - padding: 18px 22px 16px; - } - .auth-page--forgot .auth-entry-brand { - font-size: 19px; - } - .auth-page--forgot .auth-entry-brand .brand-mark { - width: 36px; - height: 27px; - } - .auth-page--forgot .auth-entry-copy > p { - margin-bottom: 7px; - font-size: 11px; - } - .auth-page--forgot .auth-entry-copy h1 { - font-size: clamp(29px, 8.4vw, 38px); - line-height: 1.1; - } - .auth-page--forgot .auth-page__content { - grid-row: 2; - grid-column: 1; - display: block; - min-height: 0; - padding: 26px 22px max(30px, env(safe-area-inset-bottom)); - background: #fff; - } - .auth-page--forgot .auth-entry-heading { - margin-bottom: 23px; - } - .auth-page--forgot .auth-entry-heading h1 { - font-size: 31px; - } - .auth-page--forgot .auth-entry-heading span { - margin-top: 8px; - font-size: 14px; - } - .auth-page--forgot .form-field { - min-height: 80px; - } - .auth-page--forgot .form-field--error { - min-height: 102px; - } - .auth-page--forgot .form-field__control { - height: 50px; - } - .auth-page--forgot .form-field__error { - top: 75px; - } - .auth-page--forgot .action-button { - min-height: 52px; - } - - .auth-page--login, - .auth-page--admin, - .auth-page--register { - display: grid; - grid-template-columns: 1fr; - min-height: 100svh; - padding: 0; - background: #fff; - } - .auth-page--login, - .auth-page--admin { - grid-template-rows: 30svh minmax(0, 1fr); - overflow: hidden; - } - .auth-page--register { - grid-template-rows: 27svh auto; - overflow: auto; - } - .auth-page--login .auth-entry-story, - .auth-page--admin .auth-entry-story, - .auth-page--register .auth-entry-story { - grid-row: 1; - min-height: 0; - padding: 18px 22px 16px; - } - .auth-page--login .auth-entry-brand, - .auth-page--admin .auth-entry-brand, - .auth-page--register .auth-entry-brand { - font-size: 19px; - } - .auth-page--login .auth-entry-brand .brand-mark, - .auth-page--admin .auth-entry-brand .brand-mark, - .auth-page--register .auth-entry-brand .brand-mark { - width: 36px; - height: 27px; - } - .auth-page--login .auth-entry-copy, - .auth-page--admin .auth-entry-copy, - .auth-page--register .auth-entry-copy { - margin-top: auto; - } - .auth-page--login .auth-entry-copy > p, - .auth-page--admin .auth-entry-copy > p, - .auth-page--register .auth-entry-copy > p { - margin-bottom: 7px; - font-size: 11px; - } - .auth-page--login .auth-entry-copy h1, - .auth-page--admin .auth-entry-copy h1, - .auth-page--register .auth-entry-copy h1 { - font-size: clamp(29px, 8.4vw, 38px); - line-height: 1.1; - } - .auth-page--login .auth-page__content, - .auth-page--admin .auth-page__content, - .auth-page--register .auth-page__content { - grid-row: 2; - grid-column: 1; - display: block; - min-height: 0; - padding: 26px 22px max(30px, env(safe-area-inset-bottom)); - background: #fff; - } - .auth-page--login .auth-page__content, - .auth-page--admin .auth-page__content { - overflow-y: auto; - } - .auth-page--login .auth-entry-heading, - .auth-page--admin .auth-entry-heading, - .auth-page--register .auth-entry-heading { - margin-bottom: 23px; - } - .auth-page--login .auth-entry-heading h1, - .auth-page--admin .auth-entry-heading h1, - .auth-page--register .auth-entry-heading h1 { - font-size: 31px; - } - .auth-page--login .auth-entry-heading span, - .auth-page--admin .auth-entry-heading span, - .auth-page--register .auth-entry-heading span { - margin-top: 8px; - font-size: 14px; - } - .auth-page--login .form-field, - .auth-page--admin .form-field, - .auth-page--register .form-field { - min-height: 80px; - } - .auth-page--login .form-field--error, - .auth-page--admin .form-field--error, - .auth-page--register .form-field--error { - min-height: 102px; - } - .auth-page--login .form-field__control, - .auth-page--admin .form-field__control, - .auth-page--register .form-field__control { - height: 50px; - } - .auth-page--login .form-field__error, - .auth-page--admin .form-field__error, - .auth-page--register .form-field__error { - top: 75px; - } - .auth-page--login .action-button, - .auth-page--admin .action-button, - .auth-page--register .action-button { - min-height: 52px; - } - .auth-page--login .auth-entry-switch, - .auth-page--admin .auth-entry-switch, - .auth-page--register .auth-entry-switch { - margin-top: 22px; - } -} - -.auth-page--login .auth-entry-copy h1, -.auth-page--register .auth-entry-copy h1, -.auth-page--forgot .auth-entry-copy h1, -.auth-page--admin .auth-entry-copy h1 { - line-height: 1.16; - letter-spacing: -.025em; -} - -/* Refined management console. Kept fully scoped so the learner workspace is unaffected. */ -.admin-page { - --admin-blue: #0d72d9; - --admin-blue-deep: #075fb9; - --admin-blue-soft: #eaf4ff; - min-height: 100vh; - background: #f6f7f9; -} -.admin-page .admin-header { - min-height: 64px; - padding: 10px clamp(24px, 4vw, 56px); - border-bottom: 1px solid #e4e7eb; - background: rgba(255, 255, 255, .94); - box-shadow: none; - backdrop-filter: blur(14px); -} -.admin-brand { display: inline-flex; align-items: center; gap: 11px; color: #202124; text-decoration: none; } -.admin-page .admin-header .admin-brand .brand-mark { width: 34px; height: 24px; } -.admin-brand > span { display: flex; min-width: 0; flex-direction: column; gap: 0; } -.admin-brand strong { color: #202124; font-size: 20px; line-height: 1.2; letter-spacing: .08em; } -.admin-header__actions { gap: 8px !important; } -.admin-page .admin-header__actions .button { - min-height: 36px; - padding: 7px 11px; - border-color: transparent; - border-radius: 9px; - color: #5f6670; - background: transparent; - box-shadow: none; -} -.admin-page .admin-header__actions .button:hover:not(:disabled) { border-color: transparent; color: #202124; background: #f0f2f4; } -.admin-header__actions svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; } -.admin-page .admin-header__actions .admin-logout { color: #747b84; } - -.admin-page .admin-content { width: min(1280px, 100%); padding: 34px clamp(24px, 4vw, 52px) 64px; } -.admin-hero { display: flex; align-items: center; justify-content: space-between; gap: 32px; margin-bottom: 24px; } -.admin-hero__copy { min-width: 0; } -.admin-hero h1 { margin: 0; color: #202124; font-size: clamp(28px, 2.6vw, 36px); line-height: 1.15; letter-spacing: -.035em; } -.admin-page .admin-grid { grid-template-columns: minmax(0, 1.1fr) minmax(360px, .9fr); gap: 16px; align-items: stretch; } -.admin-page .admin-grid > .workspace-card { - display: flex; - height: clamp(540px, calc(100vh - 230px), 700px); - min-width: 0; - padding: 0; - flex-direction: column; - overflow: hidden; - border: 1px solid #e1e4e8; - border-radius: 16px; - background: #fff; - box-shadow: none; -} -.admin-page .admin-grid article > .admin-card__header { - display: flex; - min-height: 70px; - align-items: center; - justify-content: space-between; - gap: 16px; - margin: 0; - padding: 15px 18px; - border-bottom: 1px solid #eceef1; - background: #fff; -} -.admin-card__header > div { display: flex; min-width: 0; align-items: center; gap: 12px; } -.admin-card__header > div > span:last-child { display: grid; gap: 2px; min-width: 0; } -.admin-card__header h2 { margin: 0; color: #2a2d31; font-size: 15px; letter-spacing: -.01em; } -.admin-card__icon { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border-radius: 12px; color: var(--admin-blue); background: var(--admin-blue-soft); } - -.admin-page .admin-table, .admin-page .invitation-list { border-top: 0; } -.admin-page .admin-table, -.admin-page .invitation-list { - min-height: 0; - flex: 1 1 auto; - overflow-y: auto; - scrollbar-color: #c6cbd1 transparent; - scrollbar-width: thin; -} -.admin-page .admin-table::-webkit-scrollbar, -.admin-page .invitation-list::-webkit-scrollbar { width: 7px; } -.admin-page .admin-table::-webkit-scrollbar-thumb, -.admin-page .invitation-list::-webkit-scrollbar-thumb { border-radius: 999px; background: #c6cbd1; } -.admin-page .admin-table::-webkit-scrollbar-track, -.admin-page .invitation-list::-webkit-scrollbar-track { background: transparent; } -.admin-page .admin-table > div { - grid-template-columns: minmax(0, 1fr) auto auto; - min-height: 68px; - gap: 14px; - padding: 10px 18px; - border-bottom-color: #eceef1; -} -.admin-page .admin-table > div:last-of-type { border-bottom: 0; } -.admin-page .admin-table > div:hover, .admin-page .invitation-list > div:hover { background: #f7f8fa; } -.admin-user { display: flex !important; min-width: 0; align-items: center; gap: 11px; } -.admin-user > b { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: 10px; color: #fff; background: #0d72d9; font-size: 13px; box-shadow: none; } -.admin-user > span { display: grid; min-width: 0; gap: 3px; } -.admin-user strong { color: #34383d; font-size: 13px; } -.admin-user small { color: #8a9199; font-size: 11px; } -.admin-user .admin-user__credits { color: #597793; font-size: 11px; } -.admin-user__credits b { color: var(--admin-blue); font-weight: 700; } -.admin-status { display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 999px; color: #80868e !important; background: #f0f2f4; font-size: 10px !important; font-weight: 700; } -.admin-status i { width: 6px; height: 6px; border-radius: 50%; background: #a8b0ba; } -.admin-status.is-active { color: #27835a !important; background: #e9f8f0; } -.admin-status.is-active i { background: #33a66f; box-shadow: 0 0 0 3px rgba(51, 166, 111, .12); } -.admin-row-actions { opacity: 1; } -.admin-page .admin-row-actions button:not(.danger), .admin-page .invitation-list button { - min-height: 30px; - padding: 5px 10px; - border: 1px solid #d8e1eb; - border-radius: 9px; - color: #627083; - background: #fff; - font-size: 10px; -} -.admin-page .admin-row-actions button:not(.danger):hover:not(:disabled) { border-color: #b9d7f5; color: var(--admin-blue); background: #f3f9ff; } -.admin-page .admin-row-actions button.danger { min-height: 30px; padding: 5px 9px; border: 1px solid transparent; border-radius: 9px; color: #d45c65; background: transparent; font-size: 10px; cursor: pointer; } -.admin-page .admin-row-actions button.danger:hover:not(:disabled), .admin-page .invitation-list button:hover:not(:disabled) { border-color: #f5d0d4; color: #c84853; background: #fff5f6; } - -.admin-page .admin-invitation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0; padding: 18px; border-bottom: 1px solid #eceef1; } -.admin-page .admin-invitation-form label { gap: 7px; color: #6f767f; font-size: 11px; font-weight: 600; } -.admin-page .admin-invitation-form input, .admin-page .admin-invitation-form select { min-height: 40px; padding: 8px 11px; border-color: #dde1e6; border-radius: 9px; color: #34383d; background: #f7f8fa; font-size: 12px; } -.admin-page .admin-invitation-form input:focus, .admin-page .admin-invitation-form select:focus { border-color: #9fc8f1; background: #fff; box-shadow: 0 0 0 3px rgba(13, 114, 217, .09); } -.admin-page .admin-invitation-form .button { min-height: 40px; align-self: end; gap: 5px; border-color: var(--admin-blue); border-radius: 9px; background: var(--admin-blue); font-size: 12px; font-weight: 700; box-shadow: none; } -.admin-page .admin-invitation-form .button:hover:not(:disabled) { border-color: var(--admin-blue-deep); color: #fff; background: var(--admin-blue-deep); } -.admin-page .admin-generated-codes { margin: 0; padding: 16px 18px; border-bottom: 1px solid #eceef1; font-size: 11px; } -.admin-generated-codes__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.admin-page .admin-generated-codes textarea { border-radius: 9px; background: #f7f8fa; font-size: 14px; } -.admin-page .invitation-list > div { min-height: 62px; padding: 10px 18px; border-bottom-color: #eceef1; } -.admin-code-line { display: flex; min-width: 0; align-items: center; gap: 8px; } -.admin-page .invitation-list strong { min-width: 0; color: #34383d; font-family: Consolas, "SFMono-Regular", monospace; font-size: 15px; letter-spacing: .035em; } -.admin-page .invitation-list small { color: #8a9199; font-size: 11px; } -.admin-page .invitation-list button { border-color: transparent; color: #d45c65; background: transparent; } -.admin-page .invitation-list button.admin-copy-code, -.admin-page .admin-generated-codes button.admin-copy-code { - min-height: 27px; - flex: 0 0 auto; - padding: 4px 8px; - border: 0; - border-radius: 7px; - color: var(--admin-blue); - background: transparent; - cursor: pointer; - font-size: 11px; - font-weight: 700; -} -.admin-page .invitation-list button.admin-copy-code:hover:not(:disabled), -.admin-page .admin-generated-codes button.admin-copy-code:hover:not(:disabled) { border-color: transparent; color: var(--admin-blue-deep); background: transparent; } -.admin-empty { display: grid; min-height: 130px; place-items: center; align-content: center; gap: 8px; margin: 0; color: #9298a0; font-size: 12px; } -.admin-empty span { color: #bfd5ea; font-size: 18px; letter-spacing: .3em; } -.admin-page .admin-content > .status-line { margin-top: 16px; border: 1px solid #dbe7f3; border-left: 3px solid var(--admin-blue); border-radius: 11px; color: #687586; background: rgba(245, 250, 255, .92); } -.admin-page .admin-content > .status-line--error { border-color: #f2c8cd; border-left-color: var(--danger); color: #c45656; background: #fff5f6; } -.admin-confirm-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(22, 27, 34, .42); backdrop-filter: blur(5px); } -.admin-confirm { width: min(420px, 100%); padding: 26px; border: 1px solid #e1e4e8; border-radius: 18px; background: #fff; box-shadow: 0 24px 70px -24px rgba(16, 24, 40, .42); } -.admin-confirm__eyebrow { display: block; margin-bottom: 8px; color: #c5535e; font-size: 11px; font-weight: 700; letter-spacing: .08em; } -.admin-confirm h2 { margin: 0; color: #25292e; font-size: 21px; } -.admin-confirm > p { margin: 12px 0 0; color: #6d747d; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } -.admin-confirm > p strong { color: #34383d; } -.admin-confirm .admin-confirm__error { padding: 9px 11px; border-radius: 9px; color: #b9434e; background: #fff2f3; } -.admin-password-reset { width: min(460px, 100%); } -.admin-password-reset label { display: grid; gap: 7px; margin-top: 16px; color: #4f5864; font-size: 12px; font-weight: 700; } -.admin-password-reset input { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid #d9e0e8; border-radius: 11px; color: #25292e; background: #f8fafc; outline: none; } -.admin-password-reset input:focus { border-color: var(--admin-blue); background: #fff; box-shadow: 0 0 0 3px rgba(13, 114, 217, .12); } -.admin-confirm__actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; } -.admin-confirm__actions .button { min-width: 0; min-height: 38px; flex: 1 1 0; border-radius: 10px; } -:root[data-theme="dark"] .admin-confirm { border-color: #424850; background: #292e35; } -:root[data-theme="dark"] .admin-confirm h2, :root[data-theme="dark"] .admin-confirm > p strong { color: #edf3f9; } -:root[data-theme="dark"] .admin-confirm > p { color: #b1bac5; } -:root[data-theme="dark"] .admin-confirm .admin-confirm__error { color: #ff9ca5; background: #432b30; } -:root[data-theme="dark"] .admin-password-reset label { color: #bac4cf; } -:root[data-theme="dark"] .admin-password-reset input { border-color: #444c57; color: #edf3f9; background: #242a31; } -:root[data-theme="dark"] .admin-password-reset input:focus { border-color: #4598e7; background: #20262d; } - -:root[data-theme="dark"] .admin-page { background: #20242a; } -:root[data-theme="dark"] .admin-page .admin-header { border-color: #363c45; background: rgba(31, 35, 41, .9); } -:root[data-theme="dark"] .admin-brand strong, :root[data-theme="dark"] .admin-hero h1 { color: #edf3f9; } -:root[data-theme="dark"] .admin-page .admin-header__actions .button { border-color: #414852; color: #b8c0ca; background: #292e35; } -:root[data-theme="dark"] .admin-page .admin-header__actions .admin-logout { background: transparent; } -:root[data-theme="dark"] .admin-page .admin-grid > .workspace-card { border-color: #373e48; background: rgba(42, 47, 55, .94); box-shadow: none; } -:root[data-theme="dark"] .admin-page .admin-grid article > .admin-card__header { border-color: #3a414a; background: linear-gradient(180deg, #2c323a, #292e35); } -:root[data-theme="dark"] .admin-card__header h2, :root[data-theme="dark"] .admin-user strong, :root[data-theme="dark"] .admin-page .invitation-list strong { color: #dce4ec; } -:root[data-theme="dark"] .admin-page .admin-table > div, :root[data-theme="dark"] .admin-page .admin-invitation-form, :root[data-theme="dark"] .admin-page .invitation-list > div { border-color: #383f48; } -:root[data-theme="dark"] .admin-page .admin-table > div:hover, :root[data-theme="dark"] .admin-page .invitation-list > div:hover { background: #303740; } -:root[data-theme="dark"] .admin-page .admin-row-actions button:not(.danger), :root[data-theme="dark"] .admin-page .admin-invitation-form input, :root[data-theme="dark"] .admin-page .admin-invitation-form select { border-color: #444c57; color: #c4ccd5; background: #242a31; } -:root[data-theme="dark"] .admin-page .admin-content > .status-line { border-color: #3b5066; border-left-color: #4598e7; color: #b9c7d5; background: #273441; } - -@media (max-width: 1040px) { - .admin-hero { align-items: stretch; flex-direction: column; } - .admin-page .admin-grid { grid-template-columns: 1fr; } - .admin-page .admin-grid > .workspace-card { height: auto; } - .admin-page .admin-table, .admin-page .invitation-list { max-height: 420px; } -} - -@media (max-width: 680px) { - .admin-page .admin-header { min-height: 64px; padding: 10px 16px; align-items: center; flex-direction: row; } - .admin-brand strong { font-size: 17px; } - .admin-page .admin-header__actions .button { min-height: 36px; padding: 6px 10px; font-size: 11px; } - .admin-page .admin-content { padding: 28px 16px 52px; } - .admin-hero { gap: 22px; margin-bottom: 20px; } - .admin-page .admin-grid > .workspace-card { border-radius: 16px; } - .admin-page .admin-table > div { grid-template-columns: minmax(0, 1fr) auto; padding: 12px 15px; } - .admin-row-actions { grid-column: 1 / -1; opacity: 1; } - .admin-page .admin-invitation-form { grid-template-columns: 1fr; padding: 17px 15px; } - .admin-page .invitation-list > div { padding-inline: 15px; } -} - -/* Public landing page (homepage for unauthenticated visitors). */ -.landing { - display: grid; - min-height: 100vh; - grid-template-rows: auto 1fr auto; - color: var(--ink); - background: var(--canvas); -} -.landing-nav { - position: sticky; - z-index: 50; - top: 0; - border-bottom: 1px solid var(--rule); - background: color-mix(in srgb, var(--canvas) 88%, transparent); - backdrop-filter: blur(12px); -} -.landing-nav__progress { - position: absolute; - right: 0; - bottom: -1px; - left: 0; - height: 2px; - background: linear-gradient(90deg, #3ca7ff, #0d72d9 58%, #6b5de8); - box-shadow: 0 0 8px rgba(13, 114, 217, .3); - pointer-events: none; - transform: scaleX(0); - transform-origin: left center; - will-change: transform; -} -.landing-nav__inner { - display: flex; - align-items: center; - justify-content: center; - gap: 24px; - width: min(1440px, 100%); - margin: 0 auto; - padding: 14px clamp(28px, 5vw, 76px); -} -.landing-brand { - position: absolute; - left: clamp(28px, 5vw, 76px); - display: inline-flex; - align-items: center; - gap: 11px; - color: var(--ink); - text-decoration: none; -} -.landing-brand .brand-mark { - width: 34px; - height: 24px; - color: #0D72D9; -} -.landing-brand > span { - display: flex; - min-width: 0; - flex-direction: column; -} -.landing-brand strong { - font-size: 20px; - letter-spacing: .08em; -} -.landing-brand small { - color: var(--muted); - font-size: 11px; -} -.landing-nav__links { - display: flex; - align-items: center; - gap: 8px; -} -.landing-nav__links a { - padding: 8px 12px; - border-radius: 999px; - color: #000; - text-decoration: none; - font-size: 14px; - font-weight: 700; - font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; - transition: color 140ms ease, background-color 140ms ease; -} -.landing-nav__links a:hover { - color: #000; - background: var(--surface-soft); -} -.landing-nav__actions { - position: absolute; - right: clamp(28px, 5vw, 76px); - display: flex; - align-items: center; - gap: 8px; -} -.landing-workspace-link { - display: inline-flex; - min-height: 36px; - padding: 4px 5px 4px 13px; - align-items: center; - gap: 9px; - border: 1px solid color-mix(in srgb, var(--rule) 88%, transparent); - border-radius: 12px; - color: var(--ink); - background: color-mix(in srgb, var(--surface) 72%, transparent); - box-shadow: 0 5px 16px -14px rgba(25, 35, 45, .7); - font-size: 12px; - font-weight: 700; - text-decoration: none; - backdrop-filter: blur(10px); - transition: border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease, transform 180ms var(--ease-out); -} -.landing-workspace-link svg { - width: 26px; - height: 26px; - padding: 5px; - border-radius: 8px; - stroke: currentColor; - stroke-width: 1.6; - stroke-linecap: round; - stroke-linejoin: round; - fill: none; - background: color-mix(in srgb, var(--surface-soft) 82%, transparent); - transition: color 180ms ease, background-color 180ms ease, transform 180ms var(--ease-out); -} -.landing-workspace-link:hover { - border-color: color-mix(in srgb, #0d72d9 36%, var(--rule)); - background: var(--surface); - box-shadow: 0 9px 24px -18px rgba(13, 114, 217, .65); - transform: translateY(-1px); -} -.landing-workspace-link:hover svg { - color: #0d72d9; - background: color-mix(in srgb, #0d72d9 8%, var(--surface)); - transform: translateX(2px); -} -.landing-hero { - position: relative; - overflow: hidden; - border-bottom: 1px solid var(--rule); - color: var(--ink); - background: - radial-gradient(circle at 18% 28%, rgba(60, 167, 255, .1), transparent 32%), - radial-gradient(circle at 82% 24%, rgba(118, 87, 200, .08), transparent 34%), - var(--canvas); -} -.landing-hero::before, -.landing-hero::after { - position: absolute; - z-index: 0; - border-radius: 50%; - opacity: .55; - pointer-events: none; - filter: blur(90px); - content: ""; -} -.landing-hero::before { - top: -180px; - left: -120px; - width: 420px; - height: 420px; - background: rgba(60, 167, 255, .18); - animation: landing-float-one 9s ease-in-out infinite alternate; -} -.landing-hero::after { - bottom: -220px; - right: -140px; - width: 480px; - height: 480px; - background: rgba(107, 93, 232, .16); - animation: landing-float-two 11s ease-in-out infinite alternate; -} -@keyframes landing-float-one { - from { transform: translate3d(0, 0, 0) scale(.92); } - to { transform: translate3d(60px, 40px, 0) scale(1.08); } -} -@keyframes landing-float-two { - from { transform: translate3d(0, 0, 0) scale(.94); } - to { transform: translate3d(-50px, -30px, 0) scale(1.1); } -} -.landing-hero > * { - position: relative; - z-index: 1; -} -.landing-hero__inner { - display: grid; - justify-items: center; - gap: 22px; - width: min(980px, 100%); - margin: 0 auto; - padding: clamp(56px, 9vh, 96px) clamp(28px, 5vw, 76px); - text-align: center; -} -.landing-hero__eyebrow { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - border: 1px solid #0D72D9; - border-radius: 999px; - color: var(--muted); - background: color-mix(in srgb, var(--surface) 68%, transparent); - font-size: 12px; - font-weight: 700; - letter-spacing: .1em; -} -.landing-hero__eyebrow-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #0D72D9; -} -.landing-hero h1 { - margin: 0; - font-size: clamp(44px, 5.6vw, 76px); - line-height: 1.05; - letter-spacing: -.045em; -} -.landing-hero__title { - display: grid; - width: 100%; - height: 2.32em; - place-items: center; - text-align: center; -} -.landing-hero__typed { - --typewriter-accent: #0d72d9; - display: block; - width: max-content; - max-width: 100%; - text-align: left; - white-space: nowrap; -} -.landing-hero__typed--violet { - --typewriter-accent: #6b5de8; -} -.landing-hero__typed--teal { - --typewriter-accent: #0f9f8f; -} -.landing-hero__typed--rose { - --typewriter-accent: #d95a67; -} -.landing-hero__typed--amber { - --typewriter-accent: #b86a0f; -} -.landing-hero__typed--final { - --typewriter-accent: #0f9f8f; - line-height: 1.16; -} -.landing-hero__typewriter-char { - display: inline-block; - animation: landing-typewriter-char-in 160ms var(--ease-out) both; -} -.landing-hero__typewriter-char--brand { - color: #0d72d9; -} -.landing-hero__typewriter-char--accent { - color: var(--typewriter-accent); -} -.landing-hero__typed--final .landing-hero__typewriter-char--accent { - background-image: - linear-gradient(115deg, transparent 40%, rgba(255, 255, 255, .9) 50%, transparent 60%), - linear-gradient(100deg, #0f766e, #10b981 34%, #84cc16 67%, #0891b2); - background-size: 260% 100%, 400% 100%; - background-position: 100% 50%, calc(var(--typewriter-char-index) * 33.333%) 50%; - -webkit-background-clip: text; - background-clip: text; - color: transparent; - -webkit-text-fill-color: transparent; - animation: - landing-typewriter-char-in 160ms var(--ease-out) both, - landing-typewriter-shimmer 5s linear calc(1.8s + var(--typewriter-char-index) * 150ms) infinite; -} -.landing-hero__typewriter-caret { - display: inline-block; - width: .075em; - height: .88em; - margin-left: .08em; - border-radius: 999px; - background: var(--typewriter-accent); - vertical-align: -.06em; - animation: landing-typewriter-caret 760ms step-end infinite; -} -@keyframes landing-typewriter-char-in { - from { opacity: 0; transform: translateY(.16em) scale(.96); } - to { opacity: 1; transform: translateY(0) scale(1); } -} -@keyframes landing-typewriter-caret { - 0%, 46% { opacity: 1; } - 47%, 100% { opacity: 0; } -} -@keyframes landing-typewriter-shimmer { - 0% { - background-position: 115% 50%, calc(var(--typewriter-char-index) * 33.333%) 50%; - } - 40%, 100% { - background-position: -15% 50%, calc(var(--typewriter-char-index) * 33.333%) 50%; - } -} -.landing-hero__subtitle { - margin: 0; - max-width: 680px; - color: var(--muted); - font-size: clamp(15px, 1.7vw, 17px); - line-height: 1.7; -} -.landing-hero__actions { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 12px; - margin-top: 8px; -} -.landing-hero__cta { - min-height: 50px; - padding: 10px 22px; - font-size: 16px; - border-radius: 999px; -} -.landing-glass-button { - position: relative; - overflow: hidden; - isolation: isolate; - backdrop-filter: blur(22px) saturate(145%) contrast(102%); - -webkit-backdrop-filter: blur(22px) saturate(145%) contrast(102%); - transition: transform 180ms ease, box-shadow 220ms ease, border-color 180ms ease, filter 180ms ease; -} -.landing-hero__actions .landing-glass-button { - border-radius: 999px; -} -.landing-glass-button::before { - position: absolute; - z-index: -1; - inset: 0; - border-radius: inherit; - opacity: .46; - background: - radial-gradient(circle at 18% 8%, rgba(255, 255, 255, .58), transparent 42%), - linear-gradient(115deg, rgba(255, 255, 255, .16), transparent 42% 70%, rgba(255, 255, 255, .1)); - transition: opacity 220ms ease; - content: ""; - pointer-events: none; -} -.landing-glass-button::after { - position: absolute; - z-index: -1; - top: 1px; - right: 10%; - left: 10%; - height: 42%; - border-radius: 999px; - background: linear-gradient(180deg, rgba(255, 255, 255, .42), rgba(255, 255, 255, 0)); - content: ""; - opacity: .48; - pointer-events: none; -} -.landing-glass-button:hover::before { - opacity: 1; -} -.landing-glass-button:hover { - filter: saturate(104%); - transform: translateY(-2px); -} -.landing-glass-button:active { - transform: translateY(0); -} -.button--primary.landing-glass-button { - border-color: rgba(79, 116, 190, .24); - color: #075eae; - background: - linear-gradient(135deg, rgba(60, 167, 255, .16), rgba(107, 93, 232, .1) 56%, rgba(164, 148, 196, .06)), - color-mix(in srgb, var(--surface) 72%, transparent); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, .68), - inset 0 -1px 0 rgba(59, 72, 128, .06), - 0 12px 28px -20px rgba(61, 91, 165, .36); -} -.button--primary.landing-glass-button:hover { - border-color: rgba(66, 100, 178, .36); - background: - linear-gradient(135deg, rgba(60, 167, 255, .21), rgba(107, 93, 232, .14) 56%, rgba(164, 148, 196, .08)), - color-mix(in srgb, var(--surface) 74%, transparent); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, .76), - inset 0 -1px 0 rgba(59, 72, 128, .07), - 0 15px 32px -20px rgba(61, 91, 165, .46); -} -.button--light.landing-glass-button { - border-color: rgba(119, 103, 157, .22); - color: #5f5193; - background: - linear-gradient(135deg, rgba(107, 93, 182, .1), rgba(176, 131, 157, .07) 52%, rgba(71, 164, 176, .07)), - color-mix(in srgb, var(--surface) 75%, transparent); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, .66), - inset 0 -1px 0 rgba(103, 87, 135, .05), - 0 12px 28px -20px rgba(98, 82, 138, .3); -} -.button--light.landing-glass-button:hover { - border-color: rgba(112, 94, 151, .34); - background: - linear-gradient(135deg, rgba(107, 93, 182, .14), rgba(176, 131, 157, .1) 52%, rgba(71, 164, 176, .1)), - color-mix(in srgb, var(--surface) 77%, transparent); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, .74), - inset 0 -1px 0 rgba(103, 87, 135, .06), - 0 15px 32px -20px rgba(98, 82, 138, .4); -} -.landing-hero__status { - margin: 0; - color: var(--muted); - font-size: 12px; -} -.landing-hero__preview { - border-top: 1px solid var(--rule); - background: color-mix(in srgb, var(--surface) 52%, transparent); -} -.landing-hero__preview-inner { - width: min(1200px, 100%); - margin: 0 auto; - padding: clamp(28px, 4vw, 48px) clamp(28px, 5vw, 76px); -} -.landing-app-mock { - display: grid; - grid-template-rows: auto 1fr; - min-height: 420px; - border: 1px solid var(--rule); - border-radius: 28px; - background: #ffffff; - color: #303236; - box-shadow: 0 30px 80px -30px rgba(20, 22, 30, .18); - overflow: hidden; - font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; - transition: transform 180ms ease, box-shadow 180ms ease; -} -.landing-app-mock:hover { - transform: translateY(-4px); - box-shadow: 0 40px 100px -30px rgba(20, 22, 30, .28); -} -.landing-app-mock--tilted { - transform: perspective(1200px) rotateY(-8deg) rotateX(4deg); - transform-origin: center center; -} -.landing-app-mock--tilted:hover { - transform: perspective(1200px) rotateY(-8deg) rotateX(4deg) translateY(-4px); -} -.landing-app-mock__chrome { - position: relative; - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - padding: 12px 18px; - border-bottom: 1px solid rgba(0,0,0,.06); - background: #f5f5f5; - font-size: 12px; - color: #303236; -} -.landing-app-mock__dots { - position: absolute; - left: 18px; - display: inline-flex; - align-items: center; - gap: 6px; -} -.landing-app-mock__dots > span { - width: 10px; - height: 10px; - border-radius: 50%; - display: block; -} -.landing-app-mock__dot--red { background: #ff5f57; } -.landing-app-mock__dot--yellow { background: #febc2e; } -.landing-app-mock__dot--green { background: #28c840; } -.landing-app-mock__url { - padding: 4px 12px; - border-radius: 999px; - background: #ffffff; - color: rgba(0,0,0,.5); - font-size: 11px; -} -.landing-app-mock__stage { - flex: 1 1 auto; - min-height: 0; - display: grid; - place-items: center; - background: #ffffff; - overflow: hidden; -} -.landing-app-mock__picture { - display: block; - width: 100%; - height: 100%; -} -.landing-app-mock__scene-img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} -.landing-features { - padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); - border-bottom: 1px solid var(--rule); -} -.landing-features__inner { - display: grid; - justify-items: center; - gap: 22px; - width: min(1200px, 100%); - margin: 0 auto; - text-align: center; -} -.landing-feature-split { - display: grid; - grid-template-columns: minmax(0, .9fr) minmax(0, 1fr); - gap: clamp(40px, 6vw, 80px); - align-items: center; -} -.landing-feature-split__copy { - display: grid; - gap: 18px; - text-align: left; -} -.landing-feature-split__copy h2 { - font-size: clamp(32px, 2.8vw, 40px); - line-height: 1.2; - letter-spacing: -.02em; -} -.landing-features__intro { - max-width: 520px; - color: var(--muted); - font-size: 16px; - line-height: 1.7; -} -.landing-feature-list { - display: grid; - gap: 12px; - margin-top: 8px; - padding: 0; - list-style: none; -} -.landing-feature-list li { - display: grid; - grid-template-columns: 10px minmax(0, 1fr); - gap: 12px; - align-items: start; -} -.landing-feature-list__icon { - display: grid; - width: 10px; - height: 10px; - place-items: center; - border-radius: 50%; - background: #0D72D9; - margin-top: 7px; -} -.landing-feature-list__icon svg { - display: none; -} -.landing-feature-list strong { - display: block; - font-size: 14px; - margin-bottom: 2px; -} -.landing-feature-list p { - margin: 0; - color: var(--muted); - font-size: 13px; - line-height: 1.55; -} -.landing-feature-split__visual { - display: grid; - justify-items: center; -} -.landing-visual-card { - width: 100%; - border: 1px solid var(--rule); - border-radius: 20px; - background: var(--surface); - box-shadow: 0 20px 60px -20px rgba(40, 40, 44, .22); - overflow: hidden; -} -.landing-visual-card__header { - display: flex; - align-items: center; - gap: 10px; - padding: 14px 18px; - border-bottom: 1px solid var(--rule); - background: color-mix(in srgb, var(--surface-soft) 52%, transparent); -} -.landing-visual-card__dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #0D72D9; -} -.landing-visual-card__title { - font-size: 13px; - font-weight: 700; -} -.landing-visual-card__body { - padding: 24px; - display: grid; - gap: 16px; -} -.landing-visual-card__dialogue { - display: flex; - gap: 12px; - align-items: flex-start; -} -.landing-visual-card__avatar { - width: 32px; - height: 32px; - border-radius: 10px; - background: linear-gradient(145deg, #0D72D9, #6B5DE8); - color: #fff; - display: grid; - place-items: center; - font-size: 13px; - font-weight: 700; - flex: 0 0 32px; -} -.landing-visual-card__bubble { - padding: 12px 16px; - border-radius: 14px; - background: var(--surface-soft); - border-bottom-left-radius: 4px; -} -.landing-visual-card__bubble p { - margin: 0; - font-size: 13px; - line-height: 1.5; -} -.landing-visual-card__choices { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 10px; - margin-top: 4px; -} -.landing-visual-card__choice { - height: 44px; - border-radius: 12px; - border: 1px solid var(--rule); - background: var(--surface); -} - -.section-label { - display: inline-flex; - padding: 5px 10px; - border: 1px solid var(--rule); - border-radius: 999px; - color: var(--muted); - background: color-mix(in srgb, var(--surface) 68%, transparent); - font-size: 12px; - font-weight: 700; - letter-spacing: .1em; -} - -.landing-characters { - padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); - border-bottom: 1px solid var(--rule); - background: color-mix(in srgb, var(--surface) 48%, transparent); -} -.landing-characters__inner { - display: grid; - justify-items: center; - gap: 22px; - width: min(1200px, 100%); - margin: 0 auto; - text-align: center; -} -.landing-characters__inner > h2, -.landing-workflow__inner > h2, -.landing-faq__inner > h2 { - margin: 0; - font-size: clamp(34px, 3.6vw, 52px); - line-height: 1.14; - letter-spacing: -.035em; -} -.landing-faq__inner > h2 { - transform: translateY(-8px); -} -.landing-characters__intro { - max-width: 760px; - color: var(--muted); - font-size: 16px; - line-height: 1.7; -} -.landing-characters__grid { - display: grid; - width: 100%; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 16px; - margin-top: 18px; -} -.landing-character-card { - display: grid; - grid-template-rows: 360px auto; - overflow: hidden; - border: 1px solid var(--rule); - border-radius: 20px; - background: var(--surface); - box-shadow: 0 4px 10px -6px rgba(40, 40, 44, .18); - text-align: left; - transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms var(--ease-out); -} -.landing-character-card:hover { - border-color: var(--surface-strong); - box-shadow: 0 12px 28px -14px rgba(40, 40, 44, .28); - transform: translateY(-3px); -} -.landing-character-card__portrait { - position: relative; - display: grid; - overflow: hidden; - place-items: end center; - border-bottom: 1px solid color-mix(in srgb, currentColor 12%, var(--rule)); - background-image: - radial-gradient(circle at 50% 42%, color-mix(in srgb, currentColor 17%, transparent), transparent 62%), - linear-gradient(155deg, rgba(255, 255, 255, .62), transparent 68%); - isolation: isolate; -} -.landing-character-card__portrait::before { - position: absolute; - bottom: -76px; - left: 50%; - width: 270px; - height: 270px; - border: 1px solid currentColor; - border-radius: 50%; - content: ''; - opacity: .09; - transform: translateX(-50%); -} -.landing-character-card__portrait img { - position: absolute; - inset: 10px 10px 0; - z-index: 1; - width: calc(100% - 20px); - height: calc(100% - 10px); - object-fit: contain; - object-position: center bottom; - filter: drop-shadow(0 16px 18px rgba(20, 28, 45, .14)); - transition: transform 300ms var(--ease-out), filter 300ms ease; -} -.landing-character-card:hover .landing-character-card__portrait img { - filter: drop-shadow(0 20px 22px rgba(20, 28, 45, .19)); - transform: translateY(-3px) scale(1.018); -} -.landing-character-card__portrait .landing-character-card__portrait-img--su { - transform: translateX(-4%); - transform-origin: center bottom; -} -.landing-character-card:hover .landing-character-card__portrait .landing-character-card__portrait-img--su { - transform: translateX(-4%) translateY(-3px) scale(1.018); -} -.landing-character-card__copy { - display: grid; - align-content: start; - gap: 8px; - padding: 22px 24px 26px; -} -.landing-character-card__role { - color: var(--muted); - font-size: 12px; -} -.landing-character-card h3 { - margin: 0; - font-size: 17px; -} -.landing-character-card p { - margin: 0; - color: var(--muted); - font-size: 13px; - line-height: 1.65; -} - -.landing-workflow { - padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); - border-bottom: 1px solid var(--rule); -} -.landing-workflow__inner { - display: grid; - justify-items: start; - gap: 22px; - width: min(980px, 100%); - margin: 0 auto; - text-align: left; -} -.landing-workflow__intro { - max-width: 620px; - color: var(--muted); - font-size: 16px; - line-height: 1.7; -} -.landing-workflow__timeline { - display: grid; - width: 100%; - gap: 0; - margin-top: 18px; - text-align: left; - position: relative; -} -.landing-workflow-step { - display: grid; - grid-template-columns: 64px minmax(0, 1fr); - gap: 0; - text-align: left; -} -.landing-workflow-step__marker { - display: grid; - justify-items: center; - padding-top: 4px; -} -.landing-workflow-step__num { - display: grid; - width: 48px; - height: 48px; - place-items: center; - border-radius: 16px; - color: #0D72D9; - background: #e8f4fd; - font-size: 18px; - font-weight: 800; -} -.landing-workflow-step:first-child .landing-workflow-step__marker::after { - content: none; -} -.landing-workflow-step__marker::after { - content: ""; - display: block; - width: 2px; - flex: 1 1 auto; - background: #0D72D9; - margin-top: 12px; - opacity: .25; -} -.landing-workflow-step:last-child .landing-workflow-step__marker::after { - display: none; -} -.landing-workflow-step__copy { - padding: 0 0 40px 24px; -} -.landing-workflow-step:last-child .landing-workflow-step__copy { - padding-bottom: 0; -} -.landing-workflow-step h3 { - margin: 0 0 6px; - font-size: 20px; - font-weight: 700; - color: #000; -} -.landing-workflow-step p { - margin: 0; - color: var(--muted); - font-size: 14px; - line-height: 1.7; -} - -.landing-faq { - padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); - border-bottom: 1px solid var(--rule); - background: color-mix(in srgb, var(--surface) 48%, transparent); -} -.landing-faq__inner { - display: grid; - justify-items: center; - gap: 22px; - width: min(980px, 100%); - margin: 0 auto; - text-align: center; -} -.landing-faq__list { - display: grid; - width: 100%; - gap: 10px; - margin-top: 18px; - text-align: left; -} -.landing-faq-item { - display: grid; - gap: 10px; - padding: 18px 20px; - border: 1px solid var(--rule); - border-radius: 16px; - background: var(--surface); - box-shadow: 0 4px 10px -6px rgba(40, 40, 44, .18); - transition: border-color 160ms ease, box-shadow 160ms ease; -} -.landing-faq-item[open] { - border-color: #0D72D9; -} -.landing-faq-item summary { - cursor: pointer; - font-weight: 700; - list-style: none; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - color: var(--ink); - transition: color 140ms ease; -} -.landing-faq-item summary:hover { - color: #0D72D9; -} -.landing-faq-item summary::after { - content: "+"; - display: grid; - width: 24px; - height: 24px; - flex: 0 0 24px; - place-items: center; - border-radius: 8px; - color: var(--muted); - background: var(--surface-soft); - font-size: 18px; - font-weight: 700; - transition: transform 160ms ease, background-color 160ms ease; -} -.landing-faq-item[open] summary::after { - content: "-"; - transform: rotate(180deg); -} -.landing-faq-item p { - margin: 0; - color: var(--muted); - font-size: 13px; - line-height: 1.65; -} - -.landing-cta { - padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); - background: - radial-gradient(circle at 50% 50%, rgba(60, 167, 255, .12), transparent 38%), - var(--canvas); - border-bottom: 1px solid var(--rule); -} -.landing-cta__inner { - display: grid; - justify-items: center; - gap: 18px; - width: min(860px, 100%); - margin: 0 auto; - text-align: center; -} -.landing-cta h2 { - margin: 0; - font-size: clamp(28px, 3.4vw, 40px); - letter-spacing: -.03em; -} -.landing-cta p { - margin: 0; - max-width: 620px; - color: var(--muted); - font-size: 16px; - line-height: 1.7; -} -.landing-cta__actions { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 12px; - margin-top: 8px; -} -.landing-cta .landing-glass-button { - border-radius: 999px; -} - -.landing-footer { - padding: 64px clamp(28px, 5vw, 76px) 32px; - border-top: 1px solid #2c2f33; - background: #1a1c1f; - color: rgba(255, 255, 255, .7); -} -.landing-footer__inner { - display: grid; - grid-template-columns: 1.2fr repeat(3, auto); - gap: 40px; - width: min(1440px, 100%); - margin: 0 auto; -} -.landing-footer__brand { - display: inline-flex; - align-items: center; - gap: 10px; - color: #fff; - text-decoration: none; -} -.landing-footer__brand .brand-mark { - width: 28px; - height: 20px; - color: #0D72D9; -} -.landing-footer__brand strong { - font-size: 14px; - color: #fff; -} -.landing-footer__brand p { - margin: 6px 0 0; - color: rgba(255, 255, 255, .5); - font-size: 12px; - line-height: 1.55; -} -.landing-footer__columns { - display: flex; - gap: 48px; -} -.landing-footer__columns h4 { - margin: 0 0 12px; - font-size: 12px; - font-weight: 700; - color: #fff; - letter-spacing: .08em; -} -.landing-footer__columns nav { - display: grid; - gap: 8px; -} -.landing-footer__columns a { - color: rgba(255, 255, 255, .6); - text-decoration: none; - font-size: 13px; - transition: color 140ms ease; -} -.landing-footer__columns a:hover { - color: #fff; -} -.landing-footer__copy { - grid-column: 1 / -1; - color: rgba(255, 255, 255, .35); - font-size: 12px; - margin-top: 32px; - padding-top: 20px; - border-top: 1px solid rgba(255, 255, 255, .08); -} - -@media (max-width: 1040px) { - .home-intelligence-flow { grid-template-columns: 1fr; } -} - -@media (max-width: 620px) { - .home-intelligence-flow > div { grid-template-columns: 1fr; } -} - -/* Recite-first pages: reuse GalReview surfaces and typography; no AI-styled decoration. */ -.practice-project-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(310px, .65fr); gap: 20px; align-items: start; } -.practice-project-side { display: grid; gap: 20px; align-self: start; } -.practice-mode-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; } -.practice-mode-grid article { display: grid; align-content: start; justify-items: start; gap: 12px; min-height: 230px; padding: 22px; border: 1px solid var(--rule); border-radius: var(--radius-card); background: var(--surface); } -.practice-mode-grid h2, .practice-tools h2 { margin: 0; } -.practice-mode-grid p { min-height: 72px; margin: 0; color: var(--muted); line-height: 1.65; } -.practice-tools { margin-bottom: 20px; } -.practice-readiness-note { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 20px; padding: 18px 22px; border: 1px solid var(--rule); border-left: 3px solid var(--surface-strong); background: var(--surface-soft); } -.practice-readiness-note > div { display: grid; gap: 6px; } -.practice-readiness-note strong { font-size: 17px; } -.practice-readiness-note p { max-width: 760px; margin: 0; color: var(--muted); line-height: 1.65; } -.practice-readiness-note .button { flex: 0 0 auto; } -.practice-tools > div { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-top: 18px; } -.practice-tools label { display: grid; gap: 6px; min-width: 210px; color: var(--muted); font-size: 12px; } -.practice-tools select, .practice-tools input, .practice-resource-search input { min-height: 42px; padding: 8px 12px; border: 1px solid var(--rule); border-radius: 12px; color: var(--ink); background: var(--field); } -.practice-project-list, .practice-question-list { display: grid; margin-top: 22px; border-top: 1px solid var(--rule); } -.practice-project-list > a { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px 4px; border-bottom: 1px solid var(--rule); color: var(--ink); text-decoration: none; } -.practice-project-list > a:hover { color: #0D72D9; } -.practice-project-list > a > span:first-child { display: grid; gap: 5px; } -.practice-project-list small, .practice-question-list small { color: var(--muted); } -.practice-project-create { position: static; } -.practice-question-create { position: sticky; top: 112px; } -.practice-question-create textarea, .practice-written-answer textarea { width: 100%; padding: 12px 14px; resize: vertical; border: 1px solid var(--rule); border-radius: 16px; color: var(--ink); background: var(--field); line-height: 1.6; } -.practice-question-list article { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 16px 4px; border-bottom: 1px solid var(--rule); } -.practice-question-list article > span { color: var(--muted); font-variant-numeric: tabular-nums; } -.practice-question-list article > div { display: grid; gap: 5px; } -.practice-question-list article > i { color: var(--muted); font-size: 12px; font-style: normal; } -.practice-session-page { width: min(980px, 100%); } -.practice-question-card { padding: clamp(26px, 5vw, 54px); border: 1px solid var(--rule); border-radius: var(--radius-shell); background: var(--surface); } -.practice-question-card > header { display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; letter-spacing: .08em; } -.practice-question-card h2 { margin: 32px 0; font-size: clamp(24px, 3vw, 36px); line-height: 1.45; letter-spacing: -.02em; } -.practice-options { display: grid; gap: 10px; } -.practice-options button { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 12px; align-items: center; min-height: 58px; padding: 10px 16px; border: 1px solid var(--rule); border-radius: 18px; color: var(--ink); background: var(--field); cursor: pointer; text-align: left; } -.practice-options button:hover { border-color: #0D72D9; background: var(--surface); } -.practice-options button b { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid var(--rule); border-radius: 10px; } -.practice-options--binary { grid-template-columns: repeat(2, 1fr); } -.practice-options--binary button { display: block; text-align: center; } -.practice-written-answer { display: grid; gap: 14px; } -.practice-written-answer .button { justify-self: end; } -.practice-result { display: grid; gap: 8px; margin-top: 24px; padding: 22px; border-left: 4px solid var(--surface-strong); background: var(--surface-soft); } -.practice-result.is-correct { border-left-color: #277a55; } -.practice-result.is-wrong { border-left-color: var(--danger); } -.practice-result.is-neutral { border-left-color: var(--surface-strong); } -.practice-result span { color: var(--muted); } -.practice-result .button { justify-self: start; margin-top: 8px; } -.practice-help { display: grid; gap: 10px; margin: 10px 0; padding: 16px; border: 1px solid var(--rule); border-radius: 14px; background: var(--surface); } -.practice-help blockquote { margin: 0; padding-left: 12px; border-left: 2px solid var(--surface-strong); color: var(--ink); line-height: 1.65; } -.practice-help p { margin: 0; color: var(--muted); line-height: 1.65; } -.practice-complete { display: grid; justify-items: start; gap: 18px; } -.practice-complete p { max-width: 640px; color: var(--muted); line-height: 1.7; } -.practice-resource-search { display: flex; align-items: end; gap: 12px; margin-bottom: 20px; } -.practice-resource-search label { display: grid; flex: 1; gap: 7px; color: var(--muted); } -.practice-project-list > article { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px 4px; border-bottom: 1px solid var(--rule); } -.practice-project-list > article > span { display: grid; gap: 5px; } - -@media (max-width: 900px) { - .practice-project-layout { grid-template-columns: 1fr; } - .practice-mode-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .practice-project-create, .practice-question-create { position: static; } -} -@media (max-width: 620px) { - .practice-mode-grid { grid-template-columns: 1fr; } - .practice-mode-grid article { min-height: 0; } - .practice-readiness-note { align-items: stretch; flex-direction: column; } - .practice-readiness-note .button { align-self: start; } - .practice-resource-search { align-items: stretch; flex-direction: column; } -} - -@media (max-width: 1120px) { - .landing-feature-split { - grid-template-columns: 1fr; - } - .landing-feature-split__visual { - display: none; - } - .landing-workflow-step__marker::after { - display: none; - } - .landing-footer__inner { - grid-template-columns: 1fr; - gap: 28px; - } - .landing-footer__columns { - flex-wrap: wrap; - gap: 24px; - } -} -@media (max-width: 760px) { - .landing-nav__inner { - min-height: 64px; - padding: 10px 16px; - justify-content: space-between; - gap: 12px; - } - .landing-brand, - .landing-nav__actions { - position: static; - } - .landing-brand { - flex: 0 1 auto; - } - .landing-brand .brand-mark { - width: 30px; - height: 22px; - } - .landing-brand strong { - font-size: 18px; - } - .landing-workspace-link { - min-height: 38px; - padding-left: 12px; - flex: 0 0 auto; - } - .landing-nav__links { - display: none; - } - .landing-hero__inner { - padding-block: clamp(48px, 8vh, 72px); - padding-inline: clamp(18px, 5vw, 28px); - } - .landing-hero h1 { - font-size: clamp(29px, 9.1vw, 44px); - } - .landing-hero__preview-inner { - grid-template-columns: 1fr; - } - .landing-features__grid, - .landing-characters__grid, - .landing-workflow__steps { - grid-template-columns: 1fr; - } - .landing-character-card { - grid-template-rows: 300px auto; - } - .landing-character-card__copy { - padding: 18px 20px 22px; - } - .landing-workflow-step { - grid-template-columns: 40px 1fr; - gap: 8px; - } - .landing-workflow-step__copy { - padding-left: 10px; - } - .landing-footer__inner { - grid-template-columns: 1fr; - gap: 24px; - justify-items: center; - text-align: center; - } - .landing-footer__columns { - display: grid; - width: 100%; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - } - .landing-footer__brand { - align-items: center; - justify-content: center; - } - .landing-footer__columns nav { - justify-items: center; - } - .landing-footer__copy { - width: 100%; - margin-top: 12px; - text-align: center; - } -} -.landing-app-mock__stage--review { - display: grid; - padding: 0; - background: var(--surface-soft); -} - -.landing-review-mock { - width: 100%; - height: 100%; - padding: clamp(18px, 3vw, 30px); - color: var(--ink); - background: var(--surface); - border: 0; - border-radius: 0; - box-shadow: none; -} - -.landing-review-mock > header { display: grid; gap: 5px; } -.landing-review-mock > header span { color: #0d72d9; font-size: 12px; letter-spacing: .14em; } -.landing-review-mock > header strong { font-size: clamp(20px, 2.4vw, 30px); font-weight: 650; } -.landing-review-mock > header small { color: var(--muted); } -.landing-review-mock__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 22px 0 16px; } -.landing-review-mock__stats span { display: grid; gap: 5px; padding: 13px; background: var(--surface-soft); border: 1px solid var(--rule); border-radius: 12px; } -.landing-review-mock__stats small { color: var(--muted); } -.landing-review-mock__stats strong { font-size: 20px; } -.landing-review-mock__list { display: grid; gap: 8px; } -.landing-review-mock__list > span { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; padding: 11px 12px; border-bottom: 1px solid var(--rule); } -.landing-review-mock__list i { color: var(--muted); font-style: normal; font-variant-numeric: tabular-nums; } -.landing-review-mock__list b { font-weight: 600; } -.landing-review-mock__list small { color: var(--muted); } -.landing-review-mock__actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 18px; } -.landing-review-mock__actions span { padding: 9px 13px; color: #0d72d9; background: color-mix(in srgb, #0d72d9 9%, var(--surface)); border: 1px solid color-mix(in srgb, #0d72d9 22%, var(--rule)); border-radius: 9px; font-size: 13px; } - -@media (max-width: 640px) { - .landing-review-mock__stats { grid-template-columns: 1fr; } - .landing-review-mock__list > span { grid-template-columns: 26px 1fr; } - .landing-review-mock__list small { grid-column: 2; } -} +/* + * 全局样式清单(manifest)。 + * + * 原先 3000+ 行的 global.css 按页面/区域拆分到同目录: + * base.css reset、变量、基础元素、暗色主题、加载过渡 + * workspace.css Koishi 风格学习工作台(AppShell、game-stage、知识图谱视图) + * auth.css 登录 / 注册 / 找回密码 + * admin.css 管理后台 + * landing.css 未登录访客的落地页 + * practice.css 研习(背诵练习)相关页面 + * + * @import 顺序即原文件内的物理顺序,级联顺序与拆分前完全一致; + * 拆分只做了剪切粘贴,未重排、未修改任何规则。 + */ +@import './base.css'; +@import './workspace.css'; +@import './auth.css'; +@import './admin.css'; +@import './landing.css'; +@import './practice.css'; diff --git a/frontend/src/styles/landing.css b/frontend/src/styles/landing.css new file mode 100644 index 0000000..0beb30d --- /dev/null +++ b/frontend/src/styles/landing.css @@ -0,0 +1,1041 @@ +/* Public landing page (homepage for unauthenticated visitors). */ +.landing { + display: grid; + min-height: 100vh; + grid-template-rows: auto 1fr auto; + color: var(--ink); + background: var(--canvas); +} +.landing-nav { + position: sticky; + z-index: 50; + top: 0; + border-bottom: 1px solid var(--rule); + background: color-mix(in srgb, var(--canvas) 88%, transparent); + backdrop-filter: blur(12px); +} +.landing-nav__progress { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + background: linear-gradient(90deg, #3ca7ff, #0d72d9 58%, #6b5de8); + box-shadow: 0 0 8px rgba(13, 114, 217, .3); + pointer-events: none; + transform: scaleX(0); + transform-origin: left center; + will-change: transform; +} +.landing-nav__inner { + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + width: min(1440px, 100%); + margin: 0 auto; + padding: 14px clamp(28px, 5vw, 76px); +} +.landing-brand { + position: absolute; + left: clamp(28px, 5vw, 76px); + display: inline-flex; + align-items: center; + gap: 11px; + color: var(--ink); + text-decoration: none; +} +.landing-brand .brand-mark { + width: 34px; + height: 24px; + color: #0D72D9; +} +.landing-brand > span { + display: flex; + min-width: 0; + flex-direction: column; +} +.landing-brand strong { + font-size: 20px; + letter-spacing: .08em; +} +.landing-brand small { + color: var(--muted); + font-size: 11px; +} +.landing-nav__links { + display: flex; + align-items: center; + gap: 8px; +} +.landing-nav__links a { + padding: 8px 12px; + border-radius: 999px; + color: #000; + text-decoration: none; + font-size: 14px; + font-weight: 700; + font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; + transition: color 140ms ease, background-color 140ms ease; +} +.landing-nav__links a:hover { + color: #000; + background: var(--surface-soft); +} +.landing-nav__actions { + position: absolute; + right: clamp(28px, 5vw, 76px); + display: flex; + align-items: center; + gap: 8px; +} +.landing-workspace-link { + display: inline-flex; + min-height: 36px; + padding: 4px 5px 4px 13px; + align-items: center; + gap: 9px; + border: 1px solid color-mix(in srgb, var(--rule) 88%, transparent); + border-radius: 12px; + color: var(--ink); + background: color-mix(in srgb, var(--surface) 72%, transparent); + box-shadow: 0 5px 16px -14px rgba(25, 35, 45, .7); + font-size: 12px; + font-weight: 700; + text-decoration: none; + backdrop-filter: blur(10px); + transition: border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease, transform 180ms var(--ease-out); +} +.landing-workspace-link svg { + width: 26px; + height: 26px; + padding: 5px; + border-radius: 8px; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; + background: color-mix(in srgb, var(--surface-soft) 82%, transparent); + transition: color 180ms ease, background-color 180ms ease, transform 180ms var(--ease-out); +} +.landing-workspace-link:hover { + border-color: color-mix(in srgb, #0d72d9 36%, var(--rule)); + background: var(--surface); + box-shadow: 0 9px 24px -18px rgba(13, 114, 217, .65); + transform: translateY(-1px); +} +.landing-workspace-link:hover svg { + color: #0d72d9; + background: color-mix(in srgb, #0d72d9 8%, var(--surface)); + transform: translateX(2px); +} +.landing-hero { + position: relative; + overflow: hidden; + border-bottom: 1px solid var(--rule); + color: var(--ink); + background: + radial-gradient(circle at 18% 28%, rgba(60, 167, 255, .1), transparent 32%), + radial-gradient(circle at 82% 24%, rgba(118, 87, 200, .08), transparent 34%), + var(--canvas); +} +.landing-hero::before, +.landing-hero::after { + position: absolute; + z-index: 0; + border-radius: 50%; + opacity: .55; + pointer-events: none; + filter: blur(90px); + content: ""; +} +.landing-hero::before { + top: -180px; + left: -120px; + width: 420px; + height: 420px; + background: rgba(60, 167, 255, .18); + animation: landing-float-one 9s ease-in-out infinite alternate; +} +.landing-hero::after { + bottom: -220px; + right: -140px; + width: 480px; + height: 480px; + background: rgba(107, 93, 232, .16); + animation: landing-float-two 11s ease-in-out infinite alternate; +} +@keyframes landing-float-one { + from { transform: translate3d(0, 0, 0) scale(.92); } + to { transform: translate3d(60px, 40px, 0) scale(1.08); } +} +@keyframes landing-float-two { + from { transform: translate3d(0, 0, 0) scale(.94); } + to { transform: translate3d(-50px, -30px, 0) scale(1.1); } +} +.landing-hero > * { + position: relative; + z-index: 1; +} +.landing-hero__inner { + display: grid; + justify-items: center; + gap: 22px; + width: min(980px, 100%); + margin: 0 auto; + padding: clamp(56px, 9vh, 96px) clamp(28px, 5vw, 76px); + text-align: center; +} +.landing-hero__eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border: 1px solid #0D72D9; + border-radius: 999px; + color: var(--muted); + background: color-mix(in srgb, var(--surface) 68%, transparent); + font-size: 12px; + font-weight: 700; + letter-spacing: .1em; +} +.landing-hero__eyebrow-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #0D72D9; +} +.landing-hero h1 { + margin: 0; + font-size: clamp(44px, 5.6vw, 76px); + line-height: 1.05; + letter-spacing: -.045em; +} +.landing-hero__title { + display: grid; + width: 100%; + height: 2.32em; + place-items: center; + text-align: center; +} +.landing-hero__typed { + --typewriter-accent: #0d72d9; + display: block; + width: max-content; + max-width: 100%; + text-align: left; + white-space: nowrap; +} +.landing-hero__typed--violet { + --typewriter-accent: #6b5de8; +} +.landing-hero__typed--teal { + --typewriter-accent: #0f9f8f; +} +.landing-hero__typed--rose { + --typewriter-accent: #d95a67; +} +.landing-hero__typed--amber { + --typewriter-accent: #b86a0f; +} +.landing-hero__typed--final { + --typewriter-accent: #0f9f8f; + line-height: 1.16; +} +.landing-hero__typewriter-char { + display: inline-block; + animation: landing-typewriter-char-in 160ms var(--ease-out) both; +} +.landing-hero__typewriter-char--brand { + color: #0d72d9; +} +.landing-hero__typewriter-char--accent { + color: var(--typewriter-accent); +} +.landing-hero__typed--final .landing-hero__typewriter-char--accent { + background-image: + linear-gradient(115deg, transparent 40%, rgba(255, 255, 255, .9) 50%, transparent 60%), + linear-gradient(100deg, #0f766e, #10b981 34%, #84cc16 67%, #0891b2); + background-size: 260% 100%, 400% 100%; + background-position: 100% 50%, calc(var(--typewriter-char-index) * 33.333%) 50%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + animation: + landing-typewriter-char-in 160ms var(--ease-out) both, + landing-typewriter-shimmer 5s linear calc(1.8s + var(--typewriter-char-index) * 150ms) infinite; +} +.landing-hero__typewriter-caret { + display: inline-block; + width: .075em; + height: .88em; + margin-left: .08em; + border-radius: 999px; + background: var(--typewriter-accent); + vertical-align: -.06em; + animation: landing-typewriter-caret 760ms step-end infinite; +} +@keyframes landing-typewriter-char-in { + from { opacity: 0; transform: translateY(.16em) scale(.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes landing-typewriter-caret { + 0%, 46% { opacity: 1; } + 47%, 100% { opacity: 0; } +} +@keyframes landing-typewriter-shimmer { + 0% { + background-position: 115% 50%, calc(var(--typewriter-char-index) * 33.333%) 50%; + } + 40%, 100% { + background-position: -15% 50%, calc(var(--typewriter-char-index) * 33.333%) 50%; + } +} +.landing-hero__subtitle { + margin: 0; + max-width: 680px; + color: var(--muted); + font-size: clamp(15px, 1.7vw, 17px); + line-height: 1.7; +} +.landing-hero__actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-top: 8px; +} +.landing-hero__cta { + min-height: 50px; + padding: 10px 22px; + font-size: 16px; + border-radius: 999px; +} +.landing-glass-button { + position: relative; + overflow: hidden; + isolation: isolate; + backdrop-filter: blur(22px) saturate(145%) contrast(102%); + -webkit-backdrop-filter: blur(22px) saturate(145%) contrast(102%); + transition: transform 180ms ease, box-shadow 220ms ease, border-color 180ms ease, filter 180ms ease; +} +.landing-hero__actions .landing-glass-button { + border-radius: 999px; +} +.landing-glass-button::before { + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + opacity: .46; + background: + radial-gradient(circle at 18% 8%, rgba(255, 255, 255, .58), transparent 42%), + linear-gradient(115deg, rgba(255, 255, 255, .16), transparent 42% 70%, rgba(255, 255, 255, .1)); + transition: opacity 220ms ease; + content: ""; + pointer-events: none; +} +.landing-glass-button::after { + position: absolute; + z-index: -1; + top: 1px; + right: 10%; + left: 10%; + height: 42%; + border-radius: 999px; + background: linear-gradient(180deg, rgba(255, 255, 255, .42), rgba(255, 255, 255, 0)); + content: ""; + opacity: .48; + pointer-events: none; +} +.landing-glass-button:hover::before { + opacity: 1; +} +.landing-glass-button:hover { + filter: saturate(104%); + transform: translateY(-2px); +} +.landing-glass-button:active { + transform: translateY(0); +} +.button--primary.landing-glass-button { + border-color: rgba(79, 116, 190, .24); + color: #075eae; + background: + linear-gradient(135deg, rgba(60, 167, 255, .16), rgba(107, 93, 232, .1) 56%, rgba(164, 148, 196, .06)), + color-mix(in srgb, var(--surface) 72%, transparent); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, .68), + inset 0 -1px 0 rgba(59, 72, 128, .06), + 0 12px 28px -20px rgba(61, 91, 165, .36); +} +.button--primary.landing-glass-button:hover { + border-color: rgba(66, 100, 178, .36); + background: + linear-gradient(135deg, rgba(60, 167, 255, .21), rgba(107, 93, 232, .14) 56%, rgba(164, 148, 196, .08)), + color-mix(in srgb, var(--surface) 74%, transparent); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, .76), + inset 0 -1px 0 rgba(59, 72, 128, .07), + 0 15px 32px -20px rgba(61, 91, 165, .46); +} +.button--light.landing-glass-button { + border-color: rgba(119, 103, 157, .22); + color: #5f5193; + background: + linear-gradient(135deg, rgba(107, 93, 182, .1), rgba(176, 131, 157, .07) 52%, rgba(71, 164, 176, .07)), + color-mix(in srgb, var(--surface) 75%, transparent); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, .66), + inset 0 -1px 0 rgba(103, 87, 135, .05), + 0 12px 28px -20px rgba(98, 82, 138, .3); +} +.button--light.landing-glass-button:hover { + border-color: rgba(112, 94, 151, .34); + background: + linear-gradient(135deg, rgba(107, 93, 182, .14), rgba(176, 131, 157, .1) 52%, rgba(71, 164, 176, .1)), + color-mix(in srgb, var(--surface) 77%, transparent); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, .74), + inset 0 -1px 0 rgba(103, 87, 135, .06), + 0 15px 32px -20px rgba(98, 82, 138, .4); +} +.landing-hero__status { + margin: 0; + color: var(--muted); + font-size: 12px; +} +.landing-hero__preview { + border-top: 1px solid var(--rule); + background: color-mix(in srgb, var(--surface) 52%, transparent); +} +.landing-hero__preview-inner { + width: min(1200px, 100%); + margin: 0 auto; + padding: clamp(28px, 4vw, 48px) clamp(28px, 5vw, 76px); +} +.landing-app-mock { + display: grid; + grid-template-rows: auto 1fr; + min-height: 420px; + border: 1px solid var(--rule); + border-radius: 28px; + background: #ffffff; + color: #303236; + box-shadow: 0 30px 80px -30px rgba(20, 22, 30, .18); + overflow: hidden; + font-family: "Microsoft YaHei", "Microsoft YaHei UI", sans-serif; + transition: transform 180ms ease, box-shadow 180ms ease; +} +.landing-app-mock:hover { + transform: translateY(-4px); + box-shadow: 0 40px 100px -30px rgba(20, 22, 30, .28); +} +.landing-app-mock--tilted { + transform: perspective(1200px) rotateY(-8deg) rotateX(4deg); + transform-origin: center center; +} +.landing-app-mock--tilted:hover { + transform: perspective(1200px) rotateY(-8deg) rotateX(4deg) translateY(-4px); +} +.landing-app-mock__chrome { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 12px 18px; + border-bottom: 1px solid rgba(0,0,0,.06); + background: #f5f5f5; + font-size: 12px; + color: #303236; +} +.landing-app-mock__dots { + position: absolute; + left: 18px; + display: inline-flex; + align-items: center; + gap: 6px; +} +.landing-app-mock__dots > span { + width: 10px; + height: 10px; + border-radius: 50%; + display: block; +} +.landing-app-mock__dot--red { background: #ff5f57; } +.landing-app-mock__dot--yellow { background: #febc2e; } +.landing-app-mock__dot--green { background: #28c840; } +.landing-app-mock__url { + padding: 4px 12px; + border-radius: 999px; + background: #ffffff; + color: rgba(0,0,0,.5); + font-size: 11px; +} +.landing-app-mock__stage { + flex: 1 1 auto; + min-height: 0; + display: grid; + place-items: center; + background: #ffffff; + overflow: hidden; +} +.landing-app-mock__picture { + display: block; + width: 100%; + height: 100%; +} +.landing-app-mock__scene-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.landing-features { + padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); + border-bottom: 1px solid var(--rule); +} +.landing-features__inner { + display: grid; + justify-items: center; + gap: 22px; + width: min(1200px, 100%); + margin: 0 auto; + text-align: center; +} +.landing-feature-split { + display: grid; + grid-template-columns: minmax(0, .9fr) minmax(0, 1fr); + gap: clamp(40px, 6vw, 80px); + align-items: center; +} +.landing-feature-split__copy { + display: grid; + gap: 18px; + text-align: left; +} +.landing-feature-split__copy h2 { + font-size: clamp(32px, 2.8vw, 40px); + line-height: 1.2; + letter-spacing: -.02em; +} +.landing-features__intro { + max-width: 520px; + color: var(--muted); + font-size: 16px; + line-height: 1.7; +} +.landing-feature-list { + display: grid; + gap: 12px; + margin-top: 8px; + padding: 0; + list-style: none; +} +.landing-feature-list li { + display: grid; + grid-template-columns: 10px minmax(0, 1fr); + gap: 12px; + align-items: start; +} +.landing-feature-list__icon { + display: grid; + width: 10px; + height: 10px; + place-items: center; + border-radius: 50%; + background: #0D72D9; + margin-top: 7px; +} +.landing-feature-list__icon svg { + display: none; +} +.landing-feature-list strong { + display: block; + font-size: 14px; + margin-bottom: 2px; +} +.landing-feature-list p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} +.landing-feature-split__visual { + display: grid; + justify-items: center; +} +.landing-visual-card { + width: 100%; + border: 1px solid var(--rule); + border-radius: 20px; + background: var(--surface); + box-shadow: 0 20px 60px -20px rgba(40, 40, 44, .22); + overflow: hidden; +} +.landing-visual-card__header { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 18px; + border-bottom: 1px solid var(--rule); + background: color-mix(in srgb, var(--surface-soft) 52%, transparent); +} +.landing-visual-card__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #0D72D9; +} +.landing-visual-card__title { + font-size: 13px; + font-weight: 700; +} +.landing-visual-card__body { + padding: 24px; + display: grid; + gap: 16px; +} +.landing-visual-card__dialogue { + display: flex; + gap: 12px; + align-items: flex-start; +} +.landing-visual-card__avatar { + width: 32px; + height: 32px; + border-radius: 10px; + background: linear-gradient(145deg, #0D72D9, #6B5DE8); + color: #fff; + display: grid; + place-items: center; + font-size: 13px; + font-weight: 700; + flex: 0 0 32px; +} +.landing-visual-card__bubble { + padding: 12px 16px; + border-radius: 14px; + background: var(--surface-soft); + border-bottom-left-radius: 4px; +} +.landing-visual-card__bubble p { + margin: 0; + font-size: 13px; + line-height: 1.5; +} +.landing-visual-card__choices { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-top: 4px; +} +.landing-visual-card__choice { + height: 44px; + border-radius: 12px; + border: 1px solid var(--rule); + background: var(--surface); +} + +.section-label { + display: inline-flex; + padding: 5px 10px; + border: 1px solid var(--rule); + border-radius: 999px; + color: var(--muted); + background: color-mix(in srgb, var(--surface) 68%, transparent); + font-size: 12px; + font-weight: 700; + letter-spacing: .1em; +} + +.landing-characters { + padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); + border-bottom: 1px solid var(--rule); + background: color-mix(in srgb, var(--surface) 48%, transparent); +} +.landing-characters__inner { + display: grid; + justify-items: center; + gap: 22px; + width: min(1200px, 100%); + margin: 0 auto; + text-align: center; +} +.landing-characters__inner > h2, +.landing-workflow__inner > h2, +.landing-faq__inner > h2 { + margin: 0; + font-size: clamp(34px, 3.6vw, 52px); + line-height: 1.14; + letter-spacing: -.035em; +} +.landing-faq__inner > h2 { + transform: translateY(-8px); +} +.landing-characters__intro { + max-width: 760px; + color: var(--muted); + font-size: 16px; + line-height: 1.7; +} +.landing-characters__grid { + display: grid; + width: 100%; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-top: 18px; +} +.landing-character-card { + display: grid; + grid-template-rows: 360px auto; + overflow: hidden; + border: 1px solid var(--rule); + border-radius: 20px; + background: var(--surface); + box-shadow: 0 4px 10px -6px rgba(40, 40, 44, .18); + text-align: left; + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms var(--ease-out); +} +.landing-character-card:hover { + border-color: var(--surface-strong); + box-shadow: 0 12px 28px -14px rgba(40, 40, 44, .28); + transform: translateY(-3px); +} +.landing-character-card__portrait { + position: relative; + display: grid; + overflow: hidden; + place-items: end center; + border-bottom: 1px solid color-mix(in srgb, currentColor 12%, var(--rule)); + background-image: + radial-gradient(circle at 50% 42%, color-mix(in srgb, currentColor 17%, transparent), transparent 62%), + linear-gradient(155deg, rgba(255, 255, 255, .62), transparent 68%); + isolation: isolate; +} +.landing-character-card__portrait::before { + position: absolute; + bottom: -76px; + left: 50%; + width: 270px; + height: 270px; + border: 1px solid currentColor; + border-radius: 50%; + content: ''; + opacity: .09; + transform: translateX(-50%); +} +.landing-character-card__portrait img { + position: absolute; + inset: 10px 10px 0; + z-index: 1; + width: calc(100% - 20px); + height: calc(100% - 10px); + object-fit: contain; + object-position: center bottom; + filter: drop-shadow(0 16px 18px rgba(20, 28, 45, .14)); + transition: transform 300ms var(--ease-out), filter 300ms ease; +} +.landing-character-card:hover .landing-character-card__portrait img { + filter: drop-shadow(0 20px 22px rgba(20, 28, 45, .19)); + transform: translateY(-3px) scale(1.018); +} +.landing-character-card__portrait .landing-character-card__portrait-img--su { + transform: translateX(-4%); + transform-origin: center bottom; +} +.landing-character-card:hover .landing-character-card__portrait .landing-character-card__portrait-img--su { + transform: translateX(-4%) translateY(-3px) scale(1.018); +} +.landing-character-card__copy { + display: grid; + align-content: start; + gap: 8px; + padding: 22px 24px 26px; +} +.landing-character-card__role { + color: var(--muted); + font-size: 12px; +} +.landing-character-card h3 { + margin: 0; + font-size: 17px; +} +.landing-character-card p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.65; +} + +.landing-workflow { + padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); + border-bottom: 1px solid var(--rule); +} +.landing-workflow__inner { + display: grid; + justify-items: start; + gap: 22px; + width: min(980px, 100%); + margin: 0 auto; + text-align: left; +} +.landing-workflow__intro { + max-width: 620px; + color: var(--muted); + font-size: 16px; + line-height: 1.7; +} +.landing-workflow__timeline { + display: grid; + width: 100%; + gap: 0; + margin-top: 18px; + text-align: left; + position: relative; +} +.landing-workflow-step { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 0; + text-align: left; +} +.landing-workflow-step__marker { + display: grid; + justify-items: center; + padding-top: 4px; +} +.landing-workflow-step__num { + display: grid; + width: 48px; + height: 48px; + place-items: center; + border-radius: 16px; + color: #0D72D9; + background: #e8f4fd; + font-size: 18px; + font-weight: 800; +} +.landing-workflow-step:first-child .landing-workflow-step__marker::after { + content: none; +} +.landing-workflow-step__marker::after { + content: ""; + display: block; + width: 2px; + flex: 1 1 auto; + background: #0D72D9; + margin-top: 12px; + opacity: .25; +} +.landing-workflow-step:last-child .landing-workflow-step__marker::after { + display: none; +} +.landing-workflow-step__copy { + padding: 0 0 40px 24px; +} +.landing-workflow-step:last-child .landing-workflow-step__copy { + padding-bottom: 0; +} +.landing-workflow-step h3 { + margin: 0 0 6px; + font-size: 20px; + font-weight: 700; + color: #000; +} +.landing-workflow-step p { + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.7; +} + +.landing-faq { + padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); + border-bottom: 1px solid var(--rule); + background: color-mix(in srgb, var(--surface) 48%, transparent); +} +.landing-faq__inner { + display: grid; + justify-items: center; + gap: 22px; + width: min(980px, 100%); + margin: 0 auto; + text-align: center; +} +.landing-faq__list { + display: grid; + width: 100%; + gap: 10px; + margin-top: 18px; + text-align: left; +} +.landing-faq-item { + display: grid; + gap: 10px; + padding: 18px 20px; + border: 1px solid var(--rule); + border-radius: 16px; + background: var(--surface); + box-shadow: 0 4px 10px -6px rgba(40, 40, 44, .18); + transition: border-color 160ms ease, box-shadow 160ms ease; +} +.landing-faq-item[open] { + border-color: #0D72D9; +} +.landing-faq-item summary { + cursor: pointer; + font-weight: 700; + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--ink); + transition: color 140ms ease; +} +.landing-faq-item summary:hover { + color: #0D72D9; +} +.landing-faq-item summary::after { + content: "+"; + display: grid; + width: 24px; + height: 24px; + flex: 0 0 24px; + place-items: center; + border-radius: 8px; + color: var(--muted); + background: var(--surface-soft); + font-size: 18px; + font-weight: 700; + transition: transform 160ms ease, background-color 160ms ease; +} +.landing-faq-item[open] summary::after { + content: "-"; + transform: rotate(180deg); +} +.landing-faq-item p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.65; +} + +.landing-cta { + padding: clamp(60px, 10vh, 100px) clamp(28px, 5vw, 76px); + background: + radial-gradient(circle at 50% 50%, rgba(60, 167, 255, .12), transparent 38%), + var(--canvas); + border-bottom: 1px solid var(--rule); +} +.landing-cta__inner { + display: grid; + justify-items: center; + gap: 18px; + width: min(860px, 100%); + margin: 0 auto; + text-align: center; +} +.landing-cta h2 { + margin: 0; + font-size: clamp(28px, 3.4vw, 40px); + letter-spacing: -.03em; +} +.landing-cta p { + margin: 0; + max-width: 620px; + color: var(--muted); + font-size: 16px; + line-height: 1.7; +} +.landing-cta__actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-top: 8px; +} +.landing-cta .landing-glass-button { + border-radius: 999px; +} + +.landing-footer { + padding: 64px clamp(28px, 5vw, 76px) 32px; + border-top: 1px solid #2c2f33; + background: #1a1c1f; + color: rgba(255, 255, 255, .7); +} +.landing-footer__inner { + display: grid; + grid-template-columns: 1.2fr repeat(3, auto); + gap: 40px; + width: min(1440px, 100%); + margin: 0 auto; +} +.landing-footer__brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: #fff; + text-decoration: none; +} +.landing-footer__brand .brand-mark { + width: 28px; + height: 20px; + color: #0D72D9; +} +.landing-footer__brand strong { + font-size: 14px; + color: #fff; +} +.landing-footer__brand p { + margin: 6px 0 0; + color: rgba(255, 255, 255, .5); + font-size: 12px; + line-height: 1.55; +} +.landing-footer__columns { + display: flex; + gap: 48px; +} +.landing-footer__columns h4 { + margin: 0 0 12px; + font-size: 12px; + font-weight: 700; + color: #fff; + letter-spacing: .08em; +} +.landing-footer__columns nav { + display: grid; + gap: 8px; +} +.landing-footer__columns a { + color: rgba(255, 255, 255, .6); + text-decoration: none; + font-size: 13px; + transition: color 140ms ease; +} +.landing-footer__columns a:hover { + color: #fff; +} +.landing-footer__copy { + grid-column: 1 / -1; + color: rgba(255, 255, 255, .35); + font-size: 12px; + margin-top: 32px; + padding-top: 20px; + border-top: 1px solid rgba(255, 255, 255, .08); +} + +@media (max-width: 1040px) { + .home-intelligence-flow { grid-template-columns: 1fr; } +} + +@media (max-width: 620px) { + .home-intelligence-flow > div { grid-template-columns: 1fr; } +} + diff --git a/frontend/src/styles/practice.css b/frontend/src/styles/practice.css new file mode 100644 index 0000000..da3f5c4 --- /dev/null +++ b/frontend/src/styles/practice.css @@ -0,0 +1,208 @@ +/* Recite-first pages: reuse GalReview surfaces and typography; no AI-styled decoration. */ +.practice-project-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(310px, .65fr); gap: 20px; align-items: start; } +.practice-project-side { display: grid; gap: 20px; align-self: start; } +.practice-mode-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; } +.practice-mode-grid article { display: grid; align-content: start; justify-items: start; gap: 12px; min-height: 230px; padding: 22px; border: 1px solid var(--rule); border-radius: var(--radius-card); background: var(--surface); } +.practice-mode-grid h2, .practice-tools h2 { margin: 0; } +.practice-mode-grid p { min-height: 72px; margin: 0; color: var(--muted); line-height: 1.65; } +.practice-tools { margin-bottom: 20px; } +.practice-readiness-note { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 20px; padding: 18px 22px; border: 1px solid var(--rule); border-left: 3px solid var(--surface-strong); background: var(--surface-soft); } +.practice-readiness-note > div { display: grid; gap: 6px; } +.practice-readiness-note strong { font-size: 17px; } +.practice-readiness-note p { max-width: 760px; margin: 0; color: var(--muted); line-height: 1.65; } +.practice-readiness-note .button { flex: 0 0 auto; } +.practice-tools > div { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-top: 18px; } +.practice-tools label { display: grid; gap: 6px; min-width: 210px; color: var(--muted); font-size: 12px; } +.practice-tools select, .practice-tools input, .practice-resource-search input { min-height: 42px; padding: 8px 12px; border: 1px solid var(--rule); border-radius: 12px; color: var(--ink); background: var(--field); } +.practice-project-list, .practice-question-list { display: grid; margin-top: 22px; border-top: 1px solid var(--rule); } +.practice-project-list > a { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px 4px; border-bottom: 1px solid var(--rule); color: var(--ink); text-decoration: none; } +.practice-project-list > a:hover { color: #0D72D9; } +.practice-project-list > a > span:first-child { display: grid; gap: 5px; } +.practice-project-list small, .practice-question-list small { color: var(--muted); } +.practice-project-create { position: static; } +.practice-question-create { position: sticky; top: 112px; } +.practice-question-create textarea, .practice-written-answer textarea { width: 100%; padding: 12px 14px; resize: vertical; border: 1px solid var(--rule); border-radius: 16px; color: var(--ink); background: var(--field); line-height: 1.6; } +.practice-question-list article { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 16px 4px; border-bottom: 1px solid var(--rule); } +.practice-question-list article > span { color: var(--muted); font-variant-numeric: tabular-nums; } +.practice-question-list article > div { display: grid; gap: 5px; } +.practice-question-list article > i { color: var(--muted); font-size: 12px; font-style: normal; } +.practice-session-page { width: min(980px, 100%); } +.practice-question-card { padding: clamp(26px, 5vw, 54px); border: 1px solid var(--rule); border-radius: var(--radius-shell); background: var(--surface); } +.practice-question-card > header { display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; letter-spacing: .08em; } +.practice-question-card h2 { margin: 32px 0; font-size: clamp(24px, 3vw, 36px); line-height: 1.45; letter-spacing: -.02em; } +.practice-options { display: grid; gap: 10px; } +.practice-options button { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 12px; align-items: center; min-height: 58px; padding: 10px 16px; border: 1px solid var(--rule); border-radius: 18px; color: var(--ink); background: var(--field); cursor: pointer; text-align: left; } +.practice-options button:hover { border-color: #0D72D9; background: var(--surface); } +.practice-options button b { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid var(--rule); border-radius: 10px; } +.practice-options--binary { grid-template-columns: repeat(2, 1fr); } +.practice-options--binary button { display: block; text-align: center; } +.practice-written-answer { display: grid; gap: 14px; } +.practice-written-answer .button { justify-self: end; } +.practice-result { display: grid; gap: 8px; margin-top: 24px; padding: 22px; border-left: 4px solid var(--surface-strong); background: var(--surface-soft); } +.practice-result.is-correct { border-left-color: #277a55; } +.practice-result.is-wrong { border-left-color: var(--danger); } +.practice-result.is-neutral { border-left-color: var(--surface-strong); } +.practice-result span { color: var(--muted); } +.practice-result .button { justify-self: start; margin-top: 8px; } +.practice-help { display: grid; gap: 10px; margin: 10px 0; padding: 16px; border: 1px solid var(--rule); border-radius: 14px; background: var(--surface); } +.practice-help blockquote { margin: 0; padding-left: 12px; border-left: 2px solid var(--surface-strong); color: var(--ink); line-height: 1.65; } +.practice-help p { margin: 0; color: var(--muted); line-height: 1.65; } +.practice-complete { display: grid; justify-items: start; gap: 18px; } +.practice-complete p { max-width: 640px; color: var(--muted); line-height: 1.7; } +.practice-resource-search { display: flex; align-items: end; gap: 12px; margin-bottom: 20px; } +.practice-resource-search label { display: grid; flex: 1; gap: 7px; color: var(--muted); } +.practice-project-list > article { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px 4px; border-bottom: 1px solid var(--rule); } +.practice-project-list > article > span { display: grid; gap: 5px; } + +@media (max-width: 900px) { + .practice-project-layout { grid-template-columns: 1fr; } + .practice-mode-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .practice-project-create, .practice-question-create { position: static; } +} +@media (max-width: 620px) { + .practice-mode-grid { grid-template-columns: 1fr; } + .practice-mode-grid article { min-height: 0; } + .practice-readiness-note { align-items: stretch; flex-direction: column; } + .practice-readiness-note .button { align-self: start; } + .practice-resource-search { align-items: stretch; flex-direction: column; } +} + +@media (max-width: 1120px) { + .landing-feature-split { + grid-template-columns: 1fr; + } + .landing-feature-split__visual { + display: none; + } + .landing-workflow-step__marker::after { + display: none; + } + .landing-footer__inner { + grid-template-columns: 1fr; + gap: 28px; + } + .landing-footer__columns { + flex-wrap: wrap; + gap: 24px; + } +} +@media (max-width: 760px) { + .landing-nav__inner { + min-height: 64px; + padding: 10px 16px; + justify-content: space-between; + gap: 12px; + } + .landing-brand, + .landing-nav__actions { + position: static; + } + .landing-brand { + flex: 0 1 auto; + } + .landing-brand .brand-mark { + width: 30px; + height: 22px; + } + .landing-brand strong { + font-size: 18px; + } + .landing-workspace-link { + min-height: 38px; + padding-left: 12px; + flex: 0 0 auto; + } + .landing-nav__links { + display: none; + } + .landing-hero__inner { + padding-block: clamp(48px, 8vh, 72px); + padding-inline: clamp(18px, 5vw, 28px); + } + .landing-hero h1 { + font-size: clamp(29px, 9.1vw, 44px); + } + .landing-hero__preview-inner { + grid-template-columns: 1fr; + } + .landing-features__grid, + .landing-characters__grid, + .landing-workflow__steps { + grid-template-columns: 1fr; + } + .landing-character-card { + grid-template-rows: 300px auto; + } + .landing-character-card__copy { + padding: 18px 20px 22px; + } + .landing-workflow-step { + grid-template-columns: 40px 1fr; + gap: 8px; + } + .landing-workflow-step__copy { + padding-left: 10px; + } + .landing-footer__inner { + grid-template-columns: 1fr; + gap: 24px; + justify-items: center; + text-align: center; + } + .landing-footer__columns { + display: grid; + width: 100%; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + } + .landing-footer__brand { + align-items: center; + justify-content: center; + } + .landing-footer__columns nav { + justify-items: center; + } + .landing-footer__copy { + width: 100%; + margin-top: 12px; + text-align: center; + } +} +.landing-app-mock__stage--review { + display: grid; + padding: 0; + background: var(--surface-soft); +} + +.landing-review-mock { + width: 100%; + height: 100%; + padding: clamp(18px, 3vw, 30px); + color: var(--ink); + background: var(--surface); + border: 0; + border-radius: 0; + box-shadow: none; +} + +.landing-review-mock > header { display: grid; gap: 5px; } +.landing-review-mock > header span { color: #0d72d9; font-size: 12px; letter-spacing: .14em; } +.landing-review-mock > header strong { font-size: clamp(20px, 2.4vw, 30px); font-weight: 650; } +.landing-review-mock > header small { color: var(--muted); } +.landing-review-mock__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 22px 0 16px; } +.landing-review-mock__stats span { display: grid; gap: 5px; padding: 13px; background: var(--surface-soft); border: 1px solid var(--rule); border-radius: 12px; } +.landing-review-mock__stats small { color: var(--muted); } +.landing-review-mock__stats strong { font-size: 20px; } +.landing-review-mock__list { display: grid; gap: 8px; } +.landing-review-mock__list > span { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; padding: 11px 12px; border-bottom: 1px solid var(--rule); } +.landing-review-mock__list i { color: var(--muted); font-style: normal; font-variant-numeric: tabular-nums; } +.landing-review-mock__list b { font-weight: 600; } +.landing-review-mock__list small { color: var(--muted); } +.landing-review-mock__actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 18px; } +.landing-review-mock__actions span { padding: 9px 13px; color: #0d72d9; background: color-mix(in srgb, #0d72d9 9%, var(--surface)); border: 1px solid color-mix(in srgb, #0d72d9 22%, var(--rule)); border-radius: 9px; font-size: 13px; } + +@media (max-width: 640px) { + .landing-review-mock__stats { grid-template-columns: 1fr; } + .landing-review-mock__list > span { grid-template-columns: 26px 1fr; } + .landing-review-mock__list small { grid-column: 2; } +} diff --git a/frontend/src/styles/workspace.css b/frontend/src/styles/workspace.css new file mode 100644 index 0000000..bc0dda7 --- /dev/null +++ b/frontend/src/styles/workspace.css @@ -0,0 +1,887 @@ +/* Koishi Console inspired workspace. Authentication pages intentionally keep their own layout. */ +.app-shell, .admin-page { + --canvas: #f2f3f4; + --surface: #ffffff; + --surface-soft: #eceeef; + --surface-strong: #858a90; + --accent-deep: #62676d; + --brand: #777c82; + --ink: #2d2f32; + --muted: rgba(45, 47, 50, .66); + --rule: #d0d2d5; + --field: #f5f5f6; + --danger: #f56c6c; + --radius-control: 12px; + --radius-group: 16px; + --radius-shell: 18px; + --koishi-activity-width: 64px; + --koishi-status-height: 28px; + color: var(--ink); + background: var(--canvas); +} + +.app-shell { + position: relative; + min-height: 100vh; + padding: 0 0 var(--koishi-status-height) var(--koishi-activity-width); +} + +.app-rail { + position: fixed; + z-index: 120; + inset: 0 auto 0 0; + display: flex; + width: var(--koishi-activity-width); + min-height: 0; + padding: 0; + flex-direction: column; + gap: 0; + border: 0; + border-right: 1px solid var(--rule); + background: #eceeef; + box-shadow: none; + backdrop-filter: none; +} + +.rail-brand { + position: relative; + display: grid; + width: 100%; + min-height: 64px; + place-items: center; + border-bottom: 1px solid var(--rule); +} + +.rail-brand .brand-mark { width: 34px; height: 24px; color: var(--brand); } +.rail-brand > span { display: none; } + +.rail-nav { + display: flex; + width: 100%; + margin: 0; + padding: 7px 0; + flex: 1 1 auto; + flex-direction: column; + gap: 2px; + border-radius: 0; + background: transparent; +} + +.rail-nav a, +.rail-profile, +.rail-logout { + position: relative; + display: grid; + width: 100%; + min-height: 54px; + padding: 0; + place-items: center; + border: 0; + border-radius: 0; + color: rgba(40, 40, 44, .56); + background: transparent; + cursor: pointer; + text-decoration: none; + transition: color .2s ease, background-color .2s ease; +} + +.rail-nav a::before, +.rail-profile::before { + position: absolute; + top: 50%; + left: 0; + width: 4px; + height: 28px; + border-radius: 0 4px 4px 0; + background: var(--surface-strong); + content: ""; + opacity: 0; + transform: translate(-100%, -50%); + transition: opacity .2s ease, transform .2s ease; +} + +.rail-nav a:hover, +.rail-profile:hover, +.rail-logout:hover { color: var(--ink); background: rgba(64, 158, 255, .08); } +.rail-nav a.active { color: var(--surface-strong); background: transparent; font-weight: 400; } +.rail-nav a.active::before { opacity: 1; transform: translate(0, -50%); } +.rail-nav svg, .rail-logout svg { width: 24px; height: 24px; } +.rail-nav a > span, .rail-profile > span:last-child, .rail-logout > span { display: none; } + +.rail-nav a::after, +.rail-profile::after, +.rail-logout::after { + position: absolute; + z-index: 150; + top: 50%; + left: calc(100% + 8px); + padding: 6px 9px; + border: 1px solid var(--rule); + border-radius: 10px; + color: var(--ink); + background: rgba(255, 255, 255, .98); + box-shadow: 0 4px 12px rgba(40, 40, 44, .12); + content: attr(data-label); + font-size: 12px; + opacity: 0; + pointer-events: none; + transform: translate(-4px, -50%); + transition: opacity .15s ease, transform .15s ease; + white-space: nowrap; +} + +.rail-nav a:hover::after, +.rail-profile:hover::after, +.rail-logout:hover::after { opacity: 1; transform: translate(0, -50%); } + +.rail-account { + display: flex; + width: 100%; + padding: 6px 0 8px; + flex-direction: column; + gap: 0; + border-top: 1px solid var(--rule); +} + +.rail-avatar { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border: 1px solid #c9ced4; + border-radius: 50%; + color: #3f4650; + background: #fff; + font-size: 12px; + font-weight: 700; +} + +.rail-logout { font: inherit; } +.mobile-shell-head { display: none; } + +.app-shell__content { + min-width: 0; + min-height: calc(100vh - var(--koishi-status-height)); + background: var(--canvas); +} + +.app-statusbar { + position: fixed; + z-index: 110; + right: 0; + bottom: 0; + left: var(--koishi-activity-width); + display: flex; + height: var(--koishi-status-height); + padding: 0 12px; + align-items: center; + justify-content: space-between; + border-top: 1px solid var(--rule); + color: var(--muted); + background: #f4f5f6; + font-size: 12px; +} + +.app-statusbar span { display: inline-flex; align-items: center; gap: 7px; } +.app-statusbar i { width: 7px; height: 7px; border-radius: 50%; background: #67c23a; box-shadow: 0 0 0 3px rgba(103,194,58,.12); } + +.app-shell .page { + width: 100%; + max-width: 1600px; + min-height: calc(100vh - var(--koishi-status-height)); + margin: 0 auto; + padding: 26px 32px 46px; +} + +.app-shell .page-header { + min-height: 54px; + margin: -26px -32px 24px; + padding: 0 20px; + align-items: center; + gap: 18px; + border-bottom: 1px solid var(--rule); + background: rgba(255,255,255,.92); + backdrop-filter: blur(10px); +} + +.page-header__copy { display: flex; min-width: 0; align-items: center; gap: 11px; } +.page-header__icon { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 10px; color: var(--surface-strong); background: rgba(133,138,144,.12); } +.page-header__icon svg { width: 18px; height: 18px; } +.app-shell .page-header h1, .admin-page .page-header h1 { margin: 0; font-size: 18px; line-height: 1.25; letter-spacing: 0; } +.app-shell .page-header p, .admin-page .page-header p { overflow: hidden; max-width: 720px; margin: 2px 0 0; color: var(--muted); font-size: 12px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; } +.page-header__actions { align-items: center; gap: 7px; } + +.app-shell .button, .admin-page .button, .primary-button { + min-height: 34px; + padding: 6px 13px; + border-radius: 12px; + font-size: 13px; +} + +.app-shell .button:not(.button--danger):hover:not(:disabled), .admin-page .button:not(.button--danger):hover:not(:disabled) { border-color: #0a61ba; color: #fff; background: #0a61ba; transform: none; } +.app-shell .button.graph-create-plan-button { min-height: auto; padding: 0; border: 0; border-radius: 0; color: #7a838e; background: transparent; font-weight: 700; } +.app-shell .button.graph-create-plan-button:hover:not(:disabled) { border: 0; color: #525b66; background: transparent; text-decoration: underline; text-underline-offset: 4px; } + +.form-section, +.material-library, +.plan-panel, +.side-section, +.filter-panel, +.chapter-outline, +.graph-board, +.graph-inspector, +.workspace-card, +.study-snapshot, +.knowledge-list, +.data-strip { + border: 1px solid var(--rule); + border-radius: 16px; + background: var(--surface); + box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); +} + +.form-section, .material-library, .plan-panel, .side-section, .filter-panel, .graph-board, .graph-inspector, .workspace-card { padding: 22px; } +.form-section h2, .material-library h2, .plan-panel h2, .side-section h2, .chapter-outline h2, .graph-board h2, .graph-inspector h2, .workspace-card h2 { font-size: 18px; } +.form-section header p, .material-library header p, .plan-panel > p, .side-section p, .workspace-card > p { font-size: 13px; } + +.form-section input:not([type="checkbox"]), +.form-section select, +.filter-panel input, +.filter-panel select, +.side-section input, +.side-section select, +.review-setup select, +.material-library input, +.graph-board input, +.admin-invitation-form input, +.admin-invitation-form select, +.plan-options__grid input { + min-height: 38px; + border-color: var(--rule); + border-radius: 12px; + background: var(--field); + outline: none; + transition: border-color .2s ease, box-shadow .2s ease, background-color .2s ease; +} + +.form-section input:focus, .form-section select:focus, .filter-panel input:focus, .filter-panel select:focus, .side-section input:focus, .side-section select:focus, .material-library input:focus, .graph-board input:focus, .admin-invitation-form input:focus, .admin-invitation-form select:focus { border-color: var(--surface-strong); background: #fff; box-shadow: 0 0 0 2px rgba(133,138,144,.16); } +.toggle-field { border-radius: 12px; background: #fafcfe; } +.home-theme-toggle { display: inline-flex !important; min-height: 36px; margin: 0; padding: 6px 8px; align-items: center; gap: 10px; border: 0; border-radius: 999px; background: transparent; font-size: 12px; transition: background-color .18s ease; } +.home-welcome > .home-theme-toggle { position: absolute; z-index: 2; top: 20px; right: 22px; } +.home-theme-toggle > span strong { color: var(--muted); font-weight: 600; } +.home-theme-toggle input[type="checkbox"] { flex: 0 0 auto; border: 1px solid var(--rule); } +:root[data-theme="dark"] .home-theme-toggle { border: 0; background: transparent; } +:root[data-theme="dark"] .home-theme-toggle:hover { background: rgba(255,255,255,.05); } +:root[data-theme="dark"] .home-theme-toggle > span strong { color: rgba(227,229,232,.72); } +:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"] { border-color: #5a5f66; background: #484c52; box-shadow: inset 0 1px 2px rgba(0,0,0,.24); } +:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"]::before { background: #c5c9ce; } +:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"]:checked { border-color: #6683a0; background: #58789a; } +:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"]:checked::before { background: #e2e5e9; } +:root[data-theme="dark"] .home-theme-toggle input[type="checkbox"]:focus-visible { outline-color: rgba(126,162,198,.42); } + +.status-line { + margin-top: 16px; + padding: 9px 12px; + border: 1px solid var(--rule); + border-left-width: 3px; + border-radius: 12px; + color: #62676d; + background: #eceeef; + font-size: 12px; +} +.status-line--error { border-color: #fab6b6; color: #c45656; background: #fef0f0; } + +.data-strip { overflow: hidden; box-shadow: none; } +.data-strip div { min-height: 72px; padding: 10px 18px; } +.data-strip span { font-size: 12px; } +.data-strip strong { font-size: 24px; } + +.home-workspace { grid-template-columns: minmax(0, 1.45fr) minmax(280px, .55fr); gap: 16px; } +.resume-panel { + position: relative; + min-height: 232px; + padding: 32px; + place-items: start; + align-content: center; + justify-items: start; + overflow: hidden; + border: 1px solid var(--rule); + border-radius: 18px; + color: var(--ink); + background: linear-gradient(135deg, #fff 0%, #f3f4f5 68%, #e8eaec 100%); + box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); + text-align: left; +} +.resume-panel::after { position: absolute; right: -62px; bottom: -82px; width: 230px; height: 230px; border: 38px solid rgba(133,138,144,.09); border-radius: 50%; content: ""; } +.resume-panel > div { z-index: 1; justify-items: start; } +.resume-panel span { color: var(--surface-strong); font-size: 11px; } +.resume-panel h2 { margin: 8px 0 6px; font-size: clamp(28px, 3vw, 40px); } +.resume-panel p { color: var(--muted); } +.resume-panel .button--light { z-index: 1; border-color: #0D72D9; color: #fff; background: #0D72D9; } +.study-snapshot { padding: 22px; } +.study-snapshot h2 { font-size: 18px; } +.home-quick-actions { gap: 12px; margin-top: 18px; } +.home-quick-actions > a > i { display: none; } +.home-quick-actions a { min-height: 108px; padding: 18px; border-radius: 16px; background: #fff; box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); } +.home-quick-actions a:hover { background: #f0f1f2; box-shadow: 0 8px 18px -10px rgba(40,40,44,.3); } +.quick-action-icon { border-radius: 12px; background: #eceeef; } +.quick-action-icon--materials { color: #1677d2; background: #e7f3ff; } +.quick-action-icon--knowledge { color: #27945a; background: #e7f7ed; } +.quick-action-icon--graph { color: #7657c8; background: #f0ebff; } +.quick-action-icon--review { color: #d77918; background: #fff1df; } +:root[data-theme="dark"] .quick-action-icon--materials { color: #67b6ff; background: #183b5d; } +:root[data-theme="dark"] .quick-action-icon--knowledge { color: #67d695; background: #183f2c; } +:root[data-theme="dark"] .quick-action-icon--graph { color: #b79cff; background: #352b57; } +:root[data-theme="dark"] .quick-action-icon--review { color: #ffb55e; background: #513518; } + +.process-layout { gap: 16px; } +.process-primary, .settings-main { gap: 16px; } +.app-shell .materials-page { display: flex; flex-direction: column; } +.materials-page .process-layout--materials { flex: 1 1 auto; align-items: stretch; } +.process-layout--materials .process-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; } +.process-layout--materials .process-primary { min-height: 100%; } +.process-layout--materials .upload-panel, .process-layout--materials .material-library { min-width: 0; height: 100%; } +.process-layout--materials .upload-panel { display: flex; flex-direction: column; } +.process-layout--materials .upload-panel__form { display: flex; flex: 1 1 auto; flex-direction: column; gap: 18px; } +.process-layout--materials .upload-panel__form > label { margin: 0; } +.process-layout--materials .upload-panel__form > .compact-toggle { margin: 0 !important; } +.process-layout--materials .upload-panel__form > .button { margin-top: auto; } +.process-layout--materials .material-library { display: flex; flex-direction: column; } +.process-layout--materials .material-table { flex: 1 1 auto; grid-auto-rows: max-content; align-content: start; } +.upload-panel__form > .button, .material-library > .flow-next-button { min-height: 44px; border-radius: 18px; } +.material-library > .flow-next-button { margin-top: 20px; } +.plan-panel { top: 16px; } +.segmented-control { border-radius: 14px; background: #e7e9eb; } +.segmented-control button { border-radius: 10px; } +.chapter-picker label:hover { background: #f0f1f2; } +.material-table article, .admin-table > div, .invitation-list > div { transition: background-color .2s ease; } +.material-table article:hover, .admin-table > div:hover, .invitation-list > div:hover { background: #f4f5f6; } +.material-table article.selected { box-shadow: inset 3px 0 var(--surface-strong); background: #eceeef; } +.upload-panel .option-row .toggle-field { font-size: 13px; } +.material-library { border-color: #e2e6eb; background: #fff; box-shadow: 0 10px 30px -22px rgba(31, 45, 61, .32); } +.material-library > header { align-items: center; } +.material-library > header input { border-color: #dce2e8; background: #fff; box-shadow: 0 2px 8px rgba(31, 45, 61, .05); } +.material-table { display: grid; gap: 8px; border-top: 0; } +.material-table article { overflow: hidden; border: 1px solid #e5e9ee; border-radius: 12px; background: #fff; transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; } +.material-table article:hover { border-color: #b9d9f8; background: #fff; box-shadow: 0 7px 18px -14px rgba(13, 114, 217, .55); transform: translateY(-1px); } +.material-table article.selected { border-color: #86bff2; background: #f5faff; box-shadow: inset 3px 0 #0d72d9; } +.material-open { padding: 14px 16px; } +.material-open strong { font-size: 14px; } +.material-open em { padding: 4px 8px; border-radius: 999px; color: #1677d2; background: #eaf4ff; font-size: 10px; font-weight: 700; letter-spacing: .04em; } +.material-open .material-ocr-badge { width: fit-content; padding: 0; border-radius: 0; background: transparent; letter-spacing: .02em; } +.material-open .material-status--ready { color: #27945a; background: transparent; } +.material-open .material-status--failed { color: #d92d20; background: transparent; } +.material-actions { padding-right: 10px; } +.material-actions button { border-radius: 8px; } +.material-actions button:hover:not(:disabled) { background: #edf6ff; } +.material-actions .material-delete:hover:not(:disabled) { background: #fff0f0; } +.material-table > .empty-row { margin: 0; padding: 24px 16px; border: 1px dashed #dce2e8; border-radius: 12px; background: #fff; text-align: center; } +:root[data-theme="dark"] .material-library, +:root[data-theme="dark"] .material-library > header input, +:root[data-theme="dark"] .material-table article, +:root[data-theme="dark"] .material-table > .empty-row { border-color: var(--rule); background: var(--surface); } +:root[data-theme="dark"] .material-table article:hover { border-color: #356f9f; background: #35383e; } +:root[data-theme="dark"] .material-table article.selected { border-color: #4388c4; background: #25384a; box-shadow: inset 3px 0 #409eff; } +:root[data-theme="dark"] .material-open em { color: #78bdff; background: #193c5c; } +:root[data-theme="dark"] .material-open .material-ocr-badge { background: transparent; } +:root[data-theme="dark"] .material-open .material-status--ready { color: #67d695; background: transparent; } +:root[data-theme="dark"] .material-open .material-status--failed { color: #ff8f87; background: transparent; } +.text-preview { border-radius: 18px; box-shadow: 0 18px 52px rgba(40,40,44,.18); } + +.knowledge-layout { grid-template-columns: 240px minmax(0, 1fr); gap: 0; overflow: hidden; border: 1px solid var(--rule); border-radius: 16px; background: #fff; } +.filter-panel { top: 16px; height: 100%; border: 0; border-right: 1px solid var(--rule); border-radius: 0; box-shadow: none; background: #f5f6f7; } +.knowledge-list { border: 0; border-radius: 0; box-shadow: none; } +.knowledge-list__status { padding-inline: 18px; } +.knowledge-list article > button { min-height: 76px; padding: 13px 18px; } +.knowledge-list article:hover { background: #f4f5f6; } +.knowledge-list article.expanded { background: #eff0f1; } +.mastery-value { width: 42px; height: 42px; color: #62676d; background: #eceeef; font-size: 17px; } +.point-detail { padding-bottom: 18px; } + +.settings-layout { width: min(1120px, 100%); min-height: calc(100vh - 180px); margin: 0 auto; grid-template-columns: 250px minmax(0, 852px); justify-content: center; align-items: center; gap: 18px; } +.settings-index { position: sticky; top: 16px; display: flex; min-height: 440px; grid-column: 1; grid-row: 1; overflow: hidden; border: 1px solid var(--rule); border-radius: 16px; flex-direction: column; background: #f5f6f7; box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); } +.settings-index h2 { margin: 0; padding: 18px; border-bottom: 1px solid var(--rule); font-size: 16px; } +.settings-index nav { display: grid; padding: 7px 0; flex: 1; grid-template-rows: repeat(5, minmax(0, 1fr)); } +.settings-index button { position: relative; width: 100%; min-height: 58px; padding: 10px 18px; border: 0; color: var(--muted); background: transparent; cursor: pointer; text-align: left; font-size: 13px; } +.settings-index button:hover, .settings-index button.active { color: var(--accent-deep); background: #eceeef; } +.settings-index button::before { position: absolute; top: 50%; left: 0; width: 3px; height: 20px; border-radius: 0 3px 3px 0; background: #0d72d9; content: ""; opacity: 0; transform: translateY(-50%); } +.settings-index button:hover::before, .settings-index button.active::before { opacity: 1; } +.settings-main { grid-column: 2; grid-row: 1; } +.settings-main > .form-section, .settings-main > .side-section { display: flex; min-height: 440px; padding: 36px 40px; flex-direction: column; justify-content: center; } +.settings-main input:not([type="checkbox"]), .settings-main select { background: #fff; } +:root[data-theme="dark"] .settings-main input:not([type="checkbox"]), :root[data-theme="dark"] .settings-main select { background: var(--field); } +.settings-main > #credits-settings { justify-content: flex-start; gap: 22px; } +#credits-settings > .credits-settings__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin: 0; } +.credits-settings__header > div { min-width: 0; } +.credits-settings__header p { max-width: 520px; } +#credits-settings .credit-balance { + display: grid; + min-height: 94px; + grid-template-columns: minmax(0, 1fr) auto; + padding: 16px 18px; + align-items: center; + gap: 5px 20px; + border: 1px solid #d8e8f5; + border-radius: 14px; + background: linear-gradient(135deg, #f0f8ff 0%, #f9fcff 75%); + box-shadow: none; +} +#credits-settings .credit-balance__label { grid-column: 1; color: #5f7182; font-size: 14px; font-weight: 700; letter-spacing: .03em; } +.credit-balance__value { display: flex; grid-column: 2; grid-row: 1 / 3; align-items: baseline; justify-self: end; gap: 7px; } +#credits-settings .credit-balance__value strong { color: #173d60; font-size: clamp(26px, 3.4vw, 32px); line-height: 1; letter-spacing: -.035em; } +#credits-settings .credit-balance__value span { color: #57728a; font-size: 11px; font-weight: 700; } +#credits-settings .credit-balance > small { grid-column: 1; color: #75889a; font-size: 13px; } +.credits-redeem { display: grid; gap: 17px; padding-top: 21px; border-top: 1px solid var(--rule); } +.credits-redeem__copy h3 { margin: 0; font-size: 16px; } +.credits-redeem__copy p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; } +.credits-redeem__controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: end; } +.credits-redeem__controls > label { display: grid; min-width: 0; gap: 7px; margin: 0; color: var(--muted); font-size: 12px; } +.credits-redeem__controls input { min-height: 42px !important; border-radius: 11px !important; font-family: Consolas, "SFMono-Regular", monospace; letter-spacing: .04em; } +.credits-redeem__actions { display: flex; gap: 8px; } +.credits-redeem__actions .button { min-height: 42px; padding-inline: 16px; border-radius: 999px; white-space: nowrap; } +:root[data-theme="dark"] #credits-settings .credit-balance { border-color: #354d62; background: linear-gradient(135deg, #24384a, #29333d); } +:root[data-theme="dark"] #credits-settings .credit-balance__label, +:root[data-theme="dark"] #credits-settings .credit-balance__value span, +:root[data-theme="dark"] #credits-settings .credit-balance > small { color: #a8bac9; } +:root[data-theme="dark"] #credits-settings .credit-balance__value strong { color: #e7f3ff; } +.settings-side { grid-column: 2; grid-row: 2; } +.danger-section { border-color: #fab6b6; box-shadow: none; } + +.graph-page { max-width: none !important; } +.graph-workbench { + display: grid; + grid-template-columns: 200px minmax(480px, 1fr) 280px; + gap: 0; + min-height: 620px; + margin-top: 16px; + overflow: hidden; + border: 1px solid var(--rule); + border-radius: 16px; + background: #fff; + box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); + transition: grid-template-columns 360ms var(--ease-out); +} +.graph-workbench--inspector-collapsed { grid-template-columns: 200px minmax(480px, 1fr) 48px; } +.chapter-outline, .graph-inspector { position: relative; top: auto; max-height: none; border: 0; border-radius: 0; box-shadow: none; background: #f5f6f7; } +.chapter-outline { padding: 16px 0; border-right: 1px solid var(--rule); } +.chapter-outline nav button { min-height: 44px; } +.chapter-outline nav button:hover { color: var(--ink); background: #eceeef; } +.chapter-outline nav button.active { color: var(--accent-deep); background: #eceeef; box-shadow: inset 3px 0 var(--surface-strong); } +.graph-board { padding: 18px; border: 0; border-radius: 0; box-shadow: none; } +.graph-board > header { padding-bottom: 14px; } +.graph-inspector { min-width: 0; padding: 18px; overflow: auto; border-left: 1px solid var(--rule); transition: padding 360ms var(--ease-out); } +.graph-inspector__header { display: flex; min-width: 0; align-items: center; gap: 10px; } +.graph-inspector__header h2 { flex: 1; overflow: hidden; white-space: nowrap; transition: width 260ms var(--ease-out), flex-grow 260ms var(--ease-out), opacity 180ms ease, transform 260ms var(--ease-out); } +.graph-inspector__toggle { display: grid; width: 32px; height: 32px; padding: 0; flex: 0 0 32px; place-items: center; border: 1px solid var(--rule); border-radius: 10px; color: var(--muted); background: var(--surface); cursor: pointer; transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease, transform 360ms var(--ease-out); } +.graph-inspector__toggle:hover { border-color: color-mix(in srgb, #1677d2 48%, var(--rule)); color: #1677d2; background: color-mix(in srgb, #1677d2 7%, var(--surface)); box-shadow: 0 4px 12px -8px rgba(22,119,210,.75); } +.graph-inspector__toggle svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform 360ms var(--ease-out); } +.graph-inspector__content { min-width: 243px; opacity: 1; transform: translateX(0); transition: opacity 180ms ease 100ms, transform 360ms var(--ease-out), visibility 0s linear; } +.graph-workbench--inspector-collapsed .graph-inspector { padding-right: 7px; padding-left: 7px; overflow: hidden; } +.graph-workbench--inspector-collapsed .graph-inspector__header { justify-content: center; } +.graph-workbench--inspector-collapsed .graph-inspector__header h2 { width: 0; flex-grow: 0; opacity: 0; transform: translateX(12px); } +.graph-workbench--inspector-collapsed .graph-inspector__toggle { transform: translateX(-6px); } +.graph-workbench--inspector-collapsed .graph-inspector__toggle svg { transform: rotate(180deg); } +.graph-workbench--inspector-collapsed .graph-inspector__content { visibility: hidden; opacity: 0; pointer-events: none; transform: translateX(18px); transition: opacity 120ms ease, transform 300ms var(--ease-out), visibility 0s linear 300ms; } +.graph-version-control { min-height: 34px; border-radius: 12px; background: #fff; } +.relation-filter { position: relative; display: grid; padding: 0; overflow: hidden; border: 1px solid #cbdcf0; border-radius: 16px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; background: #eff6ff; isolation: isolate; } +.relation-filter::before { position: absolute; z-index: 0; inset: 0 auto 0 0; width: 33.3334%; border-radius: 14px; background: #1677d2; box-shadow: 0 5px 13px -9px rgba(22,119,210,.95); content: ""; transition: transform 360ms var(--ease-out), background-color 240ms ease, box-shadow 240ms ease; } +.relation-filter--prerequisite { border-color: #d9d2fb; background: #f4f1ff; } +.relation-filter--prerequisite::before { background: #7657c8; box-shadow: 0 5px 13px -9px rgba(118,87,200,.95); transform: translateX(100%); } +.relation-filter--related { border-color: #c9e8d5; background: #effaf3; } +.relation-filter--related::before { background: #27945a; box-shadow: 0 5px 13px -9px rgba(39,148,90,.95); transform: translateX(200%); } +.relation-filter .relation-filter__button { position: relative; z-index: 1; min-height: 40px; border: 0; border-radius: 0; background: transparent; font-weight: 700; transition: color 180ms ease; } +.relation-filter__button--all { color: #1677d2; } +.relation-filter__button--prerequisite { color: #7657c8; } +.relation-filter__button--related { color: #27945a; } +.relation-filter .relation-filter__button.is-active { color: #fff; background: transparent; } +.relation-filter .relation-filter__button:hover { transform: none; } +:root[data-theme="dark"] .relation-filter { border-color: #315675; background: #19334a; } +:root[data-theme="dark"] .relation-filter--prerequisite { border-color: #514878; background: #2f2948; } +:root[data-theme="dark"] .relation-filter--related { border-color: #37644a; background: #203b2b; } +:root[data-theme="dark"] .relation-filter__button--all { color: #77bdff; } +:root[data-theme="dark"] .relation-filter__button--prerequisite { color: #b9a3ff; } +:root[data-theme="dark"] .relation-filter__button--related { color: #70d99b; } +:root[data-theme="dark"] .relation-filter .relation-filter__button.is-active { color: #fff; } +.relation-rows button:hover { background: #eceeef; } + +.dag-shell { margin-top: 12px; } +.dag-toolbar { min-height: 34px; margin-bottom: 8px; } +.dag-zoom-controls button { border-radius: 10px; } +.dag-viewport { + height: min(650px, calc(100vh - 270px)); + min-height: 500px; + border-color: var(--rule); + border-radius: 14px; + background-color: #f5f6f7; + background-image: radial-gradient(circle, rgba(96,112,128,.18) 1px, transparent 1px); + background-size: 20px 20px; +} +.dag-canvas svg path { stroke: #8997a5; stroke-width: 1.5; opacity: .46; transition: opacity .2s ease, stroke .2s ease, stroke-width .2s ease; } +.dag-canvas svg path.highlighted { stroke: #e6a23c; stroke-width: 2.5; opacity: 1; } +.dag-canvas marker path { fill: #8997a5; } +.dag-canvas marker.highlighted path { fill: #e6a23c; } +.dag-node { + position: absolute; + display: grid; + padding: 3px 8px 3px 28px; + align-content: center; + gap: 1px; + overflow: visible; + border: 0; + border-radius: 12px; + color: var(--ink); + background: rgba(255,255,255,.84); + box-shadow: none; + cursor: pointer; + text-align: left; + transition: color .2s ease, opacity .2s ease, background-color .2s ease; +} +.dag-node::before { + position: absolute; + top: 50%; + left: 5px; + width: 16px; + height: 16px; + border: 2px solid #fff; + border-radius: 50%; + background: #7f8b96; + box-shadow: 0 0 0 2px #7f8b96; + content: ""; + transform: translateY(-50%); + transition: width .2s ease, height .2s ease, background-color .2s ease, box-shadow .2s ease; +} +.dag-node:hover { color: var(--surface-strong); background: #fff; } +.dag-node:hover::before { background: var(--surface-strong); box-shadow: 0 0 0 2px var(--surface-strong); } +.dag-node span { font-size: 12px; } +.dag-node small { font-size: 9px; } +.dag-node--available { color: #3f7652; background: rgba(240,249,235,.94); } +.dag-node--available::before { background: #67c23a; box-shadow: 0 0 0 2px #67c23a; } +.dag-node--path { color: #8a641b; background: rgba(253,246,236,.94); } +.dag-node--path::before { background: #e6a23c; box-shadow: 0 0 0 2px #e6a23c; } +.dag-node--target { color: #b64040; background: rgba(254,240,240,.94); } +.dag-node--target::before { background: #f56c6c; box-shadow: 0 0 0 2px #f56c6c; } +.dag-node.selected { box-shadow: none; font-weight: 700; } +.dag-node.selected::before { width: 22px; height: 22px; left: 2px; border-width: 3px; } +.dag-shell.has-selection .dag-node--default:not(.selected) { opacity: .28; } +.dag-shell.has-selection .dag-canvas svg > path:not(.highlighted) { opacity: .12; } + +.review-setup { margin-top: 5vh; } +.game-stage { + min-height: min(700px, calc(100vh - 150px)); + border: 1px solid var(--rule); + border-radius: 18px; + color: var(--ink); + background: linear-gradient(150deg, rgb(248 251 255 / .16), rgb(232 244 255 / .24)), var(--scene-background, url('/bg.webp')) 50% 50% / 114% auto no-repeat; + box-shadow: 0 4px 10px -6px rgba(40,40,44,.22); +} +.dag-viewport--g6 { cursor: grab; } +.dag-viewport--g6:active { cursor: grabbing; } +.dag-viewport--g6 canvas { outline: none; } +.dag-viewport--g6 .dag-empty { z-index: 2; pointer-events: none; } +.dag-empty--error { padding: 24px; color: var(--danger); text-align: center; } +.dag-stage { position: relative; } +.dag-zoom-controls .dag-unlinked-toggle { min-width: auto; padding-inline: 11px; border-color: #aab7c3; border-style: dashed; } +.dag-zoom-controls .dag-unlinked-toggle.is-active { border-style: solid; color: #fff; background: #667b8d; } +.dag-zoom-controls button:disabled { cursor: not-allowed; opacity: .42; } +.dag-unlinked-panel { position: absolute; z-index: 4; top: 10px; right: 10px; bottom: 10px; display: grid; width: min(360px, calc(100% - 20px)); overflow: hidden; border: 1px solid var(--rule); border-radius: 14px; grid-template-rows: auto minmax(0, 1fr); background: color-mix(in srgb, var(--surface) 96%, transparent); box-shadow: 0 18px 48px rgba(25,35,45,.2); backdrop-filter: blur(12px); } +.dag-unlinked-panel > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 14px; border-bottom: 1px solid var(--rule); } +.dag-unlinked-panel > header div { display: grid; gap: 2px; } +.dag-unlinked-panel > header small { color: var(--muted); font-size: 10px; } +.dag-unlinked-panel > header button { width: 30px; height: 30px; border: 0; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: 20px; } +.dag-unlinked-panel > header button:hover { color: var(--ink); background: var(--surface-soft); } +.dag-unlinked-grid { display: grid; padding: 10px; overflow: auto; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; } +.dag-unlinked-grid > button { display: grid; min-width: 0; min-height: 54px; padding: 8px 9px; border: 1px solid var(--rule); border-radius: 10px; align-content: center; gap: 3px; color: var(--ink); background: var(--surface); cursor: pointer; text-align: left; } +.dag-unlinked-grid > button:hover { border-color: #6d9fc7; background: var(--surface-soft); } +.dag-unlinked-grid > button.selected { border-color: #f56c6c; background: color-mix(in srgb, #f56c6c 10%, var(--surface)); box-shadow: inset 3px 0 #f56c6c; } +.dag-unlinked-grid span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: 700; } +.dag-unlinked-grid small { color: var(--muted); font-size: 9px; } +.game-stage::before { background: linear-gradient(180deg, rgb(9 22 38 / .06), rgb(9 22 38 / .22)); } +.game-stage--backgrounds-loading { + background-color: #dfeaf0; +} +.game-stage--backgrounds-loading > :not(.game-stage__background-loading) { + opacity: 0; +} +.game-stage__background-loading { + position: absolute; + inset: 0; + display: grid; + place-items: center; + z-index: 3; + color: #436276; + font-size: 14px; + letter-spacing: .08em; + background: rgb(223 234 240 / .9); +} +.game-stage--scene-001 { --scene-background: url('/bg.webp'); } +.game-stage--scene-002, .game-stage--scene-004 { --scene-background: url('/bg_1.webp'); } +.game-stage--scene-003, .game-stage--scene-007 { --scene-background: url('/bg2.webp'); } +.game-stage--scene-005 { --scene-background: url('/bg3.webp'); } +.game-stage--scene-006, .game-stage--scene-008 { --scene-background: url('/bg4.webp'); } +.game-stage::after { + position: absolute; + z-index: 0; + inset: 0; + background: repeating-linear-gradient(106deg, transparent 0 42px, rgb(255 224 163 / .07) 43px 46px, transparent 47px 90px); + content: ""; + opacity: .72; + pointer-events: none; + mix-blend-mode: soft-light; +} +.game-stage__meta { color: rgb(255 238 214 / .9); text-shadow: 0 1px 8px rgb(31 20 31 / .45); } +.game-stage .game-stage__meta { right: 230px; } +.game-stage__actions { position: absolute; z-index: 2; top: 16px; right: 18px; display: flex; align-items: center; gap: 8px; } +.game-stage__end, .game-stage__voice, .game-stage__fullscreen { border: 1px solid rgb(255 223 181 / .5); color: #fff6e7; background: rgb(47 43 59 / .42); box-shadow: 0 5px 16px rgb(28 17 23 / .22); cursor: pointer; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } +.game-stage__end { height: 38px; padding: 0 14px; border-radius: 999px; font: inherit; font-size: 13px; font-weight: 700; } +.game-stage__voice, .game-stage__fullscreen { display: grid; width: 38px; height: 38px; padding: 0; border-radius: 12px; place-items: center; } +.game-stage__voice.is-playing { border-color: rgb(169 247 201 / .82); color: #baf9d3; background: rgb(34 87 73 / .64); } +.game-stage__end:hover:not(:disabled), .game-stage__voice:hover, .game-stage__fullscreen:hover { border-color: rgb(255 232 195 / .85); background: rgb(72 50 61 / .62); } +.game-stage__end:disabled { opacity: .58; cursor: wait; } +.game-stage__voice svg, .game-stage__fullscreen svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; } +.game-stage:fullscreen { width: 100vw; max-width: none; min-height: 100vh; height: 100vh; border: 0; border-radius: 0; } +.game-stage .dialogue-panel { position: absolute; bottom: 94px; left: clamp(24px, 5vw, 62px); width: fit-content; max-width: min(760px, 86%); max-height: min(52vh, 420px); display: flex; flex-direction: column; margin: 0; border-color: rgb(255 205 151 / .38); border-radius: 16px; color: #fff8ed; background: linear-gradient(125deg, rgb(55 42 60 / .53), rgb(30 49 65 / .48)); box-shadow: 0 12px 30px rgb(35 21 25 / .22); backdrop-filter: blur(11px) saturate(116%); -webkit-backdrop-filter: blur(11px) saturate(116%); } +.game-stage .dialogue-panel--advance { cursor: pointer; } +.game-stage .dialogue-panel--advance:hover { border-color: rgb(255 224 174 / .72); } +.game-stage .dialogue-panel > h2 { flex: 0 0 auto; } +.game-stage .dialogue-line strong { color: #ffd0a0; } +.game-stage .dialogue-line strong { font-size: 17px; } +.game-stage .dialogue-line p { max-width: 590px; font-size: clamp(17px, 1.35vw, 20px); line-height: 1.78; } +.game-stage .dialogue-line__text { display: grid; min-width: 0; min-height: 0; max-height: min(36vh, 280px); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: rgb(255 205 151 / .45) transparent; } +.game-stage .dialogue-line__text::-webkit-scrollbar { width: 8px; } +.game-stage .dialogue-line__text::-webkit-scrollbar-thumb { border-radius: 8px; background: rgb(255 205 151 / .4); } +.game-stage .dialogue-line__text::-webkit-scrollbar-track { background: transparent; } +.game-stage .dialogue-line__measure, .game-stage .dialogue-line__typed { grid-area: 1 / 1; } +.game-stage .dialogue-line__measure { visibility: hidden; pointer-events: none; } +.game-stage .dialogue-line__typed { margin: 0; overflow-wrap: anywhere; } +.game-stage .dialogue-line--narration { grid-template-columns: 1fr; } +.game-stage .dialogue-panel { bottom: 138px; } +.game-stage .choice-list { position: absolute; right: clamp(24px, 5vw, 62px); bottom: 28px; display: grid; width: min(440px, calc(100% - clamp(48px, 10vw, 124px))); grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 0; } +.game-stage .choice-list button { min-height: 43px; padding: 9px 12px; font-size: 13px; line-height: 1.35; }.game-stage .choice-list button { border-color: rgb(255 207 151 / .4); border-radius: 12px; color: #fff7e9; background: linear-gradient(115deg, rgb(117 65 49 / .76), rgb(78 50 65 / .76)); box-shadow: 0 5px 14px rgb(46 24 19 / .2); backdrop-filter: blur(10px) saturate(118%); -webkit-backdrop-filter: blur(10px) saturate(118%); } +.game-stage .choice-list button:hover:not(:disabled) { border-color: #ffe0ae; color: #fffaf1; background: linear-gradient(115deg, rgb(157 83 54 / .9), rgb(105 64 83 / .9)); transform: none; } +.game-stage .dialogue-panel--feedback { border-width: 2px; } +.game-stage .dialogue-panel--feedback-correct { border-color: rgb(150 245 190 / .76); background: linear-gradient(125deg, rgb(27 82 69 / .76), rgb(29 57 68 / .74)); } +.game-stage .dialogue-panel--feedback-incorrect { border-color: rgb(255 173 160 / .78); background: linear-gradient(125deg, rgb(105 48 55 / .78), rgb(54 43 65 / .76)); } +.game-stage .dialogue-panel--feedback-correct .dialogue-line strong { color: #a9f7c9; } +.game-stage .dialogue-panel--feedback-incorrect .dialogue-line strong { color: #ffc0b5; } +.game-stage .dialogue-line--feedback { grid-template-columns: minmax(92px, auto) minmax(0, 1fr); } +.game-stage .choice-list--feedback { display: block; width: auto; } +.game-stage > .primary-button { justify-self: end; margin-top: 14px; padding: 12px 22px; border: 1px solid rgb(255 218 172 / .5); border-radius: 999px; color: #fff; background: linear-gradient(115deg, #cf714c, #8f596c); box-shadow: 0 8px 18px rgb(59 30 34 / .28); font-size: 14px; font-weight: 750; } +.game-stage > .primary-button:hover:not(:disabled) { background: linear-gradient(115deg, #df8054, #a4647a); transform: translateY(-1px); } +.game-stage .choice-list:has(> button:only-child) { display: block; width: auto; justify-self: end; margin-top: 14px; } +.game-stage .choice-list:has(> button:only-child) button { min-height: 0; padding: 12px 22px; border: 1px solid rgb(255 218 172 / .5); border-radius: 999px; color: #fff; background: linear-gradient(115deg, #cf714c, #8f596c); box-shadow: 0 8px 18px rgb(59 30 34 / .28); font-size: 14px; font-weight: 750; } +.game-stage .choice-list:has(> button:only-child) button:hover:not(:disabled) { border-color: #ffe0ae; color: #fff; background: linear-gradient(115deg, #df8054, #a4647a); transform: translateY(-1px); } + +.review-page--setup { + position: relative; + isolation: isolate; + overflow: hidden; + background: + radial-gradient(circle at 18% 28%, rgba(60, 167, 255, .18), transparent 30%), + radial-gradient(circle at 82% 24%, rgba(160, 105, 255, .16), transparent 32%), + radial-gradient(circle at 68% 82%, rgba(255, 126, 179, .13), transparent 34%), + var(--canvas); +} +.review-page--setup::before { + position: absolute; + z-index: -1; + inset: 54px 0 0; + opacity: .45; + background-image: radial-gradient(circle, rgba(71, 104, 146, .16) 1px, transparent 1px); + background-size: 26px 26px; + mask-image: linear-gradient(to bottom, transparent, #000 18%, #000 72%, transparent); + content: ""; + pointer-events: none; +} +.review-page--setup .review-setup { + --review-glow-one: rgba(64, 160, 255, .66); + --review-glow-two: rgba(255, 105, 174, .52); + position: relative; + display: grid; + width: min(860px, 100%); + min-height: 520px; + margin: clamp(34px, 7vh, 78px) auto 0; + padding: clamp(34px, 5vw, 54px); + align-content: center; + gap: 26px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, .72); + border-radius: 32px; + background: linear-gradient(145deg, rgba(255, 255, 255, .7), rgba(255, 255, 255, .38)); + box-shadow: 0 32px 80px -40px rgba(43, 68, 110, .52), inset 0 1px 0 rgba(255, 255, 255, .86); + backdrop-filter: blur(28px) saturate(155%); + -webkit-backdrop-filter: blur(28px) saturate(155%); +} +.review-page--setup .review-setup[data-style="fantasy"] { --review-glow-one: rgba(137, 91, 255, .66); --review-glow-two: rgba(255, 91, 164, .56); } +.review-page--setup .review-setup[data-style="science"] { --review-glow-one: rgba(34, 139, 255, .68); --review-glow-two: rgba(34, 222, 203, .5); } +.review-setup__orb { position: absolute; border-radius: 50%; filter: blur(7px); opacity: .82; pointer-events: none; will-change: transform, opacity; } +.review-setup__orb--one { top: -130px; right: -90px; width: 360px; height: 360px; background: radial-gradient(circle at 36% 36%, var(--review-glow-one), transparent 68%); animation: review-orb-one 8s ease-in-out infinite alternate; } +.review-setup__orb--two { bottom: -170px; left: -110px; width: 420px; height: 420px; background: radial-gradient(circle at 58% 42%, var(--review-glow-two), transparent 68%); animation: review-orb-two 10s ease-in-out infinite alternate; } +@keyframes review-orb-one { from { opacity: .62; transform: translate3d(0, 0, 0) scale(.94); } to { opacity: .92; transform: translate3d(-72px, 58px, 0) scale(1.12); } } +@keyframes review-orb-two { from { opacity: .58; transform: translate3d(0, 0, 0) scale(.92); } to { opacity: .88; transform: translate3d(88px, -54px, 0) scale(1.1); } } +.review-setup > :not(.review-setup__orb) { position: relative; z-index: 1; } +.review-setup__heading { max-width: 650px; } +.review-setup__heading .section-label { display: inline-flex; padding: 6px 10px; border: 1px solid rgba(255,255,255,.78); border-radius: 999px; color: #47627f; background: rgba(255,255,255,.42); font-weight: 700; letter-spacing: .1em; backdrop-filter: blur(12px); } +.review-setup__heading h2 { margin: 14px 0 10px; font-size: clamp(34px, 4vw, 48px); line-height: 1.05; letter-spacing: -.045em; } +.review-setup__heading p { max-width: 590px; margin: 0; color: rgba(45, 58, 78, .68); font-size: 14px; line-height: 1.7; } +.review-setup__summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.review-setup__summary > div { display: flex; min-height: 76px; padding: 16px 18px; align-items: center; gap: 7px; border: 1px solid rgba(255,255,255,.66); border-radius: 18px; background: rgba(255,255,255,.34); box-shadow: inset 0 1px 0 rgba(255,255,255,.7); backdrop-filter: blur(16px); } +.review-setup__summary span { margin-right: auto; color: rgba(52, 68, 90, .62); font-size: 14px; font-weight: 700; } +.review-setup__summary strong { color: #25364b; font-size: 34px; letter-spacing: -.04em; } +.review-setup__summary small { color: rgba(52, 68, 90, .62); font-size: 13px; } +.review-setup__controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.review-page--setup .review-setup__controls label { display: grid; gap: 8px; margin: 0; color: #40536c; font-size: 12px; font-weight: 700; } +.review-page--setup .review-setup__controls select { min-height: 48px; padding: 10px 14px; border: 1px solid rgba(255,255,255,.82); border-radius: 15px; color: #273b54; background: rgba(255,255,255,.5); box-shadow: inset 0 1px 0 rgba(255,255,255,.72); backdrop-filter: blur(14px); } +.review-page--setup .review-setup__controls select:focus { border-color: rgba(51, 132, 220, .62); background: rgba(255,255,255,.72); box-shadow: 0 0 0 3px rgba(42, 135, 228, .12); } +.review-page--setup .review-setup > .primary-button { width: 100%; min-height: 50px; border-radius: 16px; background: linear-gradient(135deg, #1677d2, #7858d7); box-shadow: 0 14px 28px -16px rgba(52, 85, 190, .7); font-weight: 700; transition: transform .2s ease, box-shadow .2s ease, opacity .2s ease; } +.review-page--setup .review-setup > .primary-button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 18px 34px -17px rgba(52, 85, 190, .82); } +.review-page--setup > .status-line { width: min(860px, 100%); margin: 16px auto 0; border-color: rgba(255,255,255,.62); border-radius: 14px; background: rgba(255,255,255,.42); backdrop-filter: blur(16px); } +:root[data-theme="dark"] .review-page--setup { background: radial-gradient(circle at 18% 28%, rgba(35, 126, 207, .24), transparent 30%), radial-gradient(circle at 82% 24%, rgba(119, 72, 204, .23), transparent 32%), radial-gradient(circle at 68% 82%, rgba(194, 69, 125, .17), transparent 34%), var(--canvas); } +:root[data-theme="dark"] .review-page--setup .review-setup { border-color: rgba(255,255,255,.14); background: linear-gradient(145deg, rgba(47, 52, 65, .72), rgba(34, 38, 49, .46)); box-shadow: 0 34px 90px -42px rgba(0,0,0,.8), inset 0 1px 0 rgba(255,255,255,.12); } +:root[data-theme="dark"] .review-setup__heading .section-label { border-color: rgba(255,255,255,.14); color: #a9caeb; background: rgba(255,255,255,.07); } +:root[data-theme="dark"] .review-setup__heading p, +:root[data-theme="dark"] .review-setup__summary span, +:root[data-theme="dark"] .review-setup__summary small { color: rgba(225,232,242,.64); } +:root[data-theme="dark"] .review-setup__summary > div { border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.07); box-shadow: inset 0 1px 0 rgba(255,255,255,.1); } +:root[data-theme="dark"] .review-setup__summary strong { color: #f1f6fc; } +:root[data-theme="dark"] .review-page--setup .review-setup__controls label { color: #bed0e4; } +:root[data-theme="dark"] .review-page--setup .review-setup__controls select { border-color: rgba(255,255,255,.14); color: #edf4fb; background: rgba(255,255,255,.08); } +:root[data-theme="dark"] .review-page--setup > .status-line { border-color: rgba(255,255,255,.1); background: rgba(37,41,51,.52); } + +@media (max-width: 760px) { + .review-page--setup .review-setup { min-height: 0; margin-top: 18px; padding: 28px 20px; gap: 20px; border-radius: 24px; } + .review-setup__heading h2 { font-size: 34px; } + .review-setup__summary > div { min-height: 66px; padding: 13px 14px; } + .review-setup__summary strong { font-size: 30px; } + .review-setup__controls { grid-template-columns: 1fr; } + .review-page--setup .review-setup__controls select { min-height: 46px; } + .review-page--setup > .status-line { margin-top: 12px; } +} + +@media (max-width: 1180px) { + .graph-workbench { grid-template-columns: 210px minmax(420px, 1fr); } + .graph-inspector { grid-column: 1 / -1; min-height: 240px; border-top: 1px solid var(--rule); border-left: 0; } + .graph-workbench--inspector-collapsed .graph-inspector { min-height: 48px; padding: 8px 12px; } + .graph-workbench--inspector-collapsed .graph-inspector__header { justify-content: flex-end; } + .home-workspace { grid-template-columns: 1fr; } + .process-layout { grid-template-columns: 1fr; } + .plan-panel { position: static; } + .admin-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 760px) { + .route-transition { animation-name: route-fade; } + .app-shell { + padding: 48px 0 calc(64px + env(safe-area-inset-bottom)); + --koishi-activity-width: 0px; + --koishi-status-height: 0px; + } + + .mobile-shell-head { + position: fixed; + z-index: 115; + inset: 0 0 auto; + display: grid; + height: 48px; + padding: 0 12px; + grid-template-columns: 1fr auto 1fr; + align-items: center; + border-bottom: 1px solid var(--rule); + background: rgba(255,255,255,.96); + backdrop-filter: blur(10px); + } + .mobile-shell-head > a { display: inline-flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; } + .mobile-shell-head .brand-mark { width: 25px; height: 18px; color: var(--brand); } + .mobile-shell-head strong { font-size: 13px; } + .mobile-shell-head > span { color: var(--muted); font-size: 12px; } + .mobile-shell-head > button { display: grid; justify-self: end; width: 34px; height: 34px; padding: 0; place-items: center; border: 0; color: var(--muted); background: transparent; } + .mobile-shell-head > button svg { width: 20px; height: 20px; } + + .app-rail { + inset: auto 0 0; + display: block; + width: 100%; + height: calc(64px + env(safe-area-inset-bottom)); + padding: 4px 6px max(4px, env(safe-area-inset-bottom)); + border-top: 1px solid var(--rule); + border-right: 0; + background: rgba(248,250,252,.98); + box-shadow: 0 -4px 14px rgba(40,40,44,.08); + } + .rail-brand, .rail-account { display: none; } + .rail-nav { display: grid; height: 56px; padding: 0; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; } + .rail-nav a { min-height: 56px; border-radius: 10px; } + .rail-nav a::before { top: auto; bottom: 0; left: 50%; width: 22px; height: 3px; border-radius: 3px 3px 0 0; transform: translate(-50%, 100%); } + .rail-nav a.active::before { transform: translate(-50%, 0); } + .rail-nav a::after { display: none; } + .rail-nav a > span { display: block; margin-top: -5px; font-size: 9px; } + .rail-nav svg { width: 20px; height: 20px; } + + .app-shell__content { min-height: calc(100svh - 112px); } + .app-statusbar { display: none; } + .app-shell .page { min-height: calc(100svh - 112px); padding: 16px 14px 30px; } + .app-shell .page-header { min-height: auto; margin: -16px -14px 16px; padding: 12px 14px; align-items: flex-start; flex-direction: column; gap: 10px; } + .app-shell .page-header:not(.page-header--has-description):not(.page-header--has-actions) { display: none; } + .app-shell .page-header h1 { display: none; } + .app-shell .page-header__copy { display: none; } + .app-shell .page-header--has-description .page-header__copy { display: flex; } + .app-shell .page-header--has-description p { margin: 0; color: var(--ink); font-size: 14px; font-weight: 700; white-space: normal; } + .page-header__actions { width: 100%; } + .page-header__actions > :not(.quiet-action-divider) { flex: 1 1 auto; } + .page-header__actions > .quiet-action-divider { width: 1px; height: 14px; flex: 0 0 1px; align-self: center; } + .home-dashboard .page-header { align-items: center; flex-direction: row; } + .home-dashboard .page-header__actions { width: auto; margin-left: auto; } + .home-dashboard .page-header__actions > * { flex: 0 0 auto; } + .home-theme-toggle { min-height: 32px; padding: 4px 6px; gap: 8px; } + .home-welcome > .home-theme-toggle { top: 14px; right: 14px; } + .home-welcome__intro > div { padding-right: 104px; } + .home-theme-toggle input[type="checkbox"] { width: 36px; height: 20px; } + .home-theme-toggle input[type="checkbox"]::before { width: 14px; height: 14px; } + .home-theme-toggle input[type="checkbox"]:checked::before { transform: translateX(16px); } + .home-workspace { grid-template-columns: 1fr; } + .resume-panel { min-height: 220px; padding: 24px; } + .study-snapshot { padding: 18px; } + .home-quick-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .home-quick-actions a { min-height: 94px; gap: 10px; padding: 14px; } + .home-welcome .home-quick-actions { grid-template-columns: 1fr; } + .home-welcome .home-quick-actions a { min-height: 78px; } + .home-summary { display: none; } + .quick-action-icon { width: 38px; height: 38px; } + .data-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .data-strip div { min-height: 64px; padding: 8px; border-right: 1px solid var(--rule); border-bottom: 0; } + .data-strip div:last-child { border-right: 0; } + .data-strip strong { font-size: 19px; } + .knowledge-layout { grid-template-columns: 1fr; } + .filter-panel { position: static; border-right: 0; border-bottom: 1px solid var(--rule); } + .settings-layout { grid-template-columns: 1fr; } + .settings-index, .settings-main, .settings-side { position: static; grid-column: 1; grid-row: auto; } + .settings-index nav { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .settings-layout { min-height: 0; align-items: stretch; } + .settings-index { min-height: 0; } + .settings-index nav { grid-template-rows: none; } + .settings-main > .form-section, .settings-main > .side-section { min-height: 0; padding: 24px 22px; } + #credits-settings > .credits-settings__header { flex-direction: column; gap: 12px; } + #credits-settings .credit-balance { min-height: 88px; padding: 15px 16px; gap: 4px 12px; } + .credits-redeem__controls { grid-template-columns: 1fr; } + .credits-redeem__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .credits-redeem__actions .button { width: 100%; } + .graph-workbench { grid-template-columns: 1fr; min-height: 0; overflow: visible; } + .chapter-outline, .graph-inspector { grid-column: 1; } + .chapter-outline { max-height: 260px; border-right: 0; border-bottom: 1px solid var(--rule); } + .graph-board { padding: 14px; } + .graph-board > header, .graph-board-view-tools { align-items: stretch; flex-direction: column; } + .graph-board-view-tools { width: 100%; } + .graph-view-switch { width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .graph-board input { width: 100%; max-width: none; } + .graph-inspector { border-top: 1px solid var(--rule); } + .dag-viewport { height: 520px; min-height: 520px; } + .app-shell .materials-page { display: block; } + .materials-page .process-layout--materials, .process-layout--materials .process-primary { min-height: 0; } + .process-layout--materials .process-primary { grid-template-columns: minmax(0, 1fr); } + .process-layout--materials .upload-panel, .process-layout--materials .material-library { height: auto; } + .materials-page .form-section, .materials-page .material-library { padding: 18px; } + .dialogue-line, .choice-list { grid-template-columns: 1fr; } + .game-stage .dialogue-line--feedback { grid-template-columns: 1fr; } + .game-stage { min-height: 620px; padding: 68px 16px 20px; } + .review-summary__heading, .review-summary li > div { align-items: flex-start; flex-direction: column; gap: 6px; } + +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 673465c..bd1a24c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -24,6 +24,15 @@ export default defineConfig({ plugins: [react(), serveDevelopmentWikiIndex()], build: { sourcemap: false, + rollupOptions: { + output: { + // @antv/g6 体积巨大(~1.4MB minified),仅 KnowledgeDag.tsx 使用。 + // 拆成独立 chunk,避免其依赖被合并进主包,也让浏览器可独立缓存。 + manualChunks(id: string) { + if (id.includes('@antv')) return 'knowledge-graph' + }, + }, + }, }, server: { host: '0.0.0.0', diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..124347a --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + environment: 'node', + }, +}) diff --git a/gateway/README.md b/gateway/README.md index 427a744..f80ba1e 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -58,6 +58,29 @@ Gateway 配置、请求头或日志。 - `Authorization` 不透传给业务服务,`Idempotency-Key` 和 `X-Correlation-Id` 按契约保留。 +## 水平扩展约束(必读) + +当前实现的两项状态均在**进程内存**中: + +| 能力 | 存储 | 多实例后果 | +|---|---|---| +| 限流(`express-rate-limit`) | 内存 Map | 每实例独立配额,N 副本 ≈ N 倍攻击面 | +| 令牌内省缓存 | 内存 LRU | 仅影响命中率,语义仍正确 | + +**因此:Gateway 当前部署目标是单实例。** 需要水平扩展时: + +1. 限流改为 Redis store(或等价共享存储)后再扩副本; +2. 内省缓存可保持本地,但需接受吊销延迟 = TTL × 副本无关; +3. `/metrics` 也是进程内计数,扩展后需按实例 scrape 或上聚合。 + +Compose 与 Windows 生产脚本均按单 Gateway 实例编写;不要直接 `docker compose up --scale gateway=2`。 + +## 可观测性 + +- `GET /healthz`:存活探针。 +- `GET /readyz`:探测下游 `/healthz`。 +- `GET /metrics`:Prometheus 文本(请求数、状态码、时延和、内省缓存命中/未命中)。默认仅应从内网或本机 scrape。 + ## 容器 ```powershell diff --git a/gateway/src/app.ts b/gateway/src/app.ts index 4fad905..7cc54bf 100644 --- a/gateway/src/app.ts +++ b/gateway/src/app.ts @@ -5,6 +5,12 @@ import { traceContextMiddleware } from './middleware/traceContext.js'; import { createBodyDrain } from './middleware/bodyDrain.js'; import { headerSanitizerMiddleware } from './middleware/headerSanitizer.js'; import { createAuthenticationMiddleware } from './middleware/authentication.js'; +import { createIntrospectionCache } from './middleware/introspectionCache.js'; +import { + createMetricsMiddleware, + createMetricsRegistry, + instrumentIntrospectionCache, +} from './middleware/metrics.js'; import { createServiceIdentityMiddleware } from './middleware/serviceIdentity.js'; import { createRateLimiters } from './middleware/rateLimiter.js'; import { errorHandlerMiddleware, notFoundHandler } from './middleware/errorHandler.js'; @@ -29,6 +35,14 @@ export { export function createApp(config: GatewayConfig): express.Express { const app = express(); + const metrics = createMetricsRegistry(); + const introspectionCache = createIntrospectionCache({ + positiveTtlMs: config.introspectionCache.positiveTtlMs, + negativeTtlMs: config.introspectionCache.negativeTtlMs, + maxEntries: config.introspectionCache.maxEntries, + }); + instrumentIntrospectionCache(introspectionCache, metrics); + // 客户端 IP 还原策略。默认 false:X-Forwarded-For 一律不采信,req.ip 取 // socket 对端地址——网关端口可直连时,无条件信任该头等于把匿名限流的桶 // 键交给调用方,轮换头值即可无限绕过登录/注册/找回密码的爆破防护。 @@ -64,6 +78,13 @@ export function createApp(config: GatewayConfig): express.Express { // 3. 健康检查(无需鉴权和清洗) app.use(createHealthRouter(config)); + // 3.1 指标端点(供 scrape;不含鉴权,绑定默认 127.0.0.1) + app.use(createMetricsMiddleware(metrics)); + app.get('/metrics', (_req, res) => { + res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + res.send(metrics.render()); + }); + // 4. 请求头清洗(剥离客户端伪造的内部身份头) app.use(headerSanitizerMiddleware); @@ -85,7 +106,7 @@ export function createApp(config: GatewayConfig): express.Express { }); // ===== 按路由表注册代理 ===== - const authMiddleware = createAuthenticationMiddleware(config); + const authMiddleware = createAuthenticationMiddleware(config, introspectionCache); const serviceMiddleware = createServiceIdentityMiddleware(config); const rateLimiters = createRateLimiters(config); @@ -104,8 +125,8 @@ export function createApp(config: GatewayConfig): express.Express { middlewares.push(rateLimiters[route.rateLimitCategory]); } - // 代理 - const proxy = createProxyForRoute(route, config); + // 代理(上游失败计入 metrics,供告警) + const proxy = createProxyForRoute(route, config, (service, kind) => metrics.onUpstreamFailure(service, kind)); middlewares.push(proxy as unknown as express.RequestHandler); if (route.methods && route.methods.length > 0) { diff --git a/gateway/src/config.ts b/gateway/src/config.ts index 329b9d0..ef0e5e8 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -1,4 +1,4 @@ -import 'dotenv/config'; +import 'dotenv/config'; export interface ServiceTarget { name: string; @@ -25,6 +25,13 @@ export interface GatewayConfig { /** /readyz 必须可达的核心服务 key;省略时兼容性地探测全部服务 */ readinessServices?: string[]; services: Record; + introspectionCache: { + /** active=true 结果最大缓存时间,会被 token expiresAt 截短 */ + positiveTtlMs: number; + /** active=false 负缓存时间 */ + negativeTtlMs: number; + maxEntries: number; + }; rateLimit: { anonymous: { windowMs: number; max: number }; upload: { windowMs: number; max: number }; @@ -60,7 +67,13 @@ function envTrustProxy(key: string): boolean | number | string { } export function loadConfig(): GatewayConfig { - const gatewayKey = env('GATEWAY_KEY', 'moonstone-local-gateway-key'); + // 生产环境必须显式配置 GATEWAY_KEY,禁止静默使用开发默认密钥。 + const configuredGatewayKey = process.env.GATEWAY_KEY?.trim(); + if (!configuredGatewayKey && process.env.NODE_ENV === 'production') { + throw new Error('GATEWAY_KEY must be configured when NODE_ENV=production.'); + } + // 联调/测试兜底:仅非生产环境允许回退到已知开发密钥。 + const gatewayKey = configuredGatewayKey || 'moonstone-local-gateway-key'; /** 读取每服务独立密钥,回退到全局密钥 */ const svcKey = (envName: string) => env(envName, gatewayKey); @@ -144,6 +157,12 @@ export function loadConfig(): GatewayConfig { uploadTimeoutMs: envInt('UPLOAD_TIMEOUT_MS', 120_000), readinessServices, services, + introspectionCache: { + // 默认 30s:登录后高频读接口不必每请求内省;吊销最多延迟一个 TTL。 + positiveTtlMs: envInt('INTROSPECTION_CACHE_TTL_MS', 30_000), + negativeTtlMs: envInt('INTROSPECTION_NEGATIVE_TTL_MS', 5_000), + maxEntries: envInt('INTROSPECTION_CACHE_MAX', 10_000), + }, rateLimit: { anonymous: { windowMs: envInt('RL_ANONYMOUS_WINDOW_MS', 60_000), diff --git a/gateway/src/middleware/authentication.ts b/gateway/src/middleware/authentication.ts index 2b4d4c0..1373ed2 100644 --- a/gateway/src/middleware/authentication.ts +++ b/gateway/src/middleware/authentication.ts @@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express'; import type { GatewayConfig } from '../config.js'; import type { TokenIntrospection } from '../types.js'; import { buildApiFailure, getTraceId } from '../types.js'; +import { createIntrospectionCache, type IntrospectionCache } from './introspectionCache.js'; /** 内省请求超时(毫秒),AuthService 挂起时不会拖死 Gateway */ export const INTROSPECTION_TIMEOUT_MS = 5_000; @@ -9,13 +10,13 @@ export const INTROSPECTION_TIMEOUT_MS = 5_000; /** Token 最大允许长度(字符),防止超长令牌造成出站带宽/AuthService DoS */ const MAX_TOKEN_LENGTH = 8_192; -type ActiveTokenIntrospection = TokenIntrospection & { +export type ActiveTokenIntrospection = TokenIntrospection & { active: true; userId: string; }; /** 内省结果:只有规范响应中的 active=false 能证明令牌无效 */ -type IntrospectionResult = +export type IntrospectionResult = | { status: 'ok'; data: ActiveTokenIntrospection } | { status: 'invalid' } | { status: 'unreachable' }; @@ -164,7 +165,14 @@ async function introspectToken( * - 验证通过后注入 X-User-Id 到 req.gatewayUserId * - 验证失败返回 401 */ -export function createAuthenticationMiddleware(config: GatewayConfig) { +export function createAuthenticationMiddleware( + config: GatewayConfig, + introspectionCache: IntrospectionCache = createIntrospectionCache({ + positiveTtlMs: config.introspectionCache.positiveTtlMs, + negativeTtlMs: config.introspectionCache.negativeTtlMs, + maxEntries: config.introspectionCache.maxEntries, + }), +) { const authServiceUrl = config.services.authService.url; // 使用 AuthService 的独立密钥(如有),回退到全局 gatewayKey const gatewayKey = config.services.authService.serviceKey ?? config.gatewayKey; @@ -189,11 +197,8 @@ export function createAuthenticationMiddleware(config: GatewayConfig) { return; } - const result = await introspectToken( - authServiceUrl, - gatewayKey, - token, - traceId, + const result = await introspectionCache.resolve(token, () => + introspectToken(authServiceUrl, gatewayKey, token, traceId), ); if (result.status === 'unreachable') { diff --git a/gateway/src/middleware/introspectionCache.ts b/gateway/src/middleware/introspectionCache.ts new file mode 100644 index 0000000..029f0d5 --- /dev/null +++ b/gateway/src/middleware/introspectionCache.ts @@ -0,0 +1,145 @@ +import { createHash } from 'node:crypto'; +import type { ActiveTokenIntrospection, IntrospectionResult } from './authentication.js'; + +export interface IntrospectionCacheOptions { + /** active=true 结果的最大缓存时间(会被 token expiresAt 截短) */ + positiveTtlMs?: number; + /** active=false 负缓存时间,避免无效令牌反复打爆 AuthService */ + negativeTtlMs?: number; + /** 距 expiresAt 的安全窗口:剩余寿命短于该值则不缓存 */ + expirySkewMs?: number; + /** 最大条目数,超出后按插入序淘汰 */ + maxEntries?: number; + now?: () => number; +} + +interface CacheEntry { + result: IntrospectionResult; + expiresAtMs: number; +} + +/** + * 令牌内省结果的进程内缓存 + single-flight。 + * - 只缓存 ok / invalid;unreachable 永远穿透,保证 Auth 恢复后立刻可用。 + * - key 为 token 的 SHA-256,缓存中不保留明文令牌。 + */ +export class IntrospectionCache { + private readonly positiveTtlMs: number; + private readonly negativeTtlMs: number; + private readonly expirySkewMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + private readonly entries = new Map(); + private readonly inflight = new Map>(); + + constructor(options: IntrospectionCacheOptions = {}) { + this.positiveTtlMs = options.positiveTtlMs ?? 30_000; + this.negativeTtlMs = options.negativeTtlMs ?? 5_000; + this.expirySkewMs = options.expirySkewMs ?? 2_000; + this.maxEntries = options.maxEntries ?? 10_000; + this.now = options.now ?? Date.now; + } + + static hashToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); + } + + get(token: string): IntrospectionResult | undefined { + const key = IntrospectionCache.hashToken(token); + const entry = this.entries.get(key); + if (!entry) return undefined; + if (entry.expiresAtMs <= this.now()) { + this.entries.delete(key); + return undefined; + } + // 命中后刷新插入序(近似 LRU) + this.entries.delete(key); + this.entries.set(key, entry); + return entry.result; + } + + set(token: string, result: IntrospectionResult): void { + if (result.status === 'unreachable') return; + + const ttlMs = this.resolveTtlMs(result); + if (ttlMs <= 0) return; + + const key = IntrospectionCache.hashToken(token); + this.entries.set(key, { result, expiresAtMs: this.now() + ttlMs }); + this.evictIfNeeded(); + } + + /** + * 相同 token 并发请求只发起一次上游内省。 + * 失败(unreachable)不写入缓存,但会从 inflight 摘除,后续请求可重试。 + */ + async resolve( + token: string, + load: () => Promise, + ): Promise { + const cached = this.get(token); + if (cached) return cached; + + const key = IntrospectionCache.hashToken(token); + const existing = this.inflight.get(key); + if (existing) return existing; + + const pending = load() + .then((result) => { + this.set(token, result); + return result; + }) + .finally(() => { + this.inflight.delete(key); + }); + + this.inflight.set(key, pending); + return pending; + } + + get size(): number { + return this.entries.size; + } + + get inflightCount(): number { + return this.inflight.size; + } + + hasInflight(token: string): boolean { + return this.inflight.has(IntrospectionCache.hashToken(token)); + } + + clear(): void { + this.entries.clear(); + this.inflight.clear(); + } + + private resolveTtlMs(result: IntrospectionResult): number { + const configured = + result.status === 'ok' ? this.positiveTtlMs : this.negativeTtlMs; + + if (result.status !== 'ok' || !result.data.expiresAt) { + return configured; + } + + const expiresAtMs = Date.parse(result.data.expiresAt); + if (Number.isNaN(expiresAtMs)) return configured; + + const remaining = expiresAtMs - this.now() - this.expirySkewMs; + return Math.min(configured, remaining); + } + + private evictIfNeeded(): void { + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next(); + if (oldest.done) break; + this.entries.delete(oldest.value); + } + } +} + +export function createIntrospectionCache( + options?: IntrospectionCacheOptions, +): IntrospectionCache { + return new IntrospectionCache(options); +} diff --git a/gateway/src/middleware/metrics.ts b/gateway/src/middleware/metrics.ts new file mode 100644 index 0000000..8e14760 --- /dev/null +++ b/gateway/src/middleware/metrics.ts @@ -0,0 +1,139 @@ +import type { Request, Response, NextFunction } from 'express'; +import type { IntrospectionCache } from './introspectionCache.js'; + +/** + * 轻量进程内指标(Prometheus text format)。 + * 单实例足够;水平扩展时应换 Prometheus client + 聚合。 + */ +export interface MetricsRegistry { + onRequest(method: string, path: string, status: number, durationMs: number): void; + onIntrospection(result: 'hit' | 'miss' | 'inflight'): void; + /** 上游代理失败(连接/超时/契约错误)——告警信号 */ + onUpstreamFailure(service: string, kind: string): void; + render(): string; +} + +interface Bucket { + count: number; + durationSumMs: number; + byStatus: Map; +} + +function normalizePath(path: string): string { + // 避免高基数:UUID/数字路径段折叠 + return path + .split('/') + .map((seg) => { + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) return ':id'; + if (/^\d+$/.test(seg)) return ':n'; + return seg; + }) + .join('/') + .slice(0, 120); +} + +export function createMetricsRegistry(): MetricsRegistry { + const started = Date.now(); + const requests = new Map(); + const introspection = { hit: 0, miss: 0, inflight: 0 }; + // service|kind -> count;kind ∈ timeout/connection/contract/other + const upstreamFailures = new Map(); + + return { + onRequest(method, path, status, durationMs) { + const key = `${method.toUpperCase()} ${normalizePath(path)}`; + let bucket = requests.get(key); + if (!bucket) { + bucket = { count: 0, durationSumMs: 0, byStatus: new Map() }; + requests.set(key, bucket); + } + bucket.count += 1; + bucket.durationSumMs += durationMs; + bucket.byStatus.set(status, (bucket.byStatus.get(status) ?? 0) + 1); + }, + onIntrospection(result) { + introspection[result] += 1; + }, + onUpstreamFailure(service, kind) { + const key = `${service}|${kind}`; + upstreamFailures.set(key, (upstreamFailures.get(key) ?? 0) + 1); + }, + render() { + const lines: string[] = []; + lines.push('# HELP gateway_uptime_seconds Gateway process uptime'); + lines.push('# TYPE gateway_uptime_seconds gauge'); + lines.push(`gateway_uptime_seconds ${((Date.now() - started) / 1000).toFixed(3)}`); + + lines.push('# HELP gateway_http_requests_total HTTP requests by method/path'); + lines.push('# TYPE gateway_http_requests_total counter'); + for (const [key, bucket] of requests) { + const [method, ...rest] = key.split(' '); + const path = rest.join(' '); + lines.push( + `gateway_http_requests_total{method="${method}",path="${path}"} ${bucket.count}`, + ); + for (const [status, n] of bucket.byStatus) { + lines.push( + `gateway_http_responses_total{method="${method}",path="${path}",status="${status}"} ${n}`, + ); + } + } + + lines.push('# HELP gateway_http_request_duration_ms_sum Sum of request durations'); + lines.push('# TYPE gateway_http_request_duration_ms_sum counter'); + for (const [key, bucket] of requests) { + lines.push( + `gateway_http_request_duration_ms_sum{route="${key}"} ${bucket.durationSumMs.toFixed(1)}`, + ); + } + + lines.push('# HELP gateway_introspection_cache_events Token introspection cache events'); + lines.push('# TYPE gateway_introspection_cache_events counter'); + lines.push(`gateway_introspection_cache_events{result="hit"} ${introspection.hit}`); + lines.push(`gateway_introspection_cache_events{result="miss"} ${introspection.miss}`); + lines.push(`gateway_introspection_cache_events{result="inflight"} ${introspection.inflight}`); + + lines.push('# HELP gateway_upstream_failures_total Upstream proxy failures by service and kind'); + lines.push('# TYPE gateway_upstream_failures_total counter'); + for (const [key, count] of upstreamFailures) { + const [service, kind] = key.split('|'); + lines.push(`gateway_upstream_failures_total{service="${service}",kind="${kind}"} ${count}`); + } + + return lines.join('\n') + '\n'; + }, + }; +} + +export function createMetricsMiddleware(registry: MetricsRegistry) { + return (req: Request, res: Response, next: NextFunction): void => { + const start = process.hrtime.bigint(); + res.on('finish', () => { + const durationMs = Number(process.hrtime.bigint() - start) / 1e6; + registry.onRequest(req.method, req.path || req.url, res.statusCode, durationMs); + }); + next(); + }; +} + +/** 把认证中间件的缓存事件接到 metrics(装饰 IntrospectionCache.resolve)。 */ +export function instrumentIntrospectionCache( + cache: IntrospectionCache, + registry: MetricsRegistry, +): void { + const originalResolve = cache.resolve.bind(cache); + cache.resolve = async (token, load) => { + const cached = cache.get(token); + if (cached) { + registry.onIntrospection('hit'); + return cached; + } + // 仅该 token 已有 in-flight 时计 inflight;否则计 miss(不双计) + if (cache.hasInflight(token)) { + registry.onIntrospection('inflight'); + } else { + registry.onIntrospection('miss'); + } + return originalResolve(token, load); + }; +} diff --git a/gateway/src/proxy/createProxy.ts b/gateway/src/proxy/createProxy.ts index ee144de..4389669 100644 --- a/gateway/src/proxy/createProxy.ts +++ b/gateway/src/proxy/createProxy.ts @@ -71,10 +71,12 @@ const ERROR_MAP: Record = { * - 剥离敏感请求头(Authorization、X-Service-Key),防止凭证泄露 * - 配置超时 * - 写操作不盲目重试;GET 仅在确认幂等时有限重试 + * - 上游失败时回调 onUpstreamFailure(供 metrics 计数告警) */ export function createProxyForRoute( route: RouteEntry, config: GatewayConfig, + onUpstreamFailure?: (service: string, kind: string) => void, ): RequestHandler { const target = config.services[route.service]; if (!target) { @@ -167,6 +169,7 @@ export function createProxyForRoute( code: proxyError.code ?? 'UNKNOWN', status, })); + onUpstreamFailure?.(route.service, kind); const payload = JSON.stringify(buildApiFailure(errorCode, message, traceId)); // 显式 Content-Length:避免 chunked 编码下客户端需等待终结块才能 diff --git a/gateway/tests/app.test.ts b/gateway/tests/app.test.ts index d678631..14b43ad 100644 --- a/gateway/tests/app.test.ts +++ b/gateway/tests/app.test.ts @@ -26,7 +26,11 @@ const mockConfig: GatewayConfig = { creditService: { name: 'CreditService', url: 'http://localhost:5258' }, modelService: { name: 'ModelService', url: 'http://localhost:5259' }, }, - rateLimit: { + introspectionCache: { + positiveTtlMs: 30_000, + negativeTtlMs: 5_000, + maxEntries: 10_000, + }, rateLimit: { anonymous: { windowMs: 60000, max: 100 }, upload: { windowMs: 60000, max: 100 }, generation: { windowMs: 60000, max: 100 }, diff --git a/gateway/tests/bodyLimits.test.ts b/gateway/tests/bodyLimits.test.ts index 5521838..ddb3a53 100644 --- a/gateway/tests/bodyLimits.test.ts +++ b/gateway/tests/bodyLimits.test.ts @@ -28,7 +28,11 @@ const baseConfig: GatewayConfig = { creditService: { name: 'CreditService', url: 'http://127.0.0.1:5258' }, modelService: { name: 'ModelService', url: 'http://127.0.0.1:5259' }, }, - rateLimit: { + introspectionCache: { + positiveTtlMs: 30_000, + negativeTtlMs: 5_000, + maxEntries: 10_000, + }, rateLimit: { anonymous: { windowMs: 60_000, max: 100 }, upload: { windowMs: 60_000, max: 100 }, generation: { windowMs: 60_000, max: 100 }, diff --git a/gateway/tests/config.test.ts b/gateway/tests/config.test.ts index 2d5ec70..d9f25dd 100644 --- a/gateway/tests/config.test.ts +++ b/gateway/tests/config.test.ts @@ -31,6 +31,9 @@ describe('loadConfig', () => { ]); expect(cfg.defaultTimeoutMs).toBe(30_000); expect(cfg.uploadTimeoutMs).toBe(120_000); + expect(cfg.introspectionCache.positiveTtlMs).toBe(30_000); + expect(cfg.introspectionCache.negativeTtlMs).toBe(5_000); + expect(cfg.introspectionCache.maxEntries).toBe(10_000); expect(cfg.services.userService.url).toBe('http://localhost:5101'); expect(cfg.services.authService.url).toBe('http://localhost:5102'); expect(cfg.services.fileService.url).toBe('http://localhost:5103'); @@ -58,6 +61,7 @@ describe('loadConfig', () => { process.env.CORS_ORIGINS = 'http://localhost:5298'; process.env.DEFAULT_TIMEOUT_MS = '10000'; process.env.USER_SERVICE_URL = 'http://custom:5261'; + process.env.INTROSPECTION_CACHE_TTL_MS = '12000'; const cfg = loadConfig(); expect(cfg.port).toBe(5297); @@ -65,6 +69,7 @@ describe('loadConfig', () => { expect(cfg.gatewayKey).toBe('custom-key'); expect(cfg.corsOrigins).toEqual(['http://localhost:5298']); expect(cfg.defaultTimeoutMs).toBe(10_000); + expect(cfg.introspectionCache.positiveTtlMs).toBe(12_000); expect(cfg.services.userService.url).toBe('http://custom:5261'); }); @@ -142,6 +147,18 @@ describe('loadConfig', () => { ); }); + it('生产环境缺少 GATEWAY_KEY 时应启动失败', () => { + delete process.env.GATEWAY_KEY; + process.env.NODE_ENV = 'production'; + expect(() => loadConfig()).toThrow(/GATEWAY_KEY must be configured/); + }); + + it('非生产环境缺少 GATEWAY_KEY 时回退到开发默认密钥', () => { + delete process.env.GATEWAY_KEY; + delete process.env.NODE_ENV; + expect(loadConfig().gatewayKey).toBe('moonstone-local-gateway-key'); + }); + it('服务名应正确映射', () => { const cfg = loadConfig(); expect(cfg.services.userService.name).toBe('UserService'); diff --git a/gateway/tests/middleware/authentication.test.ts b/gateway/tests/middleware/authentication.test.ts index 9745ca6..3507a02 100644 --- a/gateway/tests/middleware/authentication.test.ts +++ b/gateway/tests/middleware/authentication.test.ts @@ -20,7 +20,11 @@ const mockConfig: GatewayConfig = { services: { authService: { name: 'AuthService', url: 'http://localhost:5259' }, }, - rateLimit: { + introspectionCache: { + positiveTtlMs: 30_000, + negativeTtlMs: 5_000, + maxEntries: 10_000, + }, rateLimit: { anonymous: { windowMs: 60000, max: 20 }, upload: { windowMs: 60000, max: 10 }, generation: { windowMs: 60000, max: 5 }, @@ -83,6 +87,53 @@ describe('authenticationMiddleware', () => { expect(res.body.error.code).toBe('AUTH_REQUIRED'); }); + it('相同令牌在缓存 TTL 内应只内省一次', async () => { + const future = new Date(Date.now() + 3_600_000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + successEnvelope({ + ...VALID_ACTIVE_INTROSPECTION, + expiresAt: future, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const first = await request(app) + .get('/protected/data') + .set('Authorization', 'Bearer cached-token'); + const second = await request(app) + .get('/protected/data') + .set('Authorization', 'Bearer cached-token'); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(second.body.userId).toBe(VALID_ACTIVE_INTROSPECTION.userId); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('不同令牌应各自内省', async () => { + const future = new Date(Date.now() + 3_600_000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + successEnvelope({ + ...VALID_ACTIVE_INTROSPECTION, + expiresAt: future, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await request(app) + .get('/protected/data') + .set('Authorization', 'Bearer token-one'); + await request(app) + .get('/protected/data') + .set('Authorization', 'Bearer token-two'); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + it('应拒绝 Bearer 后无 token 的请求', async () => { const res = await request(app) .get('/protected/data') diff --git a/gateway/tests/middleware/introspectionCache.test.ts b/gateway/tests/middleware/introspectionCache.test.ts new file mode 100644 index 0000000..c42dbe1 --- /dev/null +++ b/gateway/tests/middleware/introspectionCache.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest'; +import { IntrospectionCache } from '../../src/middleware/introspectionCache.js'; +import type { IntrospectionResult } from '../../src/middleware/authentication.js'; + +const active = (expiresAt: string | null = null): IntrospectionResult => ({ + status: 'ok', + data: { + active: true, + userId: '7bc4918a-9079-4ea2-9e8e-369ad79a9f20', + sessionId: '6fa43e7f-0383-4c60-b305-8011f4a8cab8', + scopes: ['user'], + expiresAt, + }, +}); + +const invalid: IntrospectionResult = { status: 'invalid' }; +const unreachable: IntrospectionResult = { status: 'unreachable' }; + +describe('IntrospectionCache', () => { + it('应在 TTL 内命中缓存,不再调用 load', async () => { + let now = 1_000_000; + const cache = new IntrospectionCache({ + positiveTtlMs: 30_000, + now: () => now, + }); + const load = vi.fn(async () => active()); + + await cache.resolve('token-a', load); + now += 10_000; + await cache.resolve('token-a', load); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it('TTL 过期后应重新内省', async () => { + let now = 1_000_000; + const cache = new IntrospectionCache({ + positiveTtlMs: 30_000, + now: () => now, + }); + const load = vi.fn(async () => active()); + + await cache.resolve('token-a', load); + now += 30_001; + await cache.resolve('token-a', load); + + expect(load).toHaveBeenCalledTimes(2); + }); + + it('token 即将过期时应缩短缓存寿命', async () => { + let now = Date.parse('2026-09-12T10:00:00Z'); + const cache = new IntrospectionCache({ + positiveTtlMs: 60_000, + expirySkewMs: 2_000, + now: () => now, + }); + // 剩余 10s,skew 2s → 只缓存 8s + const load = vi.fn(async () => active('2026-09-12T10:00:10Z')); + + await cache.resolve('token-b', load); + now += 7_000; + await cache.resolve('token-b', load); + expect(load).toHaveBeenCalledTimes(1); + + now += 2_000; // total +9s > 8s TTL + await cache.resolve('token-b', load); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('应负缓存 invalid,但不缓存 unreachable', async () => { + const cache = new IntrospectionCache({ negativeTtlMs: 5_000 }); + const invalidLoad = vi.fn(async () => invalid); + const unreachableLoad = vi.fn(async () => unreachable); + + await cache.resolve('bad', invalidLoad); + await cache.resolve('bad', invalidLoad); + expect(invalidLoad).toHaveBeenCalledTimes(1); + + await cache.resolve('down', unreachableLoad); + await cache.resolve('down', unreachableLoad); + expect(unreachableLoad).toHaveBeenCalledTimes(2); + }); + + it('相同 token 并发只发起一次上游调用(single-flight)', async () => { + const cache = new IntrospectionCache(); + let resolveLoad!: (r: IntrospectionResult) => void; + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + + const p1 = cache.resolve('token-c', load); + const p2 = cache.resolve('token-c', load); + expect(load).toHaveBeenCalledTimes(1); + + resolveLoad(active()); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1.status).toBe('ok'); + expect(r2.status).toBe('ok'); + }); + + it('缓存键使用 token 哈希,不保存明文', async () => { + const cache = new IntrospectionCache(); + const token = 'super-secret-access-token'; + await cache.resolve(token, async () => active()); + expect(cache.get(token)?.status).toBe('ok'); + expect(IntrospectionCache.hashToken(token)).toHaveLength(64); + expect(IntrospectionCache.hashToken(token)).not.toContain(token); + }); + + it('超出 maxEntries 时应淘汰最旧条目', async () => { + const cache = new IntrospectionCache({ maxEntries: 2 }); + await cache.resolve('t1', async () => active()); + await cache.resolve('t2', async () => active()); + await cache.resolve('t3', async () => active()); + expect(cache.size).toBe(2); + expect(cache.get('t1')).toBeUndefined(); + expect(cache.get('t3')?.status).toBe('ok'); + }); +}); diff --git a/gateway/tests/middleware/metrics.test.ts b/gateway/tests/middleware/metrics.test.ts new file mode 100644 index 0000000..4a0ca98 --- /dev/null +++ b/gateway/tests/middleware/metrics.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { + createMetricsMiddleware, + createMetricsRegistry, + instrumentIntrospectionCache, +} from '../../src/middleware/metrics.js'; +import { IntrospectionCache } from '../../src/middleware/introspectionCache.js'; + +describe('metrics registry', () => { + it('应输出 Prometheus 文本与请求计数', async () => { + const registry = createMetricsRegistry(); + const app = express(); + app.use(createMetricsMiddleware(registry)); + app.get('/ok', (_req, res) => res.status(200).send('ok')); + + await request(app).get('/ok'); + const text = registry.render(); + expect(text).toContain('gateway_uptime_seconds'); + expect(text).toContain('gateway_http_requests_total'); + expect(text).toContain('method="GET"'); + expect(text).toContain('path="/ok"'); + expect(text).toContain('status="200"'); + }); + + it('应折叠 UUID 路径段避免高基数', async () => { + const registry = createMetricsRegistry(); + const app = express(); + app.use(createMetricsMiddleware(registry)); + app.get('/api/v1/users/:id', (_req, res) => res.status(200).send('ok')); + + await request(app).get('/api/v1/users/7bc4918a-9079-4ea2-9e8e-369ad79a9f20'); + const text = registry.render(); + expect(text).toContain('path="/api/v1/users/:id"'); + expect(text).not.toContain('7bc4918a'); + }); + + it('应统计内省缓存命中', async () => { + const registry = createMetricsRegistry(); + const cache = new IntrospectionCache(); + instrumentIntrospectionCache(cache, registry); + + const active = { + status: 'ok' as const, + data: { + active: true as const, + userId: '7bc4918a-9079-4ea2-9e8e-369ad79a9f20', + sessionId: '6fa43e7f-0383-4c60-b305-8011f4a8cab8', + scopes: ['user'], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }; + await cache.resolve('tok', async () => active); + await cache.resolve('tok', async () => active); + + const text = registry.render(); + expect(text).toMatch(/result="miss"\} 1/); + expect(text).toMatch(/result="hit"\} 1/); + }); + + it('应统计上游代理失败(告警信号)', () => { + const registry = createMetricsRegistry(); + registry.onUpstreamFailure('modelService', 'timeout'); + registry.onUpstreamFailure('modelService', 'timeout'); + registry.onUpstreamFailure('creditService', 'connection'); + + const text = registry.render(); + expect(text).toContain('gateway_upstream_failures_total'); + expect(text).toMatch(/service="modelService",kind="timeout"\} 2/); + expect(text).toMatch(/service="creditService",kind="connection"\} 1/); + }); +}); diff --git a/gateway/tests/middleware/rateLimiter.test.ts b/gateway/tests/middleware/rateLimiter.test.ts index 58d4de6..70e7efc 100644 --- a/gateway/tests/middleware/rateLimiter.test.ts +++ b/gateway/tests/middleware/rateLimiter.test.ts @@ -22,7 +22,11 @@ const mockConfig: GatewayConfig = { creditService: { name: 'CreditService', url: 'http://localhost:5108' }, modelService: { name: 'ModelService', url: 'http://localhost:5109' }, }, - rateLimit: { + introspectionCache: { + positiveTtlMs: 30_000, + negativeTtlMs: 5_000, + maxEntries: 10_000, + }, rateLimit: { anonymous: { windowMs: 60000, max: 20 }, upload: { windowMs: 60000, max: 10 }, generation: { windowMs: 60000, max: 5 }, diff --git a/gateway/tests/middleware/serviceIdentity.test.ts b/gateway/tests/middleware/serviceIdentity.test.ts index bbd4cc8..e3bf47e 100644 --- a/gateway/tests/middleware/serviceIdentity.test.ts +++ b/gateway/tests/middleware/serviceIdentity.test.ts @@ -19,7 +19,11 @@ const mockConfig: GatewayConfig = { renderService: { name: 'RenderService', url: 'http://localhost:5106' }, practiceService: { name: 'PracticeService', url: 'http://localhost:5107' }, }, - rateLimit: { + introspectionCache: { + positiveTtlMs: 30_000, + negativeTtlMs: 5_000, + maxEntries: 10_000, + }, rateLimit: { anonymous: { windowMs: 60000, max: 20 }, upload: { windowMs: 60000, max: 10 }, generation: { windowMs: 60000, max: 5 }, diff --git a/gateway/tests/proxy/createProxy.test.ts b/gateway/tests/proxy/createProxy.test.ts index 963b9df..32615db 100644 --- a/gateway/tests/proxy/createProxy.test.ts +++ b/gateway/tests/proxy/createProxy.test.ts @@ -22,7 +22,11 @@ const mockConfig: GatewayConfig = { renderService: { name: 'RenderService', url: 'http://localhost:5106' }, practiceService: { name: 'PracticeService', url: 'http://localhost:5107' }, }, - rateLimit: { + introspectionCache: { + positiveTtlMs: 30_000, + negativeTtlMs: 5_000, + maxEntries: 10_000, + }, rateLimit: { anonymous: { windowMs: 60000, max: 20 }, upload: { windowMs: 60000, max: 10 }, generation: { windowMs: 60000, max: 5 }, @@ -133,7 +137,9 @@ describe('createProxyForRoute', () => { it('上游连接失败应返回 503 SERVICE_UNAVAILABLE', async () => { const unavailableOrigin = await reserveUnavailableOrigin(); - const app = createProxyTestApp(unavailableOrigin, 1_000, 'proxy-connection-trace'); + const failures: Array<{ service: string; kind: string }> = []; + const app = createProxyTestApp(unavailableOrigin, 1_000, 'proxy-connection-trace', (service, kind) => + failures.push({ service, kind })); const response = await request(app).get('/api/v1/users/me'); @@ -146,6 +152,31 @@ describe('createProxyForRoute', () => { }, traceId: 'proxy-connection-trace', }); + // 告警信号:上游失败按 service/kind 计数 + expect(failures).toEqual([{ service: 'userService', kind: 'connection' }]); + }); + + it('应向下游透传 X-Correlation-Id', async () => { + const upstream = await startUpstream(); + try { + const app = express(); + const route: RouteEntry = { + path: '/api/v1/users', + service: 'userService', + auth: 'user', + rateLimitCategory: 'general', + }; + app.use((req, _res, next) => { + req.traceId = 'trace-through-proxy'; + next(); + }); + app.use(route.path, createProxyForRoute(route, configFor(upstream.origin))); + + await request(app).get('/api/v1/users/me'); + expect(await upstream.correlationId).toBe('trace-through-proxy'); + } finally { + await closeServer(upstream.server); + } }); it('上游响应超时应返回 503 SERVICE_UNAVAILABLE', async () => { @@ -207,6 +238,7 @@ function createProxyTestApp( userServiceUrl: string, timeoutMs: number, traceId: string, + onUpstreamFailure?: (service: string, kind: string) => void, ): express.Express { const app = express(); const route: RouteEntry = { @@ -224,22 +256,25 @@ function createProxyTestApp( createProxyForRoute(route, { ...configFor(userServiceUrl), defaultTimeoutMs: timeoutMs, - }), + }, onUpstreamFailure), ); return app; } -async function startUpstream(): Promise<{ server: Server; origin: string; requestUrl: Promise }> { +async function startUpstream(): Promise<{ server: Server; origin: string; requestUrl: Promise; correlationId: Promise }> { let resolveRequestUrl!: (value: string) => void; const requestUrl = new Promise((resolve) => { resolveRequestUrl = resolve; }); + let resolveCorrelationId!: (value: string) => void; + const correlationId = new Promise((resolve) => { resolveCorrelationId = resolve; }); const server = createServer((incoming, response) => { resolveRequestUrl(incoming.url ?? '/'); + resolveCorrelationId(String(incoming.headers['x-correlation-id'] ?? '')); response.writeHead(200, { 'Content-Type': 'application/json' }); response.end('{"ok":true}'); }); const origin = await listenOnTestPort(server); - return { server, origin, requestUrl }; + return { server, origin, requestUrl, correlationId }; } async function startHangingUpstream(): Promise<{ server: Server; origin: string }> { diff --git a/gateway/tests/serviceCompatibility.test.ts b/gateway/tests/serviceCompatibility.test.ts index 786167c..e2b87be 100644 --- a/gateway/tests/serviceCompatibility.test.ts +++ b/gateway/tests/serviceCompatibility.test.ts @@ -213,6 +213,11 @@ function createConfig( serviceKey: 'model-key', }, }, + introspectionCache: { + positiveTtlMs: 30_000, + negativeTtlMs: 5_000, + maxEntries: 10_000, + }, rateLimit: { anonymous: { windowMs: 60_000, max: 100 }, upload: { windowMs: 60_000, max: 100 }, diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/mobile/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/mobile/android/app/.gitignore b/mobile/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/mobile/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle new file mode 100644 index 0000000..14bfe0f --- /dev/null +++ b/mobile/android/app/build.gradle @@ -0,0 +1,69 @@ +apply plugin: 'com.android.application' + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('keystore.properties') +if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.withReader('UTF-8') { reader -> keystoreProperties.load(reader) } +} + +android { + namespace "cn.galreview.app" + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "cn.galreview.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + signingConfigs { + release { + storeFile file('release.keystore') + storePassword keystoreProperties['storePassword'] ?: 'GalReview2026' + keyAlias keystoreProperties['keyAlias'] ?: 'galreview' + keyPassword keystoreProperties['keyPassword'] ?: 'GalReview2026' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.release + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/mobile/android/app/capacitor.build.gradle b/mobile/android/app/capacitor.build.gradle new file mode 100644 index 0000000..fdb4970 --- /dev/null +++ b/mobile/android/app/capacitor.build.gradle @@ -0,0 +1,19 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/mobile/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/mobile/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4d7ca38 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/java/cn/galreview/app/MainActivity.java b/mobile/android/app/src/main/java/cn/galreview/app/MainActivity.java new file mode 100644 index 0000000..ce7bf55 --- /dev/null +++ b/mobile/android/app/src/main/java/cn/galreview/app/MainActivity.java @@ -0,0 +1,5 @@ +package cn.galreview.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/splash.png b/mobile/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/mobile/android/app/src/main/res/drawable/splash.png differ diff --git a/mobile/android/app/src/main/res/layout/activity_main.xml b/mobile/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/mobile/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..bc356d1 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2127973 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..bc356d1 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..4d6ed59 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ed0605 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..4d6ed59 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..12cd29b Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df0f158 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..12cd29b Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..1fdead2 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2960cbb Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1fdead2 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..1f1c7c4 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d2ea9ab Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1f1c7c4 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/mobile/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..fd71958 --- /dev/null +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + 千知万理 + 千知万理 + cn.galreview.app + cn.galreview.app + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/main/res/xml/file_paths.xml b/mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/mobile/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle new file mode 100644 index 0000000..85a5dda --- /dev/null +++ b/mobile/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.2.1' + classpath 'com.google.gms:google-services:4.4.0' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/mobile/android/capacitor.settings.gradle b/mobile/android/capacitor.settings.gradle new file mode 100644 index 0000000..9a5fa87 --- /dev/null +++ b/mobile/android/capacitor.settings.gradle @@ -0,0 +1,3 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/mobile/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.jar b/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/mobile/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b5eb349 --- /dev/null +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.2.1-all.zip +networkTimeout=120000 +validateDistributionUrl=false +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mobile/android/gradlew b/mobile/android/gradlew new file mode 100644 index 0000000..fcb6fca --- /dev/null +++ b/mobile/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mobile/android/gradlew.bat b/mobile/android/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/mobile/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/mobile/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/mobile/android/variables.gradle b/mobile/android/variables.gradle new file mode 100644 index 0000000..8ef305d --- /dev/null +++ b/mobile/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 22 + compileSdkVersion = 34 + targetSdkVersion = 34 + androidxActivityVersion = '1.8.0' + androidxAppCompatVersion = '1.6.1' + androidxCoordinatorLayoutVersion = '1.2.0' + androidxCoreVersion = '1.12.0' + androidxFragmentVersion = '1.6.2' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.9.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.1.5' + androidxEspressoCoreVersion = '3.5.1' + cordovaAndroidVersion = '10.1.1' +} \ No newline at end of file diff --git a/mobile/capacitor.config.json b/mobile/capacitor.config.json new file mode 100644 index 0000000..78c5431 --- /dev/null +++ b/mobile/capacitor.config.json @@ -0,0 +1,21 @@ +{ + "appId": "cn.galreview.app", + "appName": "千知万理", + "webDir": "../frontend/dist", + "server": { + "androidScheme": "https", + "cleartext": false, + "allowNavigation": [] + }, + "android": { + "allowMixedContent": false, + "backgroundColor": "#eef8fd" + }, + "plugins": { + "SplashScreen": { + "backgroundColor": "#eef8fd", + "launchAutoHide": true, + "androidScaleType": "CENTER_CROP" + } + } +} diff --git a/mobile/package-lock.json b/mobile/package-lock.json new file mode 100644 index 0000000..9c1d833 --- /dev/null +++ b/mobile/package-lock.json @@ -0,0 +1,1259 @@ +{ + "name": "galreview-mobile", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "galreview-mobile", + "version": "0.1.0", + "dependencies": { + "@capacitor/android": "^6.2.0", + "@capacitor/core": "^6.2.0" + }, + "devDependencies": { + "@capacitor/cli": "^6.2.0" + } + }, + "node_modules/@capacitor/android": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.2.tgz", + "integrity": "sha512-JjIu1ryNE16PQTp51rb4NEygxP7cSCDMmUqx4f5f12xscKdGKTjO9bkEP3TroLnTa/J6p2QopIhRbrd3E/7DrA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^6.2.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-6.2.2.tgz", + "integrity": "sha512-5wEYSHBzRKJKZnhD2sVJAP0t0kUT7o2/vlgEGzFE3t2w+inL9qMDN7zxg6pvqveJiPNZT2k93ZvJDdRKIn/w1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.5", + "@ionic/utils-fs": "^3.1.6", + "@ionic/utils-subprocess": "2.1.11", + "@ionic/utils-terminal": "^2.3.3", + "commander": "^9.3.0", + "debug": "^4.3.4", + "env-paths": "^2.2.0", + "kleur": "^4.1.4", + "native-run": "^2.0.0", + "open": "^8.4.0", + "plist": "^3.0.5", + "prompts": "^2.4.2", + "rimraf": "^4.4.1", + "semver": "^7.3.7", + "tar": "^6.1.11", + "tslib": "^2.4.0", + "xml2js": "^0.5.0" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.2.tgz", + "integrity": "sha512-qEI3DcwJEPzhW+hVGKH8m4jbujtkdUiGkpMLHmpR8epACU7wEsV6NF8pCHzpsMYjc80oK7Cmpcw+MIienv/YRw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.5.tgz", + "integrity": "sha512-HD72a71IQVBmQckDwmA8RxNVMTbxnaLbgFOl+dO5tbvW9CkkSFCv41h6fUuNsSEVgngfkn0i98HDuZC8mk+lTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.5.tgz", + "integrity": "sha512-XnYNSwfewUqxq+yjER1hxTKggftpNjFLJH0s37jcrNDwbzmbpFTQTVAp4ikNK4rd9DOebX/jbeZb8jfD86IYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.10.tgz", + "integrity": "sha512-mZ7JEowcuGQK+SKsJXi0liYTcXd2bNMR3nE0CyTROpMECUpJeAvvaBaPGZf5ERQUPeWBVuwqAqjUmIdxhz5bxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.5", + "@ionic/utils-terminal": "2.3.3", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-process/node_modules/@ionic/utils-terminal": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.3.tgz", + "integrity": "sha512-RnuSfNZ5fLEyX3R5mtcMY97cGD1A0NVBbarsSQ6yMMfRJ5YHU7hHVyUfvZeClbqkBC/pAqI/rYJuXKCT9YeMCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.5.tgz", + "integrity": "sha512-hkm46uHvEC05X/8PHgdJi4l4zv9VQDELZTM+Kz69odtO9zZYfnt8DkfXHJqJ+PxmtiE5mk/ehJWLnn/XAczTUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-2.1.11.tgz", + "integrity": "sha512-6zCDixNmZCbMCy5np8klSxOZF85kuDyzZSTTQKQP90ZtYNCcPYmuFSzaqDwApJT4r5L3MY3JrqK1gLkc6xiUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.5", + "@ionic/utils-fs": "3.1.6", + "@ionic/utils-process": "2.1.10", + "@ionic/utils-stream": "3.1.5", + "@ionic/utils-terminal": "2.3.3", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-subprocess/node_modules/@ionic/utils-fs": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.6.tgz", + "integrity": "sha512-eikrNkK89CfGPmexjTfSWl4EYqsPSBh0Ka7by4F0PLc1hJZYtJxUZV3X4r5ecA8ikjicUmcbU7zJmAjmqutG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-subprocess/node_modules/@ionic/utils-terminal": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.3.tgz", + "integrity": "sha512-RnuSfNZ5fLEyX3R5mtcMY97cGD1A0NVBbarsSQ6yMMfRJ5YHU7hHVyUfvZeClbqkBC/pAqI/rYJuXKCT9YeMCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", + "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^9.2.0" + }, + "bin": { + "rimraf": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..f5b393d --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,18 @@ +{ + "name": "galreview-mobile", + "version": "0.1.0", + "private": true, + "description": "千知万理 GalReview Android 客户端(Capacitor)", + "scripts": { + "sync": "cap sync android", + "open": "cap open android", + "apk": "cd android && gradlew assembleDebug" + }, + "dependencies": { + "@capacitor/android": "^6.2.0", + "@capacitor/core": "^6.2.0" + }, + "devDependencies": { + "@capacitor/cli": "^6.2.0" + } +} diff --git a/scripts/Test-ProductionEnv.ps1 b/scripts/Test-ProductionEnv.ps1 new file mode 100644 index 0000000..278ceee --- /dev/null +++ b/scripts/Test-ProductionEnv.ps1 @@ -0,0 +1,158 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + 生产部署前的 .env / compose 配置门禁。 +.DESCRIPTION + 检查: + 1. .env 存在且不包含 CHANGE_ME 占位符 + 2. 不包含仓库默认联调密钥 moonstone-local-gateway-key + 3. Auth/User 模式为 MySql(禁止 Mock) + 4. 叠加 compose.production.yaml 能完成配置解析(缺变量会在解析期失败) +.EXAMPLE + ./scripts/Test-ProductionEnv.ps1 + ./scripts/Test-ProductionEnv.ps1 -EnvFile /path/to/.env +#> +[CmdletBinding()] +param( + [string]$EnvFile = (Join-Path (Split-Path -Parent $PSScriptRoot) '.env'), + [string]$RepoRoot = (Split-Path -Parent $PSScriptRoot) +) + +$ErrorActionPreference = 'Stop' +$failed = @() + +function Fail([string]$msg) { + $script:failed += $msg + Write-Host "FAIL $msg" -ForegroundColor Red +} + +function Pass([string]$msg) { + Write-Host "PASS $msg" -ForegroundColor Green +} + +if (-not (Test-Path -LiteralPath $EnvFile)) { + Fail "Env file not found: $EnvFile" + Write-Host '' + Write-Host "Copy .env.deploy.example to .env and replace every CHANGE_ME." -ForegroundColor Yellow + exit 1 +} + +$envText = Get-Content -LiteralPath $EnvFile -Raw +$lines = Get-Content -LiteralPath $EnvFile + +if ($envText -match 'CHANGE_ME') { + $hits = $lines | Where-Object { $_ -match 'CHANGE_ME' } | Select-Object -First 5 + Fail ".env still contains CHANGE_ME placeholders: $($hits -join '; ')" +} else { + Pass '.env has no CHANGE_ME placeholders' +} + +if ($envText -match 'moonstone-local-gateway-key') { + Fail '.env still contains the development default key moonstone-local-gateway-key' +} else { + Pass '.env does not use the development default gateway key' +} + +function Get-EnvValue([string]$name) { + foreach ($line in $lines) { + if ($line -match "^\s*$([regex]::Escape($name))\s*=\s*(.*)\s*$") { + return $Matches[1].Trim('"').Trim("'") + } + } + return $null +} + +$userMode = Get-EnvValue 'USER_SERVICE_MODE' +$authMode = Get-EnvValue 'AUTH_SERVICE_MODE' +if ($userMode -ne 'MySql') { + Fail "USER_SERVICE_MODE must be MySql in production (got: $userMode)" +} else { + Pass 'USER_SERVICE_MODE=MySql' +} +if ($authMode -ne 'MySql') { + Fail "AUTH_SERVICE_MODE must be MySql in production (got: $authMode)" +} else { + Pass 'AUTH_SERVICE_MODE=MySql' +} + +$requiredKeys = @( + 'GATEWAY_KEY', + 'USER_SERVICE_KEY', + 'AUTH_SERVICE_KEY', + 'FILE_SERVICE_KEY', + 'KNOWLEDGE_SERVICE_KEY', + 'GALGAME_SERVICE_KEY', + 'RENDER_SERVICE_KEY', + 'PRACTICE_SERVICE_KEY', + 'CREDIT_SERVICE_KEY', + 'MODEL_SERVICE_KEY', + 'USER_MYSQL_ROOT_PASSWORD', + 'USER_MYSQL_PASSWORD', + 'AUTH_MYSQL_ROOT_PASSWORD', + 'AUTH_MYSQL_PASSWORD', + 'CREDIT_MYSQL_ROOT_PASSWORD', + 'CREDIT_MYSQL_PASSWORD', + 'NEO4J_PASSWORD', + 'GALREVIEW_ADMIN_USERNAME', + 'GALREVIEW_ADMIN_PASSWORD_HASH', + 'GALREVIEW_ADMIN_PRINCIPAL_ID', + 'CORS_ORIGINS' +) + +foreach ($key in $requiredKeys) { + $value = Get-EnvValue $key + if ([string]::IsNullOrWhiteSpace($value)) { + Fail "Required env $key is missing or empty" + } +} + +if ($failed.Count -eq 0) { + Pass "All $($requiredKeys.Count) required production keys are present" +} + +# 生成能力密钥:叙事开启时必须有 DEEPSEEK;语音开启时必须有 MIMO +$narrativeEnabled = (Get-EnvValue 'GALGAME_NARRATIVE_ENABLED') +if ([string]::IsNullOrWhiteSpace($narrativeEnabled) -or $narrativeEnabled -eq 'true') { + $deepseek = Get-EnvValue 'DEEPSEEK_API_KEY' + if ([string]::IsNullOrWhiteSpace($deepseek)) { + Fail 'GALGAME_NARRATIVE_ENABLED is true (or unset) but DEEPSEEK_API_KEY is empty — narrative/question generation will silently degrade' + } else { + Pass 'DEEPSEEK_API_KEY is present for narrative generation' + } +} +$voiceEnabled = Get-EnvValue 'GALGAME_VOICE_ENABLED' +if ($voiceEnabled -eq 'true') { + $mimo = Get-EnvValue 'MIMO_API_KEY' + if ([string]::IsNullOrWhiteSpace($mimo)) { + Fail 'GALGAME_VOICE_ENABLED is true but MIMO_API_KEY is empty' + } else { + Pass 'MIMO_API_KEY is present for voice synthesis' + } +} + +# 解析门禁:缺变量时 docker compose config 会直接失败 +$compose = Get-Command docker -ErrorAction SilentlyContinue +if ($compose) { + Push-Location $RepoRoot + try { + & docker compose -f compose.integration.yaml -f compose.production.yaml config --quiet + if ($LASTEXITCODE -ne 0) { + Fail 'docker compose config failed with production overlay (missing required variables?)' + } else { + Pass 'docker compose config (integration + production) parses successfully' + } + } finally { + Pop-Location + } +} else { + Write-Host 'SKIP docker not available; skipped compose config check' -ForegroundColor Yellow +} + +Write-Host '' +if ($failed.Count -gt 0) { + Write-Host "$($failed.Count) production gate check(s) failed." -ForegroundColor Red + exit 1 +} + +Write-Host 'Production environment gate passed.' -ForegroundColor Green +exit 0 diff --git a/start_dev.ps1 b/start_dev.ps1 index f075aed..0d28752 100644 --- a/start_dev.ps1 +++ b/start_dev.ps1 @@ -282,6 +282,9 @@ function Start-MoonStoneStack { $env:GATEWAY_PORT = [string]$gatewayPort $env:GATEWAY_HOST = '127.0.0.1' $env:GATEWAY_URL = $gatewayBaseUrl + # 本地栈固定以 Development 运行;集成/生产环境由 compose 显式指定 + $env:ASPNETCORE_ENVIRONMENT = 'Development' + $env:NODE_ENV = 'development' $gatewayRoot = Join-Path $projectRoot 'gateway' $renderServiceRoot = Join-Path $backendRoot 'RenderService\service' diff --git a/tmp/pdfs/microbiology-review/page-1.png b/tmp/pdfs/microbiology-review/page-1.png deleted file mode 100644 index 08002fc..0000000 Binary files a/tmp/pdfs/microbiology-review/page-1.png and /dev/null differ diff --git a/tmp/pdfs/microbiology-review/page-2.png b/tmp/pdfs/microbiology-review/page-2.png deleted file mode 100644 index b118c5a..0000000 Binary files a/tmp/pdfs/microbiology-review/page-2.png and /dev/null differ diff --git a/tmp/pdfs/microbiology-review/page-30.png b/tmp/pdfs/microbiology-review/page-30.png deleted file mode 100644 index 82121b0..0000000 Binary files a/tmp/pdfs/microbiology-review/page-30.png and /dev/null differ diff --git a/tmp/pdfs/microbiology-review/page-39.png b/tmp/pdfs/microbiology-review/page-39.png deleted file mode 100644 index 4d16965..0000000 Binary files a/tmp/pdfs/microbiology-review/page-39.png and /dev/null differ