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;
+
+///