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 — never commit changes to it, never echo its contents - Test project
Dzp.Core.XunitBasichas an intentional typo ("Dzp" not "Dpz") — do not rename it
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.2.5)
# 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 (app module only) — alias for npm run dev:app
.\build.ps1 dev-all # watch all modules (app + member + ai-chat) via concurrently
.\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.5.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 |
| 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).
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
- 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.
Service DI registration (config-driven via RegisterInject)
Dpz.Core.Service/ServiceDependencyInjection.cs exposes AddDefaultServices(IConfiguration), called from Program.cs of Web, WebApi, Auth, and Web.Jobs. There is no [Service]/attribute scan and no hand-written services.AddScoped<IFoo, Foo>() for domain services.
Bindings come from a RegisterInject config section read via standard IConfiguration. In this repo the team publishes it through AgileConfig for convenience (it is intentionally absent from every local appsettings*.json), but any config source works — adding it to appsettings.Development.json is valid for local debugging. Grepping the repo for service registrations will find nothing; check AgileConfig (or whichever source you've added it to).
Schema (each list item):
InterfaceAssemblyName+InterfaceNamespace— scan all interfaces in this namespaceImplementAssemblyName+ImplementNamespace— first non-abstract type assignable to each interface is auto-registered as ScopedRemove: [<interface FQN>, ...]— skip these interfaces from auto-registrationAdd: [{ Type, ServiceFullName, ImplementFullName }]— explicit override;TypeisTransient|Singleton|Scoped(default)
Current production config (for reference — schema, not values to copy blindly):
"RegisterInject": [
{
"InterfaceAssemblyName": "Dpz.Core.Service",
"InterfaceNamespace": "Dpz.Core.Service.RepositoryService",
"ImplementAssemblyName": "Dpz.Core.Service",
"ImplementNamespace": "Dpz.Core.Service.RepositoryServiceImpl",
"Remove": ["Dpz.Core.Service.RepositoryService.ISteamGameService"]
}
]
ISteamGameService is excluded from auto-registration because it is wired via AddHttpClient<ISteamGameService, SteamGameService>() manually in each consuming project (Web.Jobs, WebApi, ServiceTest). The V4 namespace pair (Dpz.Core.Service.V4.*) is mentioned in older docs but the src/Dpz.Core.Service/V4/ directory does not exist locally — it is configured via AgileConfig only if active.
Implications when adding a new service:
- Drop the interface in a scanned interface namespace and its impl in the matching impl namespace — DI is automatic, no code change needed
- To deviate from
Scoped, register a second impl, or exclude one, edit theRegisterInjectconfig (Add/Remove) — not the codebase - Putting the impl outside the configured
ImplementNamespacewill silently leave the service unregistered - Only the first assignable impl in
ImplementNamespacewins for auto-registration; if you have multiple, useAddto be explicit
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 - 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(Web),NewsArticleMessage+BatchCompletionMessage(WebApi), several email/media messages (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— 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.27.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/ - CSS follows BEM naming. Shared partials prefixed
_(e.g._pagination.css) - Three entry bundles:
app,member,chat(internallyai-chat) — built separately vianpm run build:app|build:member|build:chat - Dev mode outputs plain
.dev.js; production adds content-hash filenames (8-char base32 for JS, 8-char lowercase hex for CSS) - Partial manifests:
esbuild/.app.manifest.json,esbuild/.member.manifest.json,esbuild/.chat.manifest.json→ merged towwwroot/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/)