nestjs改造

This commit is contained in:
桃树夭 committed 2026-10-03 15:02:19 +08:00
1 parent c0b267df8e
commit 0de7fd7390
2329 files changed
+27969 -23700

No files matched your search

@@ -1,71 +1,80 @@
---
name: daoyou-backend-api-security
description: Daoyou Hono API、认证、授权、Better Auth、ALTCHA、admin、internal cron、LLM header 安全和服务端输入校验指南。Use when adding or modifying src/server routes, middleware, auth, admin endpoints, cron/internal endpoints, shared contracts, LLM provider handling, API validation, or security-relevant frontend auth/admin loaders in this repo. Does not cover ordinary page styling or navigation.
description: Daoyou NestJS API、认证、授权、Better Auth、ALTCHA、admin、internal cron、LLM header 安全和服务端输入校验指南。Use when adding or modifying apps/api/src controllers, guards, middleware, auth, admin endpoints, cron/internal endpoints, shared contracts, LLM provider handling, API validation, or security-relevant frontend auth/admin loaders in this repo. Does not cover ordinary page styling or navigation.
---
# Daoyou Backend API Security
## Read First
- `src/server/app.ts`
- `src/server/routes/api/index.ts`
- `src/server/routes/internal/cron.router.ts`
- `src/server/lib/hono/middleware.ts`
- `src/server/lib/auth/auth.ts`
- `src/server/lib/auth/hono.ts`
- `src/server/utils/aiClient.ts`
- `src/shared/config/llm.ts`
- `src/shared/config/llmRouting.ts`
- `src/shared/contracts`
- `apps/api/src/main.ts` and `app.module.ts`
- `apps/api/src/config/configuration.module.ts`, `runtime.config.ts`, and `lib/config/environment.ts`
- `apps/api/src/http/configure-http.ts`
- `apps/api/src/runtime/internal-cron.controller.ts` and `internal-cron.guard.ts`
- `apps/api/src/auth/access.guard.ts` and `session.service.ts`
- `apps/api/src/lib/auth/auth.ts`
- `apps/api/src/lib/auth/handler.ts`
- `apps/api/src/utils/aiClient.ts`
- `packages/shared/src/config/llm.ts`
- `packages/shared/src/config/llmRouting.ts`
- `packages/shared/src/contracts`
## Configuration and Workspace Boundary
- API builds with `nest build` (Nest CLI 12 ESM Rspack); Web builds with Vite. API may import `@daoyou/shared/*`, never Web; shared cannot import either host. LLM generators and prompt rendering belong in `apps/api/src/lib/generation`.
- Nest services inject `runtimeConfig`; independent libraries read `getRuntimeEnvironment()`. The snapshot is validated once with Zod; dotenv discovery is disabled. Never log credential values on validation failure.
- `DatabaseModule` exports the existing Drizzle client, preserving one pool and transaction propagation. Runtime closes it after request/message drain.
## API Boundary Facts
- API server is Hono.
- `/api/auth/*` is handled by Better Auth through `src/server/lib/auth/hono.ts`.
- `jsonError()` handles Zod errors and uncaught API errors for `/api/*` and `/internal/*`, but auth is registered before that middleware.
- API server is NestJS 12 on Node.js 24 with Express and native ws. Legacy Hono entrypoints, routes and dependencies have been removed; do not reintroduce a compatibility router.
- `/api/auth/*` is handled by Better Auth through `apps/api/src/lib/auth/handler.ts`.
- `ApiExceptionFilter` handles uncaught API errors; route-specific filters and Zod pipes preserve existing response contracts. Better Auth uses its raw Node handler before business body parsing.
- Frontend route loaders are UX guards only. Backend handlers are the security boundary.
- Existing auth middleware:
- `requireUser()` for logged-in users.
- `requireActiveCultivatorRef()` resolves identity and sets `user` / `activeCultivatorRef`; it does not hydrate a full cultivator or inject a DB executor.
- `requireAdmin()` uses `adminAccess.ts` (`ADMIN_USER_IDS` or legacy `ADMIN_EMAILS`). `requireBetterAuthAdmin()` requires a configured user ID for account administration.
- `validateJson()` and `validateQuery()` for Zod parsing.
- Inspect each admin subroute for middleware registration; do not assume a filename or frontend loader supplies authorization.
- Existing auth boundary:
- Global `AccessGuard` requires login unless `@Access('public')` is declared.
- `@Access('active')` resolves `user` / `activeCultivatorRef` through `SessionService`; it does not hydrate a full cultivator or inject a DB executor.
- `@Access('admin')` uses `adminAccess.ts` (`ADMIN_USER_IDS` or legacy `ADMIN_EMAILS`); `account-admin` requires configured user ID authorization.
- `JsonBody` reads raw bytes in its first Pipe after guards, then passes parsed JSON to validation pipes. Keep its parameter factory synchronous: Nest 12 does not await a factory's Promise before the first Pipe. The reader does not inflate content-encoding; the business ceiling is 128 MiB and the webhook's independent limit is 256 KiB. `FirstQuery` reads the original URL without a 1,000-key truncation: query objects keep the first decoded key, named reads prefer a literal key over encoded aliases, and malformed UTF-8 escapes remain intact. Express query parsing is disabled; use `FirstQuery` for business queries. Zod pipes retain the intended 400 versus legacy-unhandled 500 behavior.
- Inspect each admin controller's access metadata; do not assume a filename or frontend loader supplies authorization. Use explicit constructor `@Inject` because the Nest CLI Rspack build disables implicit design-type metadata.
- Keep `NotFoundModule` last in `AppModule.imports`: its fallback controllers preserve authorization for unknown paths in previously protected namespaces, after every concrete feature route. Do not broaden these fallbacks to all API or admin paths.
- `/internal/cron/*` uses `Authorization: Bearer ${CRON_SECRET}`, not user sessions. In production, missing `CRON_SECRET` returns 500.
## LLM Security Facts
- The browser patches `window.fetch` in `src/react-app/main.tsx` to add `x-llm-provider`, `x-llm-api-key` and `x-llm-model` headers for `/api/` requests.
- Server LLM calls should use `src/server/utils/aiClient.ts` (`generateAiText`, `streamAiText`, `generateAiObject`, `generateAiArray`) so provider resolution, metrics, structured output, and retry behavior stay in one path.
- Server-side `LLM_PROVIDER` is a route table: `provider[/model][:weight],...`. It covers one or many providers and one or many models. Multiple routes are sticky by user id hash on the full `provider + model`. BYOK request config still wins and does not enter the split. Read/parse in `src/shared/config/llmRouting.ts`; `aiClient.ts` only maps env and picks.
- Server accepts request-level BYOK only when provider, API key, and model pass `src/shared/config/llm.ts`; partial or invalid configuration returns 400 without falling back to the server key.
- Request provider IDs are allowlisted in `src/shared/config/llm.ts`; adapters/endpoints are owned by `src/server/lib/llm/providers.ts`. Do not accept arbitrary request Base URLs.
- The browser patches `window.fetch` in `apps/web/src/main.tsx` to add `x-llm-provider`, `x-llm-api-key` and `x-llm-model` headers for `/api/` requests.
- Server LLM calls should use `apps/api/src/utils/aiClient.ts` (`generateAiText`, `streamAiText`, `generateAiObject`, `generateAiArray`) so provider resolution, metrics, structured output, and retry behavior stay in one path.
- Server-side `LLM_PROVIDER` is a route table: `provider[/model][:weight],...`. It covers one or many providers and one or many models. Multiple routes are sticky by user id hash on the full `provider + model`. BYOK request config still wins and does not enter the split. Read/parse in `packages/shared/src/config/llmRouting.ts`; `aiClient.ts` only maps env and picks.
- Server accepts request-level BYOK only when provider, API key, and model pass `packages/shared/src/config/llm.ts`; partial or invalid configuration returns 400 without falling back to the server key.
- Request provider IDs are allowlisted in `packages/shared/src/config/llm.ts`; adapters/endpoints are owned by `apps/api/src/lib/llm/providers.ts`. Do not accept arbitrary request Base URLs.
- LLM metrics use in-memory fallback plus Redis key `admin:llm-metrics:events:v1`; do not add a parallel metrics store.
- Prompt files under `src/server/prompts/*.md` have `id:` headers. New prompt scenes usually also need `LlmSceneId`, caller `sceneId`, and schema/constraint updates.
- Prompt files under `apps/api/src/prompts/*.md` have `id:` headers. New prompt scenes usually also need `LlmSceneId`, caller `sceneId`, and schema/constraint updates.
- Treat LLM output as untrusted input. Numeric state changes need Zod bounds and service/resource-layer guards.
## V6 Authority and Mutation Boundaries
- Start with `src/server/routes/api/combat-v6.router.ts` and mode-specific routers, `src/shared/contracts/combatV6*.ts`, and `src/server/lib/services/combat-v6`.
- Start with `apps/api/src/combat` and mode-specific feature modules, `packages/shared/src/contracts/combatV6*.ts`, and `apps/api/src/lib/services/combat-v6`.
- Resolve the actor from `activeCultivatorRef`; derive combat attributes, equipment, manuals and beasts server-side through `CombatV6BuildService.ts`. Client commands do not authorize client-supplied combat units, results or rewards.
- Preserve session ownership/participant checks, `expectedRevision` validation, legal-command queries and Redis CAS. Spectator and replay views must retain their existing visibility checks.
- State changes use the owning service's mutation/occupancy guards, transaction and resource response path (`CommandExecutors.ts`, `ResourceMutationResponse.ts`, `InventoryService.ts`). Check mode-specific exceptions such as dungeon recovery before reusing a blanket combat lock.
- Terminal settlement and replay archival run through `src/server/lib/mq/combatV6Messaging.ts` and V6 projectors. Retain retry/idempotency semantics; do not introduce direct route settlement alongside consumers.
- Terminal settlement and replay archival run through `apps/api/src/lib/mq/combatV6Messaging.ts` and V6 projectors. Retain retry/idempotency semantics; do not introduce direct route settlement alongside consumers.
- `/api/battle-records/*` is a 410 retirement endpoint; do not restore V5 battle handlers for new V6 history features.
## External Service Facts
- Redis access must go through `src/server/lib/redis`; do not instantiate `new Redis()` in feature code.
- Redis access must go through `apps/api/src/lib/redis`; do not instantiate `new Redis()` in feature code.
- Redis is not optional for many runtime paths even though health-check reports `disabled` when `REDIS_URL` is absent.
- NATS access uses `src/server/lib/nats`; shared messaging lifecycle is registered from `src/index.ts`. Health checks include Redis, NATS and message infrastructure.
- SMTP mail uses `src/server/lib/admin/smtp.ts`; required env includes `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, and `MAIL_FROM`.
- NATS access uses `apps/api/src/lib/nats`; Nest `RuntimeService` owns shared messaging startup and shutdown. Drain handlers and pending publications before closing database/Redis. Health checks include Redis, NATS and message infrastructure.
- Native WS upgrades bypass Express: `RealtimeAdapter` owns API admission, response headers and pending-handshake tracking; feature services own identity and connection quotas. Preserve individual Set-Cookie headers on both successful and rejected handshakes.
- SMTP mail uses `apps/api/src/lib/admin/smtp.ts`; required env includes `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, and `MAIL_FROM`.
## Workflow
1. Classify the endpoint: public, logged-in, active-cultivator, admin, or internal cron.
2. Reuse the existing middleware. Do not hand-roll session parsing.
2. Reuse the existing guards and `SessionService`. Do not hand-roll session parsing.
3. Add or reuse Zod schemas for request bodies and query strings.
4. Register new API routes in `src/server/routes/api/index.ts`; register internal jobs in `src/server/routes/internal`.
5. If the route changes shared request/response shape, update `src/shared/contracts` or `src/shared/types`.
4. Register controllers in feature modules imported by `apps/api/src/app.module.ts`; internal jobs belong to the runtime module and shared job services. Keep repositories and pure domain logic independent of Nest.
5. If the route changes shared request/response shape, update `packages/shared/src/contracts` or `packages/shared/src/types`.
6. If the route calls LLM or consumes LLM output, check prompt/schema bounds and service-layer guards.
7. If adding a prompt scene, update prompt id, `LlmSceneId`, caller `sceneId`, and schema/constraint together.
8. Verify server behavior with lint/build, code inspection, and focused manual/runtime checks; do not add route/service/provider tests.
@@ -73,11 +82,11 @@ description: Daoyou Hono API、认证、授权、Better Auth、ALTCHA、admin、
## Do Not
- Do not rely on React loaders for authorization.
- Do not bypass `src/server/lib/auth/hono.ts` for login, signup, reset, OTP, or ALTCHA-protected flows.
- Do not bypass `apps/api/src/lib/auth/handler.ts` for login, signup, reset, OTP, or ALTCHA-protected flows.
- Do not add admin files under `/api/admin` without explicit admin authorization.
- Use the validated `llmConfig` from Hono context and configured provider adapters; do not bypass the provider allowlist or introduce a client Base URL.
- Use the validated `llmConfig` from request context / framework-independent AsyncLocalStorage and configured provider adapters; do not bypass the provider allowlist or introduce a client Base URL. Singleton services must not store request identity or BYOK credentials in fields.
- Do not make public list/ranking/community endpoints private without checking frontend/product usage.
- Do not assume `src/shared/api` exists; shared contracts live under `src/shared/contracts`.
- Do not assume `packages/shared/src/api` exists; shared contracts live under `packages/shared/src/contracts`.
- Do not bypass `aiClient.ts` for LLM calls.
- Do not create feature-local Redis clients or SMTP transports.
@@ -1,4 +1,4 @@
interface:
display_name: "Daoyou Backend API Security"
short_description: "Daoyou Hono API、认证、权限与 LLM 安全"
short_description: "Daoyou NestJS API、认证、权限与 LLM 安全"
default_prompt: "Use $daoyou-backend-api-security to add or review Daoyou API routes, auth, admin, cron, or LLM security changes."
+1 -1
View File
@@ -14,7 +14,7 @@ description: 万界道友灵兽物种设计与审查规范。用于新增或调
- 设计、扩充或审查物种时,读取 [设计确认基准](references/first-release.md),对照完整名单检查形态重复、数值跨度和天赋组合;具体名单、境界分布、区间、出生概率及 ID 以基准为准。
- 后续用户明确修改设计时,以新要求为准,同步更新相应基准并说明改动;不要把本技能变成额外审批流程。未被要求改变的已确认规则继续沿用。
- 仅要求设计或整理规范时,交付设计或文档,不顺带修改游戏逻辑。配置实施同时使用 [daoyou-game-core-domain](../daoyou-game-core-domain/SKILL.md),持久化改动使用项目数据层技能。
- 实施前核对 `src/shared/engine/combat-v6/beasts/` 的内容、生成、投影与培养调用,以及 `wild/` 的野外身份和捕捉入口。旧代码中的定位、四技能模板或捕捉加技能规则不能反过来覆盖已确认设计;列明差异后在任务范围内实现。
- 实施前核对 `packages/shared/src/engine/combat-v6/beasts/` 的内容、生成、投影与培养调用,以及 `wild/` 的野外身份和捕捉入口。旧代码中的定位、四技能模板或捕捉加技能规则不能反过来覆盖已确认设计;列明差异后在任务范围内实现。
- 可用技能与真实效果以 `beasts/data/skills.json`、`beasts/skill-compiler.ts` 和运行时为准,适配差异参照 `docs/combat-v6-beast-classic-skills.md`。不要仅凭梦幻西游原技能名称推断本游戏机制。
## 妖灵传承与技能文案
+7 -6
View File
@@ -7,11 +7,12 @@ description: Daoyou Drizzle/PostgreSQL、事务、V6 角色与宗门归属、统
## Locate the Current Write Path
Read `drizzle.config.ts`, `drizzle.auth.config.ts`, `src/server/lib/drizzle/db.ts` and the relevant definitions in `src/server/lib/drizzle/schema.ts`. Follow the route → service → repository/SQL path before treating a table or DTO as authoritative. Historical migration/design files describe intent; they do not prove a deployed database has applied it.
Read `drizzle.config.ts`, `drizzle.auth.config.ts`, `apps/api/src/lib/drizzle/db.ts` and the relevant definitions in `apps/api/src/lib/drizzle/schema.ts`. Follow the route → service → repository/SQL path before treating a table or DTO as authoritative. Historical migration/design files describe intent; they do not prove a deployed database has applied it.
## Database and Transactions
- Business tables use the main `wanjiedaoyou_*` migration stream in `drizzle/`. Better Auth uses `src/server/lib/auth/schema.ts`, `drizzle.auth.config.ts` and `drizzle-auth/`, with fixed `better_auth` schema and independent history.
- Business tables use the main `wanjiedaoyou_*` migration stream in `drizzle/`. Better Auth uses `apps/api/src/lib/auth/schema.ts`, `drizzle.auth.config.ts` and `drizzle-auth/`, with fixed `better_auth` schema and independent history.
- Nest `apps/api/src/database/database.module.ts` exports the same client via `DRIZZLE_DATABASE` and `DatabaseService`. Runtime drains requests/messages before closing it. Connection settings come from validated `getRuntimeEnvironment()`; do not create feature-local pools.
- Runtime uses a module-level `pg.Pool` and `drizzle-orm/node-postgres`, not Bun SQL. `DATABASE_URL` supplies the connection; `DB_MAX_CONNECTIONS` controls pool size. Session settings live in `db.ts`.
- Reuse `db`, `getExecutor(tx?)`, `DbExecutor` and `DbTransaction`; do not create parallel DB layers or feature-local pools.
- Pass the executor through every nested write. `runDbTasks(executor, tasks)` serializes work on a transaction's single connection and permits parallel pool reads; do not replace it with unconditional `Promise.all`.
@@ -26,13 +27,13 @@ Table names below omit the `wanjiedaoyou_` prefix. The exact names and constrain
| --- | --- | --- |
| Character identity, permanent six attributes, condition | `cultivators` | `cultivatorRepository.ts`, `services/cultivator`, V6 condition services |
| Personal manuals and active slots | `cultivator_manual_states`, `cultivator_manual_slots` | `characterLoadoutRepository.ts`, `CombatV6ManualService.ts` |
| Bag/storage item instances | `inventory_items` | `InventoryService.ts`, `src/shared/inventory`, `src/shared/items` |
| Bag/storage item instances | `inventory_items` | `InventoryService.ts`, `packages/shared/src/inventory`, `packages/shared/src/items` |
| Equipped V6 equipment | `cultivator_equipment_slots` | `characterLoadoutRepository.ts`, `InventoryService.ts` |
| Beasts and lineups | `cultivator_beasts`, `cultivator_beast_lineups` | `combatV6BeastRepository.ts`, `CombatV6BeastService.ts` |
| Sect progression | `sect_combat_states`, `sect_method_progress`, `sect_meridian_loadouts`, `sect_meridian_nodes` | `sectCombatRepository.ts` |
| V6 history/replays | `combat_replay_archives`, `combat_replay_participants` | `combatV6ReplayRepository.ts` |
Repository names resolve under `src/server/lib/repositories`; V6 service names resolve under `src/server/lib/services/combat-v6`.
Repository names resolve under `apps/api/src/lib/repositories`; V6 service names resolve under `apps/api/src/lib/services/combat-v6`.
### Ownership and JSON
@@ -47,14 +48,14 @@ Repository names resolve under `src/server/lib/repositories`; V6 service names r
### Redis, Messages and Replays
- Redis is authoritative for active V6 battle state, commands and RNG. Inspect `CombatV6RuntimeStore.ts` and the mode-specific stores for CAS revisions, occupancy, expiry and outboxes; do not substitute process-local sessions.
- Access Redis through `src/server/lib/redis`; NATS through `src/server/lib/nats`. `src/server/lib/mq/combatV6Messaging.ts` coordinates terminal/replay publication and archival.
- Access Redis through `apps/api/src/lib/redis`; NATS through `apps/api/src/lib/nats`. `apps/api/src/lib/mq/combatV6Messaging.ts` coordinates terminal/replay publication and archival.
- PostgreSQL V6 archives enforce source/idempotency uniqueness. Replay participants intentionally do not cascade from character deletion; the battle archive owns their lifecycle.
- Keep settlement, resource events and replay delivery idempotent across retries. Read `docs/nats-domain-events.md` together with the relevant consumer before changing message boundaries.
## Legacy Boundary
- `creation_products`, `materials` and `consumables` still have residual code paths. Inspect their actual callers before modifying or deleting them; they are not fallback sources for V6 equipment/manuals or the unified bag.
- Legacy product views use `src/shared/legacy/products.ts`. Do not restore creation-v2 rehydration / `battleProjection` or treat `creation_products.is_equipped` as current V6 equipment state.
- Legacy product views use `packages/shared/src/legacy/products.ts`. Do not restore creation-v2 rehydration / `battleProjection` or treat `creation_products.is_equipped` as current V6 equipment state.
- `/api/battle-records/*` returns 410. V6 history uses the combat replay repository, not `battle_records_v2`.
- `battle_records_v3`, `battle_replay_archives` and `bet_battles` are deprecated historical schema, with deletion deferred by release policy. Read `docs/combat-v6-legacy-table-retirement.md` for that policy, but verify exact table names against schema. Do not generate DROP migrations simply while cleaning up skills or legacy references.
- Do not infer that every older table is still present, or already physically deleted, from a DTO, directory or migration file alone.
+15 -15
View File
@@ -7,19 +7,19 @@ description: Daoyou combat-v6 战斗内核、规则、人物投影、宗门、
## Locate the Owning Layer
Current combat is `src/shared/engine/combat-v6`. Read the affected module and its callers first; phase plans and the engine README contain historical milestones, not a reliable inventory of current runtime behavior.
Current combat is `packages/shared/src/engine/combat-v6`. Read the affected module and its callers first; phase plans and the engine README contain historical milestones, not a reliable inventory of current runtime behavior.
| Task | Code entrypoints |
| --- | --- |
| Turn pipeline, commands, effects, RNG, sessions | `src/shared/engine/combat-v6/core` |
| Daoyou combat formulas | `src/shared/engine/combat-v6/rules-daoyou` |
| Character panel and assembly | `src/shared/engine/combat-v6/projection`, `src/shared/lib/cultivatorDisplay.ts` |
| Sect methods, skills, meridians | `src/shared/engine/combat-v6/content`, `src/shared/engine/combat-v6/sect-progression` |
| Equipment compilation and generation | `src/shared/engine/combat-v6/equipment`, `src/shared/forging` |
| Manual definitions and progression | `src/shared/engine/combat-v6/manuals`, `src/shared/manuals` |
| Beasts, encounter orchestration, wild encounters | `src/shared/engine/combat-v6/beasts`, `src/shared/engine/combat-v6/encounter`, `src/shared/engine/combat-v6/wild` |
| Inventory, item definitions, drops, rewards | `src/shared/inventory`, `src/shared/items`, `src/shared/drops`, `src/shared/rewards` |
| Battle presentation, auto commands and replay contracts | `src/shared/combat-v6`, `src/shared/contracts/combatV6Runtime.ts` |
| Turn pipeline, commands, effects, RNG, sessions | `packages/shared/src/engine/combat-v6/core` |
| Daoyou combat formulas | `packages/shared/src/engine/combat-v6/rules-daoyou` |
| Character panel and assembly | `packages/shared/src/engine/combat-v6/projection`, `packages/shared/src/lib/cultivatorDisplay.ts` |
| Sect methods, skills, meridians | `packages/shared/src/engine/combat-v6/content`, `packages/shared/src/engine/combat-v6/sect-progression` |
| Equipment compilation and generation | `packages/shared/src/engine/combat-v6/equipment`, `packages/shared/src/forging` |
| Manual definitions and progression | `packages/shared/src/engine/combat-v6/manuals`, `packages/shared/src/manuals` |
| Beasts, encounter orchestration, wild encounters | `packages/shared/src/engine/combat-v6/beasts`, `packages/shared/src/engine/combat-v6/encounter`, `packages/shared/src/engine/combat-v6/wild` |
| Inventory, item definitions, drops, rewards | `packages/shared/src/inventory`, `packages/shared/src/items`, `packages/shared/src/drops`, `packages/shared/src/rewards` |
| Battle presentation, auto commands and replay contracts | `packages/shared/src/combat-v6`, `packages/shared/src/contracts/combatV6Runtime.ts` |
## Engine Boundary
@@ -32,9 +32,9 @@ Current combat is `src/shared/engine/combat-v6`. Read the affected module and it
## Character and Build Boundary
- Current `Cultivator.attributes` still stores six permanent attributes: vitality, strength, spirit, endurance, speed, willpower. Read `src/shared/types/cultivator.ts` and `projection/character-panel-v1.ts` before changing formulas. Future numerical design does not establish an implemented five-attribute model.
- Current `Cultivator.attributes` still stores six permanent attributes: vitality, strength, spirit, endurance, speed, willpower. Read `packages/shared/src/types/cultivator.ts` and `projection/character-panel-v1.ts` before changing formulas. Future numerical design does not establish an implemented five-attribute model.
- Current complete projection is `projectCharacterToCombatV6(CharacterCombatInput)`. Historical `projectCultivator*` phase entrypoints are adapters, not the default for new business code.
- `CharacterCombatInput.sect` is optional. Personal display applies equipment and manuals without sect membership. Reuse `projectCharacterDisplay` and resource helpers in `src/shared/lib/cultivatorDisplay.ts`.
- `CharacterCombatInput.sect` is optional. Personal display applies equipment and manuals without sect membership. Reuse `projectCharacterDisplay` and resource helpers in `packages/shared/src/lib/cultivatorDisplay.ts`.
- Battle admission is a separate server rule: `CombatV6BuildService.ts` assembles authoritative inputs and checks membership / selected path. Do not remove admission checks merely because pure projection permits a sectless character.
- Personal manuals, equipment and beasts belong to the character; sect methods and meridians belong to membership. Keep their revisions separate, and derive readiness from current membership/path. See `docs/combat-domain-ownership.md`.
- Respect projection diagnostics and `full` / `persistent` resource policies. Changing maximum HP/MP must not implicitly heal existing characters; recovery and rebasing use the shared display/condition helpers and server V6 condition authority.
@@ -44,13 +44,13 @@ Current combat is `src/shared/engine/combat-v6`. Read the affected module and it
- For beast species design or content changes involving aptitudes, growth or birth skills, also read [daoyou-beast-design](../daoyou-beast-design/SKILL.md). Its confirmed design baseline is distinct from current runtime behavior; do not restore fixed species roles or complete four-skill templates from older content.
- New equipment uses V6 equipment instances; equipped slots are separate from inventory. Manual progression and active slots are separate from consumable manual jades.
- Use `src/shared/items/registry.ts` and definition schemas for item facts, and `src/shared/inventory` for stack/capacity/action rules. Do not translate new items into legacy creation product models.
- Use `packages/shared/src/items/registry.ts` and definition schemas for item facts, and `packages/shared/src/inventory` for stack/capacity/action rules. Do not translate new items into legacy creation product models.
- Forge, manual, beast and reward rules have dedicated shared modules; reuse them instead of reproducing costs, eligibility or generation logic in routes/UI.
- Existing condition, alchemy and spirit-field code remains outside the battle core. Follow its live callers; removal of the old combat engine does not imply removal of all noncombat systems.
## Verify
- For pure deterministic shared logic, add or update a focused reproducing/contract test beside the affected module.
- Examples: `bun run test src/shared/engine/combat-v6/projection`, `bun run test src/shared/engine/combat-v6/rules-daoyou`, or `bun run test src/shared/inventory` depending on scope.
- Broaden to `bun run test` for changes spanning core, content and projection; run lint/build when imports or shared contracts change.
- Examples: `pnpm run test packages/shared/src/engine/combat-v6/projection`, `pnpm run test packages/shared/src/engine/combat-v6/rules-daoyou`, or `pnpm run test packages/shared/src/inventory` depending on scope.
- Broaden to `pnpm run test` for changes spanning core, content and projection; run lint/build when imports or shared contracts change.
- Server persistence and browser behavior use code inspection and focused runtime checks per `docs/testing.md`; do not add server, database or UI unit tests.
+12 -12
View File
@@ -29,10 +29,10 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
## 当前接入边界
- 先看 `src/react-app/router.tsx` 的真实布局。V6 战斗使用 `src/react-app/layouts/combat-v6-layout.tsx` 和 `src/react-app/components/feature/combat-v6`,不套主流程身份/任务/导航三层审查。
- 先看 `apps/web/src/router.tsx` 的真实布局。V6 战斗使用 `apps/web/src/layouts/combat-v6-layout.tsx` 和 `apps/web/src/components/feature/combat-v6`,不套主流程身份/任务/导航三层审查。
- 战斗展示与回放核对 `docs/combat-v6-battle-ui.md` 和现有 session/presentation 代码;客户端消费权威状态,不为展示重新计算战斗结果。
- 角色和物品状态优先复用 `src/react-app/lib/resources` 的 hooks/store/mutations。旧 `useCultivatorBundle` 已不在当前代码中。
- 角色面板使用 `src/shared/lib/cultivatorDisplay.ts` 的 V6 展示契约;物品预览与道具接入遵循 [daoyou-item-preview](../daoyou-item-preview/SKILL.md) 的固定规范,`docs/item-presentation-ui.md`、`docs/inventory-equipment-ui.md` 用于查实现脉络,不要从旧 creation product 推导新道装属性。
- 角色和物品状态优先复用 `apps/web/src/lib/resources` 的 hooks/store/mutations。旧 `useCultivatorBundle` 已不在当前代码中。
- 角色面板使用 `packages/shared/src/lib/cultivatorDisplay.ts` 的 V6 展示契约;物品预览与道具接入遵循 [daoyou-item-preview](../daoyou-item-preview/SKILL.md) 的固定规范,`docs/item-presentation-ui.md`、`docs/inventory-equipment-ui.md` 用于查实现脉络,不要从旧 creation product 推导新道装属性。
## Mental Model
@@ -163,22 +163,22 @@ description: 为本项目的主流程游戏 UI 提供抽象规范与审查方法
在这个仓库里,先检查当前主流程壳组件与共享原语,再决定改动位置。优先关注:
- `src/react-app/components/game-shell`
- `src/react-app/components/feature/world-chat`
- `src/react-app/routes/game`
- `src/react-app/router.tsx`
- `src/react-app/components/game-shell/gameNavigation.ts`
- `src/react-app/lib/router/gameShellRegistry.ts`
- `apps/web/src/components/game-shell`
- `apps/web/src/components/feature/world-chat`
- `apps/web/src/routes/game`
- `apps/web/src/router.tsx`
- `apps/web/src/components/game-shell/gameNavigation.ts`
- `apps/web/src/lib/router/gameShellRegistry.ts`
- `docs/game-layout-ownership.md`
优先复用现有主流程原语,而不是重新发明一套页面局部样式约定。只有当现有原语无法表达当前规则时,才新增一个职责单一的组件。
新增或迁移主流程场景时,不要只添加 route 文件。同步核对:
- `src/react-app/router.tsx` 中的 route、`handle={scene(...)}` 和 document title
- `src/react-app/components/game-shell/gameNavigation.ts` 中对应 scene metadata、dock label、href
- `apps/web/src/router.tsx` 中的 route、`handle={scene(...)}` 和 document title
- `apps/web/src/components/game-shell/gameNavigation.ts` 中对应 scene metadata、dock label、href
- 需要 immersive chrome 时的特殊返回 descriptor
- `src/react-app/lib/router/gameShellRegistry.ts`、`src/react-app/lib/router/routeTitle.ts` 的注册与标题行为
- `apps/web/src/lib/router/gameShellRegistry.ts`、`apps/web/src/lib/router/routeTitle.ts` 的注册与标题行为
- `docs/game-layout-ownership.md` 是否仍准确
## Review Questions
@@ -5,10 +5,10 @@
先做静态搜索,确认没有明显回退:
```bash
rg -n "font-heading|font-ma-shan-zheng" src/react-app/routes/game src/react-app/components/game-shell src/react-app/components/func
rg -n "InkSection" src/react-app/routes/game src/react-app/components
rg -n "actionBar|sceneSurfaceClassName|sceneBodyClassName|scene[A-Z][A-Za-z]*ClassName" src/react-app
rg -n "variant=\\\"drawer\\\"|onOpenCultivator|WorldChatHost|CultivatorOverviewOverlay" src/react-app
rg -n "font-heading|font-ma-shan-zheng" apps/web/src/routes/game apps/web/src/components/game-shell apps/web/src/components/func
rg -n "InkSection" apps/web/src/routes/game apps/web/src/components
rg -n "actionBar|sceneSurfaceClassName|sceneBodyClassName|scene[A-Z][A-Za-z]*ClassName" apps/web/src
rg -n "variant=\\\"drawer\\\"|onOpenCultivator|WorldChatHost|CultivatorOverviewOverlay" apps/web/src
```
如果当前任务只改了某个 feature,也要顺着引用检查它进入主流程正文时依赖的共享组件。
+1 -1
View File
@@ -48,7 +48,7 @@ description: 为万界道友生成、修改或评审写意墨像立绘,统一
美术语言固定,解剖、动作、神态、取景与色相由物种决定。头像不等于必须画头部;以目标尺寸下最有效的身份表达选择展示范围。
- **从设定提取身份**:读取当前物种概念,区分不可丢失的身体结构、比例与标志性特征,以及可以探索的姿态和表现方式。栖息环境与习性用于推导行为,不自动变成背景景物或附加装饰。项目灵兽以 `src/shared/engine/combat-v6/beasts/data/species.json` 为事实来源,必要时对照 [物种设计基准](../daoyou-beast-design/references/first-release.md);只做美术时不改玩法设定。
- **从设定提取身份**:读取当前物种概念,区分不可丢失的身体结构、比例与标志性特征,以及可以探索的姿态和表现方式。栖息环境与习性用于推导行为,不自动变成背景景物或附加装饰。项目灵兽以 `packages/shared/src/engine/combat-v6/beasts/data/species.json` 为事实来源,必要时对照 [物种设计基准](../daoyou-beast-design/references/first-release.md);只做美术时不改玩法设定。
- **以行为确定姿态和神态**:根据身体的运动方式、习性和气质选择一个能成立的状态,说明朝向、重心与主要结构关系。无需全员凶猛、沉静、侧脸或蓄势,也不以人类表情作为有生命感的前提。新设计从物种概念重新构思,不以旧版头像反推造型;仅在用户要求保留或编辑旧稿时把它作为编辑依据。
- **以识别决定构图**:先判断最能区分物种的轮廓和结构,再选择局部或全身、观看角度及画面重心。保留必要的结构联系和负形空间;不用固定头身比例、对称形式或统一动作约束不同体态。头像的紧凑程度以缩小后可读、关键轮廓完整为准。
- **把结构转译为笔墨**:用主墨面组织体量和运动方向,以少量实笔建立识别支点,细节按对物种辨识的贡献取舍。留白与断笔服务结构,不能抹去必要特征;复杂生灵也不靠均匀密集的纹理表现复杂度。
@@ -5,7 +5,7 @@
- 默认导出 **256×256、真实透明的 WebP**。从高分辨率原稿裁掉透明外边距,再等比缩放,使有效图幅的最长边为 256px,另一边居中;不拉伸、不裁断主体、不额外添加安全边距。保留用户选稿的姿态、内部留白和半透明笔触。
- 裁切边界按可见墨迹确定,避免近乎透明的孤立噪点撑大边界;当前以 alpha ≥ 4/255 定位边界,裁切范围内保留原始 alpha,不将笔触二值化。生成阶段为防止截断而预留的外圈空白,不保留到最终头像。优先从原稿导出,避免重复放大小尺寸有损图片。
- WebP 质量 90 为起点,按边缘和缩小效果调整。导出后检查至少一边的可见墨迹接近画布两端,另一边允许因物种比例自然留白;不要用 CSS 放大补偿各图片不一致的外边距。
- 按 [统一图标规范](../../../../docs/game-icons.md),素材放在 `public/assets/icons/`,由 `src/react-app/components/ui/icons/registry.ts` 注册稳定的 `icon:名称`,`BeastIcon` 取值后交给 `GameIcon` 渲染。不新增页面分支或图标协议。
- 按 [统一图标规范](../../../../docs/game-icons.md),素材放在 `apps/web/public/assets/icons/`,由 `apps/web/src/components/ui/icons/registry.ts` 注册稳定的 `icon:名称`,`BeastIcon` 取值后交给 `GameIcon` 渲染。不新增页面分支或图标协议。
- 检查独立导出图、列表、移动详情和桌面详情,显示尺寸以当前组件为准。缩小后主要轮廓和身份特征仍可辨认,正常 opacity 下与其他头像大小协调、无意外裁切或挤压。
- 检查 alpha、纸色与深底上的透明边缘,以及注册路径、图片加载、WebP 尺寸和体积。原稿与未选候选不进入生产目录;记录采用的提示词、参考和最终素材。
- 涉及接入代码时运行 lint/build 和针对性浏览器检查;纯素材或文档变更检查相关文件与显示效果,不机械运行全量游戏测试。只做头像时不修改物种、数值、技能或玩法能力,不自动启动全物种重绘。
+2 -2
View File
@@ -38,7 +38,7 @@ description: 万界道友物品预览的固定展示与接入规范。新增道
## 组件职责:数据决定内容,视图只渲染
以下路径均相对 `src/react-app/components/feature/items/`:
以下路径均相对 `apps/web/src/components/feature/items/`:
| 层 | 固定职责 |
| --- | --- |
@@ -70,7 +70,7 @@ description: 万界道友物品预览的固定展示与接入规范。新增道
- 对照分类基线逐项核对名称、头部顺序、数量、默认折叠、直接消耗、描述。不得恢复双加合计、归元后消耗、重复类型、三层传承标题或移动/合并。
- 检查公共模型组装与渲染是否新增 kind/ID 特判;摘要入口是否开始调用详细预览;正文是否出现任意 JSX 或从文案反解析数据。
- 改公共展示时覆盖装备、图纸、传承灵印、归元灵露、功法玉简、丹药、灵果,以及材料、灵种、符箓;同一物品在背包和受影响的其他入口命名、内容一致。
- UI 改动运行 `bun run lint`、`bun run build`,按 [测试规范](../../../docs/testing.md) 做本地浏览器检查:桌面/360px、长名称、长描述、空分组、键盘、触屏点击、Esc、展开后浮层仍在;涉及批量服用时检查数量上下限与快捷操作。不得用“编译通过”代替交互验收。
- UI 改动运行 `pnpm run lint`、`pnpm run build`,按 [测试规范](../../../docs/testing.md) 做本地浏览器检查:桌面/360px、长名称、长描述、空分组、键盘、触屏点击、Esc、展开后浮层仍在;涉及批量服用时检查数量上下限与快捷操作。不得用“编译通过”代替交互验收。
- 不在前端/服务端添加单元测试。改动纯共享领域逻辑时遵循领域 skill 和仓库测试规则;只改本规范时验证 skill、链接和 diff 即可。报告已执行与未执行的检查。
## 关联规范
+2 -2
View File
@@ -34,7 +34,7 @@ description: 为万界道友设计、生成、修改或评审国画水墨地图
- 基于该地区的地理、人文与修仙特色独立构图,不能裁切或放大世界图局部后添加细节。
- 保持世界归属和重要地理关系,但不直接继承总览的海岸轮廓、镜头比例或坐标。允许适合区域阅读的俯视与散点透视,地形关系仍须清楚。
- 从真实设定选择地域识别点,用地貌、聚落分布、修行场所及险地之间的关系形成特色。不要把天南的某种河谷、城池或林色布局推广为所有区域的模板。
- 涉及节点布局时,读取当前 `src/shared/data/map.json` 与 `src/shared/lib/game/mapSystem.ts`。未获新要求时保留节点 ID、名称、类型、归属和玩法;展示锚点可依构图重新确定,不把某次节点数量或坐标固化进规范。
- 涉及节点布局时,读取当前 `packages/shared/src/data/map.json` 与 `packages/shared/src/lib/game/mapSystem.ts`。未获新要求时保留节点 ID、名称、类型、归属和玩法;展示锚点可依构图重新确定,不把某次节点数量或坐标固化进规范。
## 设计与制作
@@ -46,7 +46,7 @@ description: 为万界道友设计、生成、修改或评审国画水墨地图
## 交付与边界
- 场景底画与地名、区域入口、节点、命中区、连线、选择状态及界面控件分离。生产底画不烘焙文字、旗标、题签、印章、水印或编辑标注;画中的建筑可以是景物,不代替交互节点。
- 正式地图素材保存到 `public/assets/maps/`,供 Phaser 作为场景纹理加载,不通过 UI 的 `GameIcon` 图标协议。仅制作美术时不自动接入 HTML、修改路由、发布页面或调整玩法。
- 正式地图素材保存到 `apps/web/public/assets/maps/`,供 Phaser 作为场景纹理加载,不通过 UI 的 `GameIcon` 图标协议。仅制作美术时不自动接入 HTML、修改路由、发布页面或调整玩法。
- 根据实际画幅、缩放范围、设备纹理限制与包体选择分辨率和压缩参数,不固定像素尺寸、画幅比例或压缩质量;有实测需要再分块。检查解码、尺寸、边缘与压缩后笔墨,文件体积不等于解码后的显存占用。
- 只保留约定的生产素材与必要的交付清单。项目内不归档母稿、历史提示词、生成过程记录、固定参考图包或中间版本;交付后清理本任务产生的临时文件,不删除用户原始输入或其他功能的资产。
- 交付清单只记录路径、用途、规格和接入状态,不保存构图配方。现有素材可用于具体任务的效果对照或编辑,但不要求后续每次生成都附带它们。
+2
View File
@@ -10,3 +10,5 @@ yarn-debug.log*
.env*
/env/
.turbo
+12 -2
View File
@@ -7,7 +7,6 @@ on:
env:
APP_IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/daoyou-app
IMAGE_TAG: latest
jobs:
build-and-push:
@@ -20,6 +19,16 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Image tags and revision
id: metadata
uses: docker/metadata-action@v5
with:
images: ${{ env.APP_IMAGE_NAME }}
tags: |
type=ref,event=tag
type=sha,format=long
type=raw,value=latest
# 登录 Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v3
@@ -33,4 +42,5 @@ jobs:
context: .
file: docker/Dockerfile.app
push: true
tags: ${{ env.APP_IMAGE_NAME }}:${{ env.IMAGE_TAG }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
+4
View File
@@ -196,6 +196,7 @@ dist
.env*
/env/*
!/env/example.env
!/env/local.example.env
.dev.vars*
!.dev.vars.example
@@ -209,3 +210,6 @@ sponsorkit/.cache*.json
sponsorkit/sponsors.json
.venv*
# Turborepo task cache
.turbo/
+2 -2
View File
@@ -3,7 +3,7 @@
**/*.svg
.umi
.umi-production
/dist
**/dist
.dockerignore
.DS_Store
.eslintignore
@@ -21,6 +21,6 @@ yarn-error.log
.history
CNAME
/build
/public
/apps/web/public
/drizzle
.qoder
+46 -40
View File
@@ -4,18 +4,21 @@ AI agents should read this first. Keep changes small, project-specific, and back
## Project Snapshot
- This repo is `Hono + React SPA`, not Next.js or SSR.
- Runtime stack: Bun, Hono, React 19, React Router 8, Vite, Tailwind CSS 4, PostgreSQL, Drizzle ORM, Better Auth, Redis, NATS, AI SDK.
- Use `bun` / `bunx` for local development and keep the checked-in `bun.lock`. Also maintain `package-lock.json` for deployment packaging; dependency updates must keep both lockfiles aligned with `package.json`. Do not introduce yarn/pnpm lockfiles.
- Path aliases are `@app` -> `src/react-app`, `@server` -> `src/server`, and `@shared` -> `src/shared`.
- NestJS框架代码迁移与pnpm/Turborepo工具链切换完成:开发/构建入口为`apps/api/src`,Docker运行其部署产物,旧Hono入口、路由和依赖已移除;特殊数据/发布验收仍有未完成项,尚未部署生产。进度和验收见`docs/nestjs-migration.md`。使用Nest Controller、Guard、Zod Pipe、Filter和构造器注入,不恢复Hono兼容路由层。
- This repo is `NestJS + React SPA`, not Next.js or SSR.
- Runtime stack: Node.js 24, NestJS 12 (Express/native ws), pnpm + Turborepo tooling, React 19, React Router 8, Vite, Tailwind CSS 4, PostgreSQL, Drizzle ORM, Better Auth, Redis, NATS, AI SDK.
- Use the pinned `pnpm` version and `pnpm-lock.yaml` for development and deployment. Do not introduce Bun/npm/yarn lockfiles. Turborepo orchestrates package builds, typechecks and persistent development tasks.
- pnpm workspaces: `apps/api`, `apps/web`, `packages/shared`. Each owns its runtime dependencies; root owns lint/test/maintenance tooling. Shared must not import either app; API and Web may import shared. ESLint enforces these boundaries.
- Path aliases are `@app` -> `apps/web/src`, `@server` -> `apps/api/src`, and `@daoyou/shared` -> `packages/shared/src`.
## Key Directories
- `src/index.ts`: Bun/Hono entrypoint, WebSocket adapter, cron and message infrastructure lifecycle.
- `src/server`: Hono app, routes, auth, services, repositories, jobs, Redis, LLM, SMTP.
- `src/react-app`: React SPA routes, layouts, game shell, UI, hooks, providers.
- `src/shared`: shared contracts, game engines, config, pure logic, domain types.
- `src/server/lib/drizzle/schema.ts`: Drizzle schema for `wanjiedaoyou_*` business tables.
- `apps/api/src/main.ts`: Node/Nest entrypoint, HTTP/WS adapters and shutdown coordination; runtime module owns cron and messaging lifecycle.
- `apps/api/src`: Nest feature modules, auth, services, repositories, jobs, Redis, LLM, SMTP.
- `apps/web/src`: React SPA routes, layouts, game shell, UI, hooks, providers.
- `packages/shared/src`: shared contracts, game engines, config, pure logic, domain types.
- `apps/api/src/lib/drizzle/schema.ts`: Drizzle schema for `wanjiedaoyou_*` business tables.
- `drizzle/`: Drizzle SQL migrations and snapshots.
- `drizzle-auth/`: independent Drizzle migrations and snapshots for the fixed `better_auth` schema.
- `docs/`: design and architecture notes; verify against current code before treating old docs as current truth.
@@ -24,24 +27,25 @@ AI agents should read this first. Keep changes small, project-specific, and back
## Commands
```bash
bun install
bun run dev
bun run prd
bun run lint
bun run test
bun run build
bun run db:migrate
pnpm install
pnpm run dev
pnpm run prd
pnpm run lint
pnpm run typecheck
pnpm run test
pnpm run build
pnpm run db:migrate
```
- `dev[:api|:web]` selects `env/local.env`; `prd[:api|:web]` selects `env/staging.env`. Bun's implicit env loading is disabled; other tools require explicitly injected variables or `bun --env-file=...`.
- `bun run build` sequentially invokes `build:client` and `build:server`; Vite configs separate client and server targets. The old V5 resolver Worker target was retired in Phase 10H. Preserve the remaining CI/CD entrypoints.
- Vitest uses node environment and discovers tests only under `src/shared`.
- Docker runtime contains only `dist`; ALTCHA uses the server-side `ALTCHA_HMAC_SECRET` and does not require a frontend site key.
- `dev[:api|:web]` selects `env/local.env`; `prd[:api|:web]` selects `env/staging.env`. Node/Nest explicitly loads the selected file; maintenance scripts use `node --env-file=... --import tsx`.
- `pnpm run build` uses Turbo to build the independent API and Web packages; Nest CLI 12 builds `apps/api` with its ESM Rspack builder; Vite builds `apps/web`. The old V5 resolver Worker target was retired in Phase 10H. Preserve the remaining CI/CD entrypoints.
- Vitest uses node environment and discovers tests only under `packages/shared/src`.
- Docker runtime contains Node, the pnpm-deployed API production dependencies, package metadata and `dist`; ALTCHA uses the server-side `ALTCHA_HMAC_SECRET` and does not require a frontend site key.
- GitHub Actions currently builds and pushes the Docker image on tag pushes; it is not a lint/test quality gate.
## Skills To Use
- `daoyou-backend-api-security`: Hono routes, auth, admin, cron/internal APIs, LLM/provider security, Redis/SMTP integration boundaries.
- `daoyou-backend-api-security`: Nest controllers/guards, auth, admin, cron/internal APIs, LLM/provider security, Redis/SMTP integration boundaries.
- `daoyou-data-layer`: Drizzle schema/migrations, repositories, transactions, Better Auth schema, durable models.
- `daoyou-game-ui`: `GameViewportLayout` main-flow scene UI structure and review rules.
- `daoyou-item-preview`: 新增道具或调整物品预览字段、文案、层级、交互与适配器时,遵循 [.agents/skills/daoyou-item-preview/SKILL.md](.agents/skills/daoyou-item-preview/SKILL.md) 的固定展示规范和分类基线,避免恢复已删除的冗余信息。
@@ -49,38 +53,40 @@ bun run db:migrate
- `daoyou-map-art`: 世界总览与独立区域地图的国画水墨、彩墨、地域辨识、空间尺度及素材交付;见 [.agents/skills/daoyou-map-art/SKILL.md](.agents/skills/daoyou-map-art/SKILL.md)。
- `daoyou-game-core-domain`: combat-v6 core/rules/projection, sects, equipment, manuals, beasts, and shared inventory/reward rules.
- `daoyou-beast-design`: 灵兽物种、生灵层次、命名、资质成长及出生技能池设计与审查;扩充或调整物种前读取 `.agents/skills/daoyou-beast-design/SKILL.md`,实施同时遵守领域技能。
- Only the skills present in `.agents/skills/` are project skill entrypoints. For runtime work, inspect `package.json`, `src/index.ts`, Vite/Docker configs and `docs/local-development.md`; for condition/alchemy/market work, combine the domain, data and backend skills as applicable.
- Only the skills present in `.agents/skills/` are project skill entrypoints. For runtime work, inspect `package.json`, `apps/api/src/main.ts`, Vite/Docker configs and `docs/local-development.md`; for condition/alchemy/market work, combine the domain, data and backend skills as applicable.
## Architecture Rules
- New API routes go through `src/server/routes/api/index.ts` and existing Hono middleware: `requireUser`, `requireActiveCultivatorRef`, `requireAdmin`, `validateJson`, `validateQuery`.
- New API routes belong to Nest feature modules imported by `apps/api/src/app.module.ts`. Reuse `AccessGuard`/`@Access`, `SessionService`, `JsonBody`, `FirstQuery`, Zod pipes and existing exception filters. Use explicit constructor `@Inject`; the API build deliberately disables implicit design-type metadata.
- Frontend route loaders are UX guards only; backend middleware is the security boundary.
- `/api/auth/*` is Better Auth through `src/server/lib/auth/hono.ts`.
- `/api/auth/*` is Better Auth through `apps/api/src/lib/auth/handler.ts`.
- `/internal/cron/*` uses Bearer `CRON_SECRET` when configured; production requires it, while non-production without `CRON_SECRET` currently allows the request.
- Shared request/response contracts live in `src/shared/contracts`; domain DTO/types live in `src/shared/types`.
- LLM calls should use `src/server/utils/aiClient.ts`; BYOK validation truth is `src/shared/config/llm.ts`. Server routing is one `LLM_PROVIDER` table (`provider[/model][:weight]`) parsed in `src/shared/config/llmRouting.ts`; multiple routes are sticky by user id hash. Request BYOK still wins.
- Shared request/response contracts live in `packages/shared/src/contracts`; domain DTO/types live in `packages/shared/src/types`.
- LLM calls should use `apps/api/src/utils/aiClient.ts`; BYOK validation truth is `packages/shared/src/config/llm.ts`. Server routing is one `LLM_PROVIDER` table (`provider[/model][:weight]`) parsed in `packages/shared/src/config/llmRouting.ts`; multiple routes are sticky by user id hash. Request BYOK still wins.
- Treat all LLM output as untrusted. Resource, reward, cost, drop, and other state-changing numbers need deterministic service/schema/resource-layer guards.
- Redis access must go through `src/server/lib/redis`; do not instantiate feature-local Redis clients.
- SMTP mail goes through `src/server/lib/admin/smtp.ts`.
- Server config: `apps/api/src/config/configuration.module.ts` uses `@nestjs/config`, ignores dotenv discovery, and publishes the validated immutable environment from `lib/config/environment.ts`. Use injected `runtimeConfig` in Nest services and that snapshot in framework-independent libraries; do not add direct `process.env` reads outside it.
- Redis access must go through `apps/api/src/lib/redis`; do not instantiate feature-local Redis clients.
- SMTP mail goes through `apps/api/src/lib/admin/smtp.ts`.
## Frontend Rules
- 新增或迁移图标渲染统一使用 `src/react-app/components/ui/GameIcon.tsx`:emoji 直接传值,SVG/WebP/PNG 使用 `icon:名称`;素材统一放在 `public/assets/icons/`,名称与路径只在 `components/ui/icons/registry.ts` 注册,业务组件不得自行解析协议或直接引用图标文件。见 `docs/game-icons.md`。
- 新增或迁移图标渲染统一使用 `apps/web/src/components/ui/GameIcon.tsx`:emoji 直接传值,SVG/WebP/PNG 使用 `icon:名称`;素材统一放在 `apps/web/public/assets/icons/`,名称与路径只在 `components/ui/icons/registry.ts` 注册,业务组件不得自行解析协议或直接引用图标文件。见 `docs/game-icons.md`。
- Numeric data uses Tailwind default `font-mono`; prose inherits the body font. Keep quantity weight/spacing local (`font-semibold tracking-tight`), and do not override `--font-mono` or add numeric font tokens/classes. See `docs/numeric-typography.md`.
- React routes are centralized in `src/react-app/router.tsx` and loaded with `lazyRoute`.
- Game scenes use `handle={scene(...)}`; the scene id must exist in `src/react-app/components/game-shell/gameNavigation.ts`.
- React routes are centralized in `apps/web/src/router.tsx` and loaded with `lazyRoute`.
- Game scenes use `handle={scene(...)}`; the scene id must exist in `apps/web/src/components/game-shell/gameNavigation.ts`.
- `/game` uses distinct genesis, narrative, viewport, activity, combat, map and dungeon layouts. V6 battles have `CombatV6Layout`; inspect `router.tsx` for the actual wrapper before changing a scene.
- Main-flow game UI must follow `daoyou-game-ui`: identity layer, task layer, and navigation layer stay separate.
- Do not add `InkPageShell` to game routes.
- Cross-route reusable UI belongs in `src/react-app/components/feature/**`, `src/react-app/components/ui/**`, or `src/react-app/components/game-shell/**`; `src/react-app/routes/game/**/components` is page-private.
- Reuse `src/react-app/lib/resources` hooks/store, `fetchJsonCached`, `useTaskList`, and provider contexts before adding new page-level state.
- Cross-route reusable UI belongs in `apps/web/src/components/feature/**`, `apps/web/src/components/ui/**`, or `apps/web/src/components/game-shell/**`; `apps/web/src/routes/game/**/components` is page-private.
- Reuse `apps/web/src/lib/resources` hooks/store, `fetchJsonCached`, `useTaskList`, and provider contexts before adding new page-level state.
## Data And Domain Rules
- Do not create parallel `src/db` or `src/server/db`; DB entrypoints are `src/server/lib/drizzle/db.ts` and `schema.ts`.
- Do not create parallel `src/db` or `apps/api/src/db`; DB entrypoints are `apps/api/src/lib/drizzle/db.ts` and `schema.ts`.
- The main Drizzle Kit flow manages only `wanjiedaoyou_*` business tables. `drizzle.auth.config.ts` independently manages the fixed `better_auth` schema.
- Nest `DatabaseModule` exposes the same Drizzle client through `DRIZZLE_DATABASE`; never create a second pool. `RuntimeService` closes `DatabaseService` only after draining requests and messaging.
- Pass `DbExecutor` / `DbTransaction` through write paths; do not open a fresh executor inside a transaction.
- Current equipment/items use `inventory_items` and `cultivator_equipment_slots`; personal manuals and beasts belong to the cultivator, while sect combat progression belongs to membership. See `daoyou-data-layer` for tables and ownership.
- Runtime DB access uses `pg.Pool` / node-postgres; use `runDbTasks` when a group of reads may run inside a transaction.
@@ -92,7 +98,7 @@ bun run db:migrate
## High-Risk Areas
- Client/server build separation, `src/index.ts` startup/shutdown and `src/server/app.ts` routing.
- Client/server build separation, `apps/api/src/main.ts` startup/shutdown, runtime lifecycle and HTTP/WS configuration.
- Auth, ALTCHA, Better Auth schema, admin allowlist, and session cookie passthrough.
- LLM provider headers, prompt schemas, resource/reward/cost parsing, and metrics.
- Drizzle migrations, legacy tables, JSONB model shape, and transaction boundaries.
@@ -102,15 +108,15 @@ bun run db:migrate
## Verification Checklist
- Testing has only two layers: pure `src/shared` unit tests and Codex browser/Playwright simulations following `docs/testing.md`. Do not add one-off smoke, E2E, seed, benchmark, or fault-injection scripts.
- Testing has only two layers: pure `packages/shared/src` unit tests and Codex browser/Playwright simulations following `docs/testing.md`. Do not add one-off smoke, E2E, seed, benchmark, or fault-injection scripts.
- Local browser test accounts and their shared password are documented in `docs/testing.md` section 3. Reuse them; query the local database read-only to select existing accounts and characters instead of asking the user for known credentials again. Complete email verification through Mailpit and never apply these credentials or test writes to staging/production.
- Unit tests are forbidden under `src/react-app` and `src/server`; do not add `*.test.*` or `*.spec.*` files there.
- New unit tests are allowed only for pure, deterministic, reusable engine/domain logic under `src/shared`.
- Do not write tests that exercise or mock databases, repositories, Hono routes, auth, Redis, LLM/SMTP providers, network APIs, or other third-party services.
- Unit tests are forbidden under `apps/web/src` and `apps/api/src`; do not add `*.test.*` or `*.spec.*` files there.
- New unit tests are allowed only for pure, deterministic, reusable engine/domain logic under `packages/shared/src`.
- Do not write tests that exercise or mock databases, repositories, HTTP controllers, auth, Redis, LLM/SMTP providers, network APIs, or other third-party services.
- Frontend and backend changes must be verified with lint, typecheck/build, code inspection, and focused manual/runtime checks instead of unit tests.
- For eligible shared engine changes, pick focused tests first, then broader shared-engine checks if the blast radius is large.
- Run `bun run lint`, `bun run test`, or `bun run build` when code/config changes justify it.
- Run `pnpm run lint`, `pnpm run test`, or `pnpm run build` when code/config changes justify it.
- For route/layout changes, run lint/build and inspect the affected navigation and layout behavior manually.
- For LLM/provider changes, run lint/build and inspect provider validation and runtime behavior without adding provider integration tests.
- For data/model changes, inspect generated migrations, transaction boundaries, and build output; do not add database/repository tests.
+15 -13
View File
@@ -1,14 +1,14 @@
# 万界道友
<p align="center">
<img src="public/assets/daoyou_logo.webp" alt="万界道友 Logo" width="200" />
<img src="apps/web/public/assets/daoyou_logo.webp" alt="万界道友 Logo" width="200" />
</p>
<p align="center">
<strong>一款 AIGC 驱动、高自由度文字体验、修仙世界观的开源游戏项目。</strong>
</p>
> 本仓库当前实现为 `Hono + React SPA`。这里的说明以现有代码为准,已不再适用于旧版 Next.js 架构。
> 本仓库默认入口已迁至 `NestJS + React SPA`,迁移收口进度见 [迁移记录](docs/nestjs-migration.md)。这里的说明以现有代码为准,已不再适用于旧版 Next.js 架构。
---
@@ -65,7 +65,7 @@
## 技术概览
- 服务端:`Hono 4` + `Bun`
- 服务端:`NestJS 12` + `Node.js 24`,pnpm workspace管理依赖,Turborepo编排任务
- 前端:`React 19` + `React Router 7` + `Vite 8`
- 样式:`Tailwind CSS 4`
- 数据库:`PostgreSQL` + `Drizzle ORM`
@@ -78,22 +78,24 @@
```text
.
├── src/index.ts # Bun 后端入口,导出 Hono API 与 WebSocket 配置
├── src/server/ # Hono API、认证、服务层、数据库访问
├── src/react-app/ # React SPA
├── src/shared/ # 共享引擎、配置、类型、契约
├── drizzle/ # 业务表 Drizzle migrations
├── drizzle-auth/ # Better Auth Drizzle migrations
├── scripts/ # 部署脚本与生产/NATS Compose
├── docker/Dockerfile.app # Bun 主服务镜像
└── vite.config.ts
├── apps/api/ # NestJS API(nest build → dist/main.js)
│ ├── src/ # 按功能划分的 Nest 模块及服务端基础设施
│ └── scripts/ # 数据维护工具
├── apps/web/ # React SPA(Vite → dist/)
│ ├── src/
│ └── public/
├── packages/shared/src/ # 共享契约、类型及纯游戏规则
├── drizzle/ # 业务表迁移
├── drizzle-auth/ # Better Auth 独立迁移
├── scripts/ # 部署、Compose 与 SponsorKit 工具
└── docker/Dockerfile.app # 仅包含 API 生产依赖和构建产物
```
## 本地开发与部署
环境要求、环境变量、数据库初始化、本地开发、构建、Docker、生产 cron 与部署脚本说明已整理到 [docs/development.md](docs/development.md)。
纯本地配置 `env/local.env` 随仓库提供,直接执行 `bun run services up -d --wait` → `bun run db:migrate` → `bun run dev`,访问 `http://127.0.0.1:5174`。预发布调试使用 `bun run prd`,读取被 Git 忽略的 `env/staging.env`;两组均可独立启动 `:api`/`:web`。详见 [本地开发指南](docs/local-development.md) 与 [测试规范](docs/testing.md)。
首次将 `env/local.example.env` 复制为 `env/local.env`(已有文件保留),安装依赖后执行 `pnpm run services up -d --wait` → `pnpm run db:migrate` → `pnpm run dev`,访问 `http://127.0.0.1:5174`。预发布调试使用 `pnpm run prd`,读取被 Git 忽略的 `env/staging.env`;两组均可独立启动 `:api`/`:web`。详见 [本地开发指南](docs/local-development.md) 与 [测试规范](docs/testing.md)。
## 贡献指南
+1 -1
View File
@@ -2,7 +2,7 @@
感谢每一位帮助《万界道友》持续维护与成长的道友。新的赞助统一通过爱发电进行,既有历史记录继续保留。
> 本页自动生成,请勿直接编辑。同步爱发电运行 `bun run sponsors:sync`;仅更新历史名单时运行 `bun run sponsors:render`。
> 本页自动生成,请勿直接编辑。同步爱发电运行 `pnpm run sponsors:sync`;仅更新历史名单时运行 `pnpm run sponsors:render`。
最后更新:2026-08-07
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"entryFile": "main",
"compilerOptions": {
"builder": {
"type": "rspack",
"options": {
"configPath": "rspack.config.mjs"
}
},
"tsConfigPath": "tsconfig.build.json",
"deleteOutDir": true
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@daoyou/api",
"version": "0.1.0",
"private": true,
"type": "module",
"files": [
"dist"
],
"scripts": {
"build": "nest build",
"dev": "APP_ENV=local NODE_ENV=development nest start --watch --no-shell --env-file ../../env/local.env",
"prd": "APP_ENV=staging NODE_ENV=development nest start --watch --no-shell --env-file ../../env/staging.env",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@ai-sdk/alibaba": "^2.0.45",
"@ai-sdk/deepseek": "^3.0.44",
"@better-auth/drizzle-adapter": "^1.7.4",
"@better-auth/i18n": "^1.7.4",
"@daoyou/shared": "workspace:*",
"@microfleet/ioredis-lock": "^6.0.1",
"@nestjs/common": "^12.1.2",
"@nestjs/config": "^12.0.1",
"@nestjs/core": "^12.1.2",
"@nestjs/platform-express": "^12.1.2",
"@nestjs/platform-ws": "^12.1.2",
"@nestjs/schedule": "^12.0.2",
"@nestjs/websockets": "^12.1.2",
"ai": "^7.0.99",
"altcha-lib": "^2.5.0",
"better-auth": "^1.7.4",
"cors": "^2.8.6",
"cron": "^4.4.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"ioredis": "^5.11.1",
"nats": "^2.29.3",
"nodemailer": "^9.1.1",
"pg": "^8.23.0",
"raw-body": "^3.0.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"ws": "^8.22.0",
"zod": "^4.6.4"
},
"devDependencies": {
"@nestjs/cli": "^12.0.8",
"@rspack/core": "^1.5.8",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.23.1",
"@types/ws": "^8.18.2",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"tsconfig-paths-webpack-plugin": "^4.2.0",
"webpack": "^5.105.4",
"webpack-node-externals": "^3.0.0"
}
}
+38
View File
@@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs';
import { builtinModules } from 'node:module';
const { dependencies } = JSON.parse(
readFileSync(new URL('./package.json', import.meta.url), 'utf8'),
);
// Nest CLI 12's ESM builder bundles workspace sources and Markdown.
// Runtime npm dependencies stay external for Node and the production image.
export default (options) => {
options.module.rules[0].use[0].options.jsc.transform.decoratorMetadata = false;
return {
...options,
devtool: 'source-map',
externalsPresets: { node: false },
externals: [
({ request }, callback) => {
const packageName = request?.startsWith('@')
? request.split('/').slice(0, 2).join('/')
: request?.split('/')[0];
if (
request &&
!request.startsWith('@daoyou/shared/') &&
(request.startsWith('node:') ||
builtinModules.includes(request) ||
dependencies[packageName])
) {
return callback(null, `module ${request}`);
}
callback();
},
],
module: {
...options.module,
rules: [...options.module.rules, { test: /\.md$/, type: 'asset/source' }],
},
};
};
@@ -1,8 +1,8 @@
/** Maintenance command: default read-only inventory; --apply requires explicit record IDs. */
import { and, eq, inArray, sql } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../src/server/lib/drizzle/db';
import { sectTaskRecords } from '../src/server/lib/drizzle/schema';
import { db } from '../src/lib/drizzle/db';
import { sectTaskRecords } from '../src/lib/drizzle/schema';
const args = process.argv.slice(2);
const apply = args.includes('--apply');
@@ -2,18 +2,18 @@
import { and, asc, eq, sql } from 'drizzle-orm';
import { createHash } from 'node:crypto';
import { z } from 'zod';
import { db, type DbExecutor } from '../src/server/lib/drizzle/db';
import { inventoryItems } from '../src/server/lib/drizzle/schema';
import { redisLockKeys, withRedisLock } from '../src/server/lib/redis/lock';
import { lockCultivatorForStateMutation } from '../src/server/lib/repositories/playerStateRepository';
import { db, type DbExecutor } from '../src/lib/drizzle/db';
import { inventoryItems } from '../src/lib/drizzle/schema';
import { redisLockKeys, withRedisLock } from '../src/lib/redis/lock';
import { lockCultivatorForStateMutation } from '../src/lib/repositories/playerStateRepository';
import {
assertInventoryIdle,
inventoryItemOf,
saveInventoryPlan,
} from '../src/server/lib/services/InventoryService';
import { inventoryStackKey } from '../src/server/lib/services/inventoryStackKey';
import { ResourceEventCommitter } from '../src/server/lib/services/ResourceEventCommitter';
import { compactStorage, type InventoryItem } from '../src/shared/inventory';
} from '../src/lib/services/InventoryService';
import { inventoryStackKey } from '../src/lib/services/inventoryStackKey';
import { ResourceEventCommitter } from '../src/lib/services/ResourceEventCommitter';
import { compactStorage, type InventoryItem } from '../../../packages/shared/src/inventory';
const args = process.argv.slice(2);
const all = args.includes('--all');
@@ -1,8 +1,8 @@
/** Offline maintenance only. No conversion of old requirements into new equipment rules. */
import { and, inArray, sql } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../src/server/lib/drizzle/db';
import { sectTaskRecords } from '../src/server/lib/drizzle/schema';
import { db } from '../src/lib/drizzle/db';
import { sectTaskRecords } from '../src/lib/drizzle/schema';
const args = process.argv.slice(2);
const apply = args.includes('--apply');
@@ -0,0 +1,35 @@
import { Controller, HttpCode, Inject, Post, Req } from '@nestjs/common';
import {
AccountSetPasswordRequestSchema,
type AccountSetPasswordRequest,
type AccountSetPasswordResponse,
} from '@daoyou/shared/contracts/account';
import { fromNodeHeaders } from 'better-auth/node';
import type { Request } from 'express';
import { SessionService } from '../auth/session.service';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
@Controller('api/account')
export class AccountController {
constructor(
@Inject(SessionService) private readonly sessions: SessionService,
) {}
@Post('password')
@HttpCode(200)
async setPassword(
@Req() request: Request,
@JsonBody(
{ fallback: undefined },
new ZodPipe(AccountSetPasswordRequestSchema),
)
body: AccountSetPasswordRequest,
): Promise<AccountSetPasswordResponse> {
const result = await this.sessions.setPassword(
fromNodeHeaders(request.headers),
body.newPassword,
);
return { success: true, data: { status: result.status } };
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AccountController } from './account.controller';
@Module({ imports: [AuthModule], controllers: [AccountController] })
export class AccountModule {}
+137
View File
@@ -0,0 +1,137 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Req,
Res,
UseFilters,
} from '@nestjs/common';
import type { AuthUser } from '@server/lib/auth/types';
import {
AdminAccountBanRequestSchema,
AdminAccountChangeEmailRequestSchema,
AdminAccountListQuerySchema,
type AdminAccountBanRequest,
type AdminAccountChangeEmailRequest,
type AdminAccountListQuery,
} from '@daoyou/shared/contracts/adminAccounts';
import { fromNodeHeaders } from 'better-auth/node';
import type { Request, Response } from 'express';
import { Access, CurrentUser } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { AccountsService } from './accounts.service';
import { AdminErrors } from './admin-errors';
function authHeaders(response: Response) {
return (headers: Headers) => {
headers.forEach((value, key) => {
if (key.toLowerCase() !== 'set-cookie') response.setHeader(key, value);
});
for (const cookie of headers.getSetCookie())
response.append('Set-Cookie', cookie);
};
}
@Controller('api/admin/accounts')
@Access('account-admin')
@UseFilters(AdminErrors)
export class AccountsController {
constructor(
@Inject(AccountsService) private readonly accounts: AccountsService,
) {}
@Get()
list(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@FirstQuery(new ZodPipe(AdminAccountListQuerySchema, 'legacy-unhandled'))
query: AdminAccountListQuery,
) {
return this.accounts.list(
fromNodeHeaders(request.headers),
query,
authHeaders(response),
);
}
@Post(':userId/change-email')
@HttpCode(200)
changeEmail(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@CurrentUser() actor: AuthUser,
@Param('userId') userId: string,
@JsonBody(
{ fallback: undefined },
new ZodPipe(AdminAccountChangeEmailRequestSchema, 'legacy-unhandled'),
)
input: AdminAccountChangeEmailRequest,
) {
return this.accounts.changeEmail(
fromNodeHeaders(request.headers),
actor,
userId,
input,
authHeaders(response),
);
}
@Post(':userId/ban')
@HttpCode(200)
ban(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@CurrentUser() actor: AuthUser,
@Param('userId') userId: string,
@JsonBody(
{ fallback: undefined },
new ZodPipe(AdminAccountBanRequestSchema, 'legacy-unhandled'),
)
input: AdminAccountBanRequest,
) {
return this.accounts.ban(
fromNodeHeaders(request.headers),
actor,
userId,
input,
authHeaders(response),
);
}
@Post(':userId/unban')
@HttpCode(200)
unban(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@CurrentUser() actor: AuthUser,
@Param('userId') userId: string,
) {
return this.accounts.unban(
fromNodeHeaders(request.headers),
actor,
userId,
authHeaders(response),
);
}
@Post(':userId/revoke-sessions')
@HttpCode(200)
revokeSessions(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@CurrentUser() actor: AuthUser,
@Param('userId') userId: string,
) {
return this.accounts.revokeSessions(
fromNodeHeaders(request.headers),
actor,
userId,
authHeaders(response),
);
}
}
@@ -1,20 +1,10 @@
import { HttpException, Injectable } from '@nestjs/common';
import { auth } from '@server/lib/auth/auth';
import { authAccounts, authSessions } from '@server/lib/auth/schema';
import type { AuthUser } from '@server/lib/auth/types';
import { getExecutor, runDbTasks } from '@server/lib/drizzle/db';
import { cultivators } from '@server/lib/drizzle/schema';
import {
applyAuthHeaders,
getValidatedJson,
getValidatedQuery,
requireBetterAuthAdmin,
validateJson,
validateQuery,
} from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import {
AdminAccountBanRequestSchema,
AdminAccountChangeEmailRequestSchema,
AdminAccountListQuerySchema,
AdminAccountParamsSchema,
type AdminAccountBanDuration,
type AdminAccountBanRequest,
@@ -22,13 +12,10 @@ import {
type AdminAccountErrorResponse,
type AdminAccountListItem,
type AdminAccountListQuery,
} from '@shared/contracts/adminAccounts';
} from '@daoyou/shared/contracts/adminAccounts';
import { APIError } from 'better-auth/api';
import { and, eq, inArray } from 'drizzle-orm';
import type { Context } from 'hono';
import { Hono } from 'hono';
const router = new Hono<AppEnv>();
type AuthHeaders = (headers: Headers) => void;
const BAN_DURATION_SECONDS: Record<
Exclude<AdminAccountBanDuration, 'permanent'>,
@@ -69,13 +56,12 @@ function getAuthErrorDetails(error: unknown) {
}
function accountError(
context: Context<AppEnv>,
error: string,
status: 400 | 403 | 404 | 409 | 500,
code?: string,
partial?: AdminAccountErrorResponse['partial'],
) {
return context.json(
): never {
throw new HttpException(
{
success: false,
error,
@@ -86,29 +72,29 @@ function accountError(
);
}
function mapBetterAuthError(context: Context<AppEnv>, error: unknown) {
function mapBetterAuthError(error: unknown): never {
const details = getAuthErrorDetails(error);
if (
details.code === 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL' ||
details.code === 'USER_ALREADY_EXISTS'
) {
return accountError(context, '该邮箱已被其他账号占用', 409, details.code);
return accountError('该邮箱已被其他账号占用', 409, details.code);
}
if (details.statusCode === 404 || details.code === 'USER_NOT_FOUND') {
return accountError(context, '目标账号不存在', 404, details.code);
return accountError('目标账号不存在', 404, details.code);
}
if (details.statusCode === 403) {
return accountError(context, '无账号管理权限', 403, details.code);
return accountError('无账号管理权限', 403, details.code);
}
if (details.code === 'YOU_CANNOT_BAN_YOURSELF') {
return accountError(context, '不能封禁当前管理员账号', 409, details.code);
return accountError('不能封禁当前管理员账号', 409, details.code);
}
return accountError(context, 'Better Auth 操作失败', 500, details.code);
return accountError('Better Auth 操作失败', 500, details.code);
}
function logAccountEmailOperation(event: {
@@ -158,18 +144,19 @@ function logAccountModerationOperation(event: {
console.error('[admin-account-moderation]', payload);
}
router.get(
'/',
requireBetterAuthAdmin(),
validateQuery(AdminAccountListQuerySchema),
async (c) => {
const query = getValidatedQuery<AdminAccountListQuery>(c);
@Injectable()
export class AccountsService {
async list(
headers: Headers,
query: AdminAccountListQuery,
onHeaders: AuthHeaders,
) {
const offset = (query.page - 1) * query.limit;
let result;
try {
result = await auth.api.listUsers({
headers: c.req.raw.headers,
headers: headers,
query: {
limit: query.limit,
offset,
@@ -193,10 +180,10 @@ router.get(
returnHeaders: true,
});
} catch (error) {
return mapBetterAuthError(c, error);
return mapBetterAuthError(error);
}
applyAuthHeaders(c, result.headers);
onHeaders(result.headers);
const users = result.response.users;
const userIds = users.map((user) => user.id);
@@ -303,7 +290,7 @@ router.get(
};
});
return c.json({
return {
success: true,
data: {
accounts,
@@ -311,32 +298,32 @@ router.get(
page: query.page,
limit: query.limit,
},
});
},
);
};
}
router.post(
'/:userId/change-email',
requireBetterAuthAdmin(),
validateJson(AdminAccountChangeEmailRequestSchema),
async (c) => {
const params = AdminAccountParamsSchema.safeParse(c.req.param());
async changeEmail(
headers: Headers,
actor: AuthUser,
userId: string,
input: AdminAccountChangeEmailRequest,
onHeaders: AuthHeaders,
) {
const params = AdminAccountParamsSchema.safeParse({ userId });
if (!params.success) {
return accountError(c, '目标账号 ID 格式错误', 400, 'INVALID_USER_ID');
return accountError('目标账号 ID 格式错误', 400, 'INVALID_USER_ID');
}
const operator = c.get('user');
const input = getValidatedJson<AdminAccountChangeEmailRequest>(c);
const operator = actor;
const targetUserId = params.data.userId;
let target;
try {
const result = await auth.api.getUser({
headers: c.req.raw.headers,
headers: headers,
query: { id: targetUserId },
returnHeaders: true,
});
applyAuthHeaders(c, result.headers);
onHeaders(result.headers);
target = result.response;
} catch (error) {
const details = getAuthErrorDetails(error);
@@ -348,7 +335,7 @@ router.post(
newEmail: input.newEmail,
errorCode: details.code,
});
return mapBetterAuthError(c, error);
return mapBetterAuthError(error);
}
const currentEmail = target.email.trim().toLowerCase();
@@ -364,7 +351,6 @@ router.post(
errorCode: 'STALE_CURRENT_EMAIL',
});
return accountError(
c,
'账号邮箱已发生变化,请刷新列表后重试',
409,
'STALE_CURRENT_EMAIL',
@@ -382,18 +368,13 @@ router.post(
oldEmailVerified: target.emailVerified,
errorCode: 'EMAIL_UNCHANGED',
});
return accountError(
c,
'新邮箱不能与当前邮箱相同',
409,
'EMAIL_UNCHANGED',
);
return accountError('新邮箱不能与当前邮箱相同', 409, 'EMAIL_UNCHANGED');
}
let updatedUser;
try {
const result = await auth.api.adminUpdateUser({
headers: c.req.raw.headers,
headers: headers,
body: {
userId: targetUserId,
data: {
@@ -403,7 +384,7 @@ router.post(
},
returnHeaders: true,
});
applyAuthHeaders(c, result.headers);
onHeaders(result.headers);
updatedUser = result.response;
} catch (error) {
const details = getAuthErrorDetails(error);
@@ -417,16 +398,16 @@ router.post(
oldEmailVerified: target.emailVerified,
errorCode: details.code,
});
return mapBetterAuthError(c, error);
return mapBetterAuthError(error);
}
try {
const result = await auth.api.revokeUserSessions({
headers: c.req.raw.headers,
headers: headers,
body: { userId: targetUserId },
returnHeaders: true,
});
applyAuthHeaders(c, result.headers);
onHeaders(result.headers);
} catch (error) {
const details = getAuthErrorDetails(error);
logAccountEmailOperation({
@@ -440,7 +421,6 @@ router.post(
errorCode: details.code,
});
return accountError(
c,
'邮箱已改绑,但旧会话撤销失败,请刷新后重试下线',
500,
'EMAIL_CHANGED_SESSION_REVOKE_FAILED',
@@ -463,7 +443,7 @@ router.post(
oldEmailVerified: target.emailVerified,
});
return c.json({
return {
success: true,
data: {
userId: targetUserId,
@@ -471,24 +451,23 @@ router.post(
emailVerified: false as const,
sessionsRevoked: true as const,
},
});
},
);
};
}
router.post(
'/:userId/ban',
requireBetterAuthAdmin(),
validateJson(AdminAccountBanRequestSchema),
async (c) => {
const params = AdminAccountParamsSchema.safeParse(c.req.param());
async ban(
headers: Headers,
actor: AuthUser,
userId: string,
input: AdminAccountBanRequest,
onHeaders: AuthHeaders,
) {
const params = AdminAccountParamsSchema.safeParse({ userId });
if (!params.success) {
return accountError(c, '目标账号 ID 格式错误', 400, 'INVALID_USER_ID');
return accountError('目标账号 ID 格式错误', 400, 'INVALID_USER_ID');
}
const operator = c.get('user');
const operator = actor;
const targetUserId = params.data.userId;
const input = getValidatedJson<AdminAccountBanRequest>(c);
if (operator?.id === targetUserId) {
logAccountModerationOperation({
action: 'ban',
@@ -500,7 +479,6 @@ router.post(
errorCode: 'YOU_CANNOT_BAN_YOURSELF',
});
return accountError(
c,
'不能封禁当前管理员账号',
409,
'YOU_CANNOT_BAN_YOURSELF',
@@ -509,7 +487,7 @@ router.post(
try {
const result = await auth.api.banUser({
headers: c.req.raw.headers,
headers: headers,
body: {
userId: targetUserId,
banReason: input.reason,
@@ -519,7 +497,7 @@ router.post(
},
returnHeaders: true,
});
applyAuthHeaders(c, result.headers);
onHeaders(result.headers);
const user = result.response.user;
logAccountModerationOperation({
@@ -531,7 +509,7 @@ router.post(
reason: input.reason,
});
return c.json({
return {
success: true,
data: {
userId: targetUserId,
@@ -540,7 +518,7 @@ router.post(
banExpires: toIsoString(user.banExpires),
sessionsRevoked: true,
},
});
};
} catch (error) {
const details = getAuthErrorDetails(error);
logAccountModerationOperation({
@@ -552,109 +530,117 @@ router.post(
reason: input.reason,
errorCode: details.code,
});
return mapBetterAuthError(c, error);
return mapBetterAuthError(error);
}
},
);
router.post('/:userId/unban', requireBetterAuthAdmin(), async (c) => {
const params = AdminAccountParamsSchema.safeParse(c.req.param());
if (!params.success) {
return accountError(c, '目标账号 ID 格式错误', 400, 'INVALID_USER_ID');
}
const operator = c.get('user');
const targetUserId = params.data.userId;
async unban(
headers: Headers,
actor: AuthUser,
userId: string,
onHeaders: AuthHeaders,
) {
const params = AdminAccountParamsSchema.safeParse({ userId });
if (!params.success) {
return accountError('目标账号 ID 格式错误', 400, 'INVALID_USER_ID');
}
try {
const result = await auth.api.unbanUser({
headers: c.req.raw.headers,
body: { userId: targetUserId },
returnHeaders: true,
});
applyAuthHeaders(c, result.headers);
const operator = actor;
const targetUserId = params.data.userId;
logAccountModerationOperation({
action: 'unban',
outcome: 'success',
operatorUserId: operator?.id ?? '',
targetUserId,
});
try {
const result = await auth.api.unbanUser({
headers: headers,
body: { userId: targetUserId },
returnHeaders: true,
});
onHeaders(result.headers);
return c.json({
success: true,
data: {
userId: targetUserId,
banned: false,
banReason: null,
banExpires: null,
sessionsRevoked: false,
},
});
} catch (error) {
const details = getAuthErrorDetails(error);
logAccountModerationOperation({
action: 'unban',
outcome: 'failure',
operatorUserId: operator?.id ?? '',
targetUserId,
errorCode: details.code,
});
return mapBetterAuthError(c, error);
}
});
logAccountModerationOperation({
action: 'unban',
outcome: 'success',
operatorUserId: operator?.id ?? '',
targetUserId,
});
router.post('/:userId/revoke-sessions', requireBetterAuthAdmin(), async (c) => {
const params = AdminAccountParamsSchema.safeParse(c.req.param());
if (!params.success) {
return accountError(c, '目标账号 ID 格式错误', 400, 'INVALID_USER_ID');
return {
success: true,
data: {
userId: targetUserId,
banned: false,
banReason: null,
banExpires: null,
sessionsRevoked: false,
},
};
} catch (error) {
const details = getAuthErrorDetails(error);
logAccountModerationOperation({
action: 'unban',
outcome: 'failure',
operatorUserId: operator?.id ?? '',
targetUserId,
errorCode: details.code,
});
return mapBetterAuthError(error);
}
}
const operator = c.get('user');
const targetUserId = params.data.userId;
async revokeSessions(
headers: Headers,
actor: AuthUser,
userId: string,
onHeaders: AuthHeaders,
) {
const params = AdminAccountParamsSchema.safeParse({ userId });
if (!params.success) {
return accountError('目标账号 ID 格式错误', 400, 'INVALID_USER_ID');
}
try {
const userResult = await auth.api.getUser({
headers: c.req.raw.headers,
query: { id: targetUserId },
returnHeaders: true,
});
applyAuthHeaders(c, userResult.headers);
const operator = actor;
const targetUserId = params.data.userId;
const revokeResult = await auth.api.revokeUserSessions({
headers: c.req.raw.headers,
body: { userId: targetUserId },
returnHeaders: true,
});
applyAuthHeaders(c, revokeResult.headers);
try {
const userResult = await auth.api.getUser({
headers: headers,
query: { id: targetUserId },
returnHeaders: true,
});
onHeaders(userResult.headers);
console.info('[admin-account-session-revoke]', {
event: 'admin_account_session_revoke',
occurredAt: new Date().toISOString(),
outcome: 'success',
operatorUserId: operator?.id ?? '',
targetUserId,
});
const revokeResult = await auth.api.revokeUserSessions({
headers: headers,
body: { userId: targetUserId },
returnHeaders: true,
});
onHeaders(revokeResult.headers);
return c.json({
success: true,
data: {
userId: targetUserId,
sessionsRevoked: true as const,
},
});
} catch (error) {
const details = getAuthErrorDetails(error);
console.error('[admin-account-session-revoke]', {
event: 'admin_account_session_revoke',
occurredAt: new Date().toISOString(),
outcome: 'failure',
operatorUserId: operator?.id ?? '',
targetUserId,
errorCode: details.code,
});
return mapBetterAuthError(c, error);
console.info('[admin-account-session-revoke]', {
event: 'admin_account_session_revoke',
occurredAt: new Date().toISOString(),
outcome: 'success',
operatorUserId: operator?.id ?? '',
targetUserId,
});
return {
success: true,
data: {
userId: targetUserId,
sessionsRevoked: true as const,
},
};
} catch (error) {
const details = getAuthErrorDetails(error);
console.error('[admin-account-session-revoke]', {
event: 'admin_account_session_revoke',
occurredAt: new Date().toISOString(),
outcome: 'failure',
operatorUserId: operator?.id ?? '',
targetUserId,
errorCode: details.code,
});
return mapBetterAuthError(error);
}
}
});
export default router;
}
+12
View File
@@ -0,0 +1,12 @@
import { redisLockErrorResponse } from '@server/lib/http/errors';
import { apiErrorFilter } from '../http/error-filter';
export const AdminErrors = apiErrorFilter((error) => {
const lock = redisLockErrorResponse(error);
if (lock) return lock;
console.error('Admin API error:', error);
return Response.json(
{ success: false, error: '服务器内部错误' },
{ status: 500 },
);
});
+82
View File
@@ -0,0 +1,82 @@
import {
Controller,
Get,
Inject,
Param,
Patch,
UseFilters,
} from '@nestjs/common';
import type { AuthUser } from '@server/lib/auth/types';
import { Access, CurrentUser } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { AdminErrors } from './admin-errors';
import { AdminFeedbackService } from './feedback.service';
import { MonitoringService } from './monitoring.service';
import { SettingsService } from './settings.service';
@Controller('api/admin')
@Access('admin')
@UseFilters(AdminErrors)
export class AdminController {
constructor(
@Inject(SettingsService) private readonly settings: SettingsService,
@Inject(MonitoringService) private readonly monitoring: MonitoringService,
@Inject(AdminFeedbackService)
private readonly feedback: AdminFeedbackService,
) {}
@Get('session')
session(@CurrentUser() user: AuthUser) {
return { success: true, userId: user.id, email: user.email };
}
@Get('announcement')
announcement() {
return this.settings.announcement();
}
@Patch('announcement')
updateAnnouncement(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) input: unknown,
) {
return this.settings.updateAnnouncement(user.id, input);
}
@Get('community-group')
community() {
return this.settings.community();
}
@Patch('community-group')
updateCommunity(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) input: unknown,
) {
return this.settings.updateCommunity(user.id, input);
}
@Get('llm-metrics')
llmMetrics(@FirstQuery() query: Record<string, string | undefined>) {
return this.monitoring.llmMetrics(query);
}
@Get('online-users')
onlineUsers() {
return this.monitoring.onlineUsers();
}
@Get('feedback')
listFeedback(@FirstQuery() query: Record<string, string | undefined>) {
return this.feedback.list(query);
}
@Patch('feedback/:id/status')
updateFeedback(
@Param('id') id: string,
@JsonBody({ fallback: null }) input: unknown,
) {
return this.feedback.update(id, input);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { AccountsController } from './accounts.controller';
import { AccountsService } from './accounts.service';
import { AdminController } from './admin.controller';
import { AdminFeedbackService } from './feedback.service';
import { AdminItemLibraryController } from './item-library.controller';
import { AdminItemLibraryService } from './item-library.service';
import { MonitoringService } from './monitoring.service';
import { AdminRedeemCodesController } from './redeem-codes.controller';
import { AdminRedeemCodesService } from './redeem-codes.service';
import { AdminReputationShopController } from './reputation-shop.controller';
import { AdminReputationShopService } from './reputation-shop.service';
import { AdminRewardItemsController } from './reward-items.controller';
import { AdminRewardItemsService } from './reward-items.service';
import { AdminSectShopController } from './sect-shop.controller';
import { AdminSectShopService } from './sect-shop.service';
import { SettingsService } from './settings.service';
import { AdminSponsorshipController } from './sponsorship.controller';
import { AdminSponsorshipService } from './sponsorship.service';
import { AdminSystemMailsController } from './system-mails.controller';
import { AdminSystemMailsService } from './system-mails.service';
import { AdminTowerController } from './tower.controller';
import { AdminTowerService } from './tower.service';
@Module({
controllers: [
AdminSystemMailsController,
AdminItemLibraryController,
AdminRedeemCodesController,
AdminSponsorshipController,
AdminController,
AccountsController,
AdminReputationShopController,
AdminSectShopController,
AdminRewardItemsController,
AdminTowerController,
],
providers: [
AdminSystemMailsService,
AdminItemLibraryService,
AdminRedeemCodesService,
AdminSponsorshipService,
AccountsService,
SettingsService,
MonitoringService,
AdminFeedbackService,
AdminReputationShopService,
AdminSectShopService,
AdminRewardItemsService,
AdminTowerService,
],
})
export class AdminModule {}
+235
View File
@@ -0,0 +1,235 @@
import { HttpException, Injectable } from '@nestjs/common';
import { getExecutor } from '@server/lib/drizzle/db';
import { cultivators } from '@server/lib/drizzle/schema';
import {
findFeedbackById,
findFeedbacks,
updateFeedbackStatus,
type FeedbackStatus,
type FeedbackType,
} from '@server/lib/repositories/feedbackRepository';
import { MailService } from '@server/lib/services/MailService';
import { updateSpiritStones } from '@server/lib/services/cultivator/CultivatorStateRepository';
import { and, eq, inArray } from 'drizzle-orm';
import { z } from 'zod';
const VALID_STATUSES: FeedbackStatus[] = [
'pending',
'processing',
'resolved',
'closed',
];
const VALID_TYPES: FeedbackType[] = ['bug', 'feature', 'balance', 'other'];
const STATUS_LABELS: Record<FeedbackStatus, string> = {
pending: '待处理',
processing: '处理中',
resolved: '已解决',
closed: '已关闭',
};
const TYPE_LABELS: Record<FeedbackType, string> = {
bug: 'Bug 反馈',
feature: '功能建议',
balance: '游戏平衡',
other: '其他意见',
};
const UpdateFeedbackStatusSchema = z.object({
status: z.enum(['pending', 'processing', 'resolved', 'closed']),
adminMessage: z.string().trim().max(1000).optional(),
});
const FEEDBACK_RESOLVE_REWARD = 8000;
function buildFeedbackStatusMailContent(params: {
feedbackType: FeedbackType;
status: FeedbackStatus;
adminMessage?: string;
feedbackContent: string;
}) {
const message = params.adminMessage?.trim();
const lines = [
'你提交的反馈工单状态已更新。',
'',
`反馈类型:${TYPE_LABELS[params.feedbackType]}`,
`当前状态:${STATUS_LABELS[params.status]}`,
'',
'── 你的反馈内容 ──',
params.feedbackContent,
'──────────────',
'',
`管理员留言:${message || '感谢你的反馈,我们会持续跟进。'}`,
];
if (params.status === 'resolved') {
lines.push(
'',
`奖励发放:你的问题已解决,特发放 ${FEEDBACK_RESOLVE_REWARD} 灵石作为奖励。`,
);
}
return lines.join('\n');
}
@Injectable()
export class AdminFeedbackService {
async list(query: Record<string, string | undefined>) {
const q = getExecutor();
const page = Number.parseInt(query.page ?? '1', 10);
const limit = Number.parseInt(query.limit ?? '20', 10);
const status = query.status as FeedbackStatus | undefined;
const type = query.type as FeedbackType | undefined;
const search = query.search?.trim() || undefined;
const { feedbacks, total } = await findFeedbacks({
status: status && VALID_STATUSES.includes(status) ? status : undefined,
type: type && VALID_TYPES.includes(type) ? type : undefined,
search,
page,
limit,
});
const cultivatorIds = Array.from(
new Set(
feedbacks
.map((feedback) => feedback.cultivatorId)
.filter((id): id is string => Boolean(id)),
),
);
const cultivatorMap = new Map<
string,
{ name: string | null; realm: string | null }
>();
if (cultivatorIds.length > 0) {
const cultivatorRows = await q
.select({
id: cultivators.id,
name: cultivators.name,
realm: cultivators.realm,
})
.from(cultivators)
.where(inArray(cultivators.id, cultivatorIds));
for (const row of cultivatorRows) {
cultivatorMap.set(row.id, {
name: row.name,
realm: row.realm,
});
}
}
const enrichedFeedbacks = feedbacks.map((feedback) => {
const cultivator = feedback.cultivatorId
? cultivatorMap.get(feedback.cultivatorId)
: null;
return {
...feedback,
cultivatorName: cultivator?.name ?? null,
cultivatorRealm: cultivator?.realm ?? null,
};
});
return {
feedbacks: enrichedFeedbacks,
total,
page,
limit,
};
}
async update(id: string, input: unknown) {
const body = input;
const parsed = UpdateFeedbackStatusSchema.safeParse(body);
if (!parsed.success) {
throw new HttpException(
{
success: false,
error: parsed.error.issues[0]?.message ?? '参数错误',
},
400,
);
}
const { status, adminMessage } = parsed.data;
const existing = await findFeedbackById(id);
if (!existing) {
throw new HttpException({ success: false, error: '反馈不存在' }, 404);
}
const hasStatusChanged = existing.status !== status;
const updated = await updateFeedbackStatus(id, status);
if (!updated) {
throw new HttpException({ success: false, error: '更新失败' }, 500);
}
let notifiedUser = false;
let rewardGranted = false;
if (hasStatusChanged) {
const q = getExecutor();
const fallbackCultivator = existing.cultivatorId
? null
: await q.query.cultivators.findFirst({
where: and(
eq(cultivators.userId, existing.userId),
eq(cultivators.status, 'active'),
),
columns: { id: true },
});
const recipientCultivatorId =
existing.cultivatorId ?? fallbackCultivator?.id;
if (recipientCultivatorId) {
// 发放奖励:仅当状态变为 resolved 时固定发放 8000 灵石
if (status === 'resolved') {
try {
await updateSpiritStones(
existing.userId,
recipientCultivatorId,
FEEDBACK_RESOLVE_REWARD,
);
rewardGranted = true;
} catch (err) {
console.warn(
`Failed to grant feedback reward for feedback ${id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
await MailService.sendNewRewardMail(
recipientCultivatorId,
'反馈工单状态更新',
buildFeedbackStatusMailContent({
feedbackType: existing.type as FeedbackType,
status,
adminMessage,
feedbackContent: existing.content,
}),
[],
);
notifiedUser = true;
} else {
console.warn(
`Feedback ${id} status updated but no cultivator found for user ${existing.userId}`,
);
}
}
return {
success: true,
feedback: updated,
statusChanged: hasStatusChanged,
notifiedUser,
rewardGranted,
};
}
}
@@ -0,0 +1,87 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Put,
UseFilters,
} from '@nestjs/common';
import type { AuthUser } from '@server/lib/auth/types';
import { Access, CurrentUser } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { AdminErrors } from './admin-errors';
import { AdminItemLibraryService } from './item-library.service';
@Controller('api/admin/item-library')
@Access('admin')
@UseFilters(AdminErrors)
export class AdminItemLibraryController {
constructor(
@Inject(AdminItemLibraryService)
private readonly service: AdminItemLibraryService,
) {}
@Get()
list(@FirstQuery() query: Record<string, string | undefined>) {
return this.service.list(query);
}
@Post('materials/generate')
@HttpCode(200)
generateMaterials(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.service.generateMaterials(user.id, body);
}
@Post('seeds/generate')
@HttpCode(200)
generateSeeds(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.service.generateSeeds(user.id, body);
}
@Get('materials/daily-generation-settings')
settings() {
return this.service.settings();
}
@Put('materials/daily-generation-settings')
updateSettings(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.service.updateSettings(user.id, body);
}
@Post()
@HttpCode(200)
create(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.service.create(user.id, body);
}
@Put(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.service.update(user.id, id, body);
}
@Post('artifact/preview')
@HttpCode(200)
retiredPreview() {
return this.service.retiredPreview();
}
@Post(':id/archive')
@HttpCode(200)
archive(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.archive(user.id, id);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(id);
}
}
+217
View File
@@ -0,0 +1,217 @@
import { HttpException, Injectable } from '@nestjs/common';
import {
getItemLibraryDailyMaterialGenerationSettings,
upsertItemLibraryDailyMaterialGenerationSettings,
} from '@server/lib/repositories/appSettingsRepository';
import {
archiveItemLibraryEntry,
createItemLibraryEntry,
findItemLibraryById,
listItemLibrary,
normalizeItemLibraryFilters,
updateItemLibraryEntry,
} from '@server/lib/repositories/itemLibraryRepository';
import {
generateMaterialLibraryEntries,
generateSpiritSeedLibraryEntries,
} from '@server/lib/services/MaterialLibraryService';
import { ItemLibraryDailyMaterialGenerationSettingsSchema } from '@daoyou/shared/lib/constants/appSettings';
import {
CreateItemLibraryEntrySchema,
ItemLibraryListQuerySchema,
ItemLibraryMaterialGenerateSchema,
ItemLibrarySpiritSeedGenerateSchema,
UpdateItemLibraryEntrySchema,
} from '@daoyou/shared/lib/itemLibrary';
function isUniqueViolation(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybe = error as {
code?: string;
};
return maybe.code === '23505';
}
@Injectable()
export class AdminItemLibraryService {
async list(query: Record<string, string | undefined>) {
const parsed = ItemLibraryListQuerySchema.safeParse({
status: query['status'] || undefined,
type: 'material',
materialType: query['materialType'] || undefined,
quality: query['quality'] || undefined,
q: query['q'] || undefined,
itemIds: query['itemIds'] || undefined,
page: query['page'] || undefined,
pageSize: query['pageSize'] || undefined,
});
if (!parsed.success) {
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
}
const result = await listItemLibrary(
normalizeItemLibraryFilters(parsed.data),
);
return result;
}
async generateMaterials(userId: string, inputBody: unknown) {
const body = inputBody;
const parsed = ItemLibraryMaterialGenerateSchema.safeParse(body);
if (!parsed.success) {
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
}
try {
const items = await generateMaterialLibraryEntries({
request: parsed.data,
userId: userId,
});
return { success: true, items, generated: items.length };
} catch (error) {
throw new HttpException(
{ error: error instanceof Error ? error.message : '批量生成材料失败' },
500,
);
}
}
async generateSeeds(userId: string, inputBody: unknown) {
const body = inputBody;
const parsed = ItemLibrarySpiritSeedGenerateSchema.safeParse(body);
if (!parsed.success) {
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
}
try {
const items = await generateSpiritSeedLibraryEntries({
request: parsed.data,
userId: userId,
});
return { success: true, items, generated: items.length };
} catch (error) {
throw new HttpException(
{ error: error instanceof Error ? error.message : '批量生成灵种失败' },
500,
);
}
}
async settings() {
const settings = await getItemLibraryDailyMaterialGenerationSettings();
return { settings };
}
async updateSettings(userId: string, inputBody: unknown) {
const body = inputBody;
const parsed =
ItemLibraryDailyMaterialGenerationSettingsSchema.safeParse(body);
if (!parsed.success) {
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
}
await upsertItemLibraryDailyMaterialGenerationSettings({
settings: parsed.data,
updatedBy: userId,
});
return { success: true, settings: parsed.data };
}
async create(userId: string, inputBody: unknown) {
const body = inputBody;
if ((body as { type?: unknown } | null)?.type !== 'material')
throw new HttpException(
{ error: '旧法宝与消耗品库已停用,请直接配置新版奖励' },
410,
);
const parsed = CreateItemLibraryEntrySchema.safeParse(body);
if (!parsed.success) {
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
}
try {
const entry = await createItemLibraryEntry({
entry: parsed.data,
userId: userId,
});
return { success: true, item: entry };
} catch (error) {
if (isUniqueViolation(error)) {
throw new HttpException({ error: '道具 ID 已存在' }, 409);
}
throw new HttpException(
{ error: error instanceof Error ? error.message : '创建道具失败' },
400,
);
}
}
async update(userId: string, idParam: string, inputBody: unknown) {
const id = idParam;
const existing = await findItemLibraryById(id);
if (existing && existing.type !== 'material')
throw new HttpException(
{ error: '旧法宝与消耗品库已停用,请直接配置新版奖励' },
410,
);
const body = inputBody;
if ((body as { type?: unknown } | null)?.type !== 'material')
throw new HttpException(
{ error: '旧法宝与消耗品库已停用,请直接配置新版奖励' },
410,
);
const parsed = UpdateItemLibraryEntrySchema.safeParse(body);
if (!parsed.success) {
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
}
try {
const item = await updateItemLibraryEntry({
id,
entry: parsed.data,
userId: userId,
});
if (!item) {
throw new HttpException({ error: '道具不存在' }, 404);
}
return { success: true, item };
} catch (error) {
if (error instanceof HttpException) throw error;
throw new HttpException(
{ error: error instanceof Error ? error.message : '更新道具失败' },
400,
);
}
}
async retiredPreview() {
throw new HttpException(
{ error: '旧法宝与消耗品库已停用,请直接配置新版奖励' },
410,
);
}
async archive(userId: string, idParam: string) {
const existing = await findItemLibraryById(idParam);
if (!existing || existing.type !== 'material')
throw new HttpException({ error: '材料不存在' }, 404);
const item = await archiveItemLibraryEntry({
id: idParam,
userId: userId,
});
if (!item || item.type !== 'material') {
throw new HttpException({ error: '道具不存在' }, 404);
}
return { success: true, item };
}
async detail(idParam: string) {
const item = await findItemLibraryById(idParam);
if (!item || item.type !== 'material') {
throw new HttpException({ error: '道具不存在' }, 404);
}
return { item };
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { getLlmMetricsSnapshot } from '@server/lib/llm/metricsStore';
import { getOnlineUsersSnapshot } from '@server/lib/services/onlinePresenceService';
@Injectable()
export class MonitoringService {
async llmMetrics(query: Record<string, string | undefined>) {
const limit = Number.parseInt(query.limit ?? '300', 10);
const sceneId = query.sceneId?.trim() || undefined;
return {
success: true,
data: await getLlmMetricsSnapshot({
limit: Number.isFinite(limit) ? limit : 300,
sceneId,
}),
};
}
async onlineUsers() {
return { success: true, data: await getOnlineUsersSnapshot() };
}
}
@@ -0,0 +1,42 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
UseFilters,
} from '@nestjs/common';
import type { AuthUser } from '@server/lib/auth/types';
import { Access, CurrentUser } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { AdminErrors } from './admin-errors';
import { AdminRedeemCodesService } from './redeem-codes.service';
@Controller('api/admin/redeem-codes')
@Access('admin')
@UseFilters(AdminErrors)
export class AdminRedeemCodesController {
constructor(
@Inject(AdminRedeemCodesService)
private readonly service: AdminRedeemCodesService,
) {}
@Get()
list(@FirstQuery() query: Record<string, string | undefined>) {
return this.service.list(query);
}
@Post()
@HttpCode(200)
create(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.service.create(user.id, body);
}
@Post(':id/toggle')
@HttpCode(200)
toggle(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.toggle(user.id, id);
}
}
+240
View File
@@ -0,0 +1,240 @@
import { HttpException, Injectable } from '@nestjs/common';
import { getExecutor } from '@server/lib/drizzle/db';
import { redeemCodes } from '@server/lib/drizzle/schema';
import {
generateRedeemCode,
isValidRedeemCodeFormat,
normalizeRedeemCode,
} from '@server/lib/redeem/code';
import { describeRedeemCodeReward } from '@server/lib/redeem/reward';
import {
RewardSelectionsSchema,
rewardAttachments as buildRewardAttachments,
} from '@daoyou/shared/contracts/adminRewards';
import type { MailAttachment } from '@daoyou/shared/types/mail';
import { and, desc, eq, type SQL } from 'drizzle-orm';
import { z } from 'zod';
const INVENTORY_SNAPSHOT_REWARD_PRESET_ID = '__inventory_v1_snapshot__';
const ITEM_LIBRARY_SNAPSHOT_REWARD_PRESET_ID = '__item_library_snapshot__';
const LEGACY_REWARD_CATALOG_SNAPSHOT_PRESET_ID = '__reward_catalog_snapshot__';
function isSnapshotRewardPresetId(value: string | null | undefined): boolean {
return (
value === INVENTORY_SNAPSHOT_REWARD_PRESET_ID ||
value === ITEM_LIBRARY_SNAPSHOT_REWARD_PRESET_ID ||
value === LEGACY_REWARD_CATALOG_SNAPSHOT_PRESET_ID
);
}
const CreateRedeemCodeSchema = z
.object({
code: z.string().trim().max(64).optional(),
rewardSelections: RewardSelectionsSchema.min(1, '至少选择一项奖励'),
mailTitle: z.string().trim().min(1).max(200),
mailContent: z.string().trim().min(1).max(10000),
totalLimit: z.number().int().min(1).max(100000000).nullable().optional(),
startsAt: z.string().trim().optional(),
endsAt: z.string().trim().optional(),
})
.superRefine((value, ctx) => {
if (value.startsAt && Number.isNaN(new Date(value.startsAt).getTime())) {
ctx.addIssue({
code: 'custom',
path: ['startsAt'],
message: '开始时间格式错误',
});
}
if (value.endsAt && Number.isNaN(new Date(value.endsAt).getTime())) {
ctx.addIssue({
code: 'custom',
path: ['endsAt'],
message: '结束时间格式错误',
});
}
if (value.startsAt && value.endsAt) {
const startsAt = new Date(value.startsAt).getTime();
const endsAt = new Date(value.endsAt).getTime();
if (
!Number.isNaN(startsAt) &&
!Number.isNaN(endsAt) &&
startsAt > endsAt
) {
ctx.addIssue({
code: 'custom',
path: ['endsAt'],
message: '结束时间必须晚于开始时间',
});
}
}
});
function isUniqueViolation(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybe = error as {
code?: string;
};
return maybe.code === '23505';
}
async function createWithAutoCode(params: {
rewardAttachments: MailAttachment[];
mailTitle: string;
mailContent: string;
totalLimit: number | null;
startsAt: Date | null;
endsAt: Date | null;
userId: string;
}) {
const q = getExecutor();
for (let attempt = 0; attempt < 8; attempt += 1) {
const code = generateRedeemCode();
try {
const [inserted] = await q
.insert(redeemCodes)
.values({
code,
rewardPresetId: INVENTORY_SNAPSHOT_REWARD_PRESET_ID,
rewardAttachments: params.rewardAttachments,
mailTitle: params.mailTitle,
mailContent: params.mailContent,
totalLimit: params.totalLimit,
startsAt: params.startsAt,
endsAt: params.endsAt,
status: 'active',
createdBy: params.userId,
updatedBy: params.userId,
})
.returning();
return inserted;
} catch (error) {
if (isUniqueViolation(error)) continue;
throw error;
}
}
throw new Error('自动生成兑换码失败,请重试');
}
@Injectable()
export class AdminRedeemCodesService {
async list(query: Record<string, string | undefined>) {
const q = getExecutor();
const status = query['status'];
const whereConditions: SQL<unknown>[] = [];
if (status === 'active' || status === 'disabled') {
whereConditions.push(eq(redeemCodes.status, status));
}
const items = await q.query.redeemCodes.findMany({
where: whereConditions.length > 0 ? and(...whereConditions) : undefined,
orderBy: [desc(redeemCodes.createdAt)],
});
const rows = items.map((item) => {
const hasRewardAttachments =
item.rewardAttachments !== null && item.rewardAttachments !== undefined;
let rewardSummary: string[];
let rewardSource: 'snapshot' | 'expired_legacy' | 'broken_snapshot';
if (hasRewardAttachments) {
try {
rewardSummary = describeRedeemCodeReward(item);
rewardSource = 'snapshot';
} catch {
rewardSummary = ['奖励配置异常'];
rewardSource = 'broken_snapshot';
}
} else if (isSnapshotRewardPresetId(item.rewardPresetId)) {
rewardSummary = ['奖励快照异常'];
rewardSource = 'broken_snapshot';
} else {
rewardSummary = ['旧版兑换码(已失效)'];
rewardSource = 'expired_legacy';
}
return {
...item,
rewardSummary,
rewardSource,
};
});
return { redeemCodes: rows };
}
async create(userId: string, inputBody: unknown) {
const q = getExecutor();
const body = inputBody;
const parsed = CreateRedeemCodeSchema.safeParse(body);
if (!parsed.success) {
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
}
const startsAt = parsed.data.startsAt
? new Date(parsed.data.startsAt)
: null;
const endsAt = parsed.data.endsAt ? new Date(parsed.data.endsAt) : null;
const totalLimit = parsed.data.totalLimit ?? null;
const manualCode = parsed.data.code
? normalizeRedeemCode(parsed.data.code)
: '';
const rewardAttachments = buildRewardAttachments(
parsed.data.rewardSelections,
);
try {
if (manualCode) {
if (!isValidRedeemCodeFormat(manualCode)) {
throw new HttpException(
{ error: '兑换码格式错误,仅支持 6-64 位大写字母数字' },
400,
);
}
const [inserted] = await q
.insert(redeemCodes)
.values({
code: manualCode,
rewardPresetId: INVENTORY_SNAPSHOT_REWARD_PRESET_ID,
rewardAttachments,
mailTitle: parsed.data.mailTitle,
mailContent: parsed.data.mailContent,
totalLimit,
startsAt,
endsAt,
status: 'active',
createdBy: userId,
updatedBy: userId,
})
.returning();
return { success: true, redeemCode: inserted };
}
const inserted = await createWithAutoCode({
rewardAttachments,
mailTitle: parsed.data.mailTitle,
mailContent: parsed.data.mailContent,
totalLimit,
startsAt,
endsAt,
userId: userId,
});
return { success: true, redeemCode: inserted };
} catch (error) {
if (error instanceof HttpException) throw error;
if (isUniqueViolation(error)) {
throw new HttpException({ error: '兑换码已存在' }, 409);
}
console.error('Create redeem code error:', error);
throw new HttpException({ error: '创建兑换码失败' }, 500);
}
}
async toggle(userId: string, idParam: string) {
const q = getExecutor();
const id = idParam;
const item = await q.query.redeemCodes.findFirst({
where: eq(redeemCodes.id, id),
});
if (!item) {
throw new HttpException({ error: '兑换码不存在' }, 404);
}
const nextStatus = item.status === 'active' ? 'disabled' : 'active';
const [updated] = await q
.update(redeemCodes)
.set({
status: nextStatus,
updatedBy: userId,
})
.where(eq(redeemCodes.id, item.id))
.returning();
return { success: true, redeemCode: updated };
}
}
@@ -0,0 +1,55 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Put,
UseFilters,
} from '@nestjs/common';
import type { AuthUser } from '@server/lib/auth/types';
import { Access, CurrentUser } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { AdminErrors } from './admin-errors';
import { AdminReputationShopService } from './reputation-shop.service';
@Controller('api/admin/reputation-shop')
@Access('admin')
@UseFilters(AdminErrors)
export class AdminReputationShopController {
constructor(
@Inject(AdminReputationShopService)
private readonly shop: AdminReputationShopService,
) {}
@Get()
list(@FirstQuery() query: Record<string, string | undefined>) {
return this.shop.list(query.status);
}
@Post()
@HttpCode(200)
create(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.shop.create(user.id, body);
}
@Put(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.shop.update(user.id, id, body);
}
@Post(':id/archive')
@HttpCode(200)
archive(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.shop.archive(user.id, id);
}
}
@@ -0,0 +1,81 @@
import { HttpException, Injectable } from '@nestjs/common';
import {
archiveReputationShopItem,
createReputationShopItem,
listReputationShopItems,
ReputationShopError,
updateReputationShopItem,
} from '@server/lib/services/ReputationShopService';
import {
ReputationShopItemMutationSchema,
ReputationShopListQuerySchema,
} from '@daoyou/shared/contracts/reputationShop';
@Injectable()
export class AdminReputationShopService {
async list(status?: string) {
const parsed = ReputationShopListQuerySchema.safeParse({
status: status || undefined,
});
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
return {
items: await listReputationShopItems({ status: parsed.data.status }),
};
}
async create(userId: string, body: unknown) {
const parsed = ReputationShopItemMutationSchema.safeParse(body);
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
try {
const item = await createReputationShopItem({
input: parsed.data,
userId,
});
return { success: true, item };
} catch (error) {
if (error instanceof ReputationShopError)
throw new HttpException({ error: error.message }, error.status);
throw new HttpException(
{ error: error instanceof Error ? error.message : '创建商品失败' },
400,
);
}
}
async update(userId: string, id: string, body: unknown) {
const parsed = ReputationShopItemMutationSchema.safeParse(body);
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
// Keep the missing-item response outside the legacy service-error catch.
let item;
try {
item = await updateReputationShopItem({ id, input: parsed.data, userId });
} catch (error) {
if (error instanceof ReputationShopError)
throw new HttpException({ error: error.message }, error.status);
throw new HttpException(
{ error: error instanceof Error ? error.message : '更新商品失败' },
400,
);
}
if (!item) throw new HttpException({ error: '商品不存在' }, 404);
return { success: true, item };
}
async archive(userId: string, id: string) {
const item = await archiveReputationShopItem({ id, userId });
if (!item) throw new HttpException({ error: '商品不存在' }, 404);
return { success: true, item };
}
}
@@ -0,0 +1,30 @@
import { Controller, HttpCode, Inject, Post, UseFilters } from '@nestjs/common';
import { AdminItemGenerationSchema } from '@daoyou/shared/contracts/adminItemGeneration';
import type { z } from 'zod';
import { Access } from '../auth/access';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { AdminErrors } from './admin-errors';
import { AdminRewardItemsService } from './reward-items.service';
@Controller('api/admin/reward-items')
@Access('admin')
@UseFilters(AdminErrors)
export class AdminRewardItemsController {
constructor(
@Inject(AdminRewardItemsService)
private readonly rewards: AdminRewardItemsService,
) {}
@Post('generate')
@HttpCode(200)
generate(
@JsonBody(
{ fallback: undefined },
new ZodPipe(AdminItemGenerationSchema, 'legacy-unhandled'),
)
input: z.infer<typeof AdminItemGenerationSchema>,
) {
return this.rewards.generate(input);
}
}
@@ -1,33 +1,21 @@
import {
getValidatedJson,
requireAdmin,
validateJson,
} from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import { HttpException, Injectable } from '@nestjs/common';
import { getQuotaCategoryForFamily } from '@server/lib/services/AlchemyRecipeRules';
import { TALISMAN_SCENARIO_OPTIONS } from '@shared/config/talismanScenarios';
import { AdminItemGenerationSchema } from '@shared/contracts/adminItemGeneration';
import { RewardItemSchema } from '@shared/contracts/adminRewards';
import { generateForgedEquipment } from '@shared/engine/combat-v6/equipment/forging';
import { buildSpiritFruitSpec } from '@shared/engine/spirit-field/spiritFruit';
import { TALISMAN_SCENARIO_OPTIONS } from '@daoyou/shared/config/talismanScenarios';
import { AdminItemGenerationSchema } from '@daoyou/shared/contracts/adminItemGeneration';
import { RewardItemSchema } from '@daoyou/shared/contracts/adminRewards';
import { generateForgedEquipment } from '@daoyou/shared/engine/combat-v6/equipment/forging';
import { buildSpiritFruitSpec } from '@daoyou/shared/engine/spirit-field/spiritFruit';
import {
normalizeAlchemyEffectRoute,
resolveAlchemyEffects,
} from '@shared/lib/alchemyEffectResolver';
import { getAlchemyPropertyFamily } from '@shared/lib/alchemyProperties';
import type { ConsumableSpec } from '@shared/types/consumable';
import { Hono } from 'hono';
} from '@daoyou/shared/lib/alchemyEffectResolver';
import { getAlchemyPropertyFamily } from '@daoyou/shared/lib/alchemyProperties';
import type { ConsumableSpec } from '@daoyou/shared/types/consumable';
import { randomInt, randomUUID } from 'node:crypto';
import { z } from 'zod';
const router = new Hono<AppEnv>();
router.post(
'/generate',
requireAdmin(),
validateJson(AdminItemGenerationSchema),
async (c) => {
const input =
getValidatedJson<z.infer<typeof AdminItemGenerationSchema>>(c);
@Injectable()
export class AdminRewardItemsService {
generate(input: z.infer<typeof AdminItemGenerationSchema>) {
if (input.kind === 'equipment') {
const result = generateForgedEquipment({
...input,
@@ -37,17 +25,17 @@ router.post(
boosts: { ore: 0, essence: 0, attributes: 0 },
});
if (!result.ok)
return c.json(
throw new HttpException(
{ error: result.diagnostics[0]?.message ?? '生成道装失败' },
400,
);
return c.json({
return {
item: RewardItemSchema.parse({
definitionId: 'equipment.v6',
quantity: 1,
instanceData: result.instance,
}),
});
};
}
let spec: ConsumableSpec;
let name: string;
@@ -72,7 +60,7 @@ router.post(
})),
});
if (route.effects.length !== input.effects.length)
return c.json(
throw new HttpException(
{ error: '药效不能重复,灵兽修为不能与人物药效混用' },
400,
);
@@ -101,7 +89,7 @@ router.post(
};
name = input.name;
}
return c.json({
return {
item: RewardItemSchema.parse({
definitionId: 'consumable.v1',
quantity: 1,
@@ -117,7 +105,6 @@ router.post(
spec,
},
}),
});
},
);
export default router;
};
}
}
@@ -0,0 +1,54 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Put,
UseFilters,
} from '@nestjs/common';
import type { AuthUser } from '@server/lib/auth/types';
import { Access, CurrentUser } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { AdminErrors } from './admin-errors';
import { AdminSectShopService } from './sect-shop.service';
@Controller('api/admin/sect-shop')
@Access('admin')
@UseFilters(AdminErrors)
export class AdminSectShopController {
constructor(
@Inject(AdminSectShopService) private readonly shop: AdminSectShopService,
) {}
@Get()
list(@FirstQuery() query: Record<string, string | undefined>) {
return this.shop.list(query.status);
}
@Post()
@HttpCode(200)
create(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.shop.create(user.id, body);
}
@Put(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.shop.update(user.id, id, body);
}
@Post(':id/archive')
@HttpCode(200)
archive(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.shop.archive(user.id, id);
}
}
+76
View File
@@ -0,0 +1,76 @@
import { HttpException, Injectable } from '@nestjs/common';
import {
archiveSectShopItem,
createSectShopItem,
listSectShopItems,
SectShopError,
updateSectShopItem,
} from '@server/lib/services/SectShopService';
import {
SectShopItemMutationSchema,
SectShopListQuerySchema,
} from '@daoyou/shared/contracts/sectShop';
@Injectable()
export class AdminSectShopService {
async list(status?: string) {
const parsed = SectShopListQuerySchema.safeParse({
status: status || undefined,
});
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
return { items: await listSectShopItems({ status: parsed.data.status }) };
}
async create(userId: string, body: unknown) {
const parsed = SectShopItemMutationSchema.safeParse(body);
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
try {
const item = await createSectShopItem({ input: parsed.data, userId });
return { success: true, item };
} catch (error) {
if (error instanceof SectShopError)
throw new HttpException({ error: error.message }, error.status);
throw new HttpException(
{ error: error instanceof Error ? error.message : '创建商品失败' },
400,
);
}
}
async update(userId: string, id: string, body: unknown) {
const parsed = SectShopItemMutationSchema.safeParse(body);
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
// Keep the missing-item response outside the legacy service-error catch.
let item;
try {
item = await updateSectShopItem({ id, input: parsed.data, userId });
} catch (error) {
if (error instanceof SectShopError)
throw new HttpException({ error: error.message }, error.status);
throw new HttpException(
{ error: error instanceof Error ? error.message : '更新商品失败' },
400,
);
}
if (!item) throw new HttpException({ error: '商品不存在' }, 404);
return { success: true, item };
}
async archive(userId: string, id: string) {
const item = await archiveSectShopItem({ id, userId });
if (!item) throw new HttpException({ error: '商品不存在' }, 404);
return { success: true, item };
}
}
+65
View File
@@ -0,0 +1,65 @@
import { HttpException, Injectable } from '@nestjs/common';
import {
getAppSetting,
getAuthPageAnnouncement,
getResolvedCommunityQqGroupNumber,
upsertAppSetting,
} from '@server/lib/repositories/appSettingsRepository';
import { APP_SETTING_KEYS } from '@daoyou/shared/lib/constants/appSettings';
import { z } from 'zod';
const AnnouncementSchema = z.object({ announcement: z.string() });
const CommunitySchema = z.object({
groupNumber: z
.string()
.trim()
.regex(/^\d{5,12}$/, '请输入 5 到 12 位数字 QQ 群号'),
});
@Injectable()
export class SettingsService {
async announcement() {
return { announcement: (await getAuthPageAnnouncement()) ?? '' };
}
async updateAnnouncement(userId: string, body: unknown) {
const parsed = AnnouncementSchema.safeParse(body);
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
const announcement = parsed.data.announcement.trim();
await upsertAppSetting({
key: APP_SETTING_KEYS.authPageAnnouncement,
value: announcement,
updatedBy: userId,
});
return { ok: true, announcement };
}
async community() {
const resolved = await getResolvedCommunityQqGroupNumber();
const stored = await getAppSetting(APP_SETTING_KEYS.communityQqGroupNumber);
return {
groupNumber: resolved,
customized: Boolean(stored),
storedGroupNumber: stored,
};
}
async updateCommunity(userId: string, body: unknown) {
const parsed = CommunitySchema.safeParse(body);
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
await upsertAppSetting({
key: APP_SETTING_KEYS.communityQqGroupNumber,
value: parsed.data.groupNumber,
updatedBy: userId,
});
return { ok: true, groupNumber: await getResolvedCommunityQqGroupNumber() };
}
}
@@ -0,0 +1,78 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Put,
UseFilters,
} from '@nestjs/common';
import type { AuthUser } from '@server/lib/auth/types';
import { Access, CurrentUser } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { AdminErrors } from './admin-errors';
import { AdminSponsorshipService } from './sponsorship.service';
@Controller('api/admin/sponsorship')
@Access('admin')
@UseFilters(AdminErrors)
export class AdminSponsorshipController {
constructor(
@Inject(AdminSponsorshipService)
private readonly service: AdminSponsorshipService,
) {}
@Get('config')
config() {
return this.service.config();
}
@Put('config')
updateConfig(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.service.updateConfig(user.id, body);
}
@Post('ping')
@HttpCode(200)
ping() {
return this.service.ping();
}
@Get('orders')
orders(@FirstQuery() query: Record<string, string | undefined>) {
return this.service.orders(query);
}
@Get('orders/:id')
detail(@Param('id') id: string) {
return this.service.detail(id);
}
@Post('orders/:id/retry')
@HttpCode(200)
retry(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.retry(user.id, id);
}
@Post('orders/:id/revoke')
@HttpCode(200)
revoke(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.revoke(user.id, id);
}
@Post('orders/:id/rotate-claim')
@HttpCode(200)
rotateClaim(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.rotateClaim(user.id, id);
}
@Post('snapshots/:id/reveal')
@HttpCode(200)
reveal(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.service.reveal(user.id, id);
}
@Post('manual-grants')
@HttpCode(201)
grant(
@CurrentUser() user: AuthUser,
@JsonBody({ fallback: null }) body: unknown,
) {
return this.service.grant(user.id, body);
}
}
+193
View File
@@ -0,0 +1,193 @@
import { HttpException, Injectable } from '@nestjs/common';
import { getAfdianSponsorshipConfig } from '@server/lib/repositories/appSettingsRepository';
import {
getSponsorshipOrderForAdmin,
grantManualSponsorshipMerit,
listSponsorshipOrdersForAdmin,
retrySponsorshipOrderAsAdmin,
revealSponsorshipSnapshot,
revokeSponsorshipOrderAsAdmin,
rotateSponsorshipClaimAsAdmin,
SponsorshipApplicationError,
updateSponsorshipConfigAsAdmin,
} from '@server/lib/services/SponsorshipApplicationService';
import { requireSponsorshipProvider } from '@server/lib/sponsorship/providerRegistry';
import {
AfdianSponsorshipConfigSchema,
SPONSORSHIP_TIER_IDS,
} from '@daoyou/shared/lib/sponsorship';
import { z } from 'zod';
const TierConfigSchema = z
.object({
planId: z.string().trim().max(80),
minimumAmountFen: z.number().int().min(1).max(100000000),
})
.strict();
const ConfigUpdateSchema = z
.object({
tiers: z.object({
faint_light: TierConfigSchema,
fellow_traveler: TierConfigSchema,
night_guardian: TierConfigSchema,
immortality_witness: TierConfigSchema,
}),
})
.strict();
const ManualGrantSchema = z
.object({
cultivatorId: z.uuid(),
tier: z.enum(SPONSORSHIP_TIER_IDS),
supportedAt: z.string().datetime().optional(),
publicListing: z.boolean().default(true),
sendMail: z.boolean().default(true),
})
.strict();
function sponsorshipErrorResponse(error: unknown) {
if (error instanceof SponsorshipApplicationError) {
throw new HttpException(
{ error: error.message, code: error.code },
error.status,
);
}
throw error;
}
@Injectable()
export class AdminSponsorshipService {
async config() {
return await getAfdianSponsorshipConfig();
}
async updateConfig(userId: string, inputBody: unknown) {
const parsed = ConfigUpdateSchema.safeParse(inputBody);
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
const current = await getAfdianSponsorshipConfig();
const acceptingCheckout = Object.values(parsed.data.tiers).some(
(tier) => tier.planId.length > 0,
);
const ordersAcceptedAfter = acceptingCheckout
? current.acceptingCheckout && current.ordersAcceptedAfter
? current.ordersAcceptedAfter
: new Date().toISOString()
: current.ordersAcceptedAfter;
const nextConfig = AfdianSponsorshipConfigSchema.safeParse({
...current,
acceptingCheckout,
ordersAcceptedAfter,
tiers: parsed.data.tiers,
});
if (!nextConfig.success) {
throw new HttpException(
{ error: '档位配置错误', details: nextConfig.error.flatten() },
400,
);
}
await updateSponsorshipConfigAsAdmin({
config: nextConfig.data,
adminUserId: userId,
});
return { success: true };
}
async ping() {
try {
await requireSponsorshipProvider().ping();
return { success: true };
} catch {
throw new HttpException(
{ error: '爱发电连接测试失败,请检查服务端配置' },
502,
);
}
}
async orders(query: Record<string, string | undefined>) {
const parsed = z
.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
filter: z
.enum(['all', 'attention', 'awaiting_claim', 'fulfilled', 'revoked'])
.default('all'),
})
.safeParse(query);
if (!parsed.success)
throw new HttpException({ error: '查询参数错误' }, 400);
const result = await listSponsorshipOrdersForAdmin(parsed.data);
return {
orders: result.items,
total: result.total,
page: result.page,
pageSize: result.pageSize,
};
}
async detail(idParam: string) {
const id = z.uuid().safeParse(idParam);
if (!id.success) throw new HttpException({ error: '订单不存在' }, 404);
const detail = await getSponsorshipOrderForAdmin(id.data);
if (!detail) throw new HttpException({ error: '订单不存在' }, 404);
return detail;
}
async retry(userId: string, idParam: string) {
const id = z.uuid().safeParse(idParam);
if (!id.success) throw new HttpException({ error: '订单不存在' }, 404);
try {
await retrySponsorshipOrderAsAdmin(id.data, userId);
return { success: true };
} catch (error) {
return sponsorshipErrorResponse(error);
}
}
async revoke(userId: string, idParam: string) {
const id = z.uuid().safeParse(idParam);
if (!id.success) throw new HttpException({ error: '订单不存在' }, 404);
try {
await revokeSponsorshipOrderAsAdmin(id.data, userId);
return { success: true };
} catch (error) {
return sponsorshipErrorResponse(error);
}
}
async rotateClaim(userId: string, idParam: string) {
const id = z.uuid().safeParse(idParam);
if (!id.success) throw new HttpException({ error: '订单不存在' }, 404);
try {
await rotateSponsorshipClaimAsAdmin(id.data, userId);
return { success: true };
} catch (error) {
return sponsorshipErrorResponse(error);
}
}
async reveal(userId: string, idParam: string) {
const id = z.uuid().safeParse(idParam);
if (!id.success) throw new HttpException({ error: '快照不存在' }, 404);
const snapshot = await revealSponsorshipSnapshot({
snapshotId: id.data,
adminUserId: userId,
});
if (snapshot === null)
throw new HttpException({ error: '快照不存在' }, 404);
return { snapshot };
}
async grant(userId: string, inputBody: unknown) {
const parsed = ManualGrantSchema.safeParse(inputBody);
if (!parsed.success)
throw new HttpException(
{ error: '参数错误', details: parsed.error.flatten() },
400,
);
try {
await grantManualSponsorshipMerit({
...parsed.data,
supportedAt: parsed.data.supportedAt
? new Date(parsed.data.supportedAt)
: new Date(),
adminUserId: userId,
});
return { success: true };
} catch (error) {
return sponsorshipErrorResponse(error);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import { SystemMailInputSchema } from '@daoyou/shared/contracts/systemMail';
import { z } from 'zod';
export const CreateSchema = z
.object({ requestId: z.uuid(), input: SystemMailInputSchema })
.strict();
export const UpdateSchema = z
.object({
revision: z.number().int().positive(),
input: SystemMailInputSchema,
})
.strict();
export const RevisionSchema = z
.object({ revision: z.number().int().positive() })
.strict();
export const ListSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
search: z.string().trim().max(200).default(''),
});
@@ -0,0 +1,91 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Put,
UseFilters,
} from '@nestjs/common';
import type { AuthUser } from '@server/lib/auth/types';
import type { z } from 'zod';
import { Access, CurrentUser } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { AdminErrors } from './admin-errors';
import {
CreateSchema,
ListSchema,
RevisionSchema,
UpdateSchema,
} from './system-mails-input';
import { AdminSystemMailsService } from './system-mails.service';
@Controller('api/admin/system-mails')
@Access('admin')
@UseFilters(AdminErrors)
export class AdminSystemMailsController {
constructor(
@Inject(AdminSystemMailsService)
private readonly service: AdminSystemMailsService,
) {}
@Get()
list(
@FirstQuery(new ZodPipe(ListSchema, 'legacy-unhandled'))
query: z.infer<typeof ListSchema>,
) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(id);
}
@Post()
@HttpCode(200)
create(
@CurrentUser() user: AuthUser,
@JsonBody(
{ fallback: undefined },
new ZodPipe(CreateSchema, 'legacy-unhandled'),
)
input: z.infer<typeof CreateSchema>,
) {
return this.service.create(user.id, input);
}
@Put(':id')
update(
@Param('id') id: string,
@JsonBody(
{ fallback: undefined },
new ZodPipe(UpdateSchema, 'legacy-unhandled'),
)
input: z.infer<typeof UpdateSchema>,
) {
return this.service.update(id, input);
}
@Post(':id/publish')
@HttpCode(200)
publish(
@Param('id') id: string,
@JsonBody(
{ fallback: undefined },
new ZodPipe(RevisionSchema, 'legacy-unhandled'),
)
input: z.infer<typeof RevisionSchema>,
) {
return this.service.publish(id, input);
}
@Post(':id/stop')
@HttpCode(200)
stop(
@Param('id') id: string,
@JsonBody(
{ fallback: undefined },
new ZodPipe(RevisionSchema, 'legacy-unhandled'),
)
input: z.infer<typeof RevisionSchema>,
) {
return this.service.stop(id, input);
}
}
+198
View File
@@ -0,0 +1,198 @@
import { HttpException, Injectable } from '@nestjs/common';
import { db } from '@server/lib/drizzle/db';
import { mails, systemMailCampaigns } from '@server/lib/drizzle/schema';
import { SystemMailInputSchema } from '@daoyou/shared/contracts/systemMail';
import { and, desc, eq, ilike, sql } from 'drizzle-orm';
import { createHash } from 'node:crypto';
import { z } from 'zod';
import {
CreateSchema,
ListSchema,
RevisionSchema,
UpdateSchema,
} from './system-mails-input';
const countDeliveries = sql<number>`(select count(*)::int from ${mails} delivered where delivered.system_mail_campaign_id = ${systemMailCampaigns}.id)`;
function values(input: z.infer<typeof SystemMailInputSchema>) {
return {
...input,
startsAt: new Date(input.startsAt),
endsAt: new Date(input.endsAt),
};
}
@Injectable()
export class AdminSystemMailsService {
async list(query: z.infer<typeof ListSchema>) {
const { page, search } = query;
const rows = await db
.select({
id: systemMailCampaigns.id,
title: systemMailCampaigns.title,
status: systemMailCampaigns.status,
revision: systemMailCampaigns.revision,
conditions: systemMailCampaigns.conditions,
startsAt: systemMailCampaigns.startsAt,
endsAt: systemMailCampaigns.endsAt,
createdAt: systemMailCampaigns.createdAt,
publishedAt: systemMailCampaigns.publishedAt,
deliveredCount: countDeliveries,
})
.from(systemMailCampaigns)
.where(
search ? ilike(systemMailCampaigns.title, `%${search}%`) : undefined,
)
.orderBy(
desc(systemMailCampaigns.createdAt),
desc(systemMailCampaigns.id),
)
.limit(21)
.offset((page - 1) * 20);
return { items: rows.slice(0, 20), hasMore: rows.length > 20 };
}
async detail(idParam: string) {
const id = z.uuid().parse(idParam);
const [row] = await db
.select()
.from(systemMailCampaigns)
.where(eq(systemMailCampaigns.id, id));
if (!row) throw new HttpException({ error: '邮件发布记录不存在' }, 404);
const [count] = await db
.select({ count: sql<number>`count(*)::int` })
.from(mails)
.where(eq(mails.systemMailCampaignId, id));
return {
id: row.id,
title: row.title,
content: row.content,
rewardSelections: row.rewardSelections,
conditions: row.conditions,
startsAt: row.startsAt,
endsAt: row.endsAt,
status: row.status,
revision: row.revision,
createdAt: row.createdAt,
publishedAt: row.publishedAt,
deliveredCount: count?.count ?? 0,
};
}
async create(userId: string, request: z.infer<typeof CreateSchema>) {
const { requestId, input } = request;
const creationFingerprint = createHash('sha256')
.update(JSON.stringify(input))
.digest('hex');
await db
.insert(systemMailCampaigns)
.values({
...values(input),
id: requestId,
creationFingerprint,
createdBy: userId,
})
.onConflictDoNothing({ target: systemMailCampaigns.id });
const [row] = await db
.select()
.from(systemMailCampaigns)
.where(eq(systemMailCampaigns.id, requestId));
if (!row || row.creationFingerprint !== creationFingerprint)
throw new HttpException(
{ error: '此请求已用于其他内容,请重新打开编辑器' },
409,
);
return { id: row.id, revision: row.revision, status: row.status };
}
async update(idParam: string, request: z.infer<typeof UpdateSchema>) {
const id = z.uuid().parse(idParam);
const { revision, input } = request;
const [row] = await db
.update(systemMailCampaigns)
.set({ ...values(input), revision: revision + 1 })
.where(
and(
eq(systemMailCampaigns.id, id),
eq(systemMailCampaigns.status, 'draft'),
eq(systemMailCampaigns.revision, revision),
),
)
.returning({
id: systemMailCampaigns.id,
revision: systemMailCampaigns.revision,
});
if (!row)
throw new HttpException(
{ error: '草稿已变化或已发布,请关闭后重新打开' },
409,
);
return row;
}
async publish(idParam: string, input: z.infer<typeof RevisionSchema>) {
const id = z.uuid().parse(idParam);
const { revision } = input;
const result = await db.transaction(async (tx) => {
const [row] = await tx
.select()
.from(systemMailCampaigns)
.where(eq(systemMailCampaigns.id, id))
.for('update');
if (!row) return 'missing';
if (row.status === 'published' && row.revision === revision + 1)
return 'ok';
if (row.status !== 'draft' || row.revision !== revision) return 'changed';
if (row.endsAt.getTime() <= Date.now()) return 'expired';
SystemMailInputSchema.parse({
title: row.title,
content: row.content,
rewardSelections: row.rewardSelections,
conditions: row.conditions,
startsAt: row.startsAt.toISOString(),
endsAt: row.endsAt.toISOString(),
});
await tx
.update(systemMailCampaigns)
.set({
status: 'published',
publishedAt: new Date(),
revision: revision + 1,
})
.where(eq(systemMailCampaigns.id, id));
return 'ok';
});
if (result === 'missing')
throw new HttpException({ error: '邮件发布记录不存在' }, 404);
if (result !== 'ok')
throw new HttpException(
{
error:
result === 'expired'
? '投递结束时间已过,请修改草稿'
: '草稿已变化,请重新打开核对',
},
409,
);
return { success: true };
}
async stop(idParam: string, input: z.infer<typeof RevisionSchema>) {
const id = z.uuid().parse(idParam);
const { revision } = input;
// UPDATE conflicts with the consumer's SHARE lock: after this commits,
// no new delivery transaction can observe the campaign as published.
const [row] = await db
.update(systemMailCampaigns)
.set({ status: 'stopped', revision: revision + 1 })
.where(
and(
eq(systemMailCampaigns.id, id),
eq(systemMailCampaigns.status, 'published'),
eq(systemMailCampaigns.revision, revision),
),
)
.returning({ id: systemMailCampaigns.id });
if (!row) {
const [current] = await db
.select({ status: systemMailCampaigns.status })
.from(systemMailCampaigns)
.where(eq(systemMailCampaigns.id, id));
if (current?.status !== 'stopped')
throw new HttpException({ error: '发布记录已变化,请重新打开' }, 409);
}
return { success: true };
}
}
+25
View File
@@ -0,0 +1,25 @@
import {
TOWER_ELIGIBLE_REALMS,
TOWER_MAX_FLOOR,
TOWER_MIN_REALM,
} from '@daoyou/shared/lib/tower/helpers';
import { REALM_VALUES } from '@daoyou/shared/types/constants';
import { z } from 'zod';
const SeasonKeySchema = z
.string()
.regex(/^\d{4}-W(?:0[1-9]|[1-4]\d|5[0-3])@Asia\/Shanghai$/);
export const TowerQuerySchema = z.object({
seasonKey: SeasonKeySchema.optional(),
realm: z
.enum(REALM_VALUES)
.refine((realm) => TOWER_ELIGIBLE_REALMS.includes(realm), '蜃楼境界未开放')
.default(TOWER_MIN_REALM),
floor: z.coerce.number().int().min(1).max(TOWER_MAX_FLOOR).default(1),
});
export const TowerRegenerateSchema = z.strictObject({
seasonKey: SeasonKeySchema,
expectedFingerprint: z
.string()
.regex(/^[a-f0-9]{64}$/)
.nullable(),
});
+50
View File
@@ -0,0 +1,50 @@
import {
Controller,
Get,
HttpCode,
Inject,
Post,
Res,
UseFilters,
} from '@nestjs/common';
import type { Response } from 'express';
import type { z } from 'zod';
import { Access } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { AdminErrors } from './admin-errors';
import { TowerQuerySchema, TowerRegenerateSchema } from './tower-input';
import { AdminTowerService } from './tower.service';
@Controller('api/admin/tower-enemy-sets')
@Access('admin')
@UseFilters(AdminErrors)
export class AdminTowerController {
constructor(
@Inject(AdminTowerService) private readonly tower: AdminTowerService,
) {}
@Get()
async read(
@FirstQuery(new ZodPipe(TowerQuerySchema, 'legacy-unhandled'))
query: z.infer<typeof TowerQuerySchema>,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.tower.read(query);
response.setHeader('Cache-Control', 'no-store');
return result;
}
@Post('regenerate')
@HttpCode(200)
regenerate(
@JsonBody(
{ fallback: undefined },
new ZodPipe(TowerRegenerateSchema, 'legacy-unhandled'),
)
input: z.infer<typeof TowerRegenerateSchema>,
) {
return this.tower.regenerate(input);
}
}
+76
View File
@@ -0,0 +1,76 @@
import { HttpException, Injectable } from '@nestjs/common';
import {
listTowerPublishedWeeks,
parseTowerWeekRecord,
readTowerWeekRecord,
regenerateTowerWeek,
towerWeekFingerprint,
} from '@server/lib/repositories/towerRepository';
import type { AdminTowerView } from '@daoyou/shared/contracts/adminTower';
import {
publishedTowerEncounter,
publishedTowerPreviews,
} from '@daoyou/shared/engine/combat-v6/tower/published';
import {
getNextTowerSeasonMeta,
getTowerSeasonMeta,
} from '@daoyou/shared/lib/tower/season';
import { z } from 'zod';
import { TowerQuerySchema, TowerRegenerateSchema } from './tower-input';
@Injectable()
export class AdminTowerService {
async read(query: z.infer<typeof TowerQuerySchema>) {
const currentSeason = getTowerSeasonMeta();
const nextSeason = getNextTowerSeasonMeta(
new Date(currentSeason.seasonStartedAt),
);
const seasonKey = query.seasonKey ?? currentSeason.seasonKey;
const [weeks, row] = await Promise.all([
listTowerPublishedWeeks(),
readTowerWeekRecord(seasonKey),
]);
// Read and validate this exact snapshot, keeping its fingerprint and display consistent.
const pack = parseTowerWeekRecord(row);
const summarize = (item: (typeof weeks)[number]) => ({
seasonKey: item.seasonKey,
schemaVersion: item.schemaVersion,
contentVersion: item.contentVersion,
generatorVersion: item.generatorVersion,
publishedAt: item.createdAt.toISOString(),
});
const data: AdminTowerView = {
currentSeason,
nextSeason,
seasonKey,
realm: query.realm,
floor: query.floor,
weeks: weeks.map(summarize),
fingerprint: towerWeekFingerprint(row),
published: row ? summarize(row) : null,
configuration: pack
? {
season: pack.season,
previews: publishedTowerPreviews(pack),
encounter: publishedTowerEncounter(pack, query.realm, query.floor),
}
: null,
};
return { success: true, data };
}
async regenerate(input: z.infer<typeof TowerRegenerateSchema>) {
const current = getTowerSeasonMeta();
const next = getNextTowerSeasonMeta(new Date(current.seasonStartedAt));
const season = [current, next].find(
(item) => item.seasonKey === input.seasonKey,
);
if (!season)
throw new HttpException({ error: '仅可重新生成本周或下周配置' }, 400);
const replaced = await regenerateTowerWeek(
season,
input.expectedFingerprint,
);
if (!replaced)
throw new HttpException({ error: '周配置已变化,请刷新后重新生成' }, 409);
return { success: true };
}
}
@@ -0,0 +1,107 @@
import {
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Post,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { AlchemyServiceError } from '@server/lib/services/AlchemyServiceError';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { apiErrorFilter } from '../http/error-filter';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { AlchemyFormulasService } from './alchemy-formulas.service';
import {
DiscoveryConfirmSchema,
FormulaAnalyzeSchema,
FormulaIdParamSchema,
FormulaListQuerySchema,
} from './alchemy-input';
function formulaErrors(message: string, includeDetails = true) {
return apiErrorFilter((error) => {
if (error instanceof z.ZodError)
return Response.json(
{ error: error.issues[0]?.message || '请求参数格式错误' },
{ status: 400 },
);
if (error instanceof AlchemyServiceError)
return Response.json(
{
error: error.message,
...(includeDetails ? (error.details ?? {}) : {}),
},
{ status: error.status },
);
return Response.json({ error: message }, { status: 500 });
});
}
@Controller('api/alchemy')
@Access('active')
export class AlchemyFormulasController {
constructor(
@Inject(AlchemyFormulasService)
private readonly formulas: AlchemyFormulasService,
) {}
@Get('materials')
materials(@CurrentCultivator() actor: ActiveCultivatorRef) {
return this.formulas.materials(actor.cultivatorId);
}
@Get('formulas')
@UseFilters(formulaErrors('丹方列表读取失败,请稍后再试。', false))
list(
@CurrentCultivator() actor: ActiveCultivatorRef,
@FirstQuery() query: Record<string, string | undefined>,
) {
const input = FormulaListQuerySchema.parse({
page: query.page,
pageSize: query.pageSize,
search: query.search || undefined,
family: query.family || undefined,
});
return this.formulas.list(actor.cultivatorId, input);
}
@Delete('formulas/:formulaId')
@UseFilters(formulaErrors('丹方删除失败,请稍后再试。'))
remove(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param(new ZodPipe(FormulaIdParamSchema))
params: z.infer<typeof FormulaIdParamSchema>,
) {
return this.formulas.remove(actor.cultivatorId, params.formulaId);
}
@Post('formulas/:formulaId/analyze')
@HttpCode(200)
@UseFilters(formulaErrors('推演药路失败,请稍后再试。'))
analyze(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param(new ZodPipe(FormulaIdParamSchema))
params: z.infer<typeof FormulaIdParamSchema>,
@JsonBody(new ZodPipe(FormulaAnalyzeSchema))
input: z.infer<typeof FormulaAnalyzeSchema>,
) {
return this.formulas.analyze(actor.cultivatorId, params.formulaId, input);
}
@Post('formulas/discovery/confirm')
@HttpCode(200)
@UseFilters(formulaErrors('丹方确认失败,请稍后再试。'))
confirm(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(new ZodPipe(DiscoveryConfirmSchema))
input: z.infer<typeof DiscoveryConfirmSchema>,
) {
return this.formulas.confirm(actor.cultivatorId, input);
}
}
@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import {
assertAlchemyMaterialVersions,
readAlchemyMaterials,
} from '@server/lib/services/alchemy/AlchemyInventory';
import {
analyzeFormulaMaterials,
confirmDiscoveryCandidate,
deleteCultivatorFormula,
listCultivatorFormulasPage,
} from '@server/lib/services/AlchemyFormulaService';
import type { z } from 'zod';
import type {
DiscoveryConfirmSchema,
FormulaAnalyzeSchema,
FormulaListQuerySchema,
} from './alchemy-input';
@Injectable()
export class AlchemyFormulasService {
async materials(owner: string) {
return { success: true, data: await readAlchemyMaterials(owner) };
}
async list(owner: string, query: z.infer<typeof FormulaListQuerySchema>) {
const result = await listCultivatorFormulasPage(owner, query);
return {
success: true,
data: { formulas: result.formulas, pagination: result.pagination },
};
}
async remove(owner: string, formulaId: string) {
await deleteCultivatorFormula(owner, formulaId);
return { success: true, message: '丹方已删除' };
}
async analyze(
owner: string,
formulaId: string,
input: z.infer<typeof FormulaAnalyzeSchema>,
) {
await assertAlchemyMaterialVersions(
owner,
input.materialIds,
input.materialVersions,
);
return {
success: true,
data: await analyzeFormulaMaterials(
owner,
formulaId,
input.materialIds,
input.materialQuantities,
),
};
}
async confirm(owner: string, input: z.infer<typeof DiscoveryConfirmSchema>) {
return {
success: true,
data: await confirmDiscoveryCandidate(owner, input.token, input.accept),
};
}
}
+55
View File
@@ -0,0 +1,55 @@
import {
ALCHEMY_INPUT_CONSTRAINTS,
ALCHEMY_MAX_DOSE,
} from '@daoyou/shared/config/alchemyInput';
import { JournalRequestSchema } from '@daoyou/shared/contracts/playerJournal';
import { PILL_FAMILY_VALUES } from '@daoyou/shared/types/consumable';
import { z } from 'zod';
export const DiscoveryConfirmSchema = z.object({
token: z.string().uuid(),
accept: z.boolean(),
});
export const FormulaIdParamSchema = z.object({ formulaId: z.string().uuid() });
export const FormulaAnalyzeSchema = z.object({
materialIds: z.array(z.string()).min(1).max(6),
materialVersions: z.record(z.string(), z.string().max(10000)),
materialQuantities: z
.record(
z.string(),
z
.number()
.int()
.min(ALCHEMY_INPUT_CONSTRAINTS.minQuantityPerMaterial)
.max(ALCHEMY_MAX_DOSE),
)
.optional(),
});
export const FormulaListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(5).default(5),
search: z.string().trim().max(40).optional(),
family: z.enum(PILL_FAMILY_VALUES).optional(),
});
export const CraftSchema = z
.object({
craftType: z.literal('alchemy'),
alchemyMode: z.enum(['improvised', 'formula']).default('improvised'),
materialIds: z
.array(z.string().min(1).max(160))
.min(1)
.max(6)
.refine((ids) => new Set(ids).size === ids.length, '材料不能重复'),
materialVersions: z.record(z.string(), z.string().max(10000)),
materialQuantities: z
.record(z.string(), z.number().int().min(1).max(ALCHEMY_MAX_DOSE))
.optional(),
userPrompt: z.string().trim().max(300).optional(),
formulaId: z.uuid().optional(),
analysisId: z.uuid().optional(),
})
.strict();
export const CraftCommandSchema = CraftSchema.extend(
JournalRequestSchema.shape,
);
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AlchemyFormulasController } from './alchemy-formulas.controller';
import { AlchemyFormulasService } from './alchemy-formulas.service';
import { CraftController } from './craft.controller';
import { CraftService } from './craft.service';
@Module({
controllers: [AlchemyFormulasController, CraftController],
providers: [AlchemyFormulasService, CraftService],
})
export class AlchemyModule {}
+108
View File
@@ -0,0 +1,108 @@
import {
All,
Controller,
Get,
HttpCode,
HttpException,
Inject,
Post,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { redisLockErrorResponse } from '@server/lib/http/errors';
import { AlchemyServiceError } from '@server/lib/services/AlchemyServiceError';
import { PlayerCommandIdempotencyError } from '@server/lib/services/CommandExecutors';
import { CraftCommandError } from '@server/lib/services/CraftApplicationService';
import { QiServiceError } from '@server/lib/services/QiService';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { apiErrorFilter } from '../http/error-filter';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { CraftCommandSchema, CraftSchema } from './alchemy-input';
import { CraftService } from './craft.service';
const retired = new Set(['refine', 'create_skill', 'create_gongfa']);
const retiredMessage = '旧功法、神通及装备生产已停用,历史物品保留在洞府宝库';
const CraftErrors = apiErrorFilter((error) => {
const lock = redisLockErrorResponse(error);
if (lock) return lock;
if (error instanceof z.ZodError || error instanceof SyntaxError)
return Response.json(
{ success: false, error: '请求参数无效' },
{ status: 400 },
);
if (
error instanceof AlchemyServiceError ||
error instanceof CraftCommandError ||
error instanceof PlayerCommandIdempotencyError ||
error instanceof QiServiceError
)
return Response.json(
{ success: false, error: error.message },
{ status: error.status },
);
console.error('[alchemy] request failed', error);
return Response.json(
{ success: false, error: '炼丹请求失败,请重新核对材料' },
{ status: 500 },
);
});
@Controller('api/craft')
@Access('active')
@UseFilters(CraftErrors)
export class CraftController {
constructor(@Inject(CraftService) private readonly craft: CraftService) {}
@Get()
preview(
@CurrentCultivator() actor: ActiveCultivatorRef,
@FirstQuery() query: Record<string, string | undefined>,
) {
if (query.craftType && retired.has(query.craftType))
throw new HttpException({ error: retiredMessage }, 410);
const input = CraftSchema.parse({
craftType: query.craftType,
alchemyMode: query.alchemyMode,
materialIds: (query.materialIds ?? '').split(','),
materialQuantities: query.materialQuantities
? JSON.parse(query.materialQuantities)
: undefined,
formulaId: query.formulaId,
materialVersions: query.materialVersions
? JSON.parse(query.materialVersions)
: {},
});
return this.craft.preview(actor, input);
}
@Post()
@HttpCode(200)
execute(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody() body: unknown,
) {
if (
body &&
typeof body === 'object' &&
'craftType' in body &&
typeof body.craftType === 'string' &&
retired.has(body.craftType)
)
throw new HttpException({ error: retiredMessage }, 410);
return this.craft.execute(actor, CraftCommandSchema.parse(body));
}
@All('pending')
@HttpCode(410)
pending() {
return { error: retiredMessage };
}
@All('confirm')
@HttpCode(410)
confirm() {
return { error: retiredMessage };
}
}
+63
View File
@@ -0,0 +1,63 @@
import { HttpException, Injectable } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { assertAlchemyMaterialVersions } from '@server/lib/services/alchemy/AlchemyInventory';
import { previewFormulaCraft } from '@server/lib/services/AlchemyFormulaService';
import { previewAlchemySelection } from '@server/lib/services/alchemyServiceV2';
import { executeCraftCommand } from '@server/lib/services/CraftApplicationService';
import { readCraftReadinessFacts } from '@server/lib/services/cultivator/CultivatorFactsReader';
import { getPlayerPreHeavenFates } from '@server/lib/services/cultivator/CultivatorProfileRepository';
import { toPlayerStateMutationResponse } from '@server/lib/services/ResourceMutationResponse';
import type { z } from 'zod';
import type { CraftCommandSchema, CraftSchema } from './alchemy-input';
@Injectable()
export class CraftService {
async preview(
actor: ActiveCultivatorRef,
input: z.infer<typeof CraftSchema>,
) {
const owner = actor.cultivatorId;
await assertAlchemyMaterialVersions(
owner,
input.materialIds,
input.materialVersions,
);
const [facts, fates] = await Promise.all([
readCraftReadinessFacts(owner),
getPlayerPreHeavenFates(actor.userId, owner),
]);
if (input.alchemyMode === 'formula' && !input.formulaId)
throw new HttpException({ error: '请选择丹方' }, 400);
const data =
input.alchemyMode === 'formula'
? await previewFormulaCraft(
owner,
input.formulaId!,
input.materialIds,
facts.spiritStones,
fates ?? [],
input.materialQuantities,
)
: await previewAlchemySelection(
owner,
facts.spiritStones,
input.materialIds,
fates ?? [],
input.materialQuantities,
);
return { success: true, data };
}
async execute(
actor: ActiveCultivatorRef,
input: z.infer<typeof CraftCommandSchema>,
) {
return toPlayerStateMutationResponse(
await executeCraftCommand({
userId: actor.userId,
cultivatorId: actor.cultivatorId,
input,
}),
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import { getRuntimeEnvironment } from '@server/lib/config/environment';
import { ConfigurationModule } from './config/configuration.module';
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { allowsLocalDevTools } from '@daoyou/shared/config/deployment';
import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AlchemyModule } from './alchemy/alchemy.module';
import { ArenaModule } from './arena/arena.module';
import { AuctionModule } from './auction/auction.module';
import { AuthModule } from './auth/auth.module';
import { BeastsModule } from './beasts/beasts.module';
import { BlackMarketModule } from './black-market/black-market.module';
import { CombatModule } from './combat/combat.module';
import { CommunityModule } from './community/community.module';
import { CultivatorModule } from './cultivator/cultivator.module';
import { DevToolsModule } from './dev-tools/dev-tools.module';
import { DivinationModule } from './divination/divination.module';
import { DungeonModule } from './dungeon/dungeon.module';
import { EnemiesModule } from './enemies/enemies.module';
import { EnlightenmentModule } from './enlightenment/enlightenment.module';
import { FeedbackModule } from './feedback/feedback.module';
import { ForgingModule } from './forging/forging.module';
import { GenesisModule } from './genesis/genesis.module';
import { HealthModule } from './health/health.module';
import { ApiExceptionFilter } from './http/api-exception.filter';
import { NotFoundModule } from './http/not-found.module';
import { RequestContextInterceptor } from './http/request-context.interceptor';
import { HuntsModule } from './hunts/hunts.module';
import { InscriptionsModule } from './inscriptions/inscriptions.module';
import { InventoryModule } from './inventory/inventory.module';
import { LegacyItemsModule } from './legacy-items/legacy-items.module';
import { MailModule } from './mail/mail.module';
import { ManualsModule } from './manuals/manuals.module';
import { MarketModule } from './market/market.module';
import { PlayerModule } from './player/player.module';
import { RankingsModule } from './rankings/rankings.module';
import { RealtimeModule } from './realtime/realtime.module';
import { ReputationShopModule } from './reputation-shop/reputation-shop.module';
import { ReshapeModule } from './reshape/reshape.module';
import { RuntimeModule } from './runtime/runtime.module';
import { SectsModule } from './sects/sects.module';
import { SocialModule } from './social/social.module';
import { SpiritFieldModule } from './spirit-field/spirit-field.module';
import { SponsorshipModule } from './sponsorship/sponsorship.module';
import { StoryModule } from './story/story.module';
import { TasksModule } from './tasks/tasks.module';
import { TowerModule } from './tower/tower.module';
@Module({
imports: [
ConfigurationModule,
...(allowsLocalDevTools(getRuntimeEnvironment().APP_ENV, getRuntimeEnvironment().NODE_ENV)
? [DevToolsModule]
: []),
AuthModule,
SponsorshipModule,
GenesisModule,
ReshapeModule,
CommunityModule,
EnemiesModule,
RankingsModule,
AccountModule,
AdminModule,
AuctionModule,
BlackMarketModule,
MarketModule,
ReputationShopModule,
SpiritFieldModule,
AlchemyModule,
EnlightenmentModule,
ForgingModule,
InscriptionsModule,
ArenaModule,
HealthModule,
RuntimeModule,
DivinationModule,
RealtimeModule,
PlayerModule,
SocialModule,
StoryModule,
TasksModule,
MailModule,
CultivatorModule,
FeedbackModule,
CombatModule,
SectsModule,
BeastsModule,
InventoryModule,
LegacyItemsModule,
ManualsModule,
HuntsModule,
DungeonModule,
TowerModule,
// Namespace authorization fallbacks must follow every concrete route.
NotFoundModule,
],
providers: [
{ provide: APP_FILTER, useClass: ApiExceptionFilter },
{ provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor },
],
})
export class AppModule {}
@@ -0,0 +1,111 @@
import {
Controller,
Get,
Header,
HttpCode,
HttpException,
Inject,
Param,
Post,
Req,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { isAllowedRealtimeOrigin } from '@server/lib/http/realtimeOrigin';
import { ArenaV6Error } from '@server/lib/services/combat-v6/CombatV6ArenaService';
import {
ArenaV6SubmitSchema,
type ArenaV6Submit,
} from '@daoyou/shared/contracts/combatV6Arena';
import type { Request } from 'express';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { apiErrorFilter } from '../http/error-filter';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { ArenaBattlesService } from './arena-battles.service';
const ArenaErrors = apiErrorFilter((error) => {
if (error instanceof ArenaV6Error)
return Response.json(
{ success: false, error: error.message },
{ status: error.status },
);
if (error instanceof z.ZodError)
return Response.json(
{ success: false, error: '请求参数无效' },
{ status: 400 },
);
console.error('[arena-v6] request failed', error);
return Response.json(
{ success: false, error: '战斗请求失败,请刷新重试' },
{ status: 500 },
);
});
const CursorSchema = z.coerce.number().int().min(-1).optional();
@Controller('api/combat-v6/arena')
@Access('active')
@UseFilters(ArenaErrors)
export class ArenaBattlesController {
constructor(
@Inject(ArenaBattlesService) private readonly battles: ArenaBattlesService,
) {}
@Get([':battleId/socket', ':battleId/watch/socket'])
@HttpCode(404)
async withoutUpgrade(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('battleId') id: string,
@Req() request: Request,
) {
if (!isAllowedRealtimeOrigin(request.get('origin')))
throw new HttpException({ error: 'Origin forbidden' }, 403);
await this.battles.authorizeSocket(
actor,
z.uuid().parse(id),
request.path.endsWith('/watch/socket'),
);
return { success: false, error: '接口不存在' };
}
@Get(':battleId')
@Header('Cache-Control', 'private, no-store')
read(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('battleId', new ZodPipe(z.uuid())) id: string,
@FirstQuery('afterEventSeq', new ZodPipe(CursorSchema)) cursor?: number,
) {
return this.battles.read(actor, id, false, cursor);
}
@Get(':battleId/watch')
@Header('Cache-Control', 'private, no-store')
watch(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('battleId', new ZodPipe(z.uuid())) id: string,
@FirstQuery('afterEventSeq', new ZodPipe(CursorSchema)) cursor?: number,
) {
return this.battles.read(actor, id, true, cursor);
}
@Post(':battleId/commands')
@HttpCode(200)
submit(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('battleId', new ZodPipe(z.uuid())) id: string,
@JsonBody({ fallback: undefined }, new ZodPipe(ArenaV6SubmitSchema))
input: ArenaV6Submit,
) {
return this.battles.submit(actor, id, input);
}
@Get(':battleId/replay')
replay(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('battleId', new ZodPipe(z.uuid())) id: string,
) {
return this.battles.replay(actor, id);
}
}
@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import {
arenaReplayV6,
ownedArenaV6,
submitArenaV6,
watchedArenaV6,
} from '@server/lib/services/combat-v6/CombatV6ArenaService';
import { arenaView } from '@daoyou/shared/combat-v6/arena';
import type { ArenaV6Submit } from '@daoyou/shared/contracts/combatV6Arena';
@Injectable()
export class ArenaBattlesService {
async authorizeSocket(
actor: ActiveCultivatorRef,
id: string,
spectator: boolean,
) {
await (spectator ? watchedArenaV6 : ownedArenaV6)(id, actor);
}
async read(
actor: ActiveCultivatorRef,
id: string,
spectator: boolean,
cursor?: number,
) {
const { runtime, participant } = await (
spectator ? watchedArenaV6 : ownedArenaV6
)(id, actor);
const last = runtime.lastResults[participant.unitId];
const incremental =
last?.revision === runtime.revision &&
last.playback?.fromEventSeq === cursor;
const view = incremental
? {
...last,
serverNow: Date.now(),
events: last.events.filter((e) => e.seq > cursor!),
}
: arenaView(runtime, participant.unitId, Date.now());
const unchanged = cursor === view.latestEventSeq;
return {
success: true,
data: {
session: unchanged
? { ...view, events: [], playback: undefined }
: view,
full: !incremental && !unchanged,
},
};
}
async submit(actor: ActiveCultivatorRef, id: string, input: ArenaV6Submit) {
return { success: true, data: await submitArenaV6(id, actor, input) };
}
async replay(actor: ActiveCultivatorRef, id: string) {
return { success: true, data: await arenaReplayV6(id, actor) };
}
}
@@ -0,0 +1,261 @@
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { isAllowedRealtimeOrigin } from '@server/lib/http/realtimeOrigin';
import {
arenaSocketState,
subscribeArenaSpectators,
subscribeArenaV6,
} from '@server/lib/services/combat-v6/CombatV6ArenaBroadcast';
import {
ArenaV6Error,
ownedArenaV6,
watchedArenaV6,
} from '@server/lib/services/combat-v6/CombatV6ArenaService';
import { CombatV6ArenaStore } from '@server/lib/services/combat-v6/CombatV6ArenaStore';
import { fromNodeHeaders } from 'better-auth/node';
import type { IncomingMessage } from 'node:http';
import { WebSocket } from 'ws';
import { z } from 'zod';
import { SessionService } from '../auth/session.service';
import type { HandshakeError } from '../http/websocket-request';
import { arenaSocketRoute } from './arena-socket';
type ArenaConnection = {
actor: ActiveCultivatorRef;
battleId: string;
spectator: boolean;
};
@Injectable()
export class ArenaRealtimeService implements OnModuleDestroy {
private readonly pending = new WeakMap<IncomingMessage, ArenaConnection>();
private readonly counts = new Map<string, number>();
private readonly connections = new Map<WebSocket, () => void>();
private readonly work = new Set<Promise<unknown>>();
private stopping = false;
constructor(
@Inject(SessionService) private readonly sessions: SessionService,
@Inject(CombatV6ArenaStore) private readonly store: CombatV6ArenaStore,
) {}
private track<T>(promise: Promise<T>): Promise<T> {
this.work.add(promise);
void promise.then(
() => this.work.delete(promise),
() => this.work.delete(promise),
);
return promise;
}
verify(
request: IncomingMessage,
): Promise<
HandshakeError | { connection: ArenaConnection; headers?: Headers }
> {
return this.track(this.verifyConnection(request));
}
private async verifyConnection(
request: IncomingMessage,
): Promise<
HandshakeError | { connection: ArenaConnection; headers?: Headers }
> {
if (this.stopping) return { status: 503, message: 'Server closing' };
const headers = fromNodeHeaders(request.headers);
let responseHeaders: Headers | undefined;
try {
const session = await this.sessions.getSession(headers);
responseHeaders = session.headers;
const user = session.response?.user;
if (!user)
return { status: 401, message: '未授权访问', headers: responseHeaders };
const actor = await this.sessions.getActiveCultivator(user);
if (!actor)
return {
status: 404,
message: '当前没有活跃角色',
headers: responseHeaders,
};
if (!isAllowedRealtimeOrigin(headers.get('origin')))
return {
status: 403,
message: 'Origin forbidden',
body: { error: 'Origin forbidden' },
headers: responseHeaders,
};
const route = arenaSocketRoute(
new URL(request.url ?? '/', 'http://localhost').pathname,
);
if (!route)
return { status: 404, message: '接口不存在', headers: responseHeaders };
const battleId = z.uuid().parse(route.battleId);
await (route.spectator ? watchedArenaV6 : ownedArenaV6)(battleId, actor);
if (this.stopping || request.socket.destroyed)
return { status: 503, message: 'Connection closed' };
const connection = {
actor,
battleId,
spectator: route.spectator,
};
this.pending.set(request, connection);
return { connection, headers: responseHeaders };
} catch (error) {
if (error instanceof ArenaV6Error)
return {
status: error.status,
message: error.message,
headers: responseHeaders,
};
if (error instanceof z.ZodError)
return {
status: 400,
message: '请求参数无效',
headers: responseHeaders,
};
console.error('[arena-v6] handshake failed', error);
return {
status: 500,
message: '战斗请求失败,请刷新重试',
headers: responseHeaders,
};
}
}
connect(socket: WebSocket, request: IncomingMessage): void {
const identity = this.pending.get(request);
this.pending.delete(request);
if (!identity || this.stopping) {
socket.close(1011, 'reconnect');
return;
}
const { actor, battleId: id, spectator } = identity;
if ((this.counts.get(actor.userId) ?? 0) >= 4) {
socket.close(1008, 'too many connections');
return;
}
this.counts.set(actor.userId, (this.counts.get(actor.userId) ?? 0) + 1);
const connection = crypto.randomUUID();
const authorize = spectator ? watchedArenaV6 : ownedArenaV6;
let viewerId: string | undefined;
let dispose: (() => void) | undefined;
let heartbeat: ReturnType<typeof setInterval> | undefined;
let closed = false;
let lastPong = Date.now();
let lastRevision = -1;
let chain = Promise.resolve();
const cleanup = () => {
if (closed) return;
closed = true;
this.connections.delete(socket);
const count = (this.counts.get(actor.userId) ?? 1) - 1;
if (count) this.counts.set(actor.userId, count);
else this.counts.delete(actor.userId);
clearInterval(heartbeat);
dispose?.();
if (viewerId && !spectator)
void this.track(
this.store.disconnect(`${id}:${viewerId}`, connection),
).catch((error) => console.warn('[arena-v6] disconnect failed', error));
};
const close = (code: number, reason: string) => {
socket.close(code, reason);
cleanup();
};
const send = (message: string) => {
if (closed || socket.readyState !== WebSocket.OPEN) return;
if (socket.bufferedAmount > (spectator ? 512000 : 1_048_576)) {
close(4001, 'slow consumer');
return;
}
socket.send(message, (error) => {
if (error) close(1011, 'reconnect');
});
};
const enqueue = (action: () => Promise<void>) => {
chain = this.track(
chain
.then(async () => {
if (!closed) await action();
})
.catch(() => {
if (!closed) close(1011, 'reconnect');
}),
);
};
this.connections.set(socket, cleanup);
socket.once('close', cleanup);
socket.once('error', cleanup);
socket.on('message', (data, binary) => {
if (closed) return;
const message = data.toString();
if (binary || message.length > 128) {
close(1008, 'invalid message');
return;
}
if (message === 'pong') {
lastPong = Date.now();
if (viewerId && !spectator) {
const key = `${id}:${viewerId}`;
void this.track(
this.store.touch(key, connection).then(async () => {
if (closed) await this.store.disconnect(key, connection);
}),
).catch(() => close(1011, 'reconnect'));
}
}
});
const open = async () => {
const owned = await authorize(id, actor);
viewerId = owned.participant.unitId;
if (closed) return;
dispose = spectator
? await subscribeArenaSpectators(id, {
...actor,
send,
close: () => close(1008, 'spectator access ended'),
})
: await subscribeArenaV6(id, () =>
enqueue(async () => {
const { runtime } = await authorize(id, actor);
if (runtime.revision <= lastRevision) return;
lastRevision = runtime.revision;
send(JSON.stringify(arenaSocketState(runtime, viewerId!)));
}),
);
if (closed) {
dispose();
return;
}
if (!spectator) await this.store.touch(`${id}:${viewerId}`, connection);
if (closed) {
if (!spectator)
await this.store.disconnect(`${id}:${viewerId}`, connection);
return;
}
send(JSON.stringify({ type: 'ready', serverNow: Date.now() }));
if (closed) return;
heartbeat = setInterval(() => {
if (Date.now() - lastPong > 50000) {
close(4000, 'heartbeat timeout');
return;
}
send(JSON.stringify({ type: 'ping', serverNow: Date.now() }));
enqueue(async () => {
const { runtime } = await authorize(id, actor);
send(JSON.stringify({ type: 'resync', revision: runtime.revision }));
});
}, 25000);
};
void this.track(open().catch(() => close(1011, 'reconnect')));
}
async onModuleDestroy(): Promise<void> {
this.stopping = true;
for (const [socket, cleanup] of this.connections) {
cleanup();
socket.terminate();
}
while (this.work.size) await Promise.allSettled([...this.work]);
}
}
@@ -0,0 +1,135 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Res,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import {
ArenaCreateRoomSchema,
ArenaJoinRoomSchema,
ArenaReadyCommandSchema,
ArenaStartCommandSchema,
} from '@daoyou/shared/contracts/arena';
import type { Response } from 'express';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { ArenaRoomsService } from './arena-rooms.service';
const RoomIdSchema = z
.string()
.min(1)
.max(120)
.regex(/^arena-[A-Za-z0-9-]+$/);
const EmptySchema = z.object({}).strict();
@Controller('api/arena')
@Access('active')
export class ArenaRoomsController {
constructor(
@Inject(ArenaRoomsService) private readonly rooms: ArenaRoomsService,
) {}
@Get('room')
current(@CurrentCultivator() actor: ActiveCultivatorRef) {
return this.rooms.current(actor);
}
@Get('rooms/:roomId')
read(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('roomId', new ZodPipe(RoomIdSchema)) roomId: string,
) {
return this.rooms.read(actor, roomId);
}
@Post('rooms')
@HttpCode(201)
create(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody({ fallback: undefined }, new ZodPipe(ArenaCreateRoomSchema))
_input: z.infer<typeof ArenaCreateRoomSchema>,
) {
void _input;
return this.rooms.create(actor);
}
@Post('rooms/join')
@HttpCode(200)
join(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody({ fallback: undefined }, new ZodPipe(ArenaJoinRoomSchema))
input: z.infer<typeof ArenaJoinRoomSchema>,
) {
return this.rooms.join(actor, input);
}
@Post('rooms/:roomId/ready')
@HttpCode(200)
ready(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('roomId', new ZodPipe(RoomIdSchema)) roomId: string,
@JsonBody({ fallback: undefined }, new ZodPipe(ArenaReadyCommandSchema))
input: z.infer<typeof ArenaReadyCommandSchema>,
) {
return this.rooms.change(actor, roomId, {
kind: 'ready',
ready: input.ready,
});
}
@Post('rooms/:roomId/touch')
@HttpCode(200)
touch(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('roomId', new ZodPipe(RoomIdSchema)) roomId: string,
@JsonBody({ fallback: undefined }, new ZodPipe(EmptySchema))
_input: z.infer<typeof EmptySchema>,
) {
void _input;
return this.rooms.change(actor, roomId, { kind: 'touch' });
}
@Post('rooms/:roomId/switch-team')
@HttpCode(200)
switchTeam(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('roomId', new ZodPipe(RoomIdSchema)) roomId: string,
@JsonBody({ fallback: undefined }, new ZodPipe(EmptySchema))
_input: z.infer<typeof EmptySchema>,
) {
void _input;
return this.rooms.change(actor, roomId, { kind: 'switch-team' });
}
@Post('rooms/:roomId/start')
@HttpCode(200)
async start(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('roomId', new ZodPipe(RoomIdSchema)) roomId: string,
@JsonBody({ fallback: undefined }, new ZodPipe(ArenaStartCommandSchema))
input: z.infer<typeof ArenaStartCommandSchema>,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.rooms.start(actor, roomId, input.requestId);
response.status(result.status);
return result.body;
}
@Post('rooms/:roomId/leave')
@HttpCode(200)
leave(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('roomId', new ZodPipe(RoomIdSchema)) roomId: string,
@JsonBody({ fallback: undefined }, new ZodPipe(EmptySchema))
_input: z.infer<typeof EmptySchema>,
) {
void _input;
return this.rooms.leave(actor, roomId);
}
}
+214
View File
@@ -0,0 +1,214 @@
import { HttpException, Inject, Injectable } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { getExecutor } from '@server/lib/drizzle/db';
import { cultivators } from '@server/lib/drizzle/schema';
import { ArenaBattleStartOrchestrator } from '@server/lib/services/ArenaBattleStartOrchestrator';
import { ArenaRoomService } from '@server/lib/services/ArenaRoomService';
import { publishArenaRoomChanges } from '@server/lib/services/arenaRoomBroadcaster';
import { CombatV6BuildError } from '@server/lib/services/combat-v6/CombatV6BuildService';
import type { ArenaRoomV1 } from '@daoyou/shared/contracts/arena';
import { REALM_STAGE_VALUES, REALM_VALUES } from '@daoyou/shared/types/constants';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
function roomError(error: unknown): never {
if (error instanceof CombatV6BuildError)
throw new HttpException(
{ error: error.message, code: error.code },
error.status,
);
const message = error instanceof Error ? error.message : '擂台房间操作失败';
if (/不存在|过期|邀请码无效/.test(message))
throw new HttpException({ error: message }, 404);
if (
/已经|已满|不能|需要|准备|房主|不接受|不在|不可用|状态已变化/.test(message)
)
throw new HttpException({ error: message }, 409);
throw error;
}
function userIds(room: ArenaRoomV1) {
return room.teams.alpha
.concat(room.teams.beta, room.spectators ?? [])
.map((seat) => seat.userId);
}
function publish(room: ArenaRoomV1): void {
publishArenaRoomChanges(userIds(room), {
roomId: room.roomId,
revision: room.revision,
status: room.status,
room,
});
}
@Injectable()
export class ArenaRoomsService {
constructor(
@Inject(ArenaRoomService) private readonly rooms: ArenaRoomService,
@Inject(ArenaBattleStartOrchestrator)
private readonly starts: ArenaBattleStartOrchestrator,
) {}
private async identity(actor: ActiveCultivatorRef) {
const row = await getExecutor().query.cultivators.findFirst({
columns: {
id: true,
userId: true,
name: true,
realm: true,
realm_stage: true,
},
where: and(
eq(cultivators.id, actor.cultivatorId),
eq(cultivators.userId, actor.userId),
eq(cultivators.status, 'active'),
),
});
if (!row) throw new HttpException({ error: '当前没有可用的活跃角色' }, 404);
const realm = z.enum(REALM_VALUES).safeParse(row.realm);
const realmStage = z.enum(REALM_STAGE_VALUES).safeParse(row.realm_stage);
if (!realm.success || !realmStage.success)
throw new HttpException({ error: '当前角色境界数据无效' }, 500);
return {
userId: row.userId,
cultivatorId: row.id,
displayName: row.name,
realm: realm.data,
realmStage: realmStage.data,
};
}
private async memberRoom(
actor: ActiveCultivatorRef,
roomId: string,
message: string,
) {
const room = await this.rooms.getRoom(roomId);
if (!room)
throw new HttpException({ error: '擂台房间不存在或已过期' }, 404);
if (
!room.teams.alpha
.concat(room.teams.beta, room.spectators ?? [])
.some(
(seat) =>
seat.userId === actor.userId &&
seat.cultivatorId === actor.cultivatorId,
)
)
throw new HttpException({ error: message }, 403);
return room;
}
async current(actor: ActiveCultivatorRef) {
const identity = await this.identity(actor);
return {
room: await this.rooms.getRoomForCultivator(identity.cultivatorId),
};
}
async read(actor: ActiveCultivatorRef, roomId: string) {
await this.identity(actor);
return { room: await this.memberRoom(actor, roomId, '你不在此擂台房间中') };
}
async create(actor: ActiveCultivatorRef) {
const identity = await this.identity(actor);
try {
const room = await this.rooms.createRoom(identity);
publish(room);
return { room };
} catch (error) {
roomError(error);
}
}
async join(
actor: ActiveCultivatorRef,
input: { inviteCode: string; role: 'participant' | 'spectator' },
) {
const identity = await this.identity(actor);
try {
const room = await this.rooms.joinRoom({ ...identity, ...input });
publish(room);
return { room };
} catch (error) {
roomError(error);
}
}
async change(
actor: ActiveCultivatorRef,
roomId: string,
command:
{ kind: 'ready'; ready: boolean } | { kind: 'touch' | 'switch-team' },
) {
const identity = await this.identity(actor);
await this.memberRoom(actor, roomId, '当前活跃修士不在此擂台房间中');
try {
const room = await (command.kind === 'ready'
? this.rooms.setReady(roomId, identity.userId, command.ready)
: command.kind === 'touch'
? this.rooms.touch(roomId, identity.userId)
: this.rooms.switchTeam(roomId, identity.userId));
publish(room);
return { room };
} catch (error) {
roomError(error);
}
}
async start(actor: ActiveCultivatorRef, roomId: string, requestId: string) {
const identity = await this.identity(actor);
await this.memberRoom(actor, roomId, '当前活跃修士不在此擂台房间中');
try {
const result = await this.starts.start({
roomId,
hostUserId: identity.userId,
requestId,
});
publish(result.room);
return {
status: result.pending ? 202 : 200,
body: {
room: result.room,
pending: result.pending,
...(result.room.battleMatchId
? { battleMatchId: result.room.battleMatchId }
: {}),
},
};
} catch (error) {
const current = await this.rooms.getRoom(roomId).catch(() => null);
if (current) publish(current);
if (
error instanceof Error &&
/Battle matchmaker|Battle session|fetch failed|timed out/i.test(
error.message,
)
) {
console.error('[arena-start] battle orchestration failed', {
roomId,
error,
});
throw new HttpException(
{ error: '战斗服务暂不可用,开擂状态已保留' },
503,
);
}
roomError(error);
}
}
async leave(actor: ActiveCultivatorRef, roomId: string) {
const identity = await this.identity(actor);
await this.memberRoom(actor, roomId, '当前活跃修士不在此擂台房间中');
try {
const previous = await this.rooms.getRoom(roomId);
if (!previous)
throw new HttpException({ error: '擂台房间不存在或已过期' }, 404);
const room = await this.rooms.leave(roomId, identity.userId);
publishArenaRoomChanges(userIds(previous), {
roomId,
revision: room?.revision ?? previous.revision + 1,
status: room?.status ?? 'cancelled',
});
return { room: null };
} catch (error) {
roomError(error);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
export const ARENA_SOCKET_PATH = '/api/combat-v6/arena';
export function arenaSocketRoute(pathname: string) {
const match = /^\/api\/combat-v6\/arena\/([^/]+)\/(watch\/)?socket\/?$/.exec(
pathname,
);
return match ? { battleId: match[1], spectator: !!match[2] } : null;
}
+16
View File
@@ -0,0 +1,16 @@
import { Inject } from '@nestjs/common';
import { WebSocketGateway, type OnGatewayConnection } from '@nestjs/websockets';
import type { IncomingMessage } from 'node:http';
import type { WebSocket } from 'ws';
import { ArenaRealtimeService } from './arena-realtime.service';
import { ARENA_SOCKET_PATH } from './arena-socket';
@WebSocketGateway({ path: ARENA_SOCKET_PATH, maxPayload: 2048 })
export class ArenaGateway implements OnGatewayConnection {
constructor(
@Inject(ArenaRealtimeService) private readonly arena: ArenaRealtimeService,
) {}
handleConnection(socket: WebSocket, request: IncomingMessage): void {
this.arena.connect(socket, request);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { ArenaBattleStartOrchestrator } from '@server/lib/services/ArenaBattleStartOrchestrator';
import { ArenaRoomService } from '@server/lib/services/ArenaRoomService';
import { CombatV6ArenaStore } from '@server/lib/services/combat-v6/CombatV6ArenaStore';
import { AuthModule } from '../auth/auth.module';
import { ArenaBattlesController } from './arena-battles.controller';
import { ArenaBattlesService } from './arena-battles.service';
import { ArenaRealtimeService } from './arena-realtime.service';
import { ArenaRoomsController } from './arena-rooms.controller';
import { ArenaRoomsService } from './arena-rooms.service';
import { ArenaGateway } from './arena.gateway';
@Module({
imports: [AuthModule],
controllers: [ArenaRoomsController, ArenaBattlesController],
providers: [
{ provide: ArenaRoomService, useFactory: () => new ArenaRoomService() },
{ provide: CombatV6ArenaStore, useFactory: () => new CombatV6ArenaStore() },
{
provide: ArenaBattleStartOrchestrator,
useFactory: (rooms: ArenaRoomService) =>
new ArenaBattleStartOrchestrator(rooms),
inject: [ArenaRoomService],
},
ArenaRoomsService,
ArenaBattlesService,
ArenaRealtimeService,
ArenaGateway,
],
exports: [ArenaRealtimeService],
})
export class ArenaModule {}
+71
View File
@@ -0,0 +1,71 @@
import { redisLockErrorResponse } from '@server/lib/http/errors';
import { AuctionServiceError } from '@server/lib/services/AuctionService';
import { BeastError } from '@server/lib/services/combat-v6/BeastMutationGuard';
import { PlayerCommandIdempotencyError } from '@server/lib/services/CommandExecutors';
import { InventoryError } from '@server/lib/services/InventoryService';
import { z } from 'zod';
import { apiErrorFilter } from '../http/error-filter';
const statusMap: Record<string, number> = {
INSUFFICIENT_FUNDS: 400,
LISTING_NOT_FOUND: 404,
LISTING_EXPIRED: 400,
NOT_OWNER: 403,
MAX_LISTINGS: 400,
ITEM_NOT_FOUND: 404,
CONCURRENT_PURCHASE: 429,
INVALID_ITEM_TYPE: 400,
INVALID_PRICE: 400,
INVALID_QUANTITY: 400,
INVALID_ITEM_QUALITY: 400,
SAME_OWNER: 403,
INVALID_VISIBILITY: 400,
TARGET_NOT_FRIEND: 403,
MISSING_TALISMAN: 400,
NOT_TARGET_BUYER: 403,
};
export const AuctionListingsErrors = apiErrorFilter((error) => {
if (error instanceof z.ZodError)
return Response.json(
{ error: '参数错误', details: error.issues },
{ status: 400 },
);
console.error('Auction Listings API Error:', error);
return Response.json({ error: '获取拍卖列表失败' }, { status: 500 });
});
export function auctionMutationErrors(
operation: 'buy' | 'list' | 'list-beast' | 'cancel',
) {
return apiErrorFilter((error) => {
const lock = redisLockErrorResponse(error);
if (lock) return lock;
if (operation === 'buy' && error instanceof z.ZodError)
return Response.json(
{ error: '参数错误', details: error.issues },
{ status: 400 },
);
if (
error instanceof InventoryError ||
error instanceof PlayerCommandIdempotencyError ||
(operation === 'list-beast' && error instanceof BeastError)
)
return Response.json({ error: error.message }, { status: 409 });
if (error instanceof AuctionServiceError)
return Response.json(
{ error: error.message },
{ status: statusMap[error.code] || 400 },
);
const action =
operation === 'buy' ? 'Buy' : operation === 'cancel' ? 'Cancel' : 'List';
console.error(`Auction ${action} API Error:`, error);
const message =
operation === 'buy'
? '购买失败,请稍后重试'
: operation === 'cancel'
? '下架失败,请稍后重试'
: '上架失败,请稍后重试';
return Response.json({ error: message }, { status: 500 });
});
}
+18
View File
@@ -0,0 +1,18 @@
import { AUCTION_ITEM_TYPES } from '@daoyou/shared/contracts/auction';
import { QUALITY_VALUES } from '@daoyou/shared/types/constants';
import { z } from 'zod';
export const ListingsSchema = z.object({
scope: z.enum(['all', 'mine']).default('all'),
assetType: z.enum(['item', 'beast']).optional(),
itemType: z.enum(AUCTION_ITEM_TYPES).optional(),
itemCategory: z.string().trim().min(1).max(50).optional(),
itemQuality: z.enum(QUALITY_VALUES).optional(),
itemName: z.string().trim().min(1).max(200).optional(),
sellerName: z.string().trim().min(1).max(100).optional(),
minPrice: z.number().int().min(0).optional(),
maxPrice: z.number().int().min(0).optional(),
sortBy: z.enum(['price_asc', 'price_desc', 'latest']).optional(),
page: z.number().int().min(1).optional(),
limit: z.number().int().min(1).max(100).optional(),
});
+111
View File
@@ -0,0 +1,111 @@
import {
Controller,
Delete,
Get,
HttpCode,
HttpException,
Inject,
Param,
Post,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import {
AuctionBeastListSchema,
AuctionBuySchema,
AuctionListSchema,
} from '@daoyou/shared/contracts/auction';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { AuctionListingsErrors, auctionMutationErrors } from './auction-errors';
import { ListingsSchema } from './auction-input';
import { AuctionService } from './auction.service';
@Controller('api/auction')
@Access('active')
export class AuctionController {
constructor(
@Inject(AuctionService) private readonly auction: AuctionService,
) {}
@Get('listings')
@UseFilters(AuctionListingsErrors)
listings(
@CurrentCultivator() actor: ActiveCultivatorRef,
@FirstQuery() query: Record<string, string | undefined>,
) {
const params = ListingsSchema.parse({
scope: query.scope || undefined,
assetType: query.assetType || undefined,
itemType: query.itemType || undefined,
itemCategory: query.itemCategory || undefined,
itemQuality: query.itemQuality || undefined,
itemName: query.itemName || undefined,
sellerName: query.sellerName || undefined,
minPrice: query.minPrice ? Number(query.minPrice) : undefined,
maxPrice: query.maxPrice ? Number(query.maxPrice) : undefined,
sortBy: query.sortBy || undefined,
page: query.page ? Number(query.page) : undefined,
limit: query.limit ? Number(query.limit) : undefined,
});
return this.auction.listings(actor.cultivatorId, params);
}
@Post('buy')
@HttpCode(200)
@UseFilters(auctionMutationErrors('buy'))
buy(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(new ZodPipe(AuctionBuySchema))
input: z.infer<typeof AuctionBuySchema>,
) {
return this.auction.buy(actor, input);
}
@Post('list')
@HttpCode(200)
@UseFilters(auctionMutationErrors('list'))
list(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody({ fallback: undefined }) body: unknown,
) {
const request = AuctionListSchema.safeParse(body);
if (!request.success)
throw new HttpException(
{ error: '参数错误', details: request.error.issues },
400,
);
return this.auction.list(actor, request.data);
}
@Post('list-beast')
@HttpCode(200)
@UseFilters(auctionMutationErrors('list-beast'))
listBeast(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody({ fallback: undefined }) body: unknown,
) {
const request = AuctionBeastListSchema.safeParse(body);
if (!request.success)
throw new HttpException(
{ error: '参数错误', details: request.error.issues },
400,
);
return this.auction.listBeast(actor, request.data);
}
@Delete(':id')
@UseFilters(auctionMutationErrors('cancel'))
cancel(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('id') id: string,
) {
const parsed = z.uuid().safeParse(id);
if (!parsed.success)
throw new HttpException({ error: '货单标识无效' }, 400);
return this.auction.cancel(actor, parsed.data);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { AuctionController } from './auction.controller';
import { AuctionService } from './auction.service';
@Module({ controllers: [AuctionController], providers: [AuctionService] })
export class AuctionModule {}
+70
View File
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import * as auctionRepository from '@server/lib/repositories/auctionRepository';
import {
buyAuctionListing,
cancelAuctionListing,
listAuctionBeast,
listAuctionItem,
} from '@server/lib/services/AuctionApplicationService';
import { publicAuctionListing } from '@server/lib/services/AuctionService';
import { toPlayerStateMutationResponse } from '@server/lib/services/ResourceMutationResponse';
import type {
AuctionBeastListRequest,
AuctionBuySchema,
AuctionListRequest,
} from '@daoyou/shared/contracts/auction';
import type { z } from 'zod';
import type { ListingsSchema } from './auction-input';
@Injectable()
export class AuctionService {
async listings(owner: string, params: z.infer<typeof ListingsSchema>) {
if (params.assetType === 'beast' || params.itemType === 'beast')
params.itemQuality = undefined;
const result = await auctionRepository.findActiveListings({
...params,
viewerCultivatorId: owner,
});
const page = params.page || 1;
const limit = params.limit || 20;
const totalPages = Math.ceil(result.total / limit);
return {
listings: result.listings.map(publicAuctionListing),
pagination: {
page,
limit,
total: result.total,
totalPages,
hasMore: page < totalPages,
},
};
}
async buy(
actor: ActiveCultivatorRef,
input: z.infer<typeof AuctionBuySchema>,
) {
return toPlayerStateMutationResponse(
await buyAuctionListing({ actor, ...input }),
);
}
async list(actor: ActiveCultivatorRef, input: AuctionListRequest) {
return toPlayerStateMutationResponse(
await listAuctionItem({ actor, ...input }),
);
}
async listBeast(actor: ActiveCultivatorRef, input: AuctionBeastListRequest) {
return toPlayerStateMutationResponse(
await listAuctionBeast({ actor, ...input }),
);
}
async cancel(actor: ActiveCultivatorRef, listingId: string) {
return toPlayerStateMutationResponse(
await cancelAuctionListing({ actor, listingId }),
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import {
HttpException,
Inject,
Injectable,
type CanActivate,
type ExecutionContext,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { isAdminIdentity, isAdminUserId } from '@server/lib/auth/adminAccess';
import { fromNodeHeaders } from 'better-auth/node';
import type { Response } from 'express';
import type { GameRequest } from '../http/request';
import { ACCESS_POLICY, type AccessPolicy } from './access';
import { SessionService } from './session.service';
@Injectable()
export class AccessGuard implements CanActivate {
constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(SessionService) private readonly sessions: SessionService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const policy =
this.reflector.getAllAndOverride<AccessPolicy>(ACCESS_POLICY, [
context.getHandler(),
context.getClass(),
]) ?? 'user';
if (policy === 'public') return true;
const request = context.switchToHttp().getRequest<GameRequest>();
const response = context.switchToHttp().getResponse<Response>();
const session = await this.sessions.getSession(
fromNodeHeaders(request.headers),
);
session.headers?.forEach((value, key) => {
if (key.toLowerCase() !== 'set-cookie') response.setHeader(key, value);
});
for (const cookie of session.headers?.getSetCookie() ?? []) {
response.append('Set-Cookie', cookie);
}
const user = session.response?.user;
if (!user)
throw new HttpException({ success: false, error: '未授权访问' }, 401);
request.gameContext.user = {
id: user.id,
email: user.email,
name: user.name,
};
if (policy === 'admin' && !isAdminIdentity(user)) {
throw new HttpException({ success: false, error: '无管理员权限' }, 403);
}
if (policy === 'account-admin' && !isAdminUserId(user.id)) {
throw new HttpException(
{
success: false,
error: '账号管理需要在 ADMIN_USER_IDS 中配置当前管理员用户 ID',
},
403,
);
}
if (policy === 'active') {
const ref = await this.sessions.getActiveCultivator(user);
if (!ref)
throw new HttpException(
{ success: false, error: '当前没有活跃角色' },
404,
);
request.gameContext.activeCultivatorRef = ref;
}
return true;
}
}
+23
View File
@@ -0,0 +1,23 @@
import {
createParamDecorator,
SetMetadata,
type ExecutionContext,
} from '@nestjs/common';
import type { GameRequest } from '../http/request';
export type AccessPolicy =
'public' | 'user' | 'active' | 'admin' | 'account-admin';
export const ACCESS_POLICY = Symbol('access-policy');
export const Access = (policy: AccessPolicy) =>
SetMetadata(ACCESS_POLICY, policy);
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext) =>
context.switchToHttp().getRequest<GameRequest>().gameContext.user,
);
export const CurrentCultivator = createParamDecorator(
(_data: unknown, context: ExecutionContext) =>
context.switchToHttp().getRequest<GameRequest>().gameContext
.activeCultivatorRef,
);
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AccessGuard } from './access.guard';
import { CaptchaController } from './captcha.controller';
import { CaptchaService } from './captcha.service';
import { SessionService } from './session.service';
@Module({
controllers: [CaptchaController],
providers: [
CaptchaService,
SessionService,
{ provide: APP_GUARD, useClass: AccessGuard },
],
exports: [SessionService],
})
export class AuthModule {}
+24
View File
@@ -0,0 +1,24 @@
import { Controller, Get, Header, Inject } from '@nestjs/common';
import { FirstQuery } from '../http/first-query';
import { Access } from './access';
import { CaptchaService } from './captcha.service';
@Controller('api/captcha')
@Access('public')
export class CaptchaController {
constructor(
@Inject(CaptchaService) private readonly captcha: CaptchaService,
) {}
@Get('config')
@Header('Cache-Control', 'no-store')
config() {
return this.captcha.config();
}
@Get('challenge')
@Header('Cache-Control', 'no-store')
challenge(@FirstQuery('action') action: string = '') {
return this.captcha.challenge(action);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { HttpException, Injectable } from '@nestjs/common';
import {
createAltchaChallenge,
isAltchaAction,
isAltchaServerEnabled,
} from '@server/lib/auth/altcha';
@Injectable()
export class CaptchaService {
config() {
return { enabled: isAltchaServerEnabled() };
}
async challenge(action: string) {
if (!isAltchaAction(action)) {
throw new HttpException(
{ success: false, error: '无效的人机验证场景' },
400,
);
}
if (!isAltchaServerEnabled()) {
throw new HttpException(
{ success: false, error: '人机验证服务未配置' },
503,
);
}
try {
return await createAltchaChallenge(action);
} catch (error) {
console.error('[altcha] challenge creation failed', error);
throw new HttpException(
{ success: false, error: '人机验证服务暂不可用' },
503,
);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { resolveActiveCultivatorRef } from '@server/lib/auth/activeCultivator';
import { auth } from '@server/lib/auth/auth';
import type { AuthUser } from '@server/lib/auth/types';
@Injectable()
export class SessionService {
getSession(headers: Headers) {
return auth.api.getSession({ headers, returnHeaders: true });
}
getActiveCultivator(user: AuthUser) {
return resolveActiveCultivatorRef(user);
}
setPassword(headers: Headers, newPassword: string) {
return auth.api.setPassword({ headers, body: { newPassword } });
}
}
+135
View File
@@ -0,0 +1,135 @@
import {
Controller,
Get,
Header,
HttpCode,
Inject,
Param,
Post,
Put,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { BeastError } from '@server/lib/services/combat-v6/CombatV6BeastService';
import {
BeastAllocateSchema,
BeastClaimSchema,
BeastFusionRequestSchema,
BeastLineupRequestSchema,
BeastRenameSchema,
BeastRestSchema,
} from '@daoyou/shared/contracts/combatV6Beasts';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { CombatErrors, combatErrorResponse } from '../combat/combat-errors';
import { apiErrorFilter } from '../http/error-filter';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { BeastsService } from './beasts.service';
const FusionErrors = apiErrorFilter((error) => {
if (error instanceof BeastError)
return Response.json(
{ success: false, error: error.message, code: 'BEAST_FUSION_REJECTED' },
{ status: 409 },
);
return combatErrorResponse(error);
});
@Controller('api/combat-v6/beasts')
@Access('active')
@UseFilters(CombatErrors)
export class BeastsController {
constructor(@Inject(BeastsService) private readonly beasts: BeastsService) {}
@Get()
@Header('Cache-Control', 'no-store')
read(@CurrentCultivator() actor: ActiveCultivatorRef) {
return this.beasts.read(actor.cultivatorId);
}
@Get('fusions/:requestId')
@Header('Cache-Control', 'no-store')
fusion(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('requestId', new ZodPipe(z.uuid())) requestId: string,
) {
return this.beasts.fusion(actor.cultivatorId, requestId);
}
@Post('fuse')
@HttpCode(200)
@UseFilters(FusionErrors)
fuse(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(
{ fallback: undefined },
new ZodPipe(BeastFusionRequestSchema, 'legacy-unhandled'),
)
input: z.infer<typeof BeastFusionRequestSchema>,
) {
return this.beasts.fuse(actor.cultivatorId, input);
}
@Post('claim')
@HttpCode(200)
claim(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(new ZodPipe(BeastClaimSchema))
input: z.infer<typeof BeastClaimSchema>,
) {
return this.beasts.claim(actor.cultivatorId, input.speciesId);
}
@Put('lineup')
lineup(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(new ZodPipe(BeastLineupRequestSchema))
input: z.infer<typeof BeastLineupRequestSchema>,
) {
return this.beasts.lineup(actor.cultivatorId, input);
}
@Post('rest')
@HttpCode(200)
rest(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(new ZodPipe(BeastRestSchema))
input: z.infer<typeof BeastRestSchema>,
) {
return this.beasts.rest(actor.cultivatorId, input);
}
@Post('allocate')
@HttpCode(200)
allocate(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(new ZodPipe(BeastAllocateSchema))
input: z.infer<typeof BeastAllocateSchema>,
) {
return this.beasts.allocate(actor.cultivatorId, input);
}
@Post('rename')
@HttpCode(200)
rename(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(
{ fallback: undefined },
new ZodPipe(BeastRenameSchema, 'legacy-unhandled'),
)
input: z.infer<typeof BeastRenameSchema>,
) {
return this.beasts.rename(actor.cultivatorId, input);
}
@Post('release')
@HttpCode(200)
release(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(new ZodPipe(BeastRestSchema))
input: z.infer<typeof BeastRestSchema>,
) {
return this.beasts.release(actor.cultivatorId, input);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { BeastsController } from './beasts.controller';
import { BeastsService } from './beasts.service';
@Module({ controllers: [BeastsController], providers: [BeastsService] })
export class BeastsModule {}
+74
View File
@@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { db } from '@server/lib/drizzle/db';
import { readBeastRoster } from '@server/lib/repositories/combatV6BeastRepository';
import {
allocateBeastPoints,
claimStarterBeast,
fuseOwnedBeasts,
readBeastFusion,
releaseBeast,
renameBeast,
restBeast,
updateBeastLineup,
} from '@server/lib/services/combat-v6/CombatV6BeastService';
import type {
BeastAllocateSchema,
BeastFusionRequest,
BeastLineupRequestSchema,
BeastRenameSchema,
BeastRestSchema,
} from '@daoyou/shared/contracts/combatV6Beasts';
import type { z } from 'zod';
@Injectable()
export class BeastsService {
async read(owner: string) {
return { success: true, data: await readBeastRoster(owner, db) };
}
async fusion(owner: string, requestId: string) {
return { success: true, data: await readBeastFusion(owner, requestId) };
}
async fuse(owner: string, input: BeastFusionRequest) {
return { success: true, data: await fuseOwnedBeasts(owner, input) };
}
async claim(owner: string, speciesId: string) {
return { success: true, data: await claimStarterBeast(owner, speciesId) };
}
async lineup(owner: string, input: z.infer<typeof BeastLineupRequestSchema>) {
return { success: true, data: await updateBeastLineup(owner, input) };
}
async rest(owner: string, input: z.infer<typeof BeastRestSchema>) {
return {
success: true,
data: await restBeast(owner, input.beastId, input.expectedRevision),
};
}
async allocate(owner: string, input: z.infer<typeof BeastAllocateSchema>) {
return {
success: true,
data: await allocateBeastPoints(
owner,
input.beastId,
input.expectedRevision,
input.points,
),
};
}
async rename(owner: string, input: z.infer<typeof BeastRenameSchema>) {
return {
success: true,
data: await renameBeast(
owner,
input.beastId,
input.expectedRevision,
input.name,
),
};
}
async release(owner: string, input: z.infer<typeof BeastRestSchema>) {
return {
success: true,
data: await releaseBeast(owner, input.beastId, input.expectedRevision),
};
}
}
@@ -0,0 +1,24 @@
import { BLACK_MARKET_NPC_IDS } from '@daoyou/shared/types/blackMarket';
import { z } from 'zod';
export const OpenSessionSchema = z.object({
npcId: z.enum(BLACK_MARKET_NPC_IDS),
});
export const InteractSchema = z
.object({
message: z.string().trim().min(1).max(240).optional(),
offeredPrice: z.number().int().min(1).max(2_000_000_000).optional(),
version: z.number().int().min(1),
})
.superRefine((value, context) => {
if (!value.message && !value.offeredPrice)
context.addIssue({
code: 'custom',
message: '请说点什么,或给出灵石报价',
});
});
export const LeaveSchema = z.object({ version: z.number().int().min(1) });
export const CommitSchema = z.object({
version: z.number().int().min(1),
expectedPrice: z.number().int().min(1).max(2_000_000_000),
});
@@ -0,0 +1,136 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Res,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { redisLockErrorResponse } from '@server/lib/http/errors';
import { BlackMarketServiceError } from '@server/lib/services/black-market/BlackMarketService';
import {
QiInsufficientError,
QiServiceError,
} from '@server/lib/services/QiService';
import type { Response as ExpressResponse } from 'express';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { apiErrorFilter } from '../http/error-filter';
import { JsonBody } from '../http/json-body';
import { withRequestAbort } from '../http/request-abort';
import { streamSseEvents } from '../http/sse';
import { ZodPipe } from '../http/zod.pipe';
import {
CommitSchema,
InteractSchema,
LeaveSchema,
OpenSessionSchema,
} from './black-market-input';
import { BlackMarketService } from './black-market.service';
const BlackMarketErrors = apiErrorFilter((error) => {
const lock = redisLockErrorResponse(error);
if (lock) return lock;
if (error instanceof z.ZodError)
return Response.json(
{ error: error.issues[0]?.message || '参数错误' },
{ status: 400 },
);
if (error instanceof BlackMarketServiceError)
return Response.json({ error: error.message }, { status: error.status });
if (error instanceof QiInsufficientError)
return Response.json(
{
error: error.code,
message: error.message,
required: error.required,
current: error.current,
action: error.action,
},
{ status: 409 },
);
if (error instanceof QiServiceError)
return Response.json({ error: error.message }, { status: error.status });
console.error('black market api error:', error);
return Response.json({ error: '黑市暂时闭门,请稍后再来' }, { status: 500 });
});
@Controller('api/black-market')
@Access('active')
@UseFilters(BlackMarketErrors)
export class BlackMarketController {
constructor(
@Inject(BlackMarketService) private readonly market: BlackMarketService,
) {}
@Get(':nodeId')
read(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('nodeId') nodeId: string,
) {
return this.market.read(actor, nodeId);
}
@Post(':nodeId/sessions')
@HttpCode(200)
open(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('nodeId') nodeId: string,
@JsonBody(new ZodPipe(OpenSessionSchema))
input: z.infer<typeof OpenSessionSchema>,
) {
return this.market.open(actor, nodeId, input);
}
@Post(':nodeId/sessions/:sessionId/interact')
@HttpCode(200)
interact(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('nodeId') nodeId: string,
@Param('sessionId') sessionId: string,
@JsonBody(new ZodPipe(InteractSchema))
input: z.infer<typeof InteractSchema>,
@Res() response: ExpressResponse,
) {
return withRequestAbort(response, async (signal) => {
const prepared = await this.market.prepare(
actor,
nodeId,
sessionId,
input,
signal,
);
signal.throwIfAborted();
await streamSseEvents(response, (stream, _isAborted, replySignal) =>
this.market.reply(prepared, replySignal, (event) =>
stream.writeSSE({ data: JSON.stringify(event) }),
),
);
});
}
@Post(':nodeId/sessions/:sessionId/commit')
@HttpCode(200)
commit(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('nodeId') nodeId: string,
@Param('sessionId') sessionId: string,
@JsonBody(new ZodPipe(CommitSchema)) input: z.infer<typeof CommitSchema>,
) {
return this.market.commit(actor, nodeId, sessionId, input);
}
@Post(':nodeId/sessions/:sessionId/leave')
@HttpCode(200)
leave(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('nodeId') nodeId: string,
@Param('sessionId') sessionId: string,
@JsonBody(new ZodPipe(LeaveSchema)) input: z.infer<typeof LeaveSchema>,
) {
return this.market.leave(actor, nodeId, sessionId, input);
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import {
BlackMarketConversationService,
blackMarketConversationService,
} from '@server/lib/services/black-market/BlackMarketConversationService';
import { BlackMarketController } from './black-market.controller';
import { BlackMarketService } from './black-market.service';
@Module({
controllers: [BlackMarketController],
providers: [
{
provide: BlackMarketConversationService,
useValue: blackMarketConversationService,
},
BlackMarketService,
],
})
export class BlackMarketModule {}
@@ -0,0 +1,131 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { BlackMarketConversationService } from '@server/lib/services/black-market/BlackMarketConversationService';
import {
commitBlackMarketPurchase,
completeBlackMarketReply,
getBlackMarketOverview,
leaveBlackMarketSession,
openBlackMarketSession,
prepareBlackMarketInteraction,
} from '@server/lib/services/black-market/BlackMarketService';
import { toPlayerStateMutationResponse } from '@server/lib/services/ResourceMutationResponse';
import type { BlackMarketInteractStreamEvent } from '@daoyou/shared/types/blackMarket';
import type { z } from 'zod';
import type {
CommitSchema,
InteractSchema,
LeaveSchema,
OpenSessionSchema,
} from './black-market-input';
@Injectable()
export class BlackMarketService {
constructor(
@Inject(BlackMarketConversationService)
private readonly conversation: BlackMarketConversationService,
) {}
read(actor: ActiveCultivatorRef, nodeId: string) {
return getBlackMarketOverview({ actor, nodeId });
}
async open(
actor: ActiveCultivatorRef,
nodeId: string,
input: z.infer<typeof OpenSessionSchema>,
) {
return toPlayerStateMutationResponse(
await openBlackMarketSession({ actor, nodeId, ...input }),
);
}
prepare(
actor: ActiveCultivatorRef,
nodeId: string,
sessionId: string,
command: z.infer<typeof InteractSchema>,
abortSignal: AbortSignal,
) {
return prepareBlackMarketInteraction({
actor,
nodeId,
sessionId,
command,
abortSignal,
});
}
async reply(
prepared: Awaited<ReturnType<typeof prepareBlackMarketInteraction>>,
signal: AbortSignal,
emit: (event: BlackMarketInteractStreamEvent) => Promise<void>,
) {
await emit({
type: 'resolved',
result: prepared.result,
messageId: prepared.messageId,
gesture: prepared.gesture,
fallbackBody: prepared.fallbackBody,
});
if (signal.aborted) return;
let body = '';
try {
const reply = this.conversation.streamTurnReply({
context: prepared.replyContext,
proposal: prepared.proposal,
negotiationOutcome: prepared.negotiationOutcome,
abortSignal: signal,
});
for await (const chunk of reply.textStream) {
signal.throwIfAborted();
body += chunk;
await emit({
type: 'reply-chunk',
messageId: prepared.messageId,
text: chunk,
});
}
body = body.trim();
if (!body) throw new Error('empty black market reply');
signal.throwIfAborted();
await completeBlackMarketReply({
sessionId: prepared.sessionId,
messageId: prepared.messageId,
body,
});
await emit({
type: 'reply-complete',
messageId: prepared.messageId,
body,
});
} catch (error) {
console.warn('[black-market] reply stream fallback', { error });
if (!signal.aborted)
await emit({
type: 'reply-error',
messageId: prepared.messageId,
fallbackBody: prepared.fallbackBody,
});
}
}
async commit(
actor: ActiveCultivatorRef,
nodeId: string,
sessionId: string,
input: z.infer<typeof CommitSchema>,
) {
return toPlayerStateMutationResponse(
await commitBlackMarketPurchase({ actor, nodeId, sessionId, ...input }),
);
}
leave(
actor: ActiveCultivatorRef,
nodeId: string,
sessionId: string,
input: z.infer<typeof LeaveSchema>,
) {
return leaveBlackMarketSession({ actor, nodeId, sessionId, ...input });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { apiErrorFilter } from '../http/error-filter';
import { JsonBodyParseError } from '../http/json-body';
// Auto commands parse inputs before their route-local service catch block.
export const AutoErrors = apiErrorFilter((error) => {
if (error instanceof JsonBodyParseError) return undefined;
return Response.json(
{ error: error instanceof Error ? error.message : '自动指令提交失败' },
{ status: 409 },
);
});
@@ -0,0 +1,82 @@
import {
Controller,
Delete,
Get,
Header,
Inject,
Put,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { AutoStrategyError } from '@server/lib/services/combat-v6/CombatV6AutoStrategyService';
import { CombatV6BuildError } from '@server/lib/services/combat-v6/CombatV6BuildService';
import { SaveAutoStrategySchema } from '@daoyou/shared/combat-v6/auto-strategy';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { apiErrorFilter } from '../http/error-filter';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { AutoStrategyService } from './auto-strategy.service';
const MutationSchema = z.strictObject({
pathId: z.string().min(1).max(160),
strategy: SaveAutoStrategySchema,
});
const ResetSchema = z.strictObject({ pathId: z.string().min(1).max(160) });
const StrategyErrors = apiErrorFilter((error) => {
if (error instanceof AutoStrategyError || error instanceof CombatV6BuildError)
return Response.json(
{ success: false, error: error.message },
{ status: 409 },
);
if (error instanceof z.ZodError)
return Response.json(
{ success: false, error: '策略格式无效' },
{ status: 400 },
);
console.error('[combat-auto-strategy]', error);
return Response.json(
{ success: false, error: '策略操作失败,请稍后重试' },
{ status: 500 },
);
});
@Controller('api/combat-v6/auto-strategy')
@Access('active')
@UseFilters(StrategyErrors)
export class AutoStrategyController {
constructor(
@Inject(AutoStrategyService)
private readonly strategies: AutoStrategyService,
) {}
@Get()
@Header('Cache-Control', 'no-store')
read(@CurrentCultivator() actor: ActiveCultivatorRef) {
return this.strategies.read(actor.cultivatorId);
}
@Put()
@Header('Cache-Control', 'no-store')
save(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody({ fallback: undefined }, new ZodPipe(MutationSchema))
input: z.infer<typeof MutationSchema>,
) {
return this.strategies.save(
actor.cultivatorId,
input.pathId,
input.strategy,
);
}
@Delete()
@Header('Cache-Control', 'no-store')
reset(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody({ fallback: undefined }, new ZodPipe(ResetSchema))
input: z.infer<typeof ResetSchema>,
) {
return this.strategies.reset(actor.cultivatorId, input.pathId);
}
}
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import {
readCombatAutoStrategy,
resetCombatAutoStrategy,
saveCombatAutoStrategy,
} from '@server/lib/services/combat-v6/CombatV6AutoStrategyService';
import type { AutoStrategy } from '@daoyou/shared/combat-v6/auto-strategy';
@Injectable()
export class AutoStrategyService {
async read(owner: string) {
return { success: true, data: await readCombatAutoStrategy(owner) };
}
async save(owner: string, pathId: string, strategy: AutoStrategy) {
return {
success: true,
data: await saveCombatAutoStrategy(owner, pathId, strategy),
};
}
async reset(owner: string, pathId: string) {
return {
success: true,
data: await resetCombatAutoStrategy(owner, pathId),
};
}
}
@@ -0,0 +1,104 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Put,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { CombatAutoRequestSchema } from '@daoyou/shared/combat-v6/auto';
import {
CombatV6TrainingCommandRequestSchema,
CombatV6TrainingEventsQuerySchema,
CombatV6TrainingRevisionRequestSchema,
} from '@daoyou/shared/contracts/combatV6';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { BreakthroughService } from './breakthrough.service';
import { taskBattleErrors } from './task-battle-errors';
@Controller('api/combat-v6/breakthrough/tasks/:taskId/sessions')
@Access('active')
@UseFilters(taskBattleErrors('突破战斗参数无效'))
export class BreakthroughController {
constructor(
@Inject(BreakthroughService)
private readonly breakthrough: BreakthroughService,
) {}
@Get('current')
current(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('taskId', new ZodPipe(z.uuid())) taskId: string,
) {
return this.breakthrough.read(actor.cultivatorId, taskId);
}
@Get(':id')
read(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('taskId', new ZodPipe(z.uuid())) taskId: string,
@Param('id', new ZodPipe(z.uuid())) id: string,
@FirstQuery(new ZodPipe(CombatV6TrainingEventsQuerySchema))
query: z.infer<typeof CombatV6TrainingEventsQuerySchema>,
) {
return this.breakthrough.read(
actor.cultivatorId,
taskId,
id,
query.afterEventSeq,
);
}
@Put(':id/commands/:unitId')
submit(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('taskId', new ZodPipe(z.uuid())) taskId: string,
@Param('id', new ZodPipe(z.uuid())) id: string,
@Param('unitId') unitId: string,
@JsonBody(new ZodPipe(CombatV6TrainingCommandRequestSchema))
input: z.infer<typeof CombatV6TrainingCommandRequestSchema>,
) {
return this.breakthrough.change(actor, taskId, id, input.expectedRevision, {
unitId,
commands: input.commands,
});
}
@Post(':id/resolve')
@HttpCode(200)
resolve(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('taskId', new ZodPipe(z.uuid())) taskId: string,
@Param('id', new ZodPipe(z.uuid())) id: string,
@JsonBody(new ZodPipe(CombatV6TrainingRevisionRequestSchema))
input: z.infer<typeof CombatV6TrainingRevisionRequestSchema>,
) {
return this.breakthrough.change(actor, taskId, id, input.expectedRevision);
}
@Post(':id/auto')
@HttpCode(200)
auto(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('taskId', new ZodPipe(z.uuid())) taskId: string,
@Param('id', new ZodPipe(z.uuid())) id: string,
@JsonBody(new ZodPipe(CombatAutoRequestSchema))
input: z.infer<typeof CombatAutoRequestSchema>,
) {
return this.breakthrough.change(
actor,
taskId,
id,
input.expectedRevision,
undefined,
input.round,
);
}
}
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import {
changeBreakthroughBattle,
getBreakthroughBattle,
} from '@server/lib/services/combat-v6/CombatV6BreakthroughService';
@Injectable()
export class BreakthroughService {
async read(owner: string, taskId: string, id?: string, after?: number) {
return {
success: true,
data: await getBreakthroughBattle(owner, taskId, id, after),
};
}
async change(
actor: ActiveCultivatorRef,
taskId: string,
id: string,
revision: number,
command?: Parameters<typeof changeBreakthroughBattle>[4],
round?: number,
) {
return {
success: true,
data: await changeBreakthroughBattle(
actor,
taskId,
id,
revision,
command,
round,
),
};
}
}
@@ -0,0 +1,18 @@
import { Controller, Get, Inject } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { Access, CurrentCultivator } from '../auth/access';
import { CombatActivityService } from './combat-activity.service';
@Controller('api/combat-v6/activity')
@Access('active')
export class CombatActivityController {
constructor(
@Inject(CombatActivityService)
private readonly activity: CombatActivityService,
) {}
@Get()
read(@CurrentCultivator() actor: ActiveCultivatorRef) {
return this.activity.read(actor.cultivatorId);
}
}
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { readCombatActivityNotice } from '@server/lib/services/combat-v6/CombatActivityNotice';
@Injectable()
export class CombatActivityService {
async read(cultivatorId: string) {
return {
success: true,
data: await readCombatActivityNotice(cultivatorId),
};
}
}
+86
View File
@@ -0,0 +1,86 @@
import { redisLockErrorResponse } from '@server/lib/http/errors';
import { PlayerCommandIdempotencyError } from '@server/lib/services/CommandExecutors';
import { InventoryError } from '@server/lib/services/InventoryService';
import {
QiInsufficientError,
QiServiceError,
} from '@server/lib/services/QiService';
import { BeastError } from '@server/lib/services/combat-v6/CombatV6BeastService';
import { CombatV6BuildError } from '@server/lib/services/combat-v6/CombatV6BuildService';
import { CombatV6TrainingSessionError } from '@server/lib/services/combat-v6/CombatV6TrainingSessionService';
import { WildError } from '@server/lib/services/combat-v6/CombatV6WildSessionService';
import { TrainingHostError } from '@daoyou/shared/engine/combat-v6/encounter';
import { InventoryRuleError } from '@daoyou/shared/inventory';
import { ZodError } from 'zod';
import { apiErrorFilter } from '../http/error-filter';
export function combatErrorResponse(error: unknown): Response {
if (error instanceof QiInsufficientError)
return Response.json(
{
success: false,
code: error.code,
error: '天地灵气不足,待自然恢复或使用恢复符箓后再试。',
},
{ status: 409 },
);
if (
error instanceof QiServiceError ||
error instanceof PlayerCommandIdempotencyError
)
return Response.json(
{ success: false, error: error.message },
{ status: error.status },
);
if (error instanceof InventoryError || error instanceof InventoryRuleError)
return Response.json(
{ success: false, error: error.message },
{ status: 409 },
);
if (error instanceof BeastError)
return Response.json(
{ success: false, error: error.message },
{ status: error.status },
);
const coordination = redisLockErrorResponse(error);
if (coordination) return coordination;
if (error instanceof TrainingHostError)
return Response.json(
{
success: false,
code: 'WILD_COMMAND_NOT_ALLOWED',
error: error.message,
},
{ status: 400 },
);
if (error instanceof ZodError)
return Response.json(
{
success: false,
code: 'INVALID_REQUEST',
error: error.issues[0]?.message ?? '参数错误',
details: error.issues,
},
{ status: 400 },
);
if (
error instanceof CombatV6BuildError ||
error instanceof WildError ||
error instanceof CombatV6TrainingSessionError
)
return Response.json(
{ success: false, code: error.code, error: error.message },
{ status: error.status },
);
console.error('combat-v6 api error:', error);
return Response.json(
{
success: false,
code: 'COMBAT_V6_INTERNAL_ERROR',
error: '练功房暂不可用,请稍后再试',
},
{ status: 500 },
);
}
export const CombatErrors = apiErrorFilter(combatErrorResponse);
+41
View File
@@ -0,0 +1,41 @@
import { Module } from '@nestjs/common';
import { AutoStrategyController } from './auto-strategy.controller';
import { AutoStrategyService } from './auto-strategy.service';
import { BreakthroughController } from './breakthrough.controller';
import { BreakthroughService } from './breakthrough.service';
import { CombatActivityController } from './combat-activity.controller';
import { CombatActivityService } from './combat-activity.service';
import {
ReplaysController,
SharedReplaysController,
} from './replays.controller';
import { ReplaysService } from './replays.service';
import { SectTaskBattleController } from './sect-task.controller';
import { SectTaskBattleService } from './sect-task.service';
import { TrainingController } from './training.controller';
import { TrainingService } from './training.service';
import { WildController } from './wild.controller';
import { WildService } from './wild.service';
@Module({
controllers: [
CombatActivityController,
ReplaysController,
SharedReplaysController,
TrainingController,
WildController,
AutoStrategyController,
BreakthroughController,
SectTaskBattleController,
],
providers: [
CombatActivityService,
ReplaysService,
TrainingService,
WildService,
AutoStrategyService,
BreakthroughService,
SectTaskBattleService,
],
})
export class CombatModule {}
+93
View File
@@ -0,0 +1,93 @@
import {
Controller,
Get,
Header,
HttpCode,
HttpException,
Inject,
Param,
Post,
Res,
UseFilters,
type PipeTransform,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { CombatV6ReplayParamsSchema } from '@daoyou/shared/contracts/combatV6';
import { CombatV6HistoryQuerySchema } from '@daoyou/shared/contracts/combatV6Replay';
import type { Response } from 'express';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { ZodPipe } from '../http/zod.pipe';
import { CombatErrors } from './combat-errors';
import { ReplaysService } from './replays.service';
@Controller('api/combat-v6/replays')
@Access('active')
export class ReplaysController {
constructor(
@Inject(ReplaysService) private readonly replays: ReplaysService,
) {}
@Get()
@Header('Cache-Control', 'private, no-store')
@UseFilters(CombatErrors)
list(
@CurrentCultivator() actor: ActiveCultivatorRef,
@FirstQuery(new ZodPipe(CombatV6HistoryQuerySchema))
query: z.infer<typeof CombatV6HistoryQuerySchema>,
) {
return this.replays.list(actor.cultivatorId, query);
}
@Get(':battleId')
@Header('Cache-Control', 'private, no-store')
@UseFilters(CombatErrors)
async read(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param(new ZodPipe(CombatV6ReplayParamsSchema))
params: z.infer<typeof CombatV6ReplayParamsSchema>,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.replays.read(actor, params.battleId);
response.status(result.status);
return result.body;
}
@Post(':battleId/share')
@HttpCode(200)
share(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param(new ZodPipe(CombatV6ReplayParamsSchema))
params: z.infer<typeof CombatV6ReplayParamsSchema>,
) {
return this.replays.share(actor, params.battleId);
}
}
class ShareCodePipe implements PipeTransform<string, string> {
transform(value: string) {
const parsed = z.uuid().safeParse(value);
if (!parsed.success)
throw new HttpException({ success: false, error: '分享链接无效' }, 404);
return parsed.data;
}
}
@Controller('api/combat-v6-shares')
@Access('public')
export class SharedReplaysController {
constructor(
@Inject(ReplaysService) private readonly replays: ReplaysService,
) {}
@Get(':shareCode')
async read(
@Param('shareCode', new ShareCodePipe()) shareCode: string,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.replays.shared(shareCode);
response.setHeader('Cache-Control', 'public, max-age=60');
return result;
}
}
+101
View File
@@ -0,0 +1,101 @@
import { HttpException, Injectable } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import {
createCombatV6ReplayShare,
findOwnedCombatV6Replay,
findSharedCombatV6Replay,
listOwnedCombatV6Replays,
} from '@server/lib/repositories/combatV6ReplayRepository';
import { CombatV6ArenaStore } from '@server/lib/services/combat-v6/CombatV6ArenaStore';
import { combatV6ReplayView } from '@daoyou/shared/combat-v6/replay';
import { COMBAT_V6_REPLAY_ERROR_CODE } from '@daoyou/shared/contracts/combatV6';
import type { CombatV6HistoryQuerySchema } from '@daoyou/shared/contracts/combatV6Replay';
import type { z } from 'zod';
@Injectable()
export class ReplaysService {
private readonly arena = new CombatV6ArenaStore();
async read(actor: ActiveCultivatorRef, battleId: string) {
const archive = await findOwnedCombatV6Replay(battleId, actor.cultivatorId);
if (
archive?.replay &&
archive.replay.participants.some(
(p) =>
p.cultivatorId === actor.cultivatorId && p.userId === actor.userId,
)
) {
return {
status: 200,
body: {
success: true,
data: combatV6ReplayView(
archive.replay,
actor.cultivatorId,
actor.userId,
),
},
};
}
const arena = await this.arena.get(battleId);
if (
arena?.stage === 'finished' &&
arena.participants.some(
(p) =>
p.userId === actor.userId && p.cultivatorId === actor.cultivatorId,
)
) {
return {
status: 202,
body: {
success: false,
code: COMBAT_V6_REPLAY_ERROR_CODE.Pending,
error: '战斗回放正在归档,请稍后重试',
},
};
}
return {
status: 404,
body: {
success: false,
code: COMBAT_V6_REPLAY_ERROR_CODE.NotFound,
error: '战斗回放不存在',
},
};
}
async list(owner: string, query: z.infer<typeof CombatV6HistoryQuerySchema>) {
return {
success: true,
data: await listOwnedCombatV6Replays(owner, query),
};
}
async share(actor: ActiveCultivatorRef, battleId: string) {
const shareCode = await createCombatV6ReplayShare(
battleId,
actor.cultivatorId,
actor.userId,
);
if (!shareCode)
throw new HttpException({ success: false, error: '战斗回放不存在' }, 404);
return { success: true, data: { shareCode } };
}
async shared(shareCode: string) {
const archive = await findSharedCombatV6Replay(shareCode);
const viewer = archive?.replay?.participants.find(
(p) => p.cultivatorId === archive.shareViewerCultivatorId,
);
if (!archive?.replay || !viewer)
throw new HttpException({ success: false, error: '战斗回放不存在' }, 404);
return {
success: true,
data: combatV6ReplayView(
archive.replay,
viewer.cultivatorId,
viewer.userId,
),
};
}
}
@@ -0,0 +1,91 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
Post,
Put,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { CombatAutoRequestSchema } from '@daoyou/shared/combat-v6/auto';
import {
CombatV6TrainingCommandRequestSchema,
CombatV6TrainingEventsQuerySchema,
CombatV6TrainingRevisionRequestSchema,
} from '@daoyou/shared/contracts/combatV6';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { SectTaskBattleService } from './sect-task.service';
import { taskBattleErrors } from './task-battle-errors';
@Controller('api/combat-v6/sect-tasks/sessions')
@Access('active')
@UseFilters(taskBattleErrors('宗门战斗参数无效'))
export class SectTaskBattleController {
constructor(
@Inject(SectTaskBattleService)
private readonly battle: SectTaskBattleService,
) {}
@Get('current')
current(@CurrentCultivator() actor: ActiveCultivatorRef) {
return this.battle.read(actor.cultivatorId);
}
@Get(':id')
read(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('id', new ZodPipe(z.uuid())) id: string,
@FirstQuery(new ZodPipe(CombatV6TrainingEventsQuerySchema))
query: z.infer<typeof CombatV6TrainingEventsQuerySchema>,
) {
return this.battle.read(actor.cultivatorId, id, query.afterEventSeq);
}
@Put(':id/commands/:unitId')
submit(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('id', new ZodPipe(z.uuid())) id: string,
@Param('unitId') unitId: string,
@JsonBody(new ZodPipe(CombatV6TrainingCommandRequestSchema))
input: z.infer<typeof CombatV6TrainingCommandRequestSchema>,
) {
return this.battle.change(actor, id, input.expectedRevision, {
unitId,
commands: input.commands,
});
}
@Post(':id/resolve')
@HttpCode(200)
resolve(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('id', new ZodPipe(z.uuid())) id: string,
@JsonBody(new ZodPipe(CombatV6TrainingRevisionRequestSchema))
input: z.infer<typeof CombatV6TrainingRevisionRequestSchema>,
) {
return this.battle.change(actor, id, input.expectedRevision);
}
@Post(':id/auto')
@HttpCode(200)
auto(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('id', new ZodPipe(z.uuid())) id: string,
@JsonBody(new ZodPipe(CombatAutoRequestSchema))
input: z.infer<typeof CombatAutoRequestSchema>,
) {
return this.battle.change(
actor,
id,
input.expectedRevision,
undefined,
input.round,
);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import {
changeSectTaskBattle,
getSectTaskBattle,
} from '@server/lib/services/combat-v6/CombatV6SectTaskService';
@Injectable()
export class SectTaskBattleService {
async read(owner: string, id?: string, after?: number) {
return { success: true, data: await getSectTaskBattle(owner, id, after) };
}
async change(
actor: ActiveCultivatorRef,
id: string,
revision: number,
command?: Parameters<typeof changeSectTaskBattle>[3],
round?: number,
) {
return {
success: true,
data: await changeSectTaskBattle(actor, id, revision, command, round),
};
}
}
+21
View File
@@ -0,0 +1,21 @@
import { redisLockErrorResponse } from '@server/lib/http/errors';
import { ZodError } from 'zod';
import { apiErrorFilter } from '../http/error-filter';
export function taskBattleErrors(invalidMessage: string) {
return apiErrorFilter(
(error) =>
redisLockErrorResponse(error) ??
Response.json(
{
error:
error instanceof ZodError
? invalidMessage
: error instanceof Error
? error.message
: String(error),
},
{ status: error instanceof ZodError ? 400 : 409 },
),
);
}
+163
View File
@@ -0,0 +1,163 @@
import { getRuntimeEnvironment } from '@server/lib/config/environment';
import {
Controller,
Delete,
Get,
HttpCode,
HttpException,
Inject,
Param,
Post,
Put,
UseFilters,
} from '@nestjs/common';
import type { ActiveCultivatorRef } from '@server/lib/auth/types';
import { CombatAutoRequestSchema } from '@daoyou/shared/combat-v6/auto';
import {
CombatV6TrainingCommandParamsSchema,
CombatV6TrainingCommandRequestSchema,
CombatV6TrainingCreateRequestSchema,
CombatV6TrainingEventsQuerySchema,
CombatV6TrainingRevisionRequestSchema,
CombatV6TrainingSessionParamsSchema,
} from '@daoyou/shared/contracts/combatV6';
import { z } from 'zod';
import { Access, CurrentCultivator } from '../auth/access';
import { FirstQuery } from '../http/first-query';
import { JsonBody } from '../http/json-body';
import { ZodPipe } from '../http/zod.pipe';
import { AutoErrors } from './auto-errors';
import { CombatErrors } from './combat-errors';
import { TrainingService } from './training.service';
class TraceParamsPipe extends ZodPipe<
z.infer<typeof CombatV6TrainingSessionParamsSchema>
> {
constructor() {
super(CombatV6TrainingSessionParamsSchema);
}
override transform(value: unknown) {
if (getRuntimeEnvironment().NODE_ENV === 'production')
throw new HttpException(
{
success: false,
code: 'TRAINING_SESSION_NOT_FOUND',
error: '训练会话不存在',
},
404,
);
return super.transform(value);
}
}
@Controller('api/combat-v6/training')
@Access('active')
@UseFilters(CombatErrors)
export class TrainingController {
constructor(
@Inject(TrainingService) private readonly training: TrainingService,
) {}
@Get('content')
content() {
return this.training.content();
}
@Get('sessions/current')
current(
@CurrentCultivator() actor: ActiveCultivatorRef,
@FirstQuery(new ZodPipe(CombatV6TrainingEventsQuerySchema))
query: z.infer<typeof CombatV6TrainingEventsQuerySchema>,
) {
return this.training.current(actor, query.afterEventSeq);
}
@Post('sessions')
@HttpCode(200)
create(
@CurrentCultivator() actor: ActiveCultivatorRef,
@JsonBody(new ZodPipe(CombatV6TrainingCreateRequestSchema))
input: z.infer<typeof CombatV6TrainingCreateRequestSchema>,
) {
return this.training.create(actor, input);
}
@Get('sessions/:sessionId')
read(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param(new ZodPipe(CombatV6TrainingSessionParamsSchema))
params: z.infer<typeof CombatV6TrainingSessionParamsSchema>,
@FirstQuery(new ZodPipe(CombatV6TrainingEventsQuerySchema))
query: z.infer<typeof CombatV6TrainingEventsQuerySchema>,
) {
return this.training.read(actor, params.sessionId, query.afterEventSeq);
}
@Put('sessions/:sessionId/commands/:unitId')
submit(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param(new ZodPipe(CombatV6TrainingCommandParamsSchema))
params: z.infer<typeof CombatV6TrainingCommandParamsSchema>,
@JsonBody(new ZodPipe(CombatV6TrainingCommandRequestSchema))
input: z.infer<typeof CombatV6TrainingCommandRequestSchema>,
) {
return this.training.submit(actor, params.sessionId, params.unitId, input);
}
@Post('sessions/:sessionId/resolve')
@HttpCode(200)
resolve(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param(new ZodPipe(CombatV6TrainingSessionParamsSchema))
params: z.infer<typeof CombatV6TrainingSessionParamsSchema>,
@JsonBody(new ZodPipe(CombatV6TrainingRevisionRequestSchema))
input: z.infer<typeof CombatV6TrainingRevisionRequestSchema>,
) {
return this.training.resolve(
actor,
params.sessionId,
input.expectedRevision,
);
}
@Post('sessions/:id/auto')
@HttpCode(200)
@UseFilters(AutoErrors)
auto(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param('id', new ZodPipe(z.uuid(), 'legacy-unhandled')) id: string,
@JsonBody(new ZodPipe(CombatAutoRequestSchema, 'legacy-unhandled'))
input: z.infer<typeof CombatAutoRequestSchema>,
) {
return this.training.resolve(
actor,
id,
input.expectedRevision,
input.round,
);
}
@Delete('sessions/:sessionId')
abandon(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param(new ZodPipe(CombatV6TrainingSessionParamsSchema))
params: z.infer<typeof CombatV6TrainingSessionParamsSchema>,
@JsonBody(new ZodPipe(CombatV6TrainingRevisionRequestSchema))
input: z.infer<typeof CombatV6TrainingRevisionRequestSchema>,
) {
return this.training.abandon(
actor,
params.sessionId,
input.expectedRevision,
);
}
@Get('sessions/:sessionId/trace')
trace(
@CurrentCultivator() actor: ActiveCultivatorRef,
@Param(new TraceParamsPipe())
params: z.infer<typeof CombatV6TrainingSessionParamsSchema>,
) {
return this.training.trace(actor, params.sessionId);
}
}
Loaded 100 of 2329 files, more files were not shown because too many files have changed in this diff. Show more