dpz.core — Agent Guide
Project structure
- .NET 10 monorepo, ~30 projects under
src/ - Solution file:
src/Dpz.Core.slnx(modern.slnxformat — pass it explicitly to dotnet) src/is the working root for all dotnet/tool commands (see CSharpier note below)- Sensitive secrets live in
PRIVATE.mdat the repo root — it is tracked by git, so never commit changes to it, never echo its contents - Test project
Dzp.Core.XunitBasicand shard implDzp.Core.Shard.Implement.Servicehave intentional typos ("Dzp" not "Dpz") — do not rename them
Quick commands
# Build (run from src/ or pass full path)
dotnet build ./Dpz.Core.slnx # from src/
dotnet build src/Dpz.Core.slnx # from repo root
# Test all / single project
dotnet test src/Dpz.Core.slnx
dotnet test src/Dpz.Core.Backup.Test/Dpz.Core.Backup.Test.csproj
# Format C# — tool manifest is at src/dotnet-tools.json (NOT standard .config/)
# These MUST be run from src/, not the repo root
dotnet tool restore # in src/
dotnet csharpier . # in src/ (config: src/.csharpierrc.yaml, csharpier 1.3.0)
# Frontend dev — run from src/Dpz.Core.Web/
.\build.ps1 init # one-time: npm install + verify config + smoke-test prod build
.\build.ps1 dev # watch mode — alias for npm run dev:app
.\build.ps1 build # production bundle (CSS + JS + hash + manifest)
# clean-css-cli is a local devDependency (resolved from node_modules/.bin); no global install required
# Frontend lint/format (run from src/Dpz.Core.Web/ after `npm install`)
npm run lint
npm run format
npm run format:check
Architecture essentials
| Layer | Project | Role |
|---|---|---|
| Web | Dpz.Core.Web | Main MVC app (container port 2372) |
| Auth | Dpz.Core.Auth | SSO / OpenIddict 7.6.0 (container port 2377, no GitHub Actions deploy) |
| API | Dpz.Core.WebApi | REST API (container port 2376) |
| Jobs | Dpz.Core.Web.Jobs | Hangfire background tasks — runs as systemd service, NOT Docker |
| Shared | Dpz.Core.AspNetCore | ASP.NET Core infra shared by Web/Auth/WebApi (IHttpCurrentUserService, UseDevToolsEndpoint, FormFileImageUploadExtensions) |
| DB | Dpz.Core.MongodbAccess | Repository + Unit of Work pattern |
| Config | Dpz.Core.Infrastructure | Shared utilities, rate limiting, middleware |
| Mediator | Dpz.Core.Service.Mediator | CQRS request handlers (martinothamar Mediator, NOT MediatR) |
| Messages | Dpz.Core.MessageQueue | RabbitMQ publisher/consumer + Outbox pattern |
| Models | Dpz.Core.Public.Entity / Dpz.Core.Public.ViewModel | Entities (DB-only) / DTOs (public API) |
Entrypoints: Dpz.Core.Web/Program.cs, Dpz.Core.WebApi/Program.cs, Dpz.Core.Auth/Program.cs, Dpz.Core.Web.Jobs/Program.cs. Local dotnet run uses each project's launchSettings.json (NOT the container ports above). All apps call AddBusinessServices(configuration) + app-specific AddInject()/AddProjectServices()/etc.
An older instruction file exists at src/.github/copilot-instructions.md — prefer this file when they conflict.
Microsoft / OpenIddict package versions are centralized in src/Directory.Build.props — bump versions there, not per-csproj.
Coding conventions
- Indent: 4 spaces. Max line: 100 chars.
- File-scoped namespaces:
namespace X.Y; - Private fields:
_camelCaseprefix - Braces always required (even single-line
if/for/etc.) - No trailing inline comments (
var x = 1; // bad) — convention is review-only, no Roslyn analyzer enforces it - No public fields — use properties
- Primary constructors when only one constructor
- Method names must not use the
Ensureprefix — use more descriptive names that convey the method's purpose - Async methods must end in
Async, always acceptCancellationToken cancellationToken = default - Structured logging only — no string interpolation in Serilog calls
- Return empty collections, never null, for arrays/lists (
byte[]?is ok) - Parameters: as abstract as possible. Returns: as concrete as possible.
- Uses C# 14
extensionmethod syntax (not traditionalthisparameter) - Service public methods (and interface signatures) must never return entity types directly. Entities live in
Dpz.Core.Public.Entityand are used only inside repositories/services; the public API of a service must return DTO / ViewModel / Response types fromDpz.Core.Public.ViewModel(e.g.VmVideo,MusicResponse,CommentViewModel). This isolates the persistence model from callers. - Object mapping uses Mapster (
IMapper/TypeAdapterConfig), not AutoMapper. Runtime type mapping must use the injectedIMapper; do not call staticAdapt<T>()/Adapt(...), because it bypasses DI-registered custom mappings.
Service DI registration (source-generator driven)
Dpz.Core.Service/ServiceDependencyInjection.cs exposes AddDefaultServices(), which calls AddGeneratedRepositoryServices() — a method generated at compile time by the Dpz.Core.SourceGenerator project. Do NOT hand-register domain services (no services.AddScoped<IFooService, FooService>() for interfaces in Dpz.Core.Service.RepositoryService).
The source generator:
- Scans all interfaces in
Dpz.Core.Service.RepositoryServicenamespace - Finds matching implementations in
Dpz.Core.Service.RepositoryServiceImpl(convention-based:IFooService→FooService) - Generates
GeneratedServiceRegistration.g.cswith allAddScoped/AddSingleton/AddTransient/AddHttpClientcalls - Namespace pair is hardcoded in
ServiceRegistrationProvider.cs:12-13— not config-driven
Customize registration per service via attributes (from Dpz.Core.SourceGenerator.Attributes):
[DependencyInjection(ServiceLifetime.Transient)]— change from defaultScoped[DependencyInjection(Ignore = true)]— opt out of auto-registration[HttpClientDependencyInjection("https://api.example.com", TimeoutSeconds = 180)]— wire viaAddHttpClientinstead (e.g.ISteamGameService)
Implications when adding a new service:
- Drop the interface in
Dpz.Core.Service.RepositoryServiceand its impl inDpz.Core.Service.RepositoryServiceImpl— DI is automatic, no code change needed - Service interfaces must start with
Ifollowed by a PascalCase name — the impl must match the part afterI - Putting the impl outside
RepositoryServiceImplwill silently leave the service unregistered - Only the first matching impl wins; if you need multiple, do NOT add a second — use manual registration in the app-specific DI extension
Mediator (martinothamar Mediator, NOT MediatR)
See .agent/skills/mediator/SKILL.md for full details. Key points:
- Package:
Mediatorby martinothamar v3.0.2 — source-generator based. Do NOT hand-register handlers; the generator wires them automatically. - Registered via
AddDpzMediator()called insideAddBusinessServices(). - Pipeline:
MediatorLoggingBehavior→MediatorPerformanceBehavior(≥800 ms warning) →MediatorExceptionWrappingBehavior - Handlers inject:
IRepository<>,IFusionCache,IMessagePublisher<>,IMapper,IMediator,IHttpClientFactory,IConfiguration,ILogger<>— never business service interfaces fromDpz.Core.Service - Return types:
ResponseResult/ResponseResult<T>for API-boundary requests; DTO/ViewModel/nullable for queries; neverPublic.Entity.* - Feature modules live in
src/Dpz.Core.Service.Mediator/Features/:Article,Code,Health,Markdown,Media,Search,Storage,Video, plusAccount,Auth,Mumble,Security,Sitemap,Timeline— check the folder before creating a new module - Dependency direction:
MongodbAccess → Mediator → Service → Application
Messaging (RabbitMQ + Outbox)
- All message types inherit
MessageBase(fromDpz.Core.Entity.Base) and live inDpz.Core.Public.ViewModel.Messages/ - Routing auto-derived from class name: e.g.
NewsArticleMessage→ exchangedpz.news.exchange, queuedpz.news.article.queue. Override with[MessageRoute]. AddRabbitMQ(configuration)is called insideAddBusinessServices()— all apps get the publisher automatically- Register consumers per-app with
AddMessageConsumer<TMsg, THandler>() - Consumers:
ClearCacheMessage+RefreshUserProfileMessage(Web),NewsArticleMessage+BatchCompletionMessage(WebApi, plus MQ integration-test consumers + outbox retry worker under#if DEBUG), several email/media consumers (Web.Jobs) - Outbox (
AddMessageOutbox()) provides MongoDB-backed reliable delivery; retry jobs run every minute in Web.Jobs - Config section:
"RabbitMQ"withHostName,Port,UserName,Password,VirtualHost
Caching (FusionCache)
- Primary cache abstraction: ZiggyCreatures.FusionCache (L1 memory + L2 Redis + Redis backplane)
- Registered in
AddBusinessServices()— injectIFusionCachedirectly - Services that cache inherit
AbstractCacheServicewhich wrapsGetOrSetAsyncwith tag-based invalidation (RemoveByTagAsync) - Cache prefix key defaults to
Type.FullName; override per service - In dev: distributed lock via
FileDistributedSynchronizationProvider(config keyFileLockPath, defaultD:\backup\dpz.core.lock); production usesRedisDistributedSynchronizationProvider - IP rate limiting also uses FusionCache+Redis (cache key:
"RateLimit:IP:{IP}"); middleware order:UseIpRateLimit()beforeUseRejectBots()
Testing quirks
- xUnit + Microsoft.NET.Test.Sdk
- Integration tests (Backup.Test, ServiceTest, etc.) require MongoDB running; rely on
appsettings.Test.json - MongoDB single instance does NOT support transactions — must use replica set (
--replSet rs0) - Tests that use
IUnitOfWork(transactions) only pass with MongoDB replica set - CI (
.github/workflows/build.yml) only runsdotnet restore+dotnet build— no tests run in CI yet
Config & prerequisites
Required at runtime (throw InvalidConfigurationException if absent in Web; checked in respective Program.cs):
AgileConfig:appId,AgileConfig:secret,AgileConfig:nodes— central config server; validAgileConfig:envvalues:DEV,TEST,STAGING,PRODConnectionStrings:mongodb— main MongoDBConnectionStrings:hangfireMongodb— separate connection string used exclusively for Hangfire storageConnectionStrings:redis— Redis (SignalR backplane, FusionCache L2, distributed locks)LibraryHost,AssetsHost— required byDpz.Core.WebServer:Issuer— required byDpz.Core.WebApi(JWT authority URL, points to Auth server)WebApiHangfireCollectionPrefix— required byDpz.Core.WebApi; STAGING uses"STAGING_WebAPI"to avoid MongoDB collection collisionhangfireStatus:ItHomeDelete|ItHomeUpdate|SteamUpdate|Backup|CosDirCleanup— boolean flags to enable/disable recurring Web.Jobs jobsRabbitMQ:HostName|Port|UserName|Password|VirtualHost
NuGet feed: GitHub Packages at https://nuget.pkg.github.com/pengqian089/index.json (credentials embedded in src/NuGet.config)
Frontend (Dpz.Core.Web)
- TypeScript + esbuild 0.28.1 (pinned, not webpack/vite). ESM modules. esbuild configs in
src/Dpz.Core.Web/esbuild/(NOT underwwwroot/) - Source TS lives in
src/Dpz.Core.Web/wwwroot/scripts/(entry:App.ts→app.min.js) - Single entry bundle
app— the oldmemberbundle is gone.npm run build:appis the only prod build;npm run dev:appthe only watch.build.ps1 dev-allis a renamed alias for the same app-only watch (no concurrently) - CSS follows BEM naming. Shared partials prefixed
_(e.g._pagination.css) - Dev mode outputs plain
.dev.js; production adds content-hash filenames (8-char base32 for JS, 8-char lowercase hex for CSS) - Manifest:
esbuild/.app.manifest.json→wwwroot/assets-manifest.json - Production CSS pipeline uses
clean-css-cli(local devDependency, resolved fromnode_modules/.bin/cleancssbybuild.ps1) to merge_IncludeCssFilePartial.cshtmlreferences intoglobal.min.css - Pre-built frontend assets are committed to the repo; no frontend build step in CI or Docker builds
- jQuery + SignalR client for real-time features; supports MessagePack (
application/x-msgpack) in WebApi
Deployment
All deploys are manual via GitHub Actions workflow_dispatch:
deploy-core.yml— Docker build+deployDpz.Core.Webto two servers (SERVER_1 and SERVER_2)deploy-api.yml— Docker build+deployDpz.Core.WebApito two servers (SERVER_1 and SERVER_2)deploy-job.yml—dotnet publishlocally, rsync toHONGKONG_SERVER, restartsdpz-job.servicevia systemd (NOT Docker)STAGING-WEBAPI.yml— Docker staging WebApi on SERVER_1 only (port 3509,AgileConfig:env=STAGING)- No GitHub Actions workflow exists for
Dpz.Core.Auth— deploy is manual or via a separate pipeline
Docker build pattern (from src/ context):
cd src
docker build -t dpz.core -f Dpz.Core.Web/Dockerfile .
docker run --restart=always --name dpz.core -e TZ=Asia/Shanghai -p 2372:8080 -d dpz.core:latest
Use -e "AgileConfig:env=STAGING" for non-Prod environments.
Notable paths
- Hangfire dashboard — Web:
/runtask(constantProgram.HangfireDashboardPath); Web.Jobs:/jobs(constantProgram.ConsoleUrl); WebApi:/jobsin DEBUG only - Auth cookie name — Web:
Dpz.Web.Core.Authoriza; WebApi:Dpz.Web.Api.Server.Authoriza - API docs (Scalar):
/scalar/v1(theme: Saturn) - Rate-limit policy name:
"comment"(3 req/min, FixedWindow) - EnumLibrary can be published as NuGet:
dotnet pack -c Releasethendotnet nuget push
Branch naming
<type>/<issue-id>-<short-description> — types: feature/, bugfix//fix/, hotfix/, release/, chore/, docs/, refactor/, test/
What CI does (and doesn't)
.github/workflows/build.yml (push/PR to master):
dotnet restore→dotnet build— no lint, no format check, no tests- Code formatting must be enforced locally:
dotnet csharpier .(insrc/) +npm run format:check(insrc/Dpz.Core.Web/)