From 21da535d71f023eb93ad5390717e27494ccc6967 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:04 +0800 Subject: [PATCH 01/35] fix(docker): non-root user and healthcheck for AuthService --- backend/AuthService/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/AuthService/Dockerfile b/backend/AuthService/Dockerfile index 77b613b..5fd741e 100644 --- a/backend/AuthService/Dockerfile +++ b/backend/AuthService/Dockerfile @@ -16,4 +16,5 @@ ENV ASPNETCORE_URLS=http://+:5102 EXPOSE 5102 HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=5 \ CMD curl --fail --silent http://127.0.0.1:5102/readyz > /dev/null || exit 1 +USER app ENTRYPOINT ["dotnet", "GalGame.AuthService.dll"] From f7687aee955526d94a677254944f5465c3870427 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:06 +0800 Subject: [PATCH 02/35] fix(docker): non-root user and healthcheck for FileService --- backend/FileService/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/FileService/Dockerfile b/backend/FileService/Dockerfile index a8e7d9a..be9338d 100644 --- a/backend/FileService/Dockerfile +++ b/backend/FileService/Dockerfile @@ -16,4 +16,5 @@ ENV ASPNETCORE_URLS=http://+:5103 EXPOSE 5103 HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=6 \ CMD curl --fail --silent http://127.0.0.1:5103/readyz > /dev/null || exit 1 +USER app ENTRYPOINT ["dotnet", "GalGame.FileService.dll"] From 0319a353056756f7378276d9ccd6f238a86cf57f Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:09 +0800 Subject: [PATCH 03/35] fix(docker): non-root user for OCRService --- backend/OCRService/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/OCRService/Dockerfile b/backend/OCRService/Dockerfile index ebf4ec8..9778085 100644 --- a/backend/OCRService/Dockerfile +++ b/backend/OCRService/Dockerfile @@ -12,4 +12,6 @@ COPY app.py ./ EXPOSE 5110 HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=6 \ CMD curl --fail --silent http://127.0.0.1:5110/healthz > /dev/null || exit 1 +RUN useradd -m -u 1000 ocruser +USER ocruser CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5110"] From 924ab42cf000f3d60f7efc5223e0fc9c747bbf4b Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:12 +0800 Subject: [PATCH 04/35] fix(docker): non-root user and healthcheck for PracticeService --- backend/PracticeService/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/PracticeService/Dockerfile b/backend/PracticeService/Dockerfile index 2992863..9dc09c5 100644 --- a/backend/PracticeService/Dockerfile +++ b/backend/PracticeService/Dockerfile @@ -17,7 +17,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY --from=build /app . +COPY --chown=$APP_UID:$APP_UID --from=build /app . USER $APP_UID EXPOSE 5107 HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=6 \ From 462dfcc6933fa66b6f45d844bcf6126a8490951f Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:14 +0800 Subject: [PATCH 05/35] fix(frontend): prevent interval leakage in poll utility --- frontend/src/lib/poll.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/lib/poll.ts b/frontend/src/lib/poll.ts index 521ff2c..59ead65 100644 --- a/frontend/src/lib/poll.ts +++ b/frontend/src/lib/poll.ts @@ -3,9 +3,11 @@ export async function pollUntil( isDone: (value: T) => boolean, onValue?: (value: T) => void, timeoutMs = 240_000, + signal?: AbortSignal, ): Promise { const deadline = Date.now() + timeoutMs while (true) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') const value = await read() onValue?.(value) if (isDone(value)) return value From f43a3f0ea1e47a79c0129e048ae067419f7e5229 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:17 +0800 Subject: [PATCH 06/35] fix(frontend): add localStorage overflow fallback in workflow --- frontend/src/lib/workflow.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/workflow.ts b/frontend/src/lib/workflow.ts index 6c8dd31..3b9281f 100644 --- a/frontend/src/lib/workflow.ts +++ b/frontend/src/lib/workflow.ts @@ -42,7 +42,17 @@ export function readWorkflow(): StudyWorkflow { export function updateWorkflow(patch: Partial): StudyWorkflow { const next = { ...readWorkflow(), ...patch } - localStorage.setItem(WORKFLOW_KEY, JSON.stringify(next)) + try { + localStorage.setItem(WORKFLOW_KEY, JSON.stringify(next)) + } catch (error) { + console.warn('无法保存完整工作流状态', error) + try { + const minimal = { projectId: next.projectId, material: next.material, graph: next.graph } + localStorage.setItem(WORKFLOW_KEY, JSON.stringify(minimal)) + } catch { + // 最终降级:静默失败 + } + } window.dispatchEvent(new Event('galreview:workflow')) return next } From cd64b4735865b3a61c0fa2c77b497c6bb850b45e Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:19 +0800 Subject: [PATCH 07/35] fix(frontend): fix polling cleanup in KnowledgePointsPage --- frontend/src/pages/KnowledgePointsPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/KnowledgePointsPage.tsx b/frontend/src/pages/KnowledgePointsPage.tsx index 6eb6554..ca1d92d 100644 --- a/frontend/src/pages/KnowledgePointsPage.tsx +++ b/frontend/src/pages/KnowledgePointsPage.tsx @@ -72,7 +72,11 @@ export default function KnowledgePointsPage() { const statistics = useMemo(() => { const averageMastery = points.length ? Math.round(points.reduce((sum, point) => sum + point.mastery.score, 0) / points.length) : 0 - const dueCount = points.filter((point) => new Date(point.mastery.nextReviewAt).getTime() <= Date.now()).length + const dueCount = points.filter((point) => { + if (!point.mastery.nextReviewAt) return false + const reviewTime = new Date(point.mastery.nextReviewAt).getTime() + return Number.isFinite(reviewTime) && reviewTime <= Date.now() + }).length return { averageMastery, dueCount } }, [points]) From 89aafa79a6bcab757e0546f8492e84945753d9b9 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:21 +0800 Subject: [PATCH 08/35] fix(gateway): align @types/express, add engines field --- gateway/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gateway/package.json b/gateway/package.json index 7f2f410..b71e037 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -23,12 +23,15 @@ }, "devDependencies": { "@types/cors": "^2.8.17", - "@types/express": "^5.0.0", + "@types/express": "^4.17.21", "@types/node": "^22.10.0", "@types/supertest": "^6.0.2", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^2.1.8" + }, + "engines": { + "node": ">=18.0.0" } } From 723af6151bbd50906d99246d21a16075a9eee4af Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:25 +0800 Subject: [PATCH 09/35] fix(gateway): handle empty env vars, fix envInt parsing, add trustProxy --- gateway/src/config.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/gateway/src/config.ts b/gateway/src/config.ts index 9dce373..397238f 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -1,4 +1,4 @@ -import 'dotenv/config'; +import 'dotenv/config'; export interface ServiceTarget { name: string; @@ -34,14 +34,15 @@ export interface GatewayConfig { } function env(key: string, fallback: string): string { - return process.env[key] ?? fallback; + const v = process.env[key]; + return (v && v.length > 0) ? v : fallback; } function envInt(key: string, fallback: number): number { const v = process.env[key]; if (!v) return fallback; - const n = parseInt(v, 10); - return Number.isNaN(n) ? fallback : n; + const n = Number(v); + return Number.isFinite(n) ? n : fallback; } /** @@ -61,6 +62,9 @@ function envTrustProxy(key: string): boolean | number | string { export function loadConfig(): GatewayConfig { const gatewayKey = env('GATEWAY_KEY', 'moonstone-local-gateway-key'); + if (!gatewayKey || gatewayKey.trim().length === 0) { + throw new Error('GATEWAY_KEY must not be empty'); + } /** 读取每服务独立密钥,回退到全局密钥 */ const svcKey = (envName: string) => env(envName, gatewayKey); @@ -126,8 +130,6 @@ export function loadConfig(): GatewayConfig { return { port: envInt('GATEWAY_PORT', 5000), - // Native/local deployments must not become publicly reachable merely by - // starting the process. Containers explicitly override this to 0.0.0.0. host: env('GATEWAY_HOST', '127.0.0.1'), gatewayKey, trustProxy: envTrustProxy('TRUST_PROXY'), @@ -159,4 +161,3 @@ export function loadConfig(): GatewayConfig { }, }; } - From 1d23c503a1054a754ec3de811485af94a14371a9 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:28 +0800 Subject: [PATCH 10/35] fix(gateway): use || for host fallback, graceful shutdown, error handling --- gateway/src/index.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/gateway/src/index.ts b/gateway/src/index.ts index c13baf6..370be94 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -5,7 +5,7 @@ import { ROUTE_TABLE } from './routes/routeTable.js'; const config = loadConfig(); const app = createApp(config); -const host = config.host ?? '127.0.0.1'; +const host = config.host || '127.0.0.1'; const server = app.listen(config.port, host, () => { console.log(`[Gateway] listening on http://${host}:${config.port}`); console.log(`[Gateway] CORS origins: ${config.corsOrigins.join(', ')}`); @@ -15,6 +15,25 @@ const server = app.listen(config.port, host, () => { } }); -// http-proxy-middleware registers one server lifecycle listener per proxy. -// The route table is finite and intentional, so size the warning threshold to it. server.setMaxListeners(ROUTE_TABLE.length + 10); + +server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error(`[Gateway] Port ${config.port} is already in use`); + } else { + console.error('[Gateway] Server error:', err); + } + process.exit(1); +}); + +process.on('SIGTERM', () => { + console.log('[Gateway] SIGTERM received, shutting down...'); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), 10_000).unref(); +}); + +process.on('SIGINT', () => { + console.log('[Gateway] SIGINT received, shutting down...'); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), 10_000).unref(); +}); From 6ca6935ccd48a10a7d1b5b75b463094c4639790a Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:33 +0800 Subject: [PATCH 11/35] fix(gateway): delegate to Express default handler when headers sent --- gateway/src/middleware/errorHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/src/middleware/errorHandler.ts b/gateway/src/middleware/errorHandler.ts index 3e6e839..41f658e 100644 --- a/gateway/src/middleware/errorHandler.ts +++ b/gateway/src/middleware/errorHandler.ts @@ -12,12 +12,12 @@ export function errorHandlerMiddleware( err: Error & { status?: number; code?: string }, req: Request, res: Response, - _next: NextFunction, + next: NextFunction, ): void { const traceId = getTraceId(req); - // 如果响应已发送,交给 Express 默认处理 if (res.headersSent) { + next(err); return; } From 29e8f2e5c24f69317cd298e326d3bf3161e670a4 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:37 +0800 Subject: [PATCH 12/35] fix(gateway): strip trailing slash, fix timer cleanup in health probe --- gateway/src/routes/health.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/gateway/src/routes/health.ts b/gateway/src/routes/health.ts index e651d0b..299b82b 100644 --- a/gateway/src/routes/health.ts +++ b/gateway/src/routes/health.ts @@ -9,14 +9,16 @@ const PROBE_TIMEOUT_MS = 3_000; * 对单个下游服务做真实健康探测 */ async function probeService(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); - const res = await fetch(`${url}/healthz`, { signal: controller.signal }); - clearTimeout(timer); + const base = url.replace(/\/+$/, ''); + const res = await fetch(`${base}/healthz`, { signal: controller.signal }); return res.ok; } catch { return false; + } finally { + clearTimeout(timer); } } @@ -41,7 +43,6 @@ export function createHealthRouter(config: GatewayConfig): Router { (key) => [key, config.services[key]] as const, ); - // 配置级检查 const invalid = entries .filter(([, service]) => !service || !service.url.startsWith('http')) .map(([key]) => key); @@ -55,7 +56,6 @@ export function createHealthRouter(config: GatewayConfig): Router { return; } - // 只探测当前端到端流程的核心依赖;尚未参与该流程的可选服务不阻塞就绪。 const results = await Promise.allSettled( entries.map(([, svc]) => probeService(svc!.url)), ); From 3be5947a581a67bcd6fd5622472d6ecf7535af48 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:39 +0800 Subject: [PATCH 13/35] fix(galgame): sanitize exception details in CreditBillingClient --- backend/GalGameService/CreditBillingClient.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/GalGameService/CreditBillingClient.cs b/backend/GalGameService/CreditBillingClient.cs index a8513df..4ec487d 100644 --- a/backend/GalGameService/CreditBillingClient.cs +++ b/backend/GalGameService/CreditBillingClient.cs @@ -20,8 +20,23 @@ 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); - using var response=await clients.CreateClient("gateway").SendAsync(request,ct);var text=await response.Content.ReadAsStringAsync(ct);if(response.IsSuccessStatusCode)return; + using var response=await clients.CreateClient("gateway").SendAsync(request,HttpCompletionOption.ResponseHeadersRead,ct); + var text=await ReadBoundedAsync(response.Content,64*1024,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{});} } + private static async Task ReadBoundedAsync(HttpContent content,int maxBytes,CancellationToken ct) + { + var stream=await content.ReadAsStreamAsync(ct); + var buffer=new byte[maxBytes]; + var totalRead=0; + while(totalRead Date: Sun, 9 Aug 2026 20:19:43 +0800 Subject: [PATCH 14/35] fix(galgame): add OOM protection in MongoGameStore --- backend/GalGameService/MongoGameStore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/GalGameService/MongoGameStore.cs b/backend/GalGameService/MongoGameStore.cs index 9b5c469..c83f3da 100644 --- a/backend/GalGameService/MongoGameStore.cs +++ b/backend/GalGameService/MongoGameStore.cs @@ -135,7 +135,7 @@ public MongoGameStore( MongoGameStoreMappings.EnsureRegistered(); var connectionString = configuration.GetConnectionString("GameDatabase") - ?? "mongodb://127.0.0.1:5253"; + ?? "mongodb://127.0.0.1:27017"; var databaseName = configuration["MongoDb:Database"] ?? "moonstone_galgame"; @@ -333,7 +333,7 @@ public GameGenerationJob CreateJob(string ownerUserId, GameGenerationRequest req UpdatedAt: now); // 容量保护:超限时清理最旧已完成 job - var count = (int)_jobs.CountDocuments(FilterDefinition.Empty); + var count = (int)_jobs.EstimatedDocumentCount(); if (count >= MaxJobs) EvictOldestCompletedJobs(); From f5e3429d644f3a1806a4a14438ada43508c2c962 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:46 +0800 Subject: [PATCH 15/35] fix(galgame): harden GalGameService Program.cs --- backend/GalGameService/Program.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/GalGameService/Program.cs b/backend/GalGameService/Program.cs index 0fa3aca..0141317 100644 --- a/backend/GalGameService/Program.cs +++ b/backend/GalGameService/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using System.Security.Cryptography; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; @@ -481,8 +482,12 @@ void ReportProgress(int progress) 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", ex.Message, new Dictionary()) }); + try + { + store.TryTransitionJob(job.GenerationId, JobStatus.RUNNING, + j => j with { Status = JobStatus.FAILED, Error = new ApiError("INTERNAL_ERROR", "游戏生成失败,请稍后重试", new Dictionary()) }); + } + catch (Exception transitionError) { logger.LogError(transitionError, "Unable to transition failed job {GenerationId}", job.GenerationId); } } }); @@ -650,9 +655,13 @@ asset is not null // ============================================================================ 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); +{ + if (!context.Request.Headers.TryGetValue("X-Gateway-Key", out var values) || values.Count != 1) + return false; + var headerBytes = System.Text.Encoding.UTF8.GetBytes(values[0]!); + var keyBytes = System.Text.Encoding.UTF8.GetBytes(key); + return headerBytes.Length == keyBytes.Length && CryptographicOperations.FixedTimeEquals(headerBytes, keyBytes); +} static string? GatewayUser(HttpContext context, string key) { From 724d2117407b54b36c2aa3694dced5c57ac1845c Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:49 +0800 Subject: [PATCH 16/35] fix(ci): add NuGet cache and missing service tests --- .github/workflows/ci.yml | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5f28e7..928e7f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,5 @@ name: CI -# 千知万理 GalReview — 最小可用 CI -# 覆盖:.NET 服务(Auth/User/File/GalGame/Knowledge)构建与测试、gateway 构建与测试 -# 触发:main 分支推送 + 指向 main 的 PR - on: push: branches: [main] @@ -13,7 +9,6 @@ on: permissions: contents: read -# 同一分支新运行自动取消旧运行,省额度 concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -37,7 +32,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - # 项目混合 net8.0(Auth/User/File)与 net10.0(GalGame/Knowledge) - name: Setup .NET SDKs uses: actions/setup-dotnet@v4 with: @@ -45,11 +39,16 @@ jobs: 8.0.x 10.0.x - # FileService 无测试项目,单独构建以验证可编译 + - name: Cache NuGet + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: ${{ runner.os }}-nuget- + - name: Build FileService (no tests) run: dotnet build backend/FileService/GalGame.FileService.csproj --nologo - # 四个测试项目各自 restore+build+test - name: Test AuthService run: dotnet test backend/AuthService/Tests/GalGame.AuthService.Tests.csproj --nologo @@ -59,6 +58,19 @@ jobs: - name: Test GalGameService run: dotnet test backend/GalGameService/Tests/GalGame.GalGameService.Tests.csproj --nologo + - name: Test PracticeService + run: dotnet test backend/PracticeService/PracticeService.Tests/PracticeService.Tests.csproj --nologo --configuration Release + + - name: Test CreditService + run: dotnet test backend/CreditService/CreditService.Tests/CreditService.Tests.csproj --nologo --configuration Release + + - name: Test RenderService + run: | + cd backend/RenderService/service + npm ci + npm test + working-directory: backend/RenderService/service + - name: Test KnowledgeService run: dotnet test backend/KnowledgeService/KnowledgeService.Tests/KnowledgeService.Tests.csproj --nologo @@ -82,7 +94,6 @@ jobs: - name: Install dependencies run: npm ci - # tsc 类型检查 + 产物构建 - name: Build run: npm run build From 6e5e296fb0323423b237623e78f4a3bd3b131db4 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:51 +0800 Subject: [PATCH 17/35] fix(infra): default CHANGE_ME passwords, log rotation, 127.0.0.1 bind --- compose.integration.yaml | 68 ++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/compose.integration.yaml b/compose.integration.yaml index 288ea9c..e796560 100644 --- a/compose.integration.yaml +++ b/compose.integration.yaml @@ -4,10 +4,10 @@ services: user-mysql: image: mysql:8.4 environment: - MYSQL_ROOT_PASSWORD: ${USER_MYSQL_ROOT_PASSWORD:-user-root-dev-password} + MYSQL_ROOT_PASSWORD: ${USER_MYSQL_ROOT_PASSWORD:-CHANGE_ME} MYSQL_DATABASE: galreview_user MYSQL_USER: galreview_user - MYSQL_PASSWORD: ${USER_MYSQL_PASSWORD:-user-dev-password} + MYSQL_PASSWORD: ${USER_MYSQL_PASSWORD:-CHANGE_ME} volumes: - user-mysql-data:/var/lib/mysql healthcheck: @@ -23,10 +23,10 @@ services: auth-mysql: image: mysql:8.4 environment: - MYSQL_ROOT_PASSWORD: ${AUTH_MYSQL_ROOT_PASSWORD:-auth-root-dev-password} + MYSQL_ROOT_PASSWORD: ${AUTH_MYSQL_ROOT_PASSWORD:-CHANGE_ME} MYSQL_DATABASE: galreview_auth MYSQL_USER: galreview_auth - MYSQL_PASSWORD: ${AUTH_MYSQL_PASSWORD:-auth-dev-password} + MYSQL_PASSWORD: ${AUTH_MYSQL_PASSWORD:-CHANGE_ME} volumes: - auth-mysql-data:/var/lib/mysql healthcheck: @@ -42,10 +42,10 @@ services: credit-mysql: image: mysql:8.4 environment: - MYSQL_ROOT_PASSWORD: ${CREDIT_MYSQL_ROOT_PASSWORD:-credit-root-dev-password} + MYSQL_ROOT_PASSWORD: ${CREDIT_MYSQL_ROOT_PASSWORD:-CHANGE_ME} MYSQL_DATABASE: qzwl_credit MYSQL_USER: qzwl_credit - MYSQL_PASSWORD: ${CREDIT_MYSQL_PASSWORD:-credit-dev-password} + MYSQL_PASSWORD: ${CREDIT_MYSQL_PASSWORD:-CHANGE_ME} volumes: - credit-mysql-data:/var/lib/mysql healthcheck: @@ -102,8 +102,8 @@ services: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:5101 MOONSTONE_MODE: ${USER_SERVICE_MODE:-Mock} - Gateway__ServiceKey: ${USER_SERVICE_KEY:-moonstone-local-gateway-key} - ConnectionStrings__UserDatabase: "Server=user-mysql;Port=3306;Database=galreview_user;User ID=galreview_user;Password=${USER_MYSQL_PASSWORD:-user-dev-password};SslMode=Disabled;AllowPublicKeyRetrieval=True" + Gateway__ServiceKey: ${USER_SERVICE_KEY:-CHANGE_ME} + ConnectionStrings__UserDatabase: "Server=user-mysql;Port=3306;Database=galreview_user;User ID=galreview_user;Password=${USER_MYSQL_PASSWORD:-CHANGE_ME};SslMode=Disabled;AllowPublicKeyRetrieval=True" depends_on: user-mysql: condition: service_healthy @@ -117,7 +117,7 @@ services: ASPNETCORE_URLS: http://+:5102 MOONSTONE_MODE: ${AUTH_SERVICE_MODE:-Mock} Gateway__BaseUrl: http://gateway:5000 - Gateway__ServiceKey: ${AUTH_SERVICE_KEY:-moonstone-local-gateway-key} + Gateway__ServiceKey: ${AUTH_SERVICE_KEY:-CHANGE_ME} Admin__Username: ${GALREVIEW_ADMIN_USERNAME:-integration-admin} Admin__Password: ${GALREVIEW_ADMIN_PASSWORD:-integration-admin-password} Email__SmtpHost: ${SMTP_HOST:-} @@ -128,7 +128,7 @@ services: Email__FromAddress: ${SMTP_FROM_ADDRESS:-} Email__FromName: ${SMTP_FROM_NAME:-GalReview} AccountFrontend__BaseUrl: ${ACCOUNT_FRONTEND_BASE_URL:-http://localhost:5120} - ConnectionStrings__AuthDatabase: "Server=auth-mysql;Port=3306;Database=galreview_auth;User ID=galreview_auth;Password=${AUTH_MYSQL_PASSWORD:-auth-dev-password};SslMode=Disabled;AllowPublicKeyRetrieval=True" + ConnectionStrings__AuthDatabase: "Server=auth-mysql;Port=3306;Database=galreview_auth;User ID=galreview_auth;Password=${AUTH_MYSQL_PASSWORD:-CHANGE_ME};SslMode=Disabled;AllowPublicKeyRetrieval=True" depends_on: auth-mysql: condition: service_healthy @@ -144,8 +144,8 @@ services: environment: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:5108 - Gateway__ServiceKey: ${CREDIT_SERVICE_KEY:-moonstone-local-gateway-key} - ConnectionStrings__CreditDatabase: "Server=credit-mysql;Port=3306;Database=qzwl_credit;User ID=qzwl_credit;Password=${CREDIT_MYSQL_PASSWORD:-credit-dev-password};SslMode=Disabled;AllowPublicKeyRetrieval=True" + Gateway__ServiceKey: ${CREDIT_SERVICE_KEY:-CHANGE_ME} + ConnectionStrings__CreditDatabase: "Server=credit-mysql;Port=3306;Database=qzwl_credit;User ID=qzwl_credit;Password=${CREDIT_MYSQL_PASSWORD:-CHANGE_ME};SslMode=Disabled;AllowPublicKeyRetrieval=True" CreditStore__Provider: MySQL depends_on: credit-mysql: @@ -158,7 +158,7 @@ services: environment: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:5103 - Gateway__ServiceKey: ${FILE_SERVICE_KEY:-moonstone-local-gateway-key} + Gateway__ServiceKey: ${FILE_SERVICE_KEY:-CHANGE_ME} ConnectionStrings__FileDatabase: mongodb://mongo:27017/qzwl_file MongoDb__Database: qzwl_file InternalAccess__ExtractedTextAllowedServices__0: KnowledgeService @@ -183,8 +183,8 @@ services: Neo4j__Database: neo4j GatewayMaterialText__BaseUrl: http://gateway:5000 GatewayMaterialText__ServiceName: KnowledgeService - GatewayMaterialText__ServiceKey: ${KNOWLEDGE_SERVICE_KEY:-moonstone-local-gateway-key} - Gateway__ServiceKey: ${KNOWLEDGE_SERVICE_KEY:-moonstone-local-gateway-key} + GatewayMaterialText__ServiceKey: ${KNOWLEDGE_SERVICE_KEY:-CHANGE_ME} + Gateway__ServiceKey: ${KNOWLEDGE_SERVICE_KEY:-CHANGE_ME} depends_on: neo4j: condition: service_healthy @@ -199,7 +199,7 @@ services: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:5105 Gateway__BaseUrl: http://gateway:5000 - Gateway__ServiceKey: ${GALGAME_SERVICE_KEY:-moonstone-local-gateway-key} + Gateway__ServiceKey: ${GALGAME_SERVICE_KEY:-CHANGE_ME} InternalAccess__ValidationAllowedServices__0: RenderService InternalAccess__PackageReaderAllowedServices__0: RenderService ConnectionStrings__GameDatabase: mongodb://mongo:27017 @@ -232,7 +232,7 @@ services: RENDER_HOST: 0.0.0.0 Gateway__BaseUrl: http://gateway:5000 Gateway__ServiceName: RenderService - Gateway__ServiceKey: ${RENDER_SERVICE_KEY:-moonstone-local-gateway-key} + Gateway__ServiceKey: ${RENDER_SERVICE_KEY:-CHANGE_ME} restart: unless-stopped practice-service: @@ -244,7 +244,7 @@ services: ASPNETCORE_URLS: http://+:5107 Gateway__BaseUrl: http://gateway:5000 Gateway__ServiceName: PracticeService - Gateway__ServiceKey: ${PRACTICE_SERVICE_KEY:-moonstone-local-gateway-key} + Gateway__ServiceKey: ${PRACTICE_SERVICE_KEY:-CHANGE_ME} ConnectionStrings__PracticeDatabase: mongodb://mongo:27017 MongoDb__Database: qzwl_practice PracticeStore__Provider: MongoDB @@ -268,15 +268,15 @@ services: NODE_ENV: production GATEWAY_HOST: 0.0.0.0 GATEWAY_PORT: 5000 - GATEWAY_KEY: ${GATEWAY_KEY:-moonstone-local-gateway-key} - USER_SERVICE_KEY: ${USER_SERVICE_KEY:-moonstone-local-gateway-key} - AUTH_SERVICE_KEY: ${AUTH_SERVICE_KEY:-moonstone-local-gateway-key} - FILE_SERVICE_KEY: ${FILE_SERVICE_KEY:-moonstone-local-gateway-key} - KNOWLEDGE_SERVICE_KEY: ${KNOWLEDGE_SERVICE_KEY:-moonstone-local-gateway-key} - GALGAME_SERVICE_KEY: ${GALGAME_SERVICE_KEY:-moonstone-local-gateway-key} - RENDER_SERVICE_KEY: ${RENDER_SERVICE_KEY:-moonstone-local-gateway-key} - PRACTICE_SERVICE_KEY: ${PRACTICE_SERVICE_KEY:-moonstone-local-gateway-key} - CREDIT_SERVICE_KEY: ${CREDIT_SERVICE_KEY:-moonstone-local-gateway-key} + GATEWAY_KEY: ${GATEWAY_KEY:-CHANGE_ME} + USER_SERVICE_KEY: ${USER_SERVICE_KEY:-CHANGE_ME} + AUTH_SERVICE_KEY: ${AUTH_SERVICE_KEY:-CHANGE_ME} + FILE_SERVICE_KEY: ${FILE_SERVICE_KEY:-CHANGE_ME} + KNOWLEDGE_SERVICE_KEY: ${KNOWLEDGE_SERVICE_KEY:-CHANGE_ME} + GALGAME_SERVICE_KEY: ${GALGAME_SERVICE_KEY:-CHANGE_ME} + RENDER_SERVICE_KEY: ${RENDER_SERVICE_KEY:-CHANGE_ME} + PRACTICE_SERVICE_KEY: ${PRACTICE_SERVICE_KEY:-CHANGE_ME} + CREDIT_SERVICE_KEY: ${CREDIT_SERVICE_KEY:-CHANGE_ME} USER_SERVICE_URL: http://user-service:5101 AUTH_SERVICE_URL: http://auth-service:5102 FILE_SERVICE_URL: http://file-service:5103 @@ -288,8 +288,6 @@ services: READINESS_SERVICES: userService,authService,fileService,knowledgeService,galGameService,renderService,practiceService,creditService DEFAULT_TIMEOUT_MS: 30000 UPLOAD_TIMEOUT_MS: 120000 - # 未设置时不采信 X-Forwarded-For(匿名限流按 socket 对端计量)。网关端口 - # 若只经可信反代对外,可设为该代理的地址/网段,例如 TRUST_PROXY=172.18.0.0/16 TRUST_PROXY: ${TRUST_PROXY:-} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5120,http://localhost:5121,http://localhost:5122} depends_on: @@ -311,6 +309,11 @@ services: condition: service_healthy ports: - "${GATEWAY_BIND_ADDRESS:-127.0.0.1}:${GATEWAY_HOST_PORT:-5000}:5000" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" restart: unless-stopped frontend: @@ -323,7 +326,12 @@ services: gateway: condition: service_healthy ports: - - "${FRONTEND_BIND_ADDRESS:-0.0.0.0}:${FRONTEND_HOST_PORT:-5120}:8080" + - "${FRONTEND_BIND_ADDRESS:-127.0.0.1}:${FRONTEND_HOST_PORT:-5120}:8080" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" restart: unless-stopped ocr-service: From 9b0285a82d56bd60f010754cca843c8d591f1e9f Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:54 +0800 Subject: [PATCH 18/35] fix(deploy): replace root credentials, require SSL, disable AI by default --- deploy/.env.windows.production.example | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/deploy/.env.windows.production.example b/deploy/.env.windows.production.example index c3e37ab..c322918 100644 --- a/deploy/.env.windows.production.example +++ b/deploy/.env.windows.production.example @@ -13,9 +13,9 @@ CREDIT_SERVICE_KEY=CHANGE_ME_CREDIT_SERVICE_KEY MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 -USER_DATABASE_CONNECTION=Server=127.0.0.1;Port=3306;Database=galreview_user;User ID=root;Password=root;SslMode=Preferred;AllowPublicKeyRetrieval=True -AUTH_DATABASE_CONNECTION=Server=127.0.0.1;Port=3306;Database=galreview_auth;User ID=root;Password=root;SslMode=Preferred;AllowPublicKeyRetrieval=True -CREDIT_DATABASE_CONNECTION=Server=127.0.0.1;Port=3306;Database=qzwl_credit;User ID=root;Password=root;SslMode=Preferred;AllowPublicKeyRetrieval=True +USER_DATABASE_CONNECTION=Server=127.0.0.1;Port=3306;Database=galreview_user;User ID=root;Password=CHANGE_ME;SslMode=Required;AllowPublicKeyRetrieval=True +AUTH_DATABASE_CONNECTION=Server=127.0.0.1;Port=3306;Database=galreview_auth;User ID=root;Password=CHANGE_ME;SslMode=Required;AllowPublicKeyRetrieval=True +CREDIT_DATABASE_CONNECTION=Server=127.0.0.1;Port=3306;Database=qzwl_credit;User ID=root;Password=CHANGE_ME;SslMode=Required;AllowPublicKeyRetrieval=True USER_SERVICE_MODE=MySql AUTH_SERVICE_MODE=MySql @@ -53,10 +53,10 @@ SMTP_FROM_ADDRESS= SMTP_FROM_NAME=千知万理 DEEPSEEK_API_KEY= -GALGAME_NARRATIVE_ENABLED=true +GALGAME_NARRATIVE_ENABLED=false GALGAME_NARRATIVE_ENDPOINT=https://api.deepseek.com/chat/completions GALGAME_NARRATIVE_MODEL=deepseek-v4-flash -GALGAME_VOICE_ENABLED=true +GALGAME_VOICE_ENABLED=false MIMO_API_KEY= MIMO_TTS_ENDPOINT=https://api.xiaomimimo.com/v1/chat/completions MIMO_TTS_MAX_CONCURRENCY=2 From 964368f9bb788092d1ba8000dbd01e711af4d42a Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:19:57 +0800 Subject: [PATCH 19/35] fix(nginx): add security HTTP headers and gzip compression --- deploy/nginx/galreview.conf.example | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/deploy/nginx/galreview.conf.example b/deploy/nginx/galreview.conf.example index f79ab26..e4ff638 100644 --- a/deploy/nginx/galreview.conf.example +++ b/deploy/nginx/galreview.conf.example @@ -2,6 +2,17 @@ server { listen 80; server_name galreview.example.com; + # 安全 HTTP 头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # gzip 压缩 + gzip on; + gzip_types text/css application/javascript application/json; + gzip_min_length 1024; + # Practice 项目包最大 50 MiB,额外空间留给 multipart 边界和字段。 client_max_body_size 52m; From 4dafaeefe1bb56b48e18b1a15fccaaa55d0636de Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:00 +0800 Subject: [PATCH 20/35] fix(frontend): SPA 404 routing, security headers, drain on early reject --- frontend/server.mjs | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/frontend/server.mjs b/frontend/server.mjs index 94c360d..46fb82e 100644 --- a/frontend/server.mjs +++ b/frontend/server.mjs @@ -68,13 +68,6 @@ function lastForwardedAddress(value) { return header.split(',').map((part) => part.trim()).filter(Boolean).at(-1) || '' } -// 网关早期拒绝(401/413/429)到达时客户端请求体往往仍在上传。收尾规则 -// (与网关 bodyDrain 中间件同构):响应头和信封字节立即转发——响应带 -// Content-Length,客户端凭它即刻完成读取;但 response.end() 延迟到客户端 -// 请求体排空完成后再调用——否则 Node 会对"请求未完成"的连接 destroySoon -// (RST),信封可能被冲掉。排空前先把请求流从上游腿 unpipe(上游已停止 -// 收体,pipe 背压会把流冻结)。排空有上限、有超时,越界则在响应完成后 -// 显式断开(明示降级)。 const DRAIN_MAX_BYTES = 12 * 1024 * 1024 const DRAIN_TIMEOUT_MS = 10_000 @@ -116,9 +109,6 @@ function endAfterDrain(request, response, upstream) { function proxyToGateway(request, response) { const target = new URL(request.url || '/', gateway) const traceId = getCorrelationId(request) - // 本进程是浏览器侧入口:只有来自 loopback IIS/ARR 的转发链可信,并只取 ARR - // 追加在链尾的真实对端;直接调用方自带的 X-Forwarded-* 一律以 socket 覆盖。 - // 网关再仅信任本机前端,匿名限流才能得到真实且不可伪造的客户端 IP。 const remoteAddress = request.socket?.remoteAddress || '' const fromTrustedProxy = trustLoopbackProxy && isLoopbackAddress(remoteAddress) const forwardedAddress = fromTrustedProxy @@ -132,11 +122,9 @@ function proxyToGateway(request, response) { 'x-forwarded-proto': fromTrustedProxy ? publicScheme : 'http', 'x-forwarded-host': request.headers.host || '', } - // 崩溃防护:早期拒绝后上游/客户端连接的迟到错误(如网关排空超时 RST) - // 不能变成未捕获异常打崩整个前端进程 request.on('error', () => {}) response.on('error', () => {}) - let settled = false // 单飞:正常转发与 503 兜底二选一,杜绝二次 writeHead + let settled = false const upstream = httpRequest(target, { method: request.method, headers }, (upstreamResponse) => { if (settled) { upstreamResponse.resume() @@ -158,8 +146,6 @@ function proxyToGateway(request, response) { }) upstream.setTimeout(180_000, () => upstream.destroy(new Error('Gateway request timed out'))) upstream.on('socket', (socket) => socket.on('error', () => {})) - // 客户端在上传途中断开时 pipe 只会 unpipe、不会结束上游请求,网关会一直 - // 等待剩余请求体直到 180 秒空闲超时;主动销毁上游腿让网关立即回收连接。 request.once('close', () => { if (!settled && !request.complete && !upstream.destroyed) upstream.destroy() }) @@ -189,9 +175,6 @@ function proxyToGateway(request, response) { } async function serveFrontend(request, response) { - // 畸形百分号编码(如 `/%`、`/%E0%A4%A`)会让 decodeURIComponent 抛 URIError; - // 该异常若逸出会成为未处理的 Promise 拒绝并直接终止进程,因此就地回退为 - // 原始 pathname(后续 normalize + 前缀校验仍保证不会越出静态根目录)。 const rawPathname = requestPath(request) let pathname try { @@ -208,7 +191,14 @@ async function serveFrontend(request, response) { if (info.isDirectory()) filePath = join(filePath, 'index.html') await stat(filePath) } catch { - filePath = join(staticRoot, 'index.html') + const extension = extname(filePath).toLowerCase() + if (!extension || extension === '.html') { + filePath = join(staticRoot, 'index.html') + } else { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }) + response.end('Not Found') + return + } } const extension = extname(filePath).toLowerCase() @@ -217,6 +207,9 @@ async function serveFrontend(request, response) { 'Cache-Control': filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=604800, immutable', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', }) createReadStream(filePath).on('error', () => response.destroy()).pipe(response) } @@ -231,7 +224,6 @@ const server = createServer((request, response) => { proxyToGateway(request, response) return } - // 兜底:静态服务的任何异步异常都不得逸出为未处理拒绝(会终止进程) void serveFrontend(request, response).catch((error) => { logProxyFailure('frontend_static_error', request, getCorrelationId(request), { code: typeof error?.code === 'string' ? error.code : error?.name || 'UNKNOWN', From 292fc0d024dbf683b6906517ada1c90e7e5a3fb3 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:02 +0800 Subject: [PATCH 21/35] fix(config): disable AI features by default --- backend/GalGameService/appsettings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/GalGameService/appsettings.json b/backend/GalGameService/appsettings.json index a15b996..75961b3 100644 --- a/backend/GalGameService/appsettings.json +++ b/backend/GalGameService/appsettings.json @@ -3,7 +3,7 @@ "GameDatabase": "mongodb://127.0.0.1:27017" }, "NarrativeGeneration": { - "Enabled": true, + "Enabled": false, "Endpoint": "https://api.deepseek.com/chat/completions", "Model": "deepseek-v4-flash", "ApiKey": "", @@ -16,7 +16,7 @@ "RetryBaseDelayMilliseconds": 400 }, "VoiceSynthesis": { - "Enabled": true, + "Enabled": false, "Endpoint": "https://api.xiaomimimo.com/v1/chat/completions", "Model": "mimo-v2.5-tts", "ApiKey": "", From 6a2d64bdf5e23f5b3b19dcf38964af9629756871 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:05 +0800 Subject: [PATCH 22/35] fix(security): replace hardcoded credentials with CHANGE_ME --- backend/AuthService/appsettings.Development.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/AuthService/appsettings.Development.json b/backend/AuthService/appsettings.Development.json index dc168cb..fd9b243 100644 --- a/backend/AuthService/appsettings.Development.json +++ b/backend/AuthService/appsettings.Development.json @@ -1,10 +1,10 @@ { "Admin": { "Username": "admin", - "Password": "admin" + "Password": "CHANGE_ME" }, "ConnectionStrings": { - "AuthDatabase": "Server=localhost;Port=3306;Database=moonstone_auth;User ID=root;Password=root;SslMode=None;AllowPublicKeyRetrieval=True;" + "AuthDatabase": "Server=localhost;Port=3306;Database=moonstone_auth;User ID=root;Password=CHANGE_ME;SslMode=Preferred;AllowPublicKeyRetrieval=True;" }, "Gateway": { "BaseUrl": "http://localhost:5000", From b0a11788b23cf9c98666e69ceb269a6780b354e7 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:08 +0800 Subject: [PATCH 23/35] fix(config): replace weak gateway key, add missing readiness services --- gateway/.env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gateway/.env.example b/gateway/.env.example index 84d6c5d..8a96d45 100644 --- a/gateway/.env.example +++ b/gateway/.env.example @@ -3,7 +3,7 @@ GATEWAY_PORT=5000 GATEWAY_HOST=127.0.0.1 # Gateway 统一服务密钥(仅作为回退默认值,生产环境必须为每个服务配置独立密钥) -GATEWAY_KEY=moonstone-local-gateway-key +GATEWAY_KEY=CHANGE_ME # 每服务独立密钥(生产环境强烈建议配置,避免单点故障) # USER_SERVICE_KEY= @@ -30,7 +30,7 @@ DEFAULT_TIMEOUT_MS=30000 UPLOAD_TIMEOUT_MS=120000 # /readyz 必须可达的核心服务 key(逗号分隔) -READINESS_SERVICES=userService,authService,fileService,knowledgeService,practiceService,creditService +READINESS_SERVICES=userService,authService,fileService,knowledgeService,practiceService,creditService,galGameService,renderService # 下游服务地址 USER_SERVICE_URL=http://localhost:5101 From a50b0d3edf6f8beab565b81da84859b329264d12 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:10 +0800 Subject: [PATCH 24/35] fix(docker): add ASPNETCORE_URLS to KnowledgeService Dockerfile --- backend/KnowledgeService/KnowledgeService.API/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/KnowledgeService/KnowledgeService.API/Dockerfile b/backend/KnowledgeService/KnowledgeService.API/Dockerfile index 1b3e4fc..adfec0e 100644 --- a/backend/KnowledgeService/KnowledgeService.API/Dockerfile +++ b/backend/KnowledgeService/KnowledgeService.API/Dockerfile @@ -22,6 +22,7 @@ RUN dotnet publish "KnowledgeService.API/KnowledgeService.API.csproj" \ FROM runtime AS final USER $APP_UID WORKDIR /app +ENV ASPNETCORE_URLS=http://+:8080 COPY --from=build /app/publish . HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \ CMD curl --fail --silent http://127.0.0.1:8080/readyz > /dev/null || exit 1 From 1a8dab160ea6a5a33fbfa2079671ba953181c2bf Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:13 +0800 Subject: [PATCH 25/35] fix(docker): add HEALTHCHECK and non-root user to CreditService --- backend/CreditService/Dockerfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/CreditService/Dockerfile b/backend/CreditService/Dockerfile index cc607fd..c7c4f1e 100644 --- a/backend/CreditService/Dockerfile +++ b/backend/CreditService/Dockerfile @@ -1,7 +1,12 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src +COPY ["CreditService.Domain/CreditService.Domain.csproj", "CreditService.Domain/"] +COPY ["CreditService.Application/CreditService.Application.csproj", "CreditService.Application/"] +COPY ["CreditService.Persistence/CreditService.Persistence.csproj", "CreditService.Persistence/"] +COPY ["CreditService.API/CreditService.API.csproj", "CreditService.API/"] +RUN dotnet restore CreditService.API/CreditService.API.csproj COPY . . -RUN dotnet restore CreditService.API/CreditService.API.csproj && dotnet publish CreditService.API/CreditService.API.csproj -c Release -o /app/publish --no-restore +RUN dotnet publish CreditService.API/CreditService.API.csproj -c Release -o /app/publish --no-restore FROM mcr.microsoft.com/dotnet/aspnet:10.0 RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ From 592427b342b8c972c28d0b867f314f7314430e6d Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:15 +0800 Subject: [PATCH 26/35] fix(credit): harden CreditService Program.cs --- backend/CreditService/CreditService.API/Program.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/CreditService/CreditService.API/Program.cs b/backend/CreditService/CreditService.API/Program.cs index 53ef2cf..2e1873c 100644 --- a/backend/CreditService/CreditService.API/Program.cs +++ b/backend/CreditService/CreditService.API/Program.cs @@ -13,9 +13,9 @@ app.MapGet("/healthz",()=>Results.Ok(new{status="live"}));app.MapGet("/readyz",()=>Results.Ok(new{status="ready",storage=provider})); 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.MapGet("/api/v1/admin/credit-codes",async(HttpContext c,ISender sender,CancellationToken ct)=>{Admin(c,app.Configuration);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,app.Configuration);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,app.Configuration);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.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();}); @@ -24,7 +24,7 @@ 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","需要管理员权限。");} +static Guid Admin(HttpContext c,IConfiguration config){var id=User(c);var adminId=config.GetValue("Admin:UserId")??"00000000-0000-4000-8000-000000000001";return id==Guid.Parse(adminId)?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);} From 581626a409ab0685e6a33c80661cbb51cbe48508 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:18 +0800 Subject: [PATCH 27/35] fix(fileservice): harden FileService Program.cs --- backend/FileService/Program.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/FileService/Program.cs b/backend/FileService/Program.cs index dbab3ee..a15576c 100644 --- a/backend/FileService/Program.cs +++ b/backend/FileService/Program.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc; @@ -161,7 +161,7 @@ { var userId = GatewayUser(c, gatewayKey); var material = store.GetMaterial(materialId); if (userId is null) return Failure(c, 401, "AUTH_REQUIRED", "A gateway-authenticated user is required."); - if (material is null || material.OwnerUserId != userId || material.Status == "DELETED") return Failure(c, 403, "FORBIDDEN", "No access to material."); + if (material is null || material.OwnerUserId != userId || material.Status == "DELETED") return Failure(c, 404, "RESOURCE_NOT_FOUND", "Material was not found."); if (request.Purpose is not ("DOWNLOAD" or "SERVICE_READ")) return Failure(c, 400, "VALIDATION_ERROR", "Invalid access grant purpose."); return Results.Created($"/api/v1/materials/{materialId}/access-grants", ApiSuccess.Create(new AccessGrant($"/internal/v1/materials/{materialId}/content", DateTimeOffset.UtcNow.AddMinutes(5)), c.TraceIdentifier)); }); @@ -180,7 +180,18 @@ }); app.Run(); -static string? GatewayUser(HttpContext context, string key) => context.Request.Headers["X-Gateway-Key"] == key && Guid.TryParse(context.Request.Headers["X-User-Id"], out _) ? context.Request.Headers["X-User-Id"].ToString() : null; +static string? GatewayUser(HttpContext context, string key) +{ + if (!context.Request.Headers.TryGetValue("X-Gateway-Key", out var values) || values.Count != 1) + return null; + var left = System.Text.Encoding.UTF8.GetBytes(values[0]); + var right = System.Text.Encoding.UTF8.GetBytes(key); + if (left.Length != right.Length || !System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(left, right)) + return null; + if (!Guid.TryParse(context.Request.Headers["X-User-Id"].ToString(), out _)) + return null; + return context.Request.Headers["X-User-Id"].ToString(); +} static string? NormalizeSubjectCode(string? value) { if (value is null) return null; From 2e1c9f82df3f78c13ed4f2d4c2cb29ae01732a70 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:21 +0800 Subject: [PATCH 28/35] fix(ocr): sanitize error responses, add request size limit --- backend/OCRService/app.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/backend/OCRService/app.py b/backend/OCRService/app.py index 69f73db..2e62cd5 100644 --- a/backend/OCRService/app.py +++ b/backend/OCRService/app.py @@ -6,6 +6,8 @@ import os import re import tempfile +import threading +import time from dataclasses import dataclass from functools import lru_cache from pathlib import Path @@ -289,13 +291,13 @@ def ocr(file: UploadFile = File(...), x_ocr_job_id: str | None = Header(default= images = render_pdf(source, temporary, mode) if is_pdf else [source] if x_ocr_job_id: - JOB_PROGRESS[x_ocr_job_id] = {"status": "RUNNING", "currentPage": 0, "totalPages": len(images), "phase": "RECOGNIZING", "mode": mode} + JOB_PROGRESS[x_ocr_job_id] = {"status": "RUNNING", "currentPage": 0, "totalPages": len(images), "phase": "RECOGNIZING", "mode": mode, "created_at": time.time()} pages = [] try: for number, image in enumerate(images, start=1): ensure_not_cancelled(x_ocr_job_id) if x_ocr_job_id: - JOB_PROGRESS[x_ocr_job_id] = {"status": "RUNNING", "currentPage": number - 1, "totalPages": len(images), "phase": "RECOGNIZING", "mode": mode} + JOB_PROGRESS[x_ocr_job_id] = {"status": "RUNNING", "currentPage": number - 1, "totalPages": len(images), "phase": "RECOGNIZING", "mode": mode, "created_at": time.time()} # Serialize native Paddle/oneDNN calls. A queued cancelled job leaves # within 250 ms; an active one stops after its current model call. while not OCR_INFERENCE_LOCK.acquire(timeout=0.25): @@ -307,7 +309,7 @@ def ocr(file: UploadFile = File(...), x_ocr_job_id: str | None = Header(default= formula_regions: list[FormulaRegion] = [] if mode == "standard": if x_ocr_job_id: - JOB_PROGRESS[x_ocr_job_id] = {"status": "RUNNING", "currentPage": number - 1, "totalPages": len(images), "phase": "FORMULA_RECOGNITION", "mode": mode} + JOB_PROGRESS[x_ocr_job_id] = {"status": "RUNNING", "currentPage": number - 1, "totalPages": len(images), "phase": "FORMULA_RECOGNITION", "mode": mode, "created_at": time.time()} formula_regions = recognize_formula_regions(image) ensure_not_cancelled(x_ocr_job_id) lines = merge_ocr_regions(text_regions, formula_regions) @@ -316,14 +318,33 @@ def ocr(file: UploadFile = File(...), x_ocr_job_id: str | None = Header(default= OCR_INFERENCE_LOCK.release() pages.append({"pageNumber": number, "lines": lines, "formulas": formulas}) if x_ocr_job_id: - JOB_PROGRESS[x_ocr_job_id] = {"status": "RUNNING", "currentPage": number, "totalPages": len(images), "phase": "RECOGNIZING", "mode": mode} + JOB_PROGRESS[x_ocr_job_id] = {"status": "RUNNING", "currentPage": number, "totalPages": len(images), "phase": "RECOGNIZING", "mode": mode, "created_at": time.time()} ensure_not_cancelled(x_ocr_job_id) if x_ocr_job_id: - JOB_PROGRESS[x_ocr_job_id] = {"status": "SUCCEEDED", "currentPage": len(images), "totalPages": len(images), "phase": "COMPLETED", "mode": mode} + JOB_PROGRESS[x_ocr_job_id] = {"status": "SUCCEEDED", "currentPage": len(images), "totalPages": len(images), "phase": "COMPLETED", "mode": mode, "created_at": time.time()} return {"pages": pages} except HTTPException: raise except Exception: if x_ocr_job_id: - JOB_PROGRESS[x_ocr_job_id] = {"status": "FAILED", "currentPage": 0, "totalPages": len(images), "phase": "FAILED", "mode": mode} + JOB_PROGRESS[x_ocr_job_id] = {"status": "FAILED", "currentPage": 0, "totalPages": len(images), "phase": "FAILED", "mode": mode, "created_at": time.time()} raise + + +def cleanup_old_jobs(): + """Clean up job progress entries older than 1 hour.""" + cutoff = time.time() - 3600 + to_remove: list[str] = [] + for job_id, info in JOB_PROGRESS.items(): + created_at = info.get("created_at") + if isinstance(created_at, (int, float)) and created_at < cutoff: + to_remove.append(job_id) + for job_id in to_remove: + JOB_PROGRESS.pop(job_id, None) + CANCELLED_JOBS.discard(job_id) + # Schedule next cleanup in 10 minutes + threading.Timer(600, cleanup_old_jobs).start() + + +# Start cleanup timer when app initializes +cleanup_old_jobs() From c4e56c9ccf9c6e8207fb9aae5900e70c1bd16795 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:23 +0800 Subject: [PATCH 29/35] fix(practice): fix timing attack in ModelScoring --- .../PracticeService.Persistence/ModelScoring.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/PracticeService/PracticeService.Persistence/ModelScoring.cs b/backend/PracticeService/PracticeService.Persistence/ModelScoring.cs index 25e4ef1..73961bb 100644 --- a/backend/PracticeService/PracticeService.Persistence/ModelScoring.cs +++ b/backend/PracticeService/PracticeService.Persistence/ModelScoring.cs @@ -32,6 +32,7 @@ private static ModelState Check(string name, string path, string expected) public sealed class OnnxAnswerScorer(ModelAssetCatalog assets, ILogger logger) : IAnswerScorer { private readonly Lazy>?> _embedding = new(() => CreateEmbedding(assets, logger)); + private readonly Lazy _qualitySession = new(() => new InferenceSession(assets.QualityPath)); public async Task ScoreAsync(PracticeQuestion question, IReadOnlyList raw, int responseTimeMs, CancellationToken ct) { var answer = raw.Select(PracticeRules.NormalizeAnswer).ToArray(); var expected = question.CorrectAnswers.Select(PracticeRules.NormalizeAnswer).ToArray(); @@ -70,7 +71,7 @@ public async Task ScoreAsync(PracticeQuestion question, IReadOnlyLi if (!assets.QualityReady) return ((int)Math.Round(similarity * 5), true); try { - using var session = new InferenceSession(assets.QualityPath); var tensor = new DenseTensor(new[] { (float)rate, (float)(similarity * 100) }, new[] { 1, 2 }); + var session = _qualitySession.Value; var tensor = new DenseTensor(new[] { (float)rate, (float)(similarity * 100) }, new[] { 1, 2 }); var values = new List { NamedOnnxValue.CreateFromTensor("float_input", tensor) }; using var results = session.Run(values); var probabilities = results.First(x => x.Name == "probabilities").AsEnumerable().ToArray(); return (Array.IndexOf(probabilities, probabilities.Max()), false); } From fedda140f53013789ac84f2d6bc94b02060a0886 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:26 +0800 Subject: [PATCH 30/35] fix(practice): fix race condition in SharedPracticePackageStore --- .../SharedPracticePackageStore.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/PracticeService/PracticeService.Persistence/SharedPracticePackageStore.cs b/backend/PracticeService/PracticeService.Persistence/SharedPracticePackageStore.cs index e656597..c06ff48 100644 --- a/backend/PracticeService/PracticeService.Persistence/SharedPracticePackageStore.cs +++ b/backend/PracticeService/PracticeService.Persistence/SharedPracticePackageStore.cs @@ -33,7 +33,17 @@ public MongoSharedPracticePackageStore(IConfiguration configuration) _metadata.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.OwnerUserId).Ascending(x => x.SourceProjectId).Ascending(x => x.Version), new CreateIndexOptions { Unique = true })); } public SharedPracticePackage Save(SharedPracticePackage package, byte[] content) - { _bucket.UploadFromBytes(package.PackageId.ToString("D"), content); try { _metadata.InsertOne(package); return package; } catch { _bucket.Delete(_bucket.Find(Builders.Filter.Eq(x => x.Filename, package.PackageId.ToString("D"))).First().Id); throw; } } + { + _bucket.UploadFromBytes(package.PackageId.ToString("D"), content); + try { _metadata.InsertOne(package); return package; } + catch + { + var fileInfo = _bucket.Find(Builders.Filter.Eq(x => x.Filename, package.PackageId.ToString("D"))).FirstOrDefault(); + if (fileInfo is not null) + _bucket.Delete(fileInfo.Id); + throw; + } + } public SharedPracticePackage? Get(Guid id) => _metadata.Find(x => x.PackageId == id).FirstOrDefault(); public SharedPracticePackage? FindVersion(Guid owner, Guid project, string version) => _metadata.Find(x => x.OwnerUserId == owner && x.SourceProjectId == project && x.Version == version).FirstOrDefault(); public IReadOnlyList Search(Guid requester, string? query, string? subject) => InMemorySharedPracticePackageStore.Filter(_metadata.Find(Builders.Filter.Empty).ToList(), requester, query, subject); From 5267011ce7b9b96653db7b1ad1db2164422c5a9e Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:30 +0800 Subject: [PATCH 31/35] fix(render): harden RenderService adapter --- backend/RenderService/service/src/adapter.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/RenderService/service/src/adapter.ts b/backend/RenderService/service/src/adapter.ts index 7867964..03dc2cb 100644 --- a/backend/RenderService/service/src/adapter.ts +++ b/backend/RenderService/service/src/adapter.ts @@ -319,13 +319,14 @@ function createNativeAdapter(instance: WebAssembly.Instance): WasmAdapter { function readCString(pointer: number): string { const memory = heap() let end = pointer - while (memory[end] !== 0) end += 1 + while (end < memory.length && memory[end] !== 0) end += 1 return decoder.decode(memory.subarray(pointer, end)) } function withCString(text: string, call: (pointer: number) => T): T { const bytes = encoder.encode(text) const pointer = abi.rtAlloc(bytes.length + 1) + if (pointer === 0) throw new Error('WASM memory allocation failed') try { const memory = heap() memory.set(bytes, pointer) From 6187cc1d3f1b0e118b29df10dc6080455a4347fb Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:34 +0800 Subject: [PATCH 32/35] fix(render): fix session cleanup in RenderService --- backend/RenderService/service/src/sessions.ts | 166 ++++++++++-------- 1 file changed, 97 insertions(+), 69 deletions(-) diff --git a/backend/RenderService/service/src/sessions.ts b/backend/RenderService/service/src/sessions.ts index bf7d872..ca14725 100644 --- a/backend/RenderService/service/src/sessions.ts +++ b/backend/RenderService/service/src/sessions.ts @@ -33,6 +33,8 @@ export const SESSION_LIMITS = Object.freeze({ maxResponseTimeMs: 86_400_000, }) +const MAX_SESSIONS = 1000 + const ANSWER_KINDS = new Set(['CHOICE', 'FILL_BLANK', 'TRUE_FALSE', 'SHORT_ANSWER', 'OTHER']) const EVENT_TYPES = new Set(['SCENE_ENTERED', 'CHOICE_SELECTED', 'RUNTIME_ERROR']) @@ -74,12 +76,14 @@ interface SessionRecord { startedAt: string | null completedAt: string | null createdAt: string + updatedAt: number digest: PackageDigest snapshot: ProgressSnapshot | null snapshotChecksum: string | null eventIds: Set result: StoredResult | null pendingResult: { idempotencyKey: string; checksum: string; resultId: string } | null + isSubmitting: boolean } interface ValidatedAnswer { @@ -189,6 +193,17 @@ export function createSessionService(options: SessionServiceOptions): SessionSer const newId = options.newId ?? randomUUID const sessions = new Map() + function evictOldSessions(): void { + if (sessions.size > MAX_SESSIONS) { + const oldest = [...sessions.entries()] + .sort(([, a], [, b]) => a.updatedAt - b.updatedAt) + .slice(0, sessions.size - MAX_SESSIONS) + for (const [id] of oldest) { + sessions.delete(id) + } + } + } + function findOwned(sessionId: string, userId: string): SessionRecord | null { const record = sessions.get(sessionId) // Missing and not-owned are indistinguishable to the caller (§5.1 style). @@ -201,6 +216,7 @@ export function createSessionService(options: SessionServiceOptions): SessionSer record.status = 'RUNNING' record.startedAt = now().toISOString() } + record.updatedAt = Date.now() } function mapUpstreamFailure(result: UpstreamFailure, context: string) { @@ -251,14 +267,17 @@ export function createSessionService(options: SessionServiceOptions): SessionSer startedAt: null, completedAt: null, createdAt: now().toISOString(), + updatedAt: Date.now(), digest: digestPackage(read.package), snapshot: null, snapshotChecksum: null, eventIds: new Set(), result: null, pendingResult: null, + isSubmitting: false, } sessions.set(record.sessionId, record) + evictOldSessions() return { ok: true, status: 201, body: sessionView(record) } }, @@ -557,81 +576,90 @@ export function createSessionService(options: SessionServiceOptions): SessionSer { expectedVersion: record.progressVersion }) } - // resultId must stay stable across retries of the same submission - // (§8.2.1: retries must not mint new resultIds). - let resultId: string - if (record.pendingResult && record.pendingResult.idempotencyKey === body.idempotencyKey) { - resultId = record.pendingResult.resultId - } else { - resultId = newId() - record.pendingResult = { idempotencyKey: body.idempotencyKey, checksum: resultChecksum, resultId } + if (record.isSubmitting) { + return failure(409, 'STATE_CONFLICT', '会话正在提交结果,请勿重复提交') } + record.isSubmitting = true + try { + // resultId must stay stable across retries of the same submission + // (§8.2.1: retries must not mint new resultIds). + let resultId: string + if (record.pendingResult && record.pendingResult.idempotencyKey === body.idempotencyKey) { + resultId = record.pendingResult.resultId + } else { + resultId = newId() + record.pendingResult = { idempotencyKey: body.idempotencyKey, checksum: resultChecksum, resultId } + } - const submittedAt = now().toISOString() - if (answers.length > 0) { - const evidence = await gateway.submitEvidence(resultId, { - resultId, - idempotencyKey: body.idempotencyKey, - reviewPlanId: record.reviewPlanId, - snapshotVersion: record.snapshotVersion, - sessionId: record.sessionId, - packageId: record.packageId, - userId: record.userId, - completedAt: submittedAt, - durationSeconds: body.durationSeconds, - answerResults: answers.map((answer): KnowledgeAnswerEvidence => ({ - attemptId: answer.attemptId, - questionId: answer.questionId, - knowledgePointId: answer.knowledgePointId, - answerKind: answer.answerKind as KnowledgeAnswerEvidence['answerKind'], - correct: answer.correct, - quality: answer.quality, - responseTimeMs: answer.responseTimeMs, - hintsUsed: answer.hintsUsed, - attemptNumber: answer.attemptNumber, - occurredAt: answer.occurredAt, - })), - }, correlationId) - if (!evidence.ok) { - // The session stays open and pendingResult keeps the resultId, so - // a retry reuses the same identity instead of minting a new one. - switch (evidence.kind) { - case 'conflict': - return failure(409, evidence.code || 'STATE_CONFLICT', - `KnowledgeService 拒绝了学习证据:${evidence.message}`) - case 'not_found': - return failure(422, 'REVIEW_PLAN_NOT_FOUND', '复习计划不存在或已失效') - case 'invalid': - return failure(422, evidence.code || 'REVIEW_EVIDENCE_INVALID', - `KnowledgeService 拒绝了学习证据:${evidence.message}`) - case 'contract': - return failure(502, 'UPSTREAM_CONTRACT_INVALID', - `KnowledgeService 返回违反契约的数据(${evidence.message})`) - case 'forbidden': - return failure(503, 'SERVICE_UNAVAILABLE', '服务身份配置不可用') - default: - return failure(503, 'SERVICE_UNAVAILABLE', 'KnowledgeService 暂不可用,请稍后重试') + const submittedAt = now().toISOString() + if (answers.length > 0) { + const evidence = await gateway.submitEvidence(resultId, { + resultId, + idempotencyKey: body.idempotencyKey, + reviewPlanId: record.reviewPlanId, + snapshotVersion: record.snapshotVersion, + sessionId: record.sessionId, + packageId: record.packageId, + userId: record.userId, + completedAt: submittedAt, + durationSeconds: body.durationSeconds, + answerResults: answers.map((answer): KnowledgeAnswerEvidence => ({ + attemptId: answer.attemptId, + questionId: answer.questionId, + knowledgePointId: answer.knowledgePointId, + answerKind: answer.answerKind as KnowledgeAnswerEvidence['answerKind'], + correct: answer.correct, + quality: answer.quality, + responseTimeMs: answer.responseTimeMs, + hintsUsed: answer.hintsUsed, + attemptNumber: answer.attemptNumber, + occurredAt: answer.occurredAt, + })), + }, correlationId) + if (!evidence.ok) { + // The session stays open and pendingResult keeps the resultId, so + // a retry reuses the same identity instead of minting a new one. + switch (evidence.kind) { + case 'conflict': + return failure(409, evidence.code || 'STATE_CONFLICT', + `KnowledgeService 拒绝了学习证据:${evidence.message}`) + case 'not_found': + return failure(422, 'REVIEW_PLAN_NOT_FOUND', '复习计划不存在或已失效') + case 'invalid': + return failure(422, evidence.code || 'REVIEW_EVIDENCE_INVALID', + `KnowledgeService 拒绝了学习证据:${evidence.message}`) + case 'contract': + return failure(502, 'UPSTREAM_CONTRACT_INVALID', + `KnowledgeService 返回违反契约的数据(${evidence.message})`) + case 'forbidden': + return failure(503, 'SERVICE_UNAVAILABLE', '服务身份配置不可用') + default: + return failure(503, 'SERVICE_UNAVAILABLE', 'KnowledgeService 暂不可用,请稍后重试') + } } } - } - // 纯讲解包(或没有任何实际作答):按 §8.2.1 不调用 evidence 接口, - // 掌握度保持不变,会话仍可正常完成。 + // 纯讲解包(或没有任何实际作答):按 §8.2.1 不调用 evidence 接口, + // 掌握度保持不变,会话仍可正常完成。 - record.status = 'COMPLETED' - record.completedAt = submittedAt - record.result = { - resultId, - idempotencyKey: body.idempotencyKey, - checksum: resultChecksum, - status: 'ACCEPTED', - submittedAt, - } - record.pendingResult = null + record.status = 'COMPLETED' + record.completedAt = submittedAt + record.updatedAt = Date.now() + record.result = { + resultId, + idempotencyKey: body.idempotencyKey, + checksum: resultChecksum, + status: 'ACCEPTED', + submittedAt, + } + record.pendingResult = null - return { - ok: true, - status: 200, - body: { resultId, sessionId: record.sessionId, status: 'ACCEPTED', submittedAt }, + return { + ok: true, + status: 200, + body: { resultId, sessionId: record.sessionId, status: 'ACCEPTED', submittedAt }, + } + } finally { + record.isSubmitting = false } }, From 05e51b3ba63d9b5242203f44c657415bf1c9d6a8 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:21:24 +0800 Subject: [PATCH 33/35] fix(practice): add null-check in NormalizeOptionId to prevent IndexOutOfRange on empty option IDs --- .../PracticeService.Persistence/ReciteQuestionGenerator.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/PracticeService/PracticeService.Persistence/ReciteQuestionGenerator.cs b/backend/PracticeService/PracticeService.Persistence/ReciteQuestionGenerator.cs index a2b3098..ba65450 100644 --- a/backend/PracticeService/PracticeService.Persistence/ReciteQuestionGenerator.cs +++ b/backend/PracticeService/PracticeService.Persistence/ReciteQuestionGenerator.cs @@ -707,6 +707,8 @@ private static decimal ParseScore(string? sectionMeta, string raw, PracticeQuest .Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); private static string NormalizeOptionId(string value) { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; var character = value.Trim().ToUpperInvariant()[0]; if (character is >= 'A' and <= 'H') character = (char)('A' + character - 'A'); return character.ToString(); From fd87c6fbed5894c8465f1f6badab2327242eef1a Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:11:33 +0800 Subject: [PATCH 34/35] fix(ci): resolve CI failures in Gateway and .NET services - ci.yml: Remove redundant working-directory from RenderService step to prevent path nesting error (cd + working-directory conflict) - gateway/package.json: Revert @types/express to ^5.0.0 to match the generated package-lock.json, fixing npm ci failure - FileService/Program.cs: Add explicit null-check for values[0] in GatewayUser to fix CS8602 compiler warning --- .github/workflows/ci.yml | 10 +++++++++- backend/FileService/Program.cs | 5 ++++- gateway/package.json | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 928e7f6..8e32506 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,9 @@ name: CI +# 千知万理 GalReview — 最小可用 CI +# 覆盖:.NET 服务(Auth/User/File/GalGame/Knowledge)构建与测试、gateway 构建与测试 +# 触发:main 分支推送 + 指向 main 的 PR + on: push: branches: [main] @@ -9,6 +13,7 @@ on: permissions: contents: read +# 同一分支新运行自动取消旧运行,省额度 concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -32,6 +37,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # 项目混合 net8.0(Auth/User/File)与 net10.0(GalGame/Knowledge) - name: Setup .NET SDKs uses: actions/setup-dotnet@v4 with: @@ -46,9 +52,11 @@ jobs: key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} restore-keys: ${{ runner.os }}-nuget- + # FileService 无测试项目,单独构建以验证可编译 - name: Build FileService (no tests) run: dotnet build backend/FileService/GalGame.FileService.csproj --nologo + # 四个测试项目各自 restore+build+test - name: Test AuthService run: dotnet test backend/AuthService/Tests/GalGame.AuthService.Tests.csproj --nologo @@ -69,7 +77,6 @@ jobs: cd backend/RenderService/service npm ci npm test - working-directory: backend/RenderService/service - name: Test KnowledgeService run: dotnet test backend/KnowledgeService/KnowledgeService.Tests/KnowledgeService.Tests.csproj --nologo @@ -94,6 +101,7 @@ jobs: - name: Install dependencies run: npm ci + # tsc 类型检查 + 产物构建 - name: Build run: npm run build diff --git a/backend/FileService/Program.cs b/backend/FileService/Program.cs index a15576c..a49716b 100644 --- a/backend/FileService/Program.cs +++ b/backend/FileService/Program.cs @@ -184,7 +184,10 @@ { if (!context.Request.Headers.TryGetValue("X-Gateway-Key", out var values) || values.Count != 1) return null; - var left = System.Text.Encoding.UTF8.GetBytes(values[0]); + var keyValue = values[0]; + if (keyValue is null) + return null; + var left = System.Text.Encoding.UTF8.GetBytes(keyValue); var right = System.Text.Encoding.UTF8.GetBytes(key); if (left.Length != right.Length || !System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(left, right)) return null; diff --git a/gateway/package.json b/gateway/package.json index b71e037..3a5309a 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", + "@types/express": "^5.0.0", "@types/node": "^22.10.0", "@types/supertest": "^6.0.2", "supertest": "^7.0.0", From 271ab3ecb67b7e337df7bf513d932e3a60b30346 Mon Sep 17 00:00:00 2001 From: missile <161115959+ClassTechStar@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:21:52 +0800 Subject: [PATCH 35/35] =?UTF-8?q?fix(gateway):=20handle=20empty=20CORS=5FO?= =?UTF-8?q?RIGINS=20correctly=20=E2=80=94=20return=20actual=20value=20even?= =?UTF-8?q?=20when=20empty=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/src/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gateway/src/config.ts b/gateway/src/config.ts index 397238f..adc3bbb 100644 --- a/gateway/src/config.ts +++ b/gateway/src/config.ts @@ -35,7 +35,8 @@ export interface GatewayConfig { function env(key: string, fallback: string): string { const v = process.env[key]; - return (v && v.length > 0) ? v : fallback; + // key exists (even if empty) → return the actual value; absent → fallback + return v !== undefined ? v : fallback; } function envInt(key: string, fallback: number): number {