Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
21da535
fix(docker): non-root user and healthcheck for AuthService
ClassTechStar Aug 9, 2026
f7687ae
fix(docker): non-root user and healthcheck for FileService
ClassTechStar Aug 9, 2026
0319a35
fix(docker): non-root user for OCRService
ClassTechStar Aug 9, 2026
924ab42
fix(docker): non-root user and healthcheck for PracticeService
ClassTechStar Aug 9, 2026
462dfcc
fix(frontend): prevent interval leakage in poll utility
ClassTechStar Aug 9, 2026
f43a3f0
fix(frontend): add localStorage overflow fallback in workflow
ClassTechStar Aug 9, 2026
cd64b47
fix(frontend): fix polling cleanup in KnowledgePointsPage
ClassTechStar Aug 9, 2026
89aafa7
fix(gateway): align @types/express, add engines field
ClassTechStar Aug 9, 2026
723af61
fix(gateway): handle empty env vars, fix envInt parsing, add trustProxy
ClassTechStar Aug 9, 2026
1d23c50
fix(gateway): use || for host fallback, graceful shutdown, error hand…
ClassTechStar Aug 9, 2026
6ca6935
fix(gateway): delegate to Express default handler when headers sent
ClassTechStar Aug 9, 2026
29e8f2e
fix(gateway): strip trailing slash, fix timer cleanup in health probe
ClassTechStar Aug 9, 2026
3be5947
fix(galgame): sanitize exception details in CreditBillingClient
ClassTechStar Aug 9, 2026
89a2d17
fix(galgame): add OOM protection in MongoGameStore
ClassTechStar Aug 9, 2026
f5e3429
fix(galgame): harden GalGameService Program.cs
ClassTechStar Aug 9, 2026
724d211
fix(ci): add NuGet cache and missing service tests
ClassTechStar Aug 9, 2026
6e5e296
fix(infra): default CHANGE_ME passwords, log rotation, 127.0.0.1 bind
ClassTechStar Aug 9, 2026
9b0285a
fix(deploy): replace root credentials, require SSL, disable AI by def…
ClassTechStar Aug 9, 2026
964368f
fix(nginx): add security HTTP headers and gzip compression
ClassTechStar Aug 9, 2026
4dafaee
fix(frontend): SPA 404 routing, security headers, drain on early reject
ClassTechStar Aug 9, 2026
292fc0d
fix(config): disable AI features by default
ClassTechStar Aug 9, 2026
6a2d64b
fix(security): replace hardcoded credentials with CHANGE_ME
ClassTechStar Aug 9, 2026
b0a1178
fix(config): replace weak gateway key, add missing readiness services
ClassTechStar Aug 9, 2026
a50b0d3
fix(docker): add ASPNETCORE_URLS to KnowledgeService Dockerfile
ClassTechStar Aug 9, 2026
1a8dab1
fix(docker): add HEALTHCHECK and non-root user to CreditService
ClassTechStar Aug 9, 2026
592427b
fix(credit): harden CreditService Program.cs
ClassTechStar Aug 9, 2026
581626a
fix(fileservice): harden FileService Program.cs
ClassTechStar Aug 9, 2026
2e1c9f8
fix(ocr): sanitize error responses, add request size limit
ClassTechStar Aug 9, 2026
c4e56c9
fix(practice): fix timing attack in ModelScoring
ClassTechStar Aug 9, 2026
fedda14
fix(practice): fix race condition in SharedPracticePackageStore
ClassTechStar Aug 9, 2026
5267011
fix(render): harden RenderService adapter
ClassTechStar Aug 9, 2026
6187cc1
fix(render): fix session cleanup in RenderService
ClassTechStar Aug 9, 2026
05e51b3
fix(practice): add null-check in NormalizeOptionId to prevent IndexOu…
ClassTechStar Aug 9, 2026
fd87c6f
fix(ci): resolve CI failures in Gateway and .NET services
ClassTechStar Aug 9, 2026
271ab3e
fix(gateway): handle empty CORS_ORIGINS correctly — return actual val…
ClassTechStar Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ jobs:
8.0.x
10.0.x

- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
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
Expand All @@ -59,6 +66,18 @@ 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

- name: Test KnowledgeService
run: dotnet test backend/KnowledgeService/KnowledgeService.Tests/KnowledgeService.Tests.csproj --nologo

Expand Down
1 change: 1 addition & 0 deletions backend/AuthService/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 2 additions & 2 deletions backend/AuthService/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 4 additions & 4 deletions backend/CreditService/CreditService.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreditBalance>();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();});
Expand All @@ -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<string>("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);}
Expand Down
7 changes: 6 additions & 1 deletion backend/CreditService/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 \
Expand Down
1 change: 1 addition & 0 deletions backend/FileService/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
20 changes: 17 additions & 3 deletions backend/FileService/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;

Expand Down Expand Up @@ -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));
});
Expand All @@ -180,7 +180,21 @@
});
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 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;
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;
Expand Down
17 changes: 16 additions & 1 deletion backend/GalGameService/CreditBillingClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> ReadBoundedAsync(HttpContent content,int maxBytes,CancellationToken ct)
{
var stream=await content.ReadAsStreamAsync(ct);
var buffer=new byte[maxBytes];
var totalRead=0;
while(totalRead<maxBytes)
{
var read=await stream.ReadAsync(buffer.AsMemory(totalRead,maxBytes-totalRead),ct);
if(read==0)break;
totalRead+=read;
}
return System.Text.Encoding.UTF8.GetString(buffer,0,totalRead);
}
}
4 changes: 2 additions & 2 deletions backend/GalGameService/MongoGameStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -333,7 +333,7 @@ public GameGenerationJob CreateJob(string ownerUserId, GameGenerationRequest req
UpdatedAt: now);

// 容量保护:超限时清理最旧已完成 job
var count = (int)_jobs.CountDocuments(FilterDefinition<GameGenerationJob>.Empty);
var count = (int)_jobs.EstimatedDocumentCount();
if (count >= MaxJobs)
EvictOldestCompletedJobs();

Expand Down
19 changes: 14 additions & 5 deletions backend/GalGameService/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

Expand Down Expand Up @@ -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<string, string>()) });
try
{
store.TryTransitionJob(job.GenerationId, JobStatus.RUNNING,
j => j with { Status = JobStatus.FAILED, Error = new ApiError("INTERNAL_ERROR", "游戏生成失败,请稍后重试", new Dictionary<string, string>()) });
}
catch (Exception transitionError) { logger.LogError(transitionError, "Unable to transition failed job {GenerationId}", job.GenerationId); }
}
});

Expand Down Expand Up @@ -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)
{
Expand Down
4 changes: 2 additions & 2 deletions backend/GalGameService/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand All @@ -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": "",
Expand Down
1 change: 1 addition & 0 deletions backend/KnowledgeService/KnowledgeService.API/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/OCRService/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading