From 0d6cd375798e2843c770bdbb632648584e7e3dfd Mon Sep 17 00:00:00 2001 From: phptits Date: Thu, 10 Sep 2026 06:35:55 +0000 Subject: [PATCH] DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops - Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages - PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs) - Auth/RBAC/workspace isolation, credential vault (AES-256-GCM) - Servers/SSH, Docker discovery & lifecycle, Job Center - Websites & deployments (git provider credential clone, env, health verification) - Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring - Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools) - AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals) - Notes: Vditor WYSIWYG markdown editor, non-blocking autosave - Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links) --- .env.example | 14 + .gitignore | 12 + Docs/devos-v1-phase2/00-GAP-ANALYSIS.md | 45 + Docs/devos-v1-phase2/01-TARGET-SCOPE.md | 45 + Docs/devos-v1-phase2/02-RESOURCE-MODEL.md | 44 + Docs/devos-v1-phase2/03-INFRASTRUCTURE.md | 58 + Docs/devos-v1-phase2/04-WEBSITE-DEPLOYMENT.md | 61 + .../05-GIT-DOMAIN-EMAIL-KNOWLEDGE.md | 60 + .../06-PROJECT-TASK-AUTOMATION.md | 50 + Docs/devos-v1-phase2/07-AI-AGENT-MCP.md | 57 + Docs/devos-v1-phase2/08-SECURITY-HARDENING.md | 35 + .../09-UX-UNIFIED-WORKSPACE.md | 50 + Docs/devos-v1-phase2/10-API-UI-CONTRACT.md | 41 + Docs/devos-v1-phase2/11-TEST-ACCEPTANCE.md | 35 + Docs/devos-v1-phase2/12-CODEX-EXECUTION.md | 95 ++ Docs/devos-v1-phase2/README.md | 21 + Docs/devos-v1/00-PRD.md | 83 ++ Docs/devos-v1/01-PRODUCT-ARCHITECTURE.md | 105 ++ Docs/devos-v1/02-TECH-STACK.md | 40 + Docs/devos-v1/03-DOMAIN-MODEL.md | 105 ++ Docs/devos-v1/04-DATABASE-SCHEMA.md | 119 ++ Docs/devos-v1/05-API-SPEC.md | 109 ++ Docs/devos-v1/06-FRONTEND-ARCHITECTURE.md | 71 ++ Docs/devos-v1/07-BACKEND-ARCHITECTURE.md | 73 ++ Docs/devos-v1/08-AI-AGENT-ARCHITECTURE.md | 96 ++ Docs/devos-v1/09-MCP-SPEC.md | 54 + Docs/devos-v1/10-AUTOMATION-ENGINE.md | 72 ++ Docs/devos-v1/11-SECURITY.md | 78 ++ Docs/devos-v1/12-UI-SPEC.md | 77 ++ Docs/devos-v1/13-ROADMAP.md | 75 ++ Docs/devos-v1/14-AGENTS.md | 56 + Docs/devos-v1/15-ZCODE-DEV-SPEC.md | 240 ++++ Docs/devos-v1/README.md | 20 + README.md | 172 +++ apps/api/package.json | 27 + apps/api/src/app.module.ts | 82 ++ apps/api/src/common/ai/ai-provider.module.ts | 14 + apps/api/src/common/ai/ai-provider.service.ts | 62 + apps/api/src/common/config/config.module.ts | 12 + apps/api/src/common/config/config.service.ts | 52 + apps/api/src/common/decorators/index.ts | 45 + .../common/errors/all-exceptions.filter.ts | 66 ++ apps/api/src/common/errors/api-error.ts | 49 + apps/api/src/common/guards/jwt-auth.guard.ts | 55 + apps/api/src/common/guards/workspace.guard.ts | 114 ++ apps/api/src/common/logger/logger.module.ts | 81 ++ .../common/middleware/request-context.hook.ts | 28 + .../src/common/pipes/zod-validation.pipe.ts | 25 + apps/api/src/common/request-context.ts | 30 + .../src/common/security/production-check.ts | 23 + .../security/security-policy.service.ts | 60 + .../src/common/security/security.module.ts | 14 + .../src/common/security/webhook-signature.ts | 56 + apps/api/src/common/system.controller.ts | 71 ++ apps/api/src/common/utils/html.ts | 75 ++ apps/api/src/common/utils/mask.ts | 52 + apps/api/src/integrations/dns/dns.module.ts | 11 + .../api/src/integrations/dns/dns.providers.ts | 171 +++ .../src/integrations/docker/docker.module.ts | 12 + .../src/integrations/docker/docker.service.ts | 314 +++++ .../integrations/ssh/ssh-client.service.ts | 178 +++ apps/api/src/integrations/ssh/ssh.module.ts | 12 + apps/api/src/main.ts | 96 ++ apps/api/src/modules/ai/ai-chat.controller.ts | 111 ++ apps/api/src/modules/ai/ai-chat.service.ts | 657 +++++++++++ .../src/modules/ai/ai-gateway.controller.ts | 58 + apps/api/src/modules/ai/ai-gateway.service.ts | 395 +++++++ apps/api/src/modules/ai/ai.module.ts | 28 + .../src/modules/ai/tool-registry.service.ts | 477 ++++++++ .../api/src/modules/audit/audit.controller.ts | 47 + apps/api/src/modules/audit/audit.module.ts | 13 + apps/api/src/modules/audit/audit.service.ts | 60 + .../automation/automation.controller.ts | 99 ++ .../modules/automation/automation.module.ts | 20 + .../modules/automation/automation.service.ts | 565 +++++++++ apps/api/src/modules/automation/cron.ts | 78 ++ .../certificate/certificate.controller.ts | 49 + .../modules/certificate/certificate.module.ts | 15 + .../certificate/certificate.service.ts | 243 ++++ .../modules/container/container.controller.ts | 95 ++ .../src/modules/container/container.module.ts | 19 + .../modules/container/container.service.ts | 271 +++++ .../credential/credential-vault.service.ts | 76 ++ .../credential/credential.controller.ts | 54 + .../modules/credential/credential.module.ts | 16 + .../modules/credential/credential.service.ts | 203 ++++ .../deployment/deployment.controller.ts | 51 + .../modules/deployment/deployment.module.ts | 20 + .../modules/deployment/deployment.service.ts | 429 +++++++ .../src/modules/domain/domain.controller.ts | 70 ++ apps/api/src/modules/domain/domain.module.ts | 17 + apps/api/src/modules/domain/domain.service.ts | 271 +++++ .../src/modules/event/event-bus.service.ts | 121 ++ .../api/src/modules/event/event.controller.ts | 42 + apps/api/src/modules/event/event.module.ts | 13 + apps/api/src/modules/git/git.controller.ts | 90 ++ apps/api/src/modules/git/git.module.ts | 17 + apps/api/src/modules/git/git.providers.ts | 187 +++ apps/api/src/modules/git/git.service.ts | 309 +++++ .../modules/identity/identity.controller.ts | 47 + .../src/modules/identity/identity.module.ts | 15 + .../src/modules/identity/identity.service.ts | 180 +++ apps/api/src/modules/job/job-queue.service.ts | 246 ++++ apps/api/src/modules/job/job.controller.ts | 83 ++ apps/api/src/modules/job/job.module.ts | 14 + apps/api/src/modules/mcp/mcp.controller.ts | 38 + apps/api/src/modules/mcp/mcp.module.ts | 14 + apps/api/src/modules/mcp/mcp.service.ts | 182 +++ .../monitoring/monitoring.controller.ts | 56 + .../modules/monitoring/monitoring.module.ts | 16 + .../modules/monitoring/monitoring.service.ts | 287 +++++ apps/api/src/modules/note/note.controller.ts | 85 ++ apps/api/src/modules/note/note.module.ts | 15 + apps/api/src/modules/note/note.service.ts | 232 ++++ .../notification/notification.controller.ts | 30 + .../notification/notification.module.ts | 13 + .../notification/notification.service.ts | 61 + .../migration.sql | 0 .../modules/resource/resource.controller.ts | 96 ++ .../src/modules/resource/resource.module.ts | 15 + .../src/modules/resource/resource.service.ts | 311 +++++ .../src/modules/search/search.controller.ts | 25 + apps/api/src/modules/search/search.module.ts | 13 + apps/api/src/modules/search/search.service.ts | 47 + .../src/modules/server/server.controller.ts | 95 ++ apps/api/src/modules/server/server.module.ts | 20 + apps/api/src/modules/server/server.service.ts | 353 ++++++ apps/api/src/modules/task/task.controller.ts | 62 + apps/api/src/modules/task/task.module.ts | 15 + apps/api/src/modules/task/task.service.ts | 198 ++++ .../src/modules/website/website.controller.ts | 83 ++ .../api/src/modules/website/website.module.ts | 17 + .../src/modules/website/website.service.ts | 276 +++++ .../worker/worker-scheduler.service.ts | 81 ++ apps/api/src/modules/worker/worker.module.ts | 13 + .../modules/workspace/workspace.controller.ts | 78 ++ .../src/modules/workspace/workspace.module.ts | 15 + .../modules/workspace/workspace.service.ts | 199 ++++ apps/api/src/prisma/prisma.module.ts | 12 + apps/api/src/prisma/prisma.service.ts | 24 + apps/api/tsconfig.build.json | 4 + apps/api/tsconfig.json | 12 + apps/web/index.html | 13 + apps/web/package.json | 35 + apps/web/src/api/client.ts | 23 + apps/web/src/app.tsx | 87 ++ apps/web/src/components/form-modal.tsx | 61 + apps/web/src/components/shared.tsx | 61 + apps/web/src/layouts/app-layout.tsx | 326 ++++++ apps/web/src/main.tsx | 55 + apps/web/src/pages/ai.tsx | 469 ++++++++ apps/web/src/pages/audit.tsx | 87 ++ apps/web/src/pages/automations.tsx | 257 ++++ apps/web/src/pages/certificates.tsx | 135 +++ apps/web/src/pages/containers.tsx | 68 ++ apps/web/src/pages/credentials.tsx | 121 ++ apps/web/src/pages/dashboard.tsx | 156 +++ apps/web/src/pages/deployment-detail.tsx | 104 ++ apps/web/src/pages/deployments.tsx | 79 ++ apps/web/src/pages/domain-detail.tsx | 143 +++ apps/web/src/pages/domains.tsx | 142 +++ apps/web/src/pages/git.tsx | 205 ++++ apps/web/src/pages/jobs.tsx | 135 +++ apps/web/src/pages/login.tsx | 113 ++ apps/web/src/pages/monitoring.tsx | 193 +++ apps/web/src/pages/notes.tsx | 475 ++++++++ apps/web/src/pages/projects.tsx | 235 ++++ apps/web/src/pages/repositories.tsx | 155 +++ apps/web/src/pages/resources.tsx | 75 ++ apps/web/src/pages/server-detail.tsx | 250 ++++ apps/web/src/pages/servers.tsx | 247 ++++ apps/web/src/pages/tasks.tsx | 195 ++++ apps/web/src/pages/website-detail.tsx | 162 +++ apps/web/src/pages/websites.tsx | 229 ++++ apps/web/src/services/api.ts | 557 +++++++++ apps/web/src/stores/auth.ts | 36 + apps/web/src/stores/theme.ts | 28 + apps/web/tsconfig.json | 13 + bun.lock | 1036 +++++++++++++++++ docker-compose.yml | 46 + docs/API.md | 117 ++ docs/DATABASE.md | 63 + infra/ssh-target/Dockerfile | 9 + package.json | 35 + packages/api-client/package.json | 14 + packages/api-client/src/index.ts | 118 ++ packages/api-client/tsconfig.json | 18 + packages/auth/package.json | 13 + packages/auth/src/index.ts | 153 +++ packages/auth/tsconfig.json | 15 + packages/types/package.json | 10 + packages/types/src/index.ts | 276 +++++ packages/types/tsconfig.json | 9 + packages/ui/package.json | 14 + .../20260908000000_init/migration.sql | 581 +++++++++ .../20260908180000_phase2/migration.sql | 173 +++ .../migration.sql | 3 + .../migration.sql | 4 + .../20260908195000_airun_source/migration.sql | 3 + prisma/migrations/migration_lock.toml | 1 + prisma/schema.prisma | 734 ++++++++++++ prisma/seed.ts | 57 + prisma/set-admin-password.ts | 35 + scripts/api-start.sh | 7 + scripts/api-stop.sh | 3 + tests/auth.test.ts | 58 + tests/security.test.ts | 84 ++ tests/unit.test.ts | 131 +++ tsconfig.base.json | 19 + 209 files changed, 22783 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Docs/devos-v1-phase2/00-GAP-ANALYSIS.md create mode 100644 Docs/devos-v1-phase2/01-TARGET-SCOPE.md create mode 100644 Docs/devos-v1-phase2/02-RESOURCE-MODEL.md create mode 100644 Docs/devos-v1-phase2/03-INFRASTRUCTURE.md create mode 100644 Docs/devos-v1-phase2/04-WEBSITE-DEPLOYMENT.md create mode 100644 Docs/devos-v1-phase2/05-GIT-DOMAIN-EMAIL-KNOWLEDGE.md create mode 100644 Docs/devos-v1-phase2/06-PROJECT-TASK-AUTOMATION.md create mode 100644 Docs/devos-v1-phase2/07-AI-AGENT-MCP.md create mode 100644 Docs/devos-v1-phase2/08-SECURITY-HARDENING.md create mode 100644 Docs/devos-v1-phase2/09-UX-UNIFIED-WORKSPACE.md create mode 100644 Docs/devos-v1-phase2/10-API-UI-CONTRACT.md create mode 100644 Docs/devos-v1-phase2/11-TEST-ACCEPTANCE.md create mode 100644 Docs/devos-v1-phase2/12-CODEX-EXECUTION.md create mode 100644 Docs/devos-v1-phase2/README.md create mode 100644 Docs/devos-v1/00-PRD.md create mode 100644 Docs/devos-v1/01-PRODUCT-ARCHITECTURE.md create mode 100644 Docs/devos-v1/02-TECH-STACK.md create mode 100644 Docs/devos-v1/03-DOMAIN-MODEL.md create mode 100644 Docs/devos-v1/04-DATABASE-SCHEMA.md create mode 100644 Docs/devos-v1/05-API-SPEC.md create mode 100644 Docs/devos-v1/06-FRONTEND-ARCHITECTURE.md create mode 100644 Docs/devos-v1/07-BACKEND-ARCHITECTURE.md create mode 100644 Docs/devos-v1/08-AI-AGENT-ARCHITECTURE.md create mode 100644 Docs/devos-v1/09-MCP-SPEC.md create mode 100644 Docs/devos-v1/10-AUTOMATION-ENGINE.md create mode 100644 Docs/devos-v1/11-SECURITY.md create mode 100644 Docs/devos-v1/12-UI-SPEC.md create mode 100644 Docs/devos-v1/13-ROADMAP.md create mode 100644 Docs/devos-v1/14-AGENTS.md create mode 100644 Docs/devos-v1/15-ZCODE-DEV-SPEC.md create mode 100644 Docs/devos-v1/README.md create mode 100644 README.md create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/common/ai/ai-provider.module.ts create mode 100644 apps/api/src/common/ai/ai-provider.service.ts create mode 100644 apps/api/src/common/config/config.module.ts create mode 100644 apps/api/src/common/config/config.service.ts create mode 100644 apps/api/src/common/decorators/index.ts create mode 100644 apps/api/src/common/errors/all-exceptions.filter.ts create mode 100644 apps/api/src/common/errors/api-error.ts create mode 100644 apps/api/src/common/guards/jwt-auth.guard.ts create mode 100644 apps/api/src/common/guards/workspace.guard.ts create mode 100644 apps/api/src/common/logger/logger.module.ts create mode 100644 apps/api/src/common/middleware/request-context.hook.ts create mode 100644 apps/api/src/common/pipes/zod-validation.pipe.ts create mode 100644 apps/api/src/common/request-context.ts create mode 100644 apps/api/src/common/security/production-check.ts create mode 100644 apps/api/src/common/security/security-policy.service.ts create mode 100644 apps/api/src/common/security/security.module.ts create mode 100644 apps/api/src/common/security/webhook-signature.ts create mode 100644 apps/api/src/common/system.controller.ts create mode 100644 apps/api/src/common/utils/html.ts create mode 100644 apps/api/src/common/utils/mask.ts create mode 100644 apps/api/src/integrations/dns/dns.module.ts create mode 100644 apps/api/src/integrations/dns/dns.providers.ts create mode 100644 apps/api/src/integrations/docker/docker.module.ts create mode 100644 apps/api/src/integrations/docker/docker.service.ts create mode 100644 apps/api/src/integrations/ssh/ssh-client.service.ts create mode 100644 apps/api/src/integrations/ssh/ssh.module.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/modules/ai/ai-chat.controller.ts create mode 100644 apps/api/src/modules/ai/ai-chat.service.ts create mode 100644 apps/api/src/modules/ai/ai-gateway.controller.ts create mode 100644 apps/api/src/modules/ai/ai-gateway.service.ts create mode 100644 apps/api/src/modules/ai/ai.module.ts create mode 100644 apps/api/src/modules/ai/tool-registry.service.ts create mode 100644 apps/api/src/modules/audit/audit.controller.ts create mode 100644 apps/api/src/modules/audit/audit.module.ts create mode 100644 apps/api/src/modules/audit/audit.service.ts create mode 100644 apps/api/src/modules/automation/automation.controller.ts create mode 100644 apps/api/src/modules/automation/automation.module.ts create mode 100644 apps/api/src/modules/automation/automation.service.ts create mode 100644 apps/api/src/modules/automation/cron.ts create mode 100644 apps/api/src/modules/certificate/certificate.controller.ts create mode 100644 apps/api/src/modules/certificate/certificate.module.ts create mode 100644 apps/api/src/modules/certificate/certificate.service.ts create mode 100644 apps/api/src/modules/container/container.controller.ts create mode 100644 apps/api/src/modules/container/container.module.ts create mode 100644 apps/api/src/modules/container/container.service.ts create mode 100644 apps/api/src/modules/credential/credential-vault.service.ts create mode 100644 apps/api/src/modules/credential/credential.controller.ts create mode 100644 apps/api/src/modules/credential/credential.module.ts create mode 100644 apps/api/src/modules/credential/credential.service.ts create mode 100644 apps/api/src/modules/deployment/deployment.controller.ts create mode 100644 apps/api/src/modules/deployment/deployment.module.ts create mode 100644 apps/api/src/modules/deployment/deployment.service.ts create mode 100644 apps/api/src/modules/domain/domain.controller.ts create mode 100644 apps/api/src/modules/domain/domain.module.ts create mode 100644 apps/api/src/modules/domain/domain.service.ts create mode 100644 apps/api/src/modules/event/event-bus.service.ts create mode 100644 apps/api/src/modules/event/event.controller.ts create mode 100644 apps/api/src/modules/event/event.module.ts create mode 100644 apps/api/src/modules/git/git.controller.ts create mode 100644 apps/api/src/modules/git/git.module.ts create mode 100644 apps/api/src/modules/git/git.providers.ts create mode 100644 apps/api/src/modules/git/git.service.ts create mode 100644 apps/api/src/modules/identity/identity.controller.ts create mode 100644 apps/api/src/modules/identity/identity.module.ts create mode 100644 apps/api/src/modules/identity/identity.service.ts create mode 100644 apps/api/src/modules/job/job-queue.service.ts create mode 100644 apps/api/src/modules/job/job.controller.ts create mode 100644 apps/api/src/modules/job/job.module.ts create mode 100644 apps/api/src/modules/mcp/mcp.controller.ts create mode 100644 apps/api/src/modules/mcp/mcp.module.ts create mode 100644 apps/api/src/modules/mcp/mcp.service.ts create mode 100644 apps/api/src/modules/monitoring/monitoring.controller.ts create mode 100644 apps/api/src/modules/monitoring/monitoring.module.ts create mode 100644 apps/api/src/modules/monitoring/monitoring.service.ts create mode 100644 apps/api/src/modules/note/note.controller.ts create mode 100644 apps/api/src/modules/note/note.module.ts create mode 100644 apps/api/src/modules/note/note.service.ts create mode 100644 apps/api/src/modules/notification/notification.controller.ts create mode 100644 apps/api/src/modules/notification/notification.module.ts create mode 100644 apps/api/src/modules/notification/notification.service.ts create mode 100644 apps/api/src/modules/prisma/migrations/20260908181000_enum_certificate/migration.sql create mode 100644 apps/api/src/modules/resource/resource.controller.ts create mode 100644 apps/api/src/modules/resource/resource.module.ts create mode 100644 apps/api/src/modules/resource/resource.service.ts create mode 100644 apps/api/src/modules/search/search.controller.ts create mode 100644 apps/api/src/modules/search/search.module.ts create mode 100644 apps/api/src/modules/search/search.service.ts create mode 100644 apps/api/src/modules/server/server.controller.ts create mode 100644 apps/api/src/modules/server/server.module.ts create mode 100644 apps/api/src/modules/server/server.service.ts create mode 100644 apps/api/src/modules/task/task.controller.ts create mode 100644 apps/api/src/modules/task/task.module.ts create mode 100644 apps/api/src/modules/task/task.service.ts create mode 100644 apps/api/src/modules/website/website.controller.ts create mode 100644 apps/api/src/modules/website/website.module.ts create mode 100644 apps/api/src/modules/website/website.service.ts create mode 100644 apps/api/src/modules/worker/worker-scheduler.service.ts create mode 100644 apps/api/src/modules/worker/worker.module.ts create mode 100644 apps/api/src/modules/workspace/workspace.controller.ts create mode 100644 apps/api/src/modules/workspace/workspace.module.ts create mode 100644 apps/api/src/modules/workspace/workspace.service.ts create mode 100644 apps/api/src/prisma/prisma.module.ts create mode 100644 apps/api/src/prisma/prisma.service.ts create mode 100644 apps/api/tsconfig.build.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/api/client.ts create mode 100644 apps/web/src/app.tsx create mode 100644 apps/web/src/components/form-modal.tsx create mode 100644 apps/web/src/components/shared.tsx create mode 100644 apps/web/src/layouts/app-layout.tsx create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/ai.tsx create mode 100644 apps/web/src/pages/audit.tsx create mode 100644 apps/web/src/pages/automations.tsx create mode 100644 apps/web/src/pages/certificates.tsx create mode 100644 apps/web/src/pages/containers.tsx create mode 100644 apps/web/src/pages/credentials.tsx create mode 100644 apps/web/src/pages/dashboard.tsx create mode 100644 apps/web/src/pages/deployment-detail.tsx create mode 100644 apps/web/src/pages/deployments.tsx create mode 100644 apps/web/src/pages/domain-detail.tsx create mode 100644 apps/web/src/pages/domains.tsx create mode 100644 apps/web/src/pages/git.tsx create mode 100644 apps/web/src/pages/jobs.tsx create mode 100644 apps/web/src/pages/login.tsx create mode 100644 apps/web/src/pages/monitoring.tsx create mode 100644 apps/web/src/pages/notes.tsx create mode 100644 apps/web/src/pages/projects.tsx create mode 100644 apps/web/src/pages/repositories.tsx create mode 100644 apps/web/src/pages/resources.tsx create mode 100644 apps/web/src/pages/server-detail.tsx create mode 100644 apps/web/src/pages/servers.tsx create mode 100644 apps/web/src/pages/tasks.tsx create mode 100644 apps/web/src/pages/website-detail.tsx create mode 100644 apps/web/src/pages/websites.tsx create mode 100644 apps/web/src/services/api.ts create mode 100644 apps/web/src/stores/auth.ts create mode 100644 apps/web/src/stores/theme.ts create mode 100644 apps/web/tsconfig.json create mode 100644 bun.lock create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/DATABASE.md create mode 100644 infra/ssh-target/Dockerfile create mode 100644 package.json create mode 100644 packages/api-client/package.json create mode 100644 packages/api-client/src/index.ts create mode 100644 packages/api-client/tsconfig.json create mode 100644 packages/auth/package.json create mode 100644 packages/auth/src/index.ts create mode 100644 packages/auth/tsconfig.json create mode 100644 packages/types/package.json create mode 100644 packages/types/src/index.ts create mode 100644 packages/types/tsconfig.json create mode 100644 packages/ui/package.json create mode 100644 prisma/migrations/20260908000000_init/migration.sql create mode 100644 prisma/migrations/20260908180000_phase2/migration.sql create mode 100644 prisma/migrations/20260908181000_enum_certificate/migration.sql create mode 100644 prisma/migrations/20260908193000_note_pinned_folder/migration.sql create mode 100644 prisma/migrations/20260908195000_airun_source/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 prisma/set-admin-password.ts create mode 100755 scripts/api-start.sh create mode 100755 scripts/api-stop.sh create mode 100644 tests/auth.test.ts create mode 100644 tests/security.test.ts create mode 100644 tests/unit.test.ts create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c461455 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +PORT=4100 +HOST=0.0.0.0 +APP_SECRET=change-me-to-a-long-random-secret +ENCRYPTION_KEY=change-me-64-hex-chars-32-bytes-0000000000000000000000000000000000 +DATABASE_URL=postgresql://devos:devos@127.0.0.1:15432/devos +REDIS_URL=redis://127.0.0.1:16379 +SEED_ADMIN_EMAIL=admin@devos.local +SEED_ADMIN_PASSWORD=Devos12345! + +# ── AI 功能(Notes AI 助手 / AI Gateway 规划器)── +# 任意 OpenAI 兼容端点;配置后重启生效 +# AI_PROVIDER_BASE_URL=https://api.openai.com/v1 +# AI_PROVIDER_API_KEY=sk-xxx +# AI_PROVIDER_MODEL=gpt-4o-mini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..163d543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +build/ +*.tsbuildinfo +apps/web/dist/ +.env +*.local +.DS_Store +coverage/ +prisma/*.db +devos.sqlite* +logs/ diff --git a/Docs/devos-v1-phase2/00-GAP-ANALYSIS.md b/Docs/devos-v1-phase2/00-GAP-ANALYSIS.md new file mode 100644 index 0000000..9c1898b --- /dev/null +++ b/Docs/devos-v1-phase2/00-GAP-ANALYSIS.md @@ -0,0 +1,45 @@ +# 00 — Gap Analysis + +## 当前状态判断 +现有实现已经具备优秀的基础设施骨架:NestJS/Fastify、React/Ant Design/ProComponents、PostgreSQL/Prisma、Redis、SSH、Docker、DNS、部署、监控、Automation、AI/MCP、Credential Vault、RBAC/Audit。 + +问题不是“没有基础”,而是大量模块停留在 MVP/adapter/demo 层,尚未形成真正的 Developer Work OS。 + +## 最大缺口 + +### P0 — 必须补齐 +1. 统一 Resource Model 没有成为所有模块的真正核心。 +2. Project → Repository → Website → Server → Deployment → Domain → Monitoring 关系链不完整。 +3. Website 部署仍依赖 HTTPS 仓库 URL 内嵌 token,必须改成 Git Provider Credential + clone adapter。 +4. Git Provider 集成需要 GitHub/GitLab/Gitea 可扩展模型,而不是只存 URL。 +5. Server 管理需要从“连接/发现”升级为完整生命周期管理。 +6. Docker 管理需要更多实际操作、实时日志、资源信息。 +7. Domain/DNS/SSL 需要从 CRUD 升级为真实 provider abstraction。 +8. Automation 需要完整 trigger/action/event/job 模型和 UI。 +9. AI Agent 需要真正的 planning → approval → execution → verification,而不是 AI run demo。 +10. MCP 必须复用内部 application services,不能形成第二套业务逻辑。 +11. Email、Knowledge/Notes、Documents、Projects/Tasks 仍明显不足。 +12. Dashboard 必须成为跨资源工作台,而非统计页。 + +### P1 +- 通知中心 +- 全局搜索 +- Command Palette +- Activity Timeline +- Job Center +- Resource relation UI +- Provider connection management +- Environment/secret 管理 +- Backup/restore abstraction +- Webhook HMAC + +### P2 +- Kubernetes +- Cloud provider +- 多租户高级策略 +- 完整邮件客户端 +- 高级文档协作 +- DAG/parallel workflow + +## 核心判断 +后续开发不得继续以“增加一个页面/接口”为单位。必须以“完整业务闭环”为单位。 diff --git a/Docs/devos-v1-phase2/01-TARGET-SCOPE.md b/Docs/devos-v1-phase2/01-TARGET-SCOPE.md new file mode 100644 index 0000000..c2281cc --- /dev/null +++ b/Docs/devos-v1-phase2/01-TARGET-SCOPE.md @@ -0,0 +1,45 @@ +# 01 — Target Scope + +DevOS 的目标不是替代每个专业软件,而是建立统一的开发者工作入口。 + +## 一级能力 +- Dashboard +- AI +- Projects +- Infrastructure +- Websites +- Code +- Domains +- Email +- Knowledge +- Tasks +- Automation +- Credentials +- Monitoring +- Audit +- Settings + +## 四个核心抽象 + +```text +Resource → Action → Event → Agent +``` + +所有业务能力都应该尽可能映射到这四个抽象。 + +## Developer Workspace +用户进入平台后,应能从 Project 出发看到: +- 代码仓库 +- 网站 +- 服务器 +- 容器 +- 数据库 +- 域名 +- SSL +- 部署 +- 监控 +- 任务 +- 文档 +- 自动化 + +并能从任何资源进入关联资源。 diff --git a/Docs/devos-v1-phase2/02-RESOURCE-MODEL.md b/Docs/devos-v1-phase2/02-RESOURCE-MODEL.md new file mode 100644 index 0000000..fd826bd --- /dev/null +++ b/Docs/devos-v1-phase2/02-RESOURCE-MODEL.md @@ -0,0 +1,44 @@ +# 02 — Unified Resource Model + +## 原则 +Resource Registry 必须从“通用 CRUD”升级为平台的统一索引层。 + +## Resource 基础字段 +```ts +interface Resource { + id: string + workspaceId: string + type: ResourceType + name: string + status: string + provider?: string + externalId?: string + metadata: Record + createdAt: Date + updatedAt: Date +} +``` + +## 必须支持的类型 +Server, Container, Website, Domain, Certificate, Repository, Deployment, Database, Project, Task, Note, Document, Credential, Automation, Job, Monitor。 + +## Relation +实现有方向关系并支持 relation type,例如: +- PROJECT_HAS_REPOSITORY +- PROJECT_HAS_WEBSITE +- WEBSITE_DEPLOYED_TO_SERVER +- WEBSITE_USES_DOMAIN +- WEBSITE_HAS_DEPLOYMENT +- DEPLOYMENT_FROM_REPOSITORY +- DEPLOYMENT_TO_SERVER +- SERVER_HAS_CONTAINER +- PROJECT_HAS_TASK +- PROJECT_HAS_DOCUMENT + +## 关键要求 +- 所有资源必须 workspace scoped。 +- 资源详情页必须显示 Relations。 +- 支持反向关系。 +- 支持按关系跳转。 +- 删除资源前检查关系影响。 +- 不要让 Resource Registry 替代领域模型;它负责索引/关系/统一查询,领域模块负责业务规则。 diff --git a/Docs/devos-v1-phase2/03-INFRASTRUCTURE.md b/Docs/devos-v1-phase2/03-INFRASTRUCTURE.md new file mode 100644 index 0000000..8901588 --- /dev/null +++ b/Docs/devos-v1-phase2/03-INFRASTRUCTURE.md @@ -0,0 +1,58 @@ +# 03 — Infrastructure Completion + +## Server +完善: +- create/edit/delete +- connect/disconnect +- connection health +- OS/kernel/arch +- CPU/RAM/disk/network +- uptime +- running processes摘要 +- Docker availability +- last seen +- tags +- credentials reference + +## SSH +必须: +- connect timeout +- command timeout +- output size limit +- stderr separation +- exit code +- audit log +- secret masking +- cancellation + +## Docker +至少实现: +- list containers +- inspect +- start/stop/restart +- logs with tail/follow abstraction +- image list +- container resource usage +- ports +- environment key names(禁止返回 secret values) +- status/health + +## Jobs +所有长任务统一 Job Center: +- queued/running/success/failed/cancelled +- progress +- logs +- retry +- actor +- resource relation + +## Monitoring +建立 provider-neutral model: +- HTTP health check +- TCP check +- uptime state +- response time +- incident +- recovery event + +V1 不需要复杂 Prometheus clone,但必须让 Website/Server/Deployment 都能进入统一 monitoring/activity 体系。 diff --git a/Docs/devos-v1-phase2/04-WEBSITE-DEPLOYMENT.md b/Docs/devos-v1-phase2/04-WEBSITE-DEPLOYMENT.md new file mode 100644 index 0000000..0d9aea6 --- /dev/null +++ b/Docs/devos-v1-phase2/04-WEBSITE-DEPLOYMENT.md @@ -0,0 +1,61 @@ +# 04 — Website & Deployment Completion + +## Website 生命周期 +```text +Website + → Repository + → Environment + → Server + → Build + → Deploy + → Health Check + → Domain + → SSL + → Monitoring +``` + +## Repository access +禁止要求用户把 private repository token 放进 URL。 + +建立: +```text +GitProviderConnection + ├── provider + ├── credentialId + ├── externalAccountId + └── metadata +``` + +Repository 使用 provider + externalId/clone metadata。 + +## Deployment +Deployment 必须成为 Job 的一种领域操作,并保存: +- commit/ref +- source repository +- target server +- environment +- build strategy +- deployment strategy +- started/finished +- logs +- result +- health check + +## 部署策略 +V1 支持: +1. Docker Compose / Dockerfile 项目 +2. 静态网站 +3. 可扩展 deployment adapter + +不要把部署逻辑硬编码到 Website Controller。 + +## Environment +支持: +- development +- staging +- production + +环境变量值使用 Credential Vault 或加密存储,UI 默认 mask。 + +## Health verification +部署成功不能只看 SSH exit code,必须执行 health check;失败则 Deployment 状态为 failed/verification_failed,并展示证据。 diff --git a/Docs/devos-v1-phase2/05-GIT-DOMAIN-EMAIL-KNOWLEDGE.md b/Docs/devos-v1-phase2/05-GIT-DOMAIN-EMAIL-KNOWLEDGE.md new file mode 100644 index 0000000..b888fff --- /dev/null +++ b/Docs/devos-v1-phase2/05-GIT-DOMAIN-EMAIL-KNOWLEDGE.md @@ -0,0 +1,60 @@ +# 05 — Code / Domain / Email / Knowledge + +## Git +建立 provider adapter: +- GitHub +- GitLab +- Gitea + +统一能力: +- account connection +- repositories +- branches +- commits +- pull/merge requests +- issues(基础) +- webhook + +V1 不需要实现 Git hosting,只需要聚合外部 provider。 + +## Domain/DNS +建立 provider interface: +- listZones +- listRecords +- createRecord +- updateRecord +- deleteRecord +- verify + +Cloudflare 是第一个正式 adapter,Manual 是 fallback。 + +DNS 操作必须有 preview + audit。 + +## SSL +统一 Certificate 模型,provider/adaptor 可插拔。 +至少支持状态:pending/active/expired/error。 + +## Email +V1 不做完整 Gmail clone。先实现: +- EmailAccount +- provider connection +- inbox message metadata +- send email abstraction +- search metadata +- attachments metadata + +支持 SMTP/IMAP adapter;Provider OAuth 可后续加入。 + +## Knowledge +实现: +- Note +- Document +- folder/tree +- tags +- markdown/rich editor +- code block +- backlinks/resource links + +TipTap 为默认编辑器。 + +重点:Note/Document 可以关联 Project/Resource/Task,成为操作上下文的一部分。 diff --git a/Docs/devos-v1-phase2/06-PROJECT-TASK-AUTOMATION.md b/Docs/devos-v1-phase2/06-PROJECT-TASK-AUTOMATION.md new file mode 100644 index 0000000..a49e744 --- /dev/null +++ b/Docs/devos-v1-phase2/06-PROJECT-TASK-AUTOMATION.md @@ -0,0 +1,50 @@ +# 06 — Projects / Tasks / Automation + +## Project +Project 是用户工作的顶层业务上下文,而不是简单 CRUD。 + +Project overview 必须汇总: +- repositories +- websites +- servers +- domains +- deployments +- tasks +- notes +- documents +- automations +- recent activity + +## Task +支持: +- backlog/todo/doing/review/done +- priority +- labels +- due date +- relations +- resource links +- activity + +## Automation +统一: +```text +Trigger → Condition → Action → Job → Event +``` + +Triggers: +- manual +- cron +- webhook +- event + +Actions 至少: +- deployment.run +- server.command(受权限和风险策略限制) +- container.restart +- task.create/update +- note.create +- notification.send + +Webhook 必须从明文 secret 比对升级到 HMAC 签名,支持 timestamp/replay protection。 + +Automation UI 必须能创建、编辑、启停、测试、查看执行历史。 diff --git a/Docs/devos-v1-phase2/07-AI-AGENT-MCP.md b/Docs/devos-v1-phase2/07-AI-AGENT-MCP.md new file mode 100644 index 0000000..c15b5e0 --- /dev/null +++ b/Docs/devos-v1-phase2/07-AI-AGENT-MCP.md @@ -0,0 +1,57 @@ +# 07 — AI Agent / MCP Completion + +## Agent architecture +```text +Intent + → Context Discovery + → Plan + → Risk + → Approval + → Execute + → Verify + → Report +``` + +## Tool registry +每个 tool 必须包含: +- name +- description +- input schema +- output schema +- required permissions +- risk level +- idempotency behavior +- audit event + +## V1 必须支持的 AI 场景 +1. “列出所有服务器和当前状态。” +2. “查看 blog 项目最近一次部署。” +3. “分析为什么网站不可用。” +4. “部署 blog 到指定服务器。” +5. “重启异常容器。” +6. “创建任务记录这个问题。” +7. “把这个事件配置成自动化。” + +## Approval +LOW read-only 可直接执行。 +MEDIUM 根据 policy。 +HIGH/CRITICAL 必须二次确认。 + +审批内容必须显示: +- 操作 +- 资源 +- 影响 +- 参数摘要 +- 风险 + +## Verification +任何 mutation tool 都应该定义 verify handler。 +例如 deployment.run → deployment.status + health.check。 + +## Memory +V1 先做短期运行上下文 + resource references,不要过早建设复杂向量数据库。 + +## MCP +MCP 是 DevOS Tool Registry 的 adapter。 +REST、Web UI、Agent、MCP 必须共享 application service。 +禁止 MCP 直接访问 Prisma。 diff --git a/Docs/devos-v1-phase2/08-SECURITY-HARDENING.md b/Docs/devos-v1-phase2/08-SECURITY-HARDENING.md new file mode 100644 index 0000000..22bf363 --- /dev/null +++ b/Docs/devos-v1-phase2/08-SECURITY-HARDENING.md @@ -0,0 +1,35 @@ +# 08 — Security Hardening + +这是继续开发前的 P0 gate。 + +## 立即处理 +1. 修改/禁用 README 中暴露的默认管理员账号密码。 +2. 轮换 APP_SECRET。 +3. 轮换 ENCRYPTION_KEY 前先设计 credential migration/clear strategy;不要盲目轮换导致现有凭据全部不可解密。 +4. 检查公网注册策略;生产模式默认关闭公开注册,或增加 invite-only。 +5. 检查 `/root/.ldns/config.toml`,确认 token 不进入项目、日志、git、API 响应。 +6. 删除冒烟测试 credential/test-token。 + +## Auth +增加: +- password change +- session invalidation strategy(即使 V1 暂不实现完整 revoke list,也必须保留扩展点) +- login rate limit +- register rate limit +- optional invite-only mode + +## Credentials +- AES-256-GCM 保留 +- key 不存数据库 +- secret access audit +- API never returns plaintext +- logs redact tokens/passwords/keys +- export disabled by default + +## Command security +危险命令检测只是最后一道防线,不是授权机制。 +真正权限模型必须先于 command execution。 + +## Production config +生产启动时必须拒绝明显开发默认 secret。 +`.env.example` 可以有 placeholder,但 runtime 必须校验。 diff --git a/Docs/devos-v1-phase2/09-UX-UNIFIED-WORKSPACE.md b/Docs/devos-v1-phase2/09-UX-UNIFIED-WORKSPACE.md new file mode 100644 index 0000000..46504e8 --- /dev/null +++ b/Docs/devos-v1-phase2/09-UX-UNIFIED-WORKSPACE.md @@ -0,0 +1,50 @@ +# 09 — Unified Workspace UX + +## Global shell +ProLayout + workspace switcher + global search + notifications + AI entry。 + +## Command Palette +支持: +- search resource +- go to project +- create task +- deploy website +- open server +- run automation +- ask AI + +## Global Search +搜索: +- projects +- resources +- repositories +- websites +- domains +- tasks +- notes +- deployments + +结果显示 resource type + status + project。 + +## Activity +统一 Activity Feed,至少包括: +- deployment +- server command +- automation execution +- credential access +- task changes +- AI tool call + +## Dashboard +Dashboard 不以数字 KPI 为核心,而以“今天我要处理什么”为核心: +- infrastructure incidents +- failed deployments +- pending approvals +- tasks due +- recent projects +- recent activity +- AI recommendations + +## Resource detail +统一 tabs:Overview / Relations / Activity / Logs / Actions。 +按资源类型隐藏不适用 tab。 diff --git a/Docs/devos-v1-phase2/10-API-UI-CONTRACT.md b/Docs/devos-v1-phase2/10-API-UI-CONTRACT.md new file mode 100644 index 0000000..fcaf065 --- /dev/null +++ b/Docs/devos-v1-phase2/10-API-UI-CONTRACT.md @@ -0,0 +1,41 @@ +# 10 — API/UI Contract + +## API +统一 `/api/v1`。 + +所有 response/error 使用已有错误契约,不得出现模块私有错误格式。 + +Mutation API: +- authorization +- validation +- audit +- job if long-running + +## Pagination +列表统一 cursor 或 page/limit,避免每个模块自定义。 + +## Filtering +Resource list 支持: +- type +- status +- tag +- project +- search + +## UI +ProTable + ProForm + ProDescriptions 作为默认模式。 + +长任务统一 JobDrawer/JobDetail。 + +危险操作统一 Confirm + risk explanation。 + +## Loading/error/empty +所有页面必须处理: +- loading +- empty +- permission denied +- network error +- operation failure + +## API client +前端不得在页面中直接拼 fetch URL;统一 api client/service。 diff --git a/Docs/devos-v1-phase2/11-TEST-ACCEPTANCE.md b/Docs/devos-v1-phase2/11-TEST-ACCEPTANCE.md new file mode 100644 index 0000000..92ca609 --- /dev/null +++ b/Docs/devos-v1-phase2/11-TEST-ACCEPTANCE.md @@ -0,0 +1,35 @@ +# 11 — Test & Acceptance + +## Quality gate +每个 milestone 必须: +- typecheck +- unit tests +- API integration tests +- critical E2E +- security regression + +## Critical E2E +1. login → workspace → dashboard +2. add server → connect → inspect +3. server → containers → restart → audit +4. create project → connect repository +5. create website → deploy → health check +6. domain → DNS verify +7. create task → link project/resource +8. create automation → run → inspect job +9. AI read-only query +10. AI mutation → approval → execution → verification +11. MCP tool call → same permission/audit behavior +12. credential create → access → redaction + +## Security tests +- cross-workspace resource access denied +- unauthorized server command denied +- secret never returned by API +- secret never appears in logs +- webhook replay rejected +- destructive AI action requires approval +- production defaults rejected + +## Done definition +V1 Phase 2 不是“页面全部存在”,而是上述闭环可真实运行。 diff --git a/Docs/devos-v1-phase2/12-CODEX-EXECUTION.md b/Docs/devos-v1-phase2/12-CODEX-EXECUTION.md new file mode 100644 index 0000000..73988ed --- /dev/null +++ b/Docs/devos-v1-phase2/12-CODEX-EXECUTION.md @@ -0,0 +1,95 @@ +# 12 — Codex Execution Plan + +## Rules +- Continue from `/home/dev/bun/devos`. +- Do NOT recreate the project. +- Before changes, inspect current source and Prisma schema. +- Preserve working functionality. +- Make incremental migrations only. +- Do not reset/drop production-like databases. +- Do not expose secrets. + +## Phase A — Audit and hardening +1. Inspect auth, workspace guard, credential vault, mask utilities. +2. Remove/disable default admin credential exposure. +3. Add production secret validation. +4. Disable public registration by default in production. +5. Remove test credential. +6. Upgrade webhook authentication to HMAC + timestamp. +7. Add security regression tests. + +## Phase B — Resource graph +1. Extend ResourceRelation. +2. Build relation service. +3. Add resource relation endpoints. +4. Add relation UI. +5. Add project-centric overview. + +## Phase C — Infrastructure +1. Complete server lifecycle. +2. Complete Docker operations. +3. Standardize Job Center. +4. Add monitoring/incident model. + +## Phase D — Website/deployment +1. Git provider connection model. +2. Repository model. +3. Credential-based private repo access. +4. Deployment adapters. +5. Environment management. +6. Health verification. +7. Deployment timeline/logs. + +## Phase E — Code/domain/productivity +1. GitHub/GitLab/Gitea adapters. +2. Domain/DNS provider abstraction. +3. Certificate model. +4. Project/Task completion. +5. Note/Document completion. +6. Email account/send/search foundation. + +## Phase F — Automation +1. Event catalog. +2. Trigger/action schemas. +3. Sequential workflow executor. +4. HMAC webhook. +5. Job Center integration. +6. Automation UI. + +## Phase G — AI +1. Tool registry. +2. Tool permission resolver. +3. Risk engine. +4. Approval model/UI. +5. Planner. +6. Executor. +7. Verification. +8. Execution report. + +## Phase H — MCP +Expose selected stable tools through MCP, reusing the exact application services and authorization pipeline. + +## Phase I — Unified UX +1. Global search. +2. Command palette. +3. Activity feed. +4. Job center. +5. AI command entry. +6. Project dashboard. +7. Resource relation navigation. + +## Execution protocol +For each phase: +1. inspect current implementation +2. write/update design notes +3. implement backend +4. migrate DB safely +5. implement frontend +6. add tests +7. run typecheck +8. run unit/integration/E2E +9. update docs +10. report files changed and verification + +## Important +Do not mark a feature complete merely because its endpoint returns 200. Acceptance requires a complete user-facing workflow and tests. diff --git a/Docs/devos-v1-phase2/README.md b/Docs/devos-v1-phase2/README.md new file mode 100644 index 0000000..355362a --- /dev/null +++ b/Docs/devos-v1-phase2/README.md @@ -0,0 +1,21 @@ +# DevOS V1 Phase 2 — Completion Specification + +目标:在现有 `/home/dev/bun/devos` V1 基础上继续开发,不推倒重来,补齐当前与目标产品约 80% 的差距。 + +## 使用方式 +建议先阅读: +1. 00-GAP-ANALYSIS.md +2. 01-TARGET-SCOPE.md +3. 02-RESOURCE-MODEL.md +4. 03-INFRASTRUCTURE.md +5. 04-WEBSITE-DEPLOYMENT.md +6. 05-GIT-DOMAIN-EMAIL-KNOWLEDGE.md +7. 06-PROJECT-TASK-AUTOMATION.md +8. 07-AI-AGENT-MCP.md +9. 08-SECURITY-HARDENING.md +10. 09-UX-UNIFIED-WORKSPACE.md +11. 10-API-UI-CONTRACT.md +12. 11-TEST-ACCEPTANCE.md +13. 12-CODEX-EXECUTION.md + +本文件包是“增量开发规范”,不是要求重新创建 DevOS。 diff --git a/Docs/devos-v1/00-PRD.md b/Docs/devos-v1/00-PRD.md new file mode 100644 index 0000000..9d02114 --- /dev/null +++ b/Docs/devos-v1/00-PRD.md @@ -0,0 +1,83 @@ +# DevOS v1 Product Requirements Document + +## 1. Product Definition +DevOS is a Developer Work OS: a unified workspace for servers, websites, domains, code, databases, email, notes, tasks, documents, monitoring and automation, with AI as an operational Agent rather than merely a chat interface. + +## 2. Core Product Model +The platform is organized around four primitives: + +**Resource + Action + Event + Agent** + +- Resource: everything managed by DevOS. +- Action: an operation performed against a resource. +- Event: a state change or external signal. +- Agent: observes resources, plans actions, executes through tools, verifies outcomes and reports. + +The central architectural principle is the **Unified Resource Model**. + +## 3. Primary Users +- Individual developers +- Small development teams + +## 4. V1 Scope +V1 focuses on Infrastructure OS capabilities: +- Server +- Docker / Containers +- Website +- Domain +- Deployment +- Monitoring +- Authentication, Workspace, Resource, Permission and Audit foundations + +## 5. V2 Scope +Developer OS: +- GitHub / GitLab / other Git providers +- Projects +- Tasks +- Notes +- Documents +- Email + +## 6. V3 Scope +Automation OS: +- Event Bus +- Workflow Engine +- Cron +- Webhook +- Notifications + +## 7. V4 Scope +AI OS: +- Resource Discovery +- Planning +- Tool Calling +- Execution +- Verification +- Memory +- Approval + +## 8. Representative User Journey +“Deploy my blog to the Tokyo server, use Docker, configure blog.example.com and enable HTTPS.” + +The intended Agent flow: +1. Find Project and Repository +2. Find target Server +3. Check Docker +4. Clone repository +5. Build image +6. Run container +7. Configure reverse proxy +8. Configure DNS +9. Obtain SSL +10. Health check +11. Return an action log and result + +## 9. Non-Goals +- Reimplementing Git +- Reimplementing SMTP +- Building a Kubernetes control plane in V1 +- Building a full Notion replacement in the first release +- Starting with microservices + +## 10. Product Success Criteria +A developer can manage the core infrastructure lifecycle from one workspace, while resources remain related and discoverable enough for later automation and AI operation. diff --git a/Docs/devos-v1/01-PRODUCT-ARCHITECTURE.md b/Docs/devos-v1/01-PRODUCT-ARCHITECTURE.md new file mode 100644 index 0000000..42dbb4b --- /dev/null +++ b/Docs/devos-v1/01-PRODUCT-ARCHITECTURE.md @@ -0,0 +1,105 @@ +# DevOS v1 Product Architecture + +## 1. System Concept + +```text + DevOS AI + Agent / Copilot / MCP + | + +-------------------+-------------------+ + | | | + Infrastructure Projects Productivity + | | | + Server / Docker / DB Git / Website / API Notes / Tasks / Docs + +-------------------+-------------------+ + | + Unified Resource Model +``` + +## 2. Core Primitives + +```text +Resource + | + Action + | + Event + | + Automation / Agent + | + Resource +``` + +Example: +- Resource: Website/blog.com +- Action: Deploy +- Event: deployment.success +- Automation: Send notification +- Agent: Analyze deployment + +## 3. Resource Graph +Resources are related rather than isolated: + +```text +Project + ├── Repository + ├── Website + ├── Server + ├── Database + ├── Deployment + ├── Domain + ├── Email + └── Monitoring +``` + +## 4. Architectural Style +- Frontend: React application +- Backend: modular monolith +- Persistence: PostgreSQL +- Cache/async infrastructure: Redis +- Integrations: provider adapters +- AI: Agent + Tool + MCP + Workflow layer +- Infrastructure execution: SSH, Docker API, provider APIs + +## 5. Module Boundaries +Core modules: +- Identity +- Workspace +- Resource +- Permission +- Audit + +Infrastructure: +- Server +- Container +- Website +- Domain +- Deployment +- Monitoring + +Integration: +- Git Provider +- DNS Provider +- Cloud Provider +- Email Provider +- Docker + +Automation: +- Event +- Workflow +- Trigger +- Action +- Job + +AI: +- AI Gateway +- Agent +- Planner +- Executor +- Memory +- Approval +- Tools +- MCP + +## 6. Key Principle +Do not turn every domain into a separate service in V1. Modules must have explicit boundaries while sharing one deployable backend. diff --git a/Docs/devos-v1/02-TECH-STACK.md b/Docs/devos-v1/02-TECH-STACK.md new file mode 100644 index 0000000..772168c --- /dev/null +++ b/Docs/devos-v1/02-TECH-STACK.md @@ -0,0 +1,40 @@ +# DevOS v1 Technology Stack + +## Frontend +- React +- TypeScript +- Ant Design +- Ant Design ProComponents +- React Router +- TanStack Query +- Zustand +- TipTap + +## Backend +- Node.js +- TypeScript +- NestJS +- Prefer Fastify adapter where appropriate + +## Data +- PostgreSQL +- Prisma +- Redis + +## Infrastructure +- Docker +- SSH +- Docker API +- Nginx or Caddy integration +- Linux +- External DNS / cloud / Git / email APIs + +## Architecture Decision +Prefer NestJS + Prisma for the initial implementation because the source discussion prioritizes modular structure and AI-friendly code comprehension. + +## Dependency Rule +Do not introduce a new dependency without documenting: +1. Why existing dependencies cannot satisfy the requirement +2. Security and maintenance implications +3. Operational impact +4. Alternative considered diff --git a/Docs/devos-v1/03-DOMAIN-MODEL.md b/Docs/devos-v1/03-DOMAIN-MODEL.md new file mode 100644 index 0000000..b551e0b --- /dev/null +++ b/Docs/devos-v1/03-DOMAIN-MODEL.md @@ -0,0 +1,105 @@ +# DevOS v1 Domain Model + +## 1. Resource + +```typescript +interface Resource { + id: string + type: ResourceType + name: string + workspaceId: string + provider?: string + status: ResourceStatus + metadata: Record + createdAt: Date + updatedAt: Date +} +``` + +## 2. Resource Types +Initial model: +- Server +- Domain +- Website +- Repository +- Database +- Container +- EmailAccount +- Project +- Task +- Note +- Document +- Backup +- Deployment +- Monitor +- Credential + +## 3. Core Entities + +### Workspace +Tenant/security boundary for users and resources. + +### Project +Business/work boundary connecting repositories, websites, servers, databases, deployments, domains, email and monitoring. + +### Server +Managed compute target. Supports SSH connection and infrastructure discovery. + +### Website +Deployable application resource associated with domain, repository, deployment, server/container, database, SSL, environment, logs, monitoring and backup. + +### Deployment +A concrete execution of deploying a repository/version to a target. + +### Credential +Encrypted provider or infrastructure secret reference. Secret material is never exposed as ordinary resource metadata. + +### Task +Work item associated with a project and optionally related to resources and automations. + +## 4. Relationships + +```text +Workspace + ├── Projects + ├── Resources + ├── Users + ├── Credentials + └── Automations + +Project + ├── Repository* + ├── Website* + ├── Server* + ├── Database* + ├── Deployment* + ├── Domain* + ├── Task* + └── Monitoring* +``` + +## 5. Actions +Every mutable operation should be modeled as an auditable action: +- server.exec +- server.restart +- server.installPackage +- website.deploy +- website.logs +- domain.updateRecords +- deployment.create +- container.restart + +## 6. Events +Canonical event names should use `.` or `.`: +- deployment.created +- deployment.started +- deployment.success +- deployment.failed +- server.online +- server.offline +- website.down +- website.recovered +- email.received +- github.push +- task.created +- task.completed diff --git a/Docs/devos-v1/04-DATABASE-SCHEMA.md b/Docs/devos-v1/04-DATABASE-SCHEMA.md new file mode 100644 index 0000000..f404f82 --- /dev/null +++ b/Docs/devos-v1/04-DATABASE-SCHEMA.md @@ -0,0 +1,119 @@ +# DevOS v1 Database Schema + +## 1. Persistence Strategy +PostgreSQL is the system of record. Prisma is the initial ORM. + +## 2. Core Tables + +Recommended initial tables: +- users +- workspaces +- workspace_members +- resources +- resource_relations +- projects +- project_resources +- credentials +- audit_logs +- events +- jobs +- deployments +- servers +- websites +- domains +- containers +- monitors + +## 3. Resource Table + +Conceptual schema: + +```text +resources +- id +- workspace_id +- type +- name +- provider +- status +- metadata_json +- created_at +- updated_at +``` + +Use a typed domain table when the resource has substantial operational state; use `resources` for identity, discovery and graph-level common fields. + +## 4. Resource Relations + +```text +resource_relations +- id +- workspace_id +- source_resource_id +- target_resource_id +- relation_type +- metadata_json +- created_at +``` + +This allows the resource graph to evolve without embedding every relationship into one giant table. + +## 5. Credential Storage +Store encrypted credential payloads separately from general resource metadata. + +```text +credentials +- id +- workspace_id +- type +- provider +- encrypted_data +- metadata_json +- created_at +- updated_at +``` + +The application must never log decrypted credential material. + +## 6. Audit Log + +```text +audit_logs +- id +- workspace_id +- actor_type +- actor_id +- action +- resource_type +- resource_id +- risk_level +- status +- request_id +- metadata_json +- created_at +``` + +## 7. Event + +```text +events +- id +- workspace_id +- event_type +- resource_id +- payload_json +- correlation_id +- created_at +``` + +## 8. Indexing +At minimum index: +- workspace_id +- resource type/status +- resource relation endpoints +- event type + created_at +- audit workspace + created_at +- deployment website/project + created_at +- jobs status + scheduled_at + +Exact migrations should be generated from the implementation model and reviewed before production. diff --git a/Docs/devos-v1/05-API-SPEC.md b/Docs/devos-v1/05-API-SPEC.md new file mode 100644 index 0000000..a5ef91e --- /dev/null +++ b/Docs/devos-v1/05-API-SPEC.md @@ -0,0 +1,109 @@ +# DevOS v1 API Specification + +## 1. API Style +Use versioned HTTP APIs under `/api/v1`. + +All mutations use explicit DTOs and return structured resources/results. + +## 2. Core Endpoints + +### Auth +```text +POST /api/v1/auth/login +POST /api/v1/auth/logout +GET /api/v1/auth/me +``` + +### Workspaces +```text +GET /api/v1/workspaces +POST /api/v1/workspaces +GET /api/v1/workspaces/:workspaceId +``` + +### Resources +```text +GET /api/v1/resources +POST /api/v1/resources +GET /api/v1/resources/:id +PATCH /api/v1/resources/:id +DELETE /api/v1/resources/:id +GET /api/v1/resources/:id/relations +``` + +### Servers +```text +GET /api/v1/servers +POST /api/v1/servers +GET /api/v1/servers/:id +POST /api/v1/servers/:id/connect +GET /api/v1/servers/:id/status +GET /api/v1/servers/:id/containers +POST /api/v1/servers/:id/exec +POST /api/v1/servers/:id/restart +``` + +### Websites +```text +GET /api/v1/websites +POST /api/v1/websites +GET /api/v1/websites/:id +POST /api/v1/websites/:id/deploy +GET /api/v1/websites/:id/logs +GET /api/v1/websites/:id/health +``` + +### Deployments +```text +GET /api/v1/deployments +GET /api/v1/deployments/:id +POST /api/v1/deployments +POST /api/v1/deployments/:id/cancel +``` + +### Domains +```text +GET /api/v1/domains +POST /api/v1/domains +GET /api/v1/domains/:id +POST /api/v1/domains/:id/records +``` + +### Automation +```text +GET /api/v1/automations +POST /api/v1/automations +GET /api/v1/automations/:id +POST /api/v1/automations/:id/enable +POST /api/v1/automations/:id/disable +``` + +### AI +```text +POST /api/v1/ai/runs +GET /api/v1/ai/runs/:id +POST /api/v1/ai/runs/:id/approve +``` + +## 3. Mutation Contract +Every mutation: +- validates a DTO +- checks workspace authorization +- determines risk +- writes an audit record +- emits domain events when appropriate +- returns a request/correlation identifier + +## 4. Error Contract + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "Resource was not found", + "requestId": "..." + } +} +``` + +Never expose stack traces, credentials or provider secrets through API errors. diff --git a/Docs/devos-v1/06-FRONTEND-ARCHITECTURE.md b/Docs/devos-v1/06-FRONTEND-ARCHITECTURE.md new file mode 100644 index 0000000..e1377ca --- /dev/null +++ b/Docs/devos-v1/06-FRONTEND-ARCHITECTURE.md @@ -0,0 +1,71 @@ +# DevOS v1 Frontend Architecture + +## 1. Stack +React + TypeScript + Ant Design + ProComponents + TanStack Query + Zustand + React Router + TipTap. + +## 2. Application Layout + +```text +apps/web +├── app +├── routes +├── layouts +├── features +│ ├── auth +│ ├── dashboard +│ ├── resources +│ ├── servers +│ ├── websites +│ ├── deployments +│ ├── domains +│ ├── monitoring +│ ├── automation +│ └── ai +├── components +└── services +``` + +Shared packages: + +```text +packages/ +├── ui/ +├── api-client/ +├── types/ +└── auth/ +``` + +## 3. UI Principle +Use Ant Design and ProComponents instead of building common enterprise controls from scratch. + +Primary components: +- ProLayout +- ProTable +- ProForm +- ProDescriptions +- ProCard +- ProList + +## 4. State +- Server state: TanStack Query +- Local UI state: Zustand / React state +- Do not duplicate server state into global stores without a clear reason. + +## 5. Dashboard +The home page should prioritize: +- AI command entry +- Infrastructure health +- Projects +- Recent activity +- Deployment/monitoring warnings + +## 6. Resource UI +Resource detail pages should expose: +- Overview +- Relations +- Actions +- Activity/Audit +- Logs where applicable +- Related resources + +Dangerous actions require explicit confirmation and risk presentation. diff --git a/Docs/devos-v1/07-BACKEND-ARCHITECTURE.md b/Docs/devos-v1/07-BACKEND-ARCHITECTURE.md new file mode 100644 index 0000000..4f9a877 --- /dev/null +++ b/Docs/devos-v1/07-BACKEND-ARCHITECTURE.md @@ -0,0 +1,73 @@ +# DevOS v1 Backend Architecture + +## 1. Style +Modular monolith with NestJS. + +## 2. Suggested Structure + +```text +apps/api/ +├── src/ +│ ├── modules/ +│ │ ├── identity/ +│ │ ├── workspace/ +│ │ ├── resource/ +│ │ ├── permission/ +│ │ ├── audit/ +│ │ ├── server/ +│ │ ├── container/ +│ │ ├── website/ +│ │ ├── domain/ +│ │ ├── deployment/ +│ │ ├── monitoring/ +│ │ ├── automation/ +│ │ └── ai/ +│ ├── integrations/ +│ │ ├── ssh/ +│ │ ├── docker/ +│ │ ├── git/ +│ │ ├── dns/ +│ │ ├── cloud/ +│ │ └── email/ +│ ├── common/ +│ └── main.ts +``` + +## 3. Rules +- Controllers handle transport only. +- DTOs define input/output contracts. +- Business logic belongs in application/domain services. +- Provider-specific logic belongs behind adapters. +- Credential access is centralized. +- Mutations are auditable. +- Long-running operations run asynchronously. +- External calls have timeouts, retries where safe, and idempotency where possible. + +## 4. Infrastructure Adapters +DevOS does not reimplement: +- SSH +- Git +- Docker +- DNS +- SMTP +- cloud control planes + +It provides unified adapters over those systems. + +## 5. Async Execution +Use Redis-backed jobs for: +- deployment +- server discovery +- monitoring checks +- long-running SSH commands +- automation execution + +## 6. Observability +Every operation should carry: +- request ID +- correlation ID +- actor +- workspace +- resource +- action +- outcome diff --git a/Docs/devos-v1/08-AI-AGENT-ARCHITECTURE.md b/Docs/devos-v1/08-AI-AGENT-ARCHITECTURE.md new file mode 100644 index 0000000..73f5e62 --- /dev/null +++ b/Docs/devos-v1/08-AI-AGENT-ARCHITECTURE.md @@ -0,0 +1,96 @@ +# DevOS v1 AI Agent Architecture + +## 1. Principle +AI is an operating layer, not merely a chat feature. + +```text + AI Gateway + | + +----------+----------+ + | | + Agent Memory + | + +------+------+------+ + | | | | + Tools MCP Workflow Approval +``` + +## 2. Agent Loop + +```text +Observe + ↓ +Discover Resources + ↓ +Plan + ↓ +Risk Evaluation + ↓ +Approval if required + ↓ +Execute Tools + ↓ +Verify + ↓ +Report +``` + +## 3. Tool Examples +- server.list +- server.exec +- server.restart +- server.getStatus +- server.getDockerContainers +- website.list +- website.deploy +- website.logs +- git.repositories +- git.commits +- domain.records +- email.search +- note.create +- task.create + +## 4. Context +Agent context should be built from: +- workspace +- user permissions +- relevant resources +- resource relations +- recent events +- task/run history +- tool schemas + +Do not indiscriminately inject the entire database into the model. + +## 5. Risk +Risk levels: +- LOW +- MEDIUM +- HIGH +- CRITICAL + +HIGH and CRITICAL operations require user approval by default. + +Examples: +- production deployment +- destructive filesystem operations +- database deletion +- credential changes + +## 6. Verification +An Agent action is not successful merely because a command returned zero. The Agent should perform domain-specific verification where possible: +- deployment health +- HTTP health check +- container state +- DNS propagation/result +- SSL availability + +## 7. Action Log +Agent runs should expose human-readable steps such as: +- found target server +- Docker available +- repository connected +- deployment created +- container started +- health check passed diff --git a/Docs/devos-v1/09-MCP-SPEC.md b/Docs/devos-v1/09-MCP-SPEC.md new file mode 100644 index 0000000..352538b --- /dev/null +++ b/Docs/devos-v1/09-MCP-SPEC.md @@ -0,0 +1,54 @@ +# DevOS v1 MCP Specification + +## 1. Purpose +DevOS should expose its capabilities as an MCP Server so external Agents such as ChatGPT, Claude, Cursor, Codex and other compatible agents can operate the developer infrastructure layer. + +## 2. Conceptual Interface + +```text +External Agent + | + v + DevOS MCP + | + +-- Servers + +-- Websites + +-- Git + +-- Domains + +-- Email + +-- Notes + +-- Tasks + +-- Automation +``` + +## 3. Tool Design Rules +Tools should: +- have narrow, explicit responsibilities +- declare structured input/output schemas +- be workspace-aware +- enforce authorization server-side +- expose risk classification +- return stable machine-readable results +- never return raw secrets + +## 4. Initial Tool Set +```text +devos.server.list +devos.server.status +devos.server.exec +devos.website.list +devos.website.deploy +devos.website.logs +devos.deployment.get +devos.domain.list +devos.domain.records +devos.task.create +devos.note.create +devos.automation.run +``` + +## 5. Safety +MCP is not a bypass around DevOS security. All requests must pass the same permission, credential, risk and audit layers as first-party UI/API actions. + +## 6. Resource Exposure +Prefer references and summarized metadata. Secrets and unnecessary infrastructure internals must not be exposed by default. diff --git a/Docs/devos-v1/10-AUTOMATION-ENGINE.md b/Docs/devos-v1/10-AUTOMATION-ENGINE.md new file mode 100644 index 0000000..87466f8 --- /dev/null +++ b/Docs/devos-v1/10-AUTOMATION-ENGINE.md @@ -0,0 +1,72 @@ +# DevOS v1 Automation Engine + +## 1. Model + +```text +Trigger + ↓ +Condition + ↓ +Action + ↓ +Action + ↓ +Action +``` + +## 2. Triggers +Initial trigger classes: +- Manual +- Cron +- Webhook +- Event +- AI Trigger + +## 3. Example + +```text +WHEN github.push +IF branch = main +THEN build +THEN deploy +THEN health check +THEN notify +``` + +## 4. Execution Model +Automation execution should be represented as a job/run with: +- automation ID +- trigger event +- execution ID +- step index +- input/output references +- status +- retry state +- timestamps +- correlation ID + +## 5. Event Bus +Canonical events include: +- deployment.created +- deployment.started +- deployment.success +- deployment.failed +- server.online +- server.offline +- website.down +- website.recovered +- email.received +- github.push +- task.created +- task.completed + +## 6. Reliability +- idempotency for externally triggered workflows +- bounded retries +- timeout per action +- explicit failure states +- dead-letter handling for unrecoverable jobs +- full audit trail + +## 7. AI Integration +AI may create or execute automation, but generated workflows must still pass schema validation, permissions and risk policies. diff --git a/Docs/devos-v1/11-SECURITY.md b/Docs/devos-v1/11-SECURITY.md new file mode 100644 index 0000000..bed5f13 --- /dev/null +++ b/Docs/devos-v1/11-SECURITY.md @@ -0,0 +1,78 @@ +# DevOS v1 Security Specification + +## 1. Threat Model +DevOS manages highly sensitive credentials: +- SSH keys +- API keys +- cloud tokens +- Git tokens +- SMTP credentials +- DNS API credentials +- database passwords + +Therefore security is a core architecture boundary. + +## 2. Credential Vault +Credentials are stored encrypted, separately from ordinary resource metadata. + +```text +Application Master Key + ↓ +Envelope Encryption + ↓ +Encrypted Credential +``` + +Do not store plaintext passwords or tokens in PostgreSQL. + +## 3. Authorization +Implement: +- authentication +- workspace isolation +- RBAC +- resource-level permission checks where required +- action-level authorization + +## 4. Audit +All mutations and sensitive reads/actions should be auditable: +- actor +- workspace +- action +- resource +- risk +- outcome +- timestamp +- correlation/request ID + +## 5. Secret Masking +Secrets must be masked in: +- logs +- UI +- Agent context +- error messages +- audit metadata +- tool results + +## 6. AI Safety +AI execution must use the same authorization path as humans. + +HIGH / CRITICAL: +```text +Plan + ↓ +User Approval + ↓ +Execute + ↓ +Verify +``` + +## 7. Infrastructure Execution +- SSH command execution must be scoped and logged. +- Command timeouts are mandatory. +- Production targets should be distinguishable. +- Destructive actions require explicit confirmation. +- Never allow prompt text alone to override security policy. + +## 8. Security Non-Goals +This document does not claim a complete production security certification. Before public production deployment, perform threat modeling, dependency review, penetration testing and secret-rotation drills. diff --git a/Docs/devos-v1/12-UI-SPEC.md b/Docs/devos-v1/12-UI-SPEC.md new file mode 100644 index 0000000..5dbb534 --- /dev/null +++ b/Docs/devos-v1/12-UI-SPEC.md @@ -0,0 +1,77 @@ +# DevOS v1 UI Specification + +## 1. Navigation + +```text +DevOS +├── Dashboard +├── AI +├── Projects +├── Infrastructure +│ ├── Servers +│ ├── Containers +│ ├── Databases +│ ├── Storage +│ └── Monitoring +├── Websites +│ ├── Websites +│ ├── Domains +│ ├── SSL +│ └── Deployments +├── Code +│ ├── Repositories +│ ├── Issues +│ ├── Pull Requests +│ └── Releases +├── Email +├── Knowledge +│ ├── Notes +│ ├── Documents +│ └── Knowledge Base +├── Tasks +├── Automation +├── Credentials +└── Settings +``` + +## 2. Dashboard +The dashboard is an operational command center, not a conventional analytics page. + +```text +Good morning. + +[ AI: What do you want to do? ] + +Infrastructure +3 Servers | 24 Containers | 99.98% Uptime + +Projects +Blog | DevOS | SaaS + +Activity +Deployment successful +SSL renewed +Server disk 82% +``` + +## 3. Resource Detail +Every major resource should expose a consistent information architecture: +- Summary +- Status +- Relations +- Actions +- Activity +- Logs/metrics where relevant + +## 4. Action UX +Actions should show: +- what will happen +- target resource +- risk level +- expected impact +- confirmation requirement +- progress +- final verification + +## 5. Design System +Use Ant Design as the primary visual/system foundation and ProComponents for data-heavy administration. diff --git a/Docs/devos-v1/13-ROADMAP.md b/Docs/devos-v1/13-ROADMAP.md new file mode 100644 index 0000000..4ad63ee --- /dev/null +++ b/Docs/devos-v1/13-ROADMAP.md @@ -0,0 +1,75 @@ +# DevOS v1 Roadmap + +## V0 — Foundation +- Monorepo +- React +- Ant Design +- NestJS +- PostgreSQL +- Prisma +- Redis +- Docker +- Auth +- User +- Workspace +- Resource +- Permission +- Audit + +## V1 — Infrastructure OS +- Server +- SSH +- Docker/container discovery +- Website +- Domain +- Deployment +- SSL integration +- Logs +- Monitoring + +Target flow: +```text +Add Server + ↓ +SSH + ↓ +Discover Docker + ↓ +View Containers + ↓ +Deploy Website + ↓ +Bind Domain + ↓ +SSL + ↓ +Logs +``` + +## V2 — Developer OS +- GitHub / GitLab +- Projects +- Tasks +- Notes +- Documents +- Email + +## V3 — Automation OS +- Event Bus +- Workflow Engine +- Cron +- Webhook +- Notifications + +## V4 — AI OS +- Resource discovery +- Planning +- Tool calling +- Execution +- Verification +- Memory +- Approval +- MCP + +## Delivery Rule +Do not start by implementing every product category. Deliver vertical slices that prove the Resource + Action + Event model. diff --git a/Docs/devos-v1/14-AGENTS.md b/Docs/devos-v1/14-AGENTS.md new file mode 100644 index 0000000..cdc8c00 --- /dev/null +++ b/Docs/devos-v1/14-AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md — DevOS Coding Rules + +## Mission +Build DevOS as a maintainable modular monolith and preserve the Resource + Action + Event + Agent architecture. + +## Mandatory Rules +1. TypeScript strict mode. +2. Never use `any` unless explicitly justified. +3. All API endpoints use DTOs. +4. Controllers contain transport concerns only. +5. Business logic belongs in services/domain/application layers. +6. Every mutation requires an audit record. +7. Every credential is encrypted at rest. +8. Secrets must never appear in logs or model context unless explicitly required and policy-approved. +9. UI uses Ant Design / ProComponents unless there is a documented exception. +10. Every feature must have an API contract. +11. Every module must have tests appropriate to its risk. +12. Do not introduce dependencies without justification. +13. Provider-specific behavior belongs behind integration adapters. +14. Long-running work must be asynchronous. +15. External operations require timeout and explicit failure handling. +16. HIGH/CRITICAL AI actions require approval. +17. Security controls cannot be bypassed by UI, API, MCP or Agent paths. +18. Preserve workspace isolation. +19. Prefer idempotent operations. +20. Update documentation when changing a domain contract. + +## Development Sequence +```text +Requirement + ↓ +Domain Model + ↓ +API Contract + ↓ +UI / Interaction + ↓ +Tests + ↓ +Implementation + ↓ +Verification + ↓ +Documentation +``` + +## Definition of Done +A feature is not done until: +- type checks pass +- tests pass +- API contract is documented +- authorization is enforced +- audit behavior is implemented where applicable +- error handling exists +- UI states cover loading/empty/error/success +- documentation is updated diff --git a/Docs/devos-v1/15-ZCODE-DEV-SPEC.md b/Docs/devos-v1/15-ZCODE-DEV-SPEC.md new file mode 100644 index 0000000..b2f29a2 --- /dev/null +++ b/Docs/devos-v1/15-ZCODE-DEV-SPEC.md @@ -0,0 +1,240 @@ +# DevOS v1 ZCODE Development Specification + +## 1. Purpose +This document converts the architecture into executable work units for an AI coding agent such as ZCODE, Codex, Cursor or Claude Code. + +The agent must work from specifications instead of inventing architecture during implementation. + +## 2. Repository Target + +```text +devos/ +├── apps/ +│ ├── web/ +│ └── api/ +├── packages/ +│ ├── ui/ +│ ├── api-client/ +│ ├── types/ +│ └── auth/ +├── packages/integrations/ +├── prisma/ +├── docs/ +├── specs/ +└── AGENTS.md +``` + +## 3. Initial Task Queue + +### Task 001 — Monorepo +Create the workspace structure for web, API and shared packages. + +Acceptance: +- package manager/workspaces configured +- TypeScript shared configuration +- lint/format/test commands +- build pipeline + +### Task 002 — Backend Bootstrap +Create NestJS API with versioned `/api/v1` routing. + +Acceptance: +- health endpoint +- configuration management +- structured logging +- error handling +- request ID + +### Task 003 — Frontend Bootstrap +Create React + Ant Design application. + +Acceptance: +- ProLayout +- routing +- authentication shell +- dashboard shell +- API client +- loading/error/empty states + +### Task 004 — Database +Create PostgreSQL + Prisma setup. + +Acceptance: +- migrations +- core identity/workspace/resource schema +- seed strategy +- database documentation + +### Task 005 — Authentication +Implement authentication and current-user endpoint. + +Acceptance: +- login/logout +- session/token strategy +- password/credential handling appropriate to chosen auth design +- authorization guards + +### Task 006 — Workspace +Implement workspace and membership. + +Acceptance: +- create/list/read workspace +- membership +- workspace isolation in service and query layers + +### Task 007 — Resource System +Implement unified Resource abstraction. + +Acceptance: +- CRUD +- typed resource type +- status +- metadata +- relation graph +- workspace scope + +### Task 008 — Audit +Implement audit service and mutation hooks. + +Acceptance: +- audit record for every mutation +- actor/workspace/resource/action +- request/correlation ID +- sensitive data masking + +### Task 009 — Server Module +Implement Server resource and provider adapter. + +Acceptance: +- create server +- credential reference +- connectivity check +- status +- SSH execution +- audit +- timeout/error handling + +### Task 010 — Docker +Implement Docker discovery through Server integration. + +Acceptance: +- list containers +- inspect container +- restart container +- resource relation +- audit + +### Task 011 — Website +Implement Website domain model and UI. + +Acceptance: +- website CRUD +- repository/server/domain relations +- status +- detail page + +### Task 012 — Deployment +Implement deployment job model and asynchronous execution. + +Acceptance: +- create deployment +- run deployment +- progress +- logs +- cancellation where safe +- verification +- success/failure events + +### Task 013 — Domain +Implement domain and DNS provider adapter. + +Acceptance: +- domain CRUD +- record read/update +- authorization +- audit + +### Task 014 — Monitoring +Implement initial health checks. + +Acceptance: +- website health +- server status +- monitor result history +- down/recovered events + +### Task 015 — Event Bus +Implement internal domain events and persistence/dispatch model. + +Acceptance: +- canonical event names +- correlation IDs +- durable execution for required events + +### Task 016 — Automation +Implement Trigger → Condition → Action workflow model. + +Acceptance: +- manual trigger +- event trigger +- cron design +- execution history +- retries/timeouts + +### Task 017 — AI Gateway +Implement an AI run abstraction without allowing unrestricted execution. + +Acceptance: +- create AI run +- plan +- tool registry +- risk evaluation +- approval state +- execution history +- verification result + +### Task 018 — MCP +Expose selected DevOS capabilities through MCP. + +Acceptance: +- server list/status +- website list/deploy +- deployment get +- domain list/records +- task/note tools when corresponding modules exist +- all requests pass normal authorization/audit/risk controls + +## 4. Agent Execution Protocol + +For each task: +1. Read relevant docs. +2. Inspect existing code. +3. State implementation plan internally. +4. Implement the smallest coherent change. +5. Add/update tests. +6. Run typecheck/lint/tests. +7. Verify API and UI contracts. +8. Update documentation. +9. Do not refactor unrelated code. + +## 5. Stop Conditions +The coding agent must stop and ask for a human decision when: +- architecture requires a new major dependency +- a security boundary must change +- destructive production behavior is requested +- domain terminology conflicts with existing specifications +- an API breaking change is unavoidable +- two specification documents materially conflict + +## 6. Priority +When requirements conflict, use this order: +1. Security +2. Explicit user requirement +3. Domain model +4. API contract +5. Architecture +6. UI convenience +7. Implementation preference + +## 7. Important Constraint +Do not convert DevOS into microservices during V1 unless a concrete operational requirement proves modular monolith insufficient. diff --git a/Docs/devos-v1/README.md b/Docs/devos-v1/README.md new file mode 100644 index 0000000..1f06eb6 --- /dev/null +++ b/Docs/devos-v1/README.md @@ -0,0 +1,20 @@ +# DevOS v1 Development Document Package + +This package was generated from the supplied architecture discussion. + +## Source basis +The source defines DevOS as a Developer Work OS centered on: +- Unified Resource Model +- Resource + Action + Event + Agent +- Modular Monolith +- React + Ant Design + ProComponents +- NestJS + TypeScript +- PostgreSQL + Prisma + Redis +- Infrastructure/provider adapters +- Event Bus / Automation +- AI Agent + MCP +- Credential Vault / RBAC / Audit / Approval +- V0 → V1 → V2 → V3 → V4 roadmap + +## Important +This package preserves the source decisions and turns them into implementation-oriented specifications. Where the source did not define exact production-level details (for example exact auth protocol, complete SQL constraints, or a full MCP wire contract), this package intentionally leaves them as implementation decisions rather than pretending they were already decided. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffece75 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# DevOS — Developer Work OS + +统一管理服务器、容器、网站、域名、部署、监控与自动化的开发者工作操作系统。 +架构遵循 `Docs/devos-v1/`(统一资源模型:Resource + Action + Event + Agent,模块化单体)。 + +## 技术栈 + +| 层 | 技术 | +| --- | --- | +| 后端 | NestJS 11 + Fastify 适配器 + zod DTO + Prisma 6 | +| 前端 | React 18 + Ant Design 5 + ProComponents + TanStack Query + Zustand | +| 数据 | PostgreSQL 16 + Redis 7(Docker Compose) | +| 运行时 | Bun(包管理/测试/运行;后端编译为 CommonJS 后由 Bun/Node 运行) | +| 集成 | SSH (ssh2)、Docker(CLI over SSH / 本机 socket)、Cloudflare DNS | + +## 目录结构 + +```text +devos/ +├── apps/ +│ ├── api/ # NestJS 模块化单体(/api/v1 + /api/v1/mcp) +│ └── web/ # React SPA(ProLayout) +├── packages/ +│ ├── types/ # 共享领域类型 +│ ├── auth/ # JWT(HS256) + 密码哈希(零第三方依赖) +│ ├── api-client/ # 浏览器 API 客户端 +│ └── ui/ # 共享 UI(预留) +├── prisma/ # schema + migrations + seed +├── tests/ # bun test 单元测试 +├── infra/ssh-target/ # 开发用 SSH 目标机(--profile dev-target) +├── docker-compose.yml +└── Docs/devos-v1/ # 规范文档(唯一事实源) +``` + +## 快速开始 + +```bash +# 1. 基础设施(PostgreSQL 15432 / Redis 16379,避免与本机其它实例冲突) +docker compose up -d postgres redis + +# 2. 安装依赖 +bun install + +# 3. 数据库迁移 + 种子(管理员账号来自 .env) +bunx prisma migrate deploy +bun run prisma/seed.ts + +# 4. 构建 +bun run build:packages +bun run build:api +bun run build:web + +# 5. 启动 API(默认 :4100) +bun apps/api/dist/main.js + +# 6. 开发前端(任一 dev server 端口,API 直连 :4100) +bun --hot apps/web/index.html +``` + +默认管理员:`admin@devos.local`,密码为部署时 `.env` 中 `SEED_ADMIN_PASSWORD` 的值(**不要使用示例值上线**;本实例已轮换为随机密码)。 + +## 环境变量(.env) + +| 变量 | 说明 | +| --- | --- | +| `PORT` / `HOST` | API 监听(默认 4100 / 0.0.0.0) | +| `APP_SECRET` | JWT HS256 密钥(必填) | +| `ENCRYPTION_KEY` | 凭据信封加密主密钥,64 位 hex(必填) | +| `DATABASE_URL` | PostgreSQL 连接串(必填) | +| `REDIS_URL` | Redis(任务派发信号,可选降级) | +| `AI_PROVIDER_*` | OpenAI 兼容 AI Provider(可选;缺省用启发式规划器) | + +## 功能地图(V1 = 15-ZCODE-DEV-SPEC 任务 001–018) + +- **认证与租户**:注册/登录(HS256 JWT 12h)、工作区 + 成员 RBAC(OWNER/ADMIN/MEMBER/VIEWER)、workspace isolation 全层强制 +- **统一资源**:`resources` 身份/状态 + 类型化领域表(servers/websites/domains/containers/deployments/monitors/credentials/automations/ai_runs)+ 关系图谱(resource_relations) +- **审计**:每个变更写 `audit_logs`(actor/workspace/action/risk/requestId,元数据自动脱敏) +- **服务器**:SSH 连通性探测、命令执行(危险命令 CRITICAL 标记)、重启(显式 confirm)、local provider(本机 docker socket) +- **Docker 发现**:容器 list/inspect/restart(SSH `docker` CLI 或 Engine API 双适配),容器入资源图谱(runs_container 关系) +- **网站与部署**:网站 CRUD + 关系;部署 = QUEUED→RUNNING→SUCCEEDED/FAILED 异步流水线(git 拉取 → docker build → 容器替换 → curl 健康验证),步骤级日志与进度、运行中取消(步骤间生效) +- **域名**:Cloudflare(token 凭据)与 MANUAL 双 provider;记录读取/写入/删除 + 审计 +- **监控**:HTTP/TCP 检查调度器(interval),结果历史,`website.down` / `website.recovered` 事件 +- **事件总线**:规范事件名(`deployment.success` 等),持久化 `events` + 进程内分发 + Redis PUBLISH +- **任务队列**:Postgres `jobs` 状态机(PENDING/RUNNING/…/DEAD)+ SKIP LOCKED 认领 + 指数退避 + 僵死回收 + Redis 派发信号 +- **自动化**:Trigger(MANUAL/CRON/EVENT/WEBHOOK)→ Condition(path/op/value)→ Action(LOG/WEBHOOK/DEPLOY_WEBSITE),重试/超时/执行历史 +- **AI Gateway**:objective → 计划(LLM 或启发式)→ 风险评估 →(HIGH/CRITICAL 需审批)→ 工具执行 → 领域验证 → 报告;工具层与 UI/MCP 共享同一授权与审计 +- **MCP**:`POST /api/v1/mcp`(Streamable HTTP,无状态),Bearer JWT 鉴权,actorType=MCP 审计;工具 14 个(server/website/deployment/domain/automation) + +## 测试 + +```bash +bun test # 26 个用例:JWT(alg 混淆/过期/篡改)、cron、脱敏、危险命令、docker 输出解析、条件评估 +``` + +## MCP 接入示例 + +```bash +curl -X POST http://localhost:4100/api/v1/mcp \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +## 安全设计(11-SECURITY.md) + +- 凭据 AES-256-GCM 信封加密,AAD 绑定 workspaceId;无任何明文读取端点 +- 日志/审计/工具结果统一脱敏(键名+内嵌 token 模式) +- HIGH/CRITICAL 动作(部署、重启、凭据变更)需显式确认或审批 +- MCP 不是绕过安全旁路:同 JWT、同授权、同审计 +- API 错误契约:`{error:{code,message,requestId}}`,不暴露堆栈与机密 + +## AI 聊天(产品核心入口) + +`/ai` 是对话式管理入口:用自然语言完成查询、创建任务/笔记、部署网站、重启容器、DNS 解析、URL 总结存笔记等**全部操作**。 + +- **双引擎**:配置 LLM(`AI_PROVIDER_*`)时走 OpenAI function-calling 多轮编排(工具白名单 29 个,覆盖增删改查与复合流程);未配置时内置规则引擎,核心操作开箱可用 +- **安全**:HIGH/CRITICAL 操作(部署/重启容器/删除)不直接执行,聊天中出审批卡片,确认后经 AiRun 管线执行;所有工具调用与人工/MCP 走同一授权与审计 +- **验证**:以 Mock LLM(脚本化 function-calling)完成端到端验证 —— + 「总结 保存到 Note」→ web.fetch + note.create ✓ + 「把 安装到 JP-001 并用 xx.com 在 CF 代理解析」→ repository.createFromUrl + server.find + domain.find + website.create + domain.upsertRecord(proxied) ✓ + website.deploy 转审批 → 用户确认 → 部署 job 执行 ✓ + +## Notes AI 助手与配置 + +笔记页(`/notes`)为双栏笔记应用:TipTap 富文本(标题/列表/任务列表/代码块/引用 + 占位符 + 自动保存 1.2s)+ 置顶/标签/搜索 + AI 助手。 + +AI 助手动作:续写、总结要点、润色、中译英、英译中、生成大纲、起标题(选中文字后可只处理选区)。 + +启用 AI:在 `.env` 配置任意 OpenAI 兼容端点后重启: + +```bash +AI_PROVIDER_BASE_URL=http://127.0.0.1:23030/openai/v1 # 示例:本机 claude-relay +AI_PROVIDER_API_KEY=<你的访问令牌> +AI_PROVIDER_MODEL=claude-sonnet-4-5 +systemctl restart devos-api +``` + +未配置时 AI 菜单会明确提示,不影响其它功能。 + +## Phase 2 增量(Docs/devos-v1-phase2) + +| 阶段 | 内容 | 状态 | +| --- | --- | --- | +| A 安全加固 | 生产 secret 校验、注册开关(`ENABLE_REGISTRATION`,生产默认关)、登录/注册限流、webhook HMAC+防重放、默认密码轮换、测试凭据清理、9 个安全回归测试 | ✅ | +| B 资源图谱 | 关系类型标准化(`RELATION_TYPES` 大写规范 + 数据迁移)、删除影响检查(`GET /resources/:id/relations/impact`)、项目页(Projects 聚合关系与活动) | ✅ | +| C 基础设施 | 服务器 stats(uptime/CPU/内存/磁盘/Top 进程)、Docker start/stop/logs/stats/images、Job Center(列表/详情/重试/取消) | ✅ | +| D 网站部署 | GitProviderConnection(GitHub/GitLab/Gitea 适配器)、Repository 聚合 + 凭据克隆(token 不进 URL/不残留 .git/config)、Website environment、部署 buildStrategy/deployStrategy/verification 记录 | ✅ | +| E 生产力 | Tasks(5 状态看板字段)、Notes(markdown+预览;TipTap 留待下迭代)、Certificates(TLS 探针+到期预警);**Email 模块延后** | ✅(Email 延后) | +| F 自动化 | PATCH 编辑、HMAC webhook、新动作(CONTAINER_RESTART/TASK_CREATE/NOTE_CREATE/NOTIFICATION_SEND)+ 站内通知 | ✅ | +| G/H AI+MCP | 工具注册表扩至 18 个(task.list/create、note.create、container.restart 等)、启发式规划器新场景(故障诊断/任务记录/容器重启)、MCP 同步暴露 | ✅ | +| I 统一 UX | 全局搜索(`/search` + 头部入口 ⌘K 提示)、通知中心(铃铛+未读数)、任务中心页、项目页、主题切换(亮/暗) | ✅ | + +新增 API 一览:`/git/*`、`/tasks`、`/notes`、`/certificates`、`/jobs`、`/search`、`/notifications`、`/automations/:id`(PATCH)、`/servers/:id/stats`、`/servers/:id/images`、`/containers/:id/start|stop|logs|stats`、`/resources/:id/relations/impact`。 + +## 生产部署(本机) + +- systemd 常驻:`systemctl status devos-api`(/etc/systemd/system/devos-api.service,监听 127.0.0.1:4100,自动重启/开机自启) +- API 同进程托管 Web 静态资源与 SPA fallback(`apps/web/dist`),nginx 仅纯反代 +- 域名:`ldns devos.opentest.cc 127.0.0.1:4100`(nginx 反代 + Cloudflare DNS/CDN) +- 线上地址:**https://devos.opentest.cc** + +更新部署流程: + +```bash +bun run build:api && bun run build:web && systemctl restart devos-api +``` + +## 已知限制(V1 范围内取舍) + +- `bun --hot` dev server 下 ProComponents 存在 CJS 互操作问题(生产 bundle 与 nginx 部署不受影响) +- 登出为无状态(客户端丢弃 JWT);撤销列表留待 V2 +- Webhook 触发的自动化以 `X-Webhook-Secret` 头校验(HMAC 签名留待 V2) diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..859bb11 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,27 @@ +{ + "name": "devos-api", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "start": "bun dist/main.js", + "test": "bun test ../../tests" + }, + "dependencies": { + "@devos/auth": "workspace:*", + "@devos/types": "workspace:*", + "@modelcontextprotocol/sdk": "^1.12.0", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-fastify": "^11.0.0", + "@prisma/client": "6.19.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "ssh2": "^1.16.0", + "zod": "^3.24.0", + "zod-to-json-schema": "^3.25.2" + }, + "devDependencies": { + "@types/ssh2": "^1.15.0" + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..441ec03 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,82 @@ +/** + * # DevOS API 根模块(模块化单体) + * 核心域:identity / workspace / resource / permission(守卫) / audit + * 基础设施域:server / container / website / domain / deployment / monitoring + * 自动化与智能:event / job / worker / automation / ai / mcp / credential + */ +import { Module } from "@nestjs/common"; +import { APP_GUARD, APP_FILTER } from "@nestjs/core"; +import { ConfigModule } from "./common/config/config.module"; +import { LoggerModule } from "./common/logger/logger.module"; +import { SecurityModule } from "./common/security/security.module"; +import { AiProviderModule } from "./common/ai/ai-provider.module"; +import { PrismaModule } from "./prisma/prisma.module"; +import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; +import { WorkspaceGuard } from "./common/guards/workspace.guard"; +import { AllExceptionsFilter } from "./common/errors/all-exceptions.filter"; +import { SystemController } from "./common/system.controller"; + +// 模块 +import { IdentityModule } from "./modules/identity/identity.module"; +import { WorkspaceModule } from "./modules/workspace/workspace.module"; +import { ResourceModule } from "./modules/resource/resource.module"; +import { AuditModule } from "./modules/audit/audit.module"; +import { CredentialModule } from "./modules/credential/credential.module"; +import { EventModule } from "./modules/event/event.module"; +import { JobModule } from "./modules/job/job.module"; +import { ServerModule } from "./modules/server/server.module"; +import { ContainerModule } from "./modules/container/container.module"; +import { WebsiteModule } from "./modules/website/website.module"; +import { DeploymentModule } from "./modules/deployment/deployment.module"; +import { DomainModule } from "./modules/domain/domain.module"; +import { MonitoringModule } from "./modules/monitoring/monitoring.module"; +import { AutomationModule } from "./modules/automation/automation.module"; +import { AiModule } from "./modules/ai/ai.module"; +import { McpModule } from "./modules/mcp/mcp.module"; +import { WorkerModule } from "./modules/worker/worker.module"; +import { NotificationModule } from "./modules/notification/notification.module"; +import { TaskModule } from "./modules/task/task.module"; +import { NoteModule } from "./modules/note/note.module"; +import { CertificateModule } from "./modules/certificate/certificate.module"; +import { GitModule } from "./modules/git/git.module"; +import { SearchModule } from "./modules/search/search.module"; + +@Module({ + imports: [ + ConfigModule, + LoggerModule, + SecurityModule, + AiProviderModule, + PrismaModule, + IdentityModule, + WorkspaceModule, + ResourceModule, + AuditModule, + CredentialModule, + EventModule, + JobModule, + ServerModule, + ContainerModule, + WebsiteModule, + DeploymentModule, + DomainModule, + MonitoringModule, + AutomationModule, + AiModule, + McpModule, + WorkerModule, + NotificationModule, + TaskModule, + NoteModule, + CertificateModule, + GitModule, + SearchModule, + ], + controllers: [SystemController], + providers: [ + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: WorkspaceGuard }, + { provide: APP_FILTER, useClass: AllExceptionsFilter }, + ], +}) +export class AppModule {} diff --git a/apps/api/src/common/ai/ai-provider.module.ts b/apps/api/src/common/ai/ai-provider.module.ts new file mode 100644 index 0000000..e66baad --- /dev/null +++ b/apps/api/src/common/ai/ai-provider.module.ts @@ -0,0 +1,14 @@ +/** + * # AI Provider 模块(全局共享 LLM 补全) + */ +import { Global, Module } from "@nestjs/common"; +import { AiProviderService } from "./ai-provider.service"; +import { ConfigModule } from "../config/config.module"; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [AiProviderService], + exports: [AiProviderService], +}) +export class AiProviderModule {} diff --git a/apps/api/src/common/ai/ai-provider.service.ts b/apps/api/src/common/ai/ai-provider.service.ts new file mode 100644 index 0000000..3c15528 --- /dev/null +++ b/apps/api/src/common/ai/ai-provider.service.ts @@ -0,0 +1,62 @@ +/** + * # AI Provider 服务(共享 LLM 补全) + * OpenAI 兼容 /chat/completions;未配置 provider 时可优雅降级。 + * 供 AI Gateway 规划器与 Notes AI 助手等场景共用。 + */ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "../config/config.service"; +import { ApiError } from "../errors/api-error"; + +@Injectable() +export class AiProviderService { + constructor(private readonly config: ConfigService) {} + + /** # 是否已配置 LLM 提供商 */ + get enabled(): boolean { + return this.config.aiEnabled; + } + + /** # 文本补全(单轮 system+user → text) + * @param system 系统提示词 + * @param user 用户内容 + * @param opts 温度/最大输出 + */ + async complete( + system: string, + user: string, + opts: { temperature?: number; maxTokens?: number } = {}, + ): Promise { + if (!this.config.aiEnabled) { + throw ApiError.validation( + "AI provider is not configured (set AI_PROVIDER_BASE_URL / AI_PROVIDER_API_KEY)", + ); + } + const res = await fetch( + `${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.config.aiApiKey}`, + }, + body: JSON.stringify({ + model: this.config.aiModel, + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + temperature: opts.temperature ?? 0.7, + max_tokens: opts.maxTokens ?? 2000, + }), + signal: AbortSignal.timeout(60_000), + }, + ); + if (!res.ok) { + throw ApiError.upstream(`AI provider HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`); + } + const body = (await res.json()) as { choices?: { message?: { content?: string } }[] }; + const content = body.choices?.[0]?.message?.content; + if (!content) throw ApiError.upstream("AI provider returned no content"); + return content; + } +} diff --git a/apps/api/src/common/config/config.module.ts b/apps/api/src/common/config/config.module.ts new file mode 100644 index 0000000..e9454cf --- /dev/null +++ b/apps/api/src/common/config/config.module.ts @@ -0,0 +1,12 @@ +/** + * # 全局配置模块 + */ +import { Global, Module } from "@nestjs/common"; +import { ConfigService } from "./config.service"; + +@Global() +@Module({ + providers: [ConfigService], + exports: [ConfigService], +}) +export class ConfigModule {} diff --git a/apps/api/src/common/config/config.service.ts b/apps/api/src/common/config/config.service.ts new file mode 100644 index 0000000..5306a88 --- /dev/null +++ b/apps/api/src/common/config/config.service.ts @@ -0,0 +1,52 @@ +/** + * # 配置服务 + * 集中读取环境变量(Bun 自动加载 .env),类型化访问,缺失必填项时快速失败。 + */ +import { Injectable } from "@nestjs/common"; +import { assertProductionSecrets } from "../security/production-check"; + +/** # 必填环境变量列表 **/ +const REQUIRED_KEYS = ["APP_SECRET", "ENCRYPTION_KEY", "DATABASE_URL"] as const; + +@Injectable() +export class ConfigService { + /** # API 监听端口 **/ + readonly port = Number(process.env.PORT ?? 4100); + /** # 监听地址 **/ + readonly host = process.env.HOST ?? "0.0.0.0"; + /** # JWT/应用密钥 **/ + readonly appSecret = process.env.APP_SECRET ?? ""; + /** # 凭据信封加密主密钥(64 位 hex = 32 字节) **/ + readonly encryptionKey = process.env.ENCRYPTION_KEY ?? ""; + /** # PostgreSQL 连接串 **/ + readonly databaseUrl = process.env.DATABASE_URL ?? ""; + /** # Redis 连接串 **/ + readonly redisUrl = process.env.REDIS_URL ?? ""; + /** # 服务版本标识 **/ + readonly version = process.env.APP_VERSION ?? "1.0.0"; + /** # AI Provider(OpenAI 兼容)配置,可选 **/ + readonly aiBaseUrl = process.env.AI_PROVIDER_BASE_URL ?? ""; + readonly aiApiKey = process.env.AI_PROVIDER_API_KEY ?? ""; + readonly aiModel = process.env.AI_PROVIDER_MODEL ?? "gpt-4o-mini"; + + constructor() { + for (const key of REQUIRED_KEYS) { + if (!process.env[key]) { + throw new Error(`Missing required env: ${key}`); + } + } + if (this.encryptionKey.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(this.encryptionKey)) { + throw new Error("ENCRYPTION_KEY must be 64 hex chars (32 bytes)"); + } + assertProductionSecrets({ + nodeEnv: process.env.NODE_ENV, + appSecret: this.appSecret, + encryptionKey: this.encryptionKey, + }); + } + + /** # 是否配置了 AI Provider */ + get aiEnabled(): boolean { + return Boolean(this.aiBaseUrl && this.aiApiKey); + } +} diff --git a/apps/api/src/common/decorators/index.ts b/apps/api/src/common/decorators/index.ts new file mode 100644 index 0000000..10f4d4e --- /dev/null +++ b/apps/api/src/common/decorators/index.ts @@ -0,0 +1,45 @@ +/** + * # 认证与授权相关自定义装饰器 + */ +import { SetMetadata, createParamDecorator, ExecutionContext } from "@nestjs/common"; +import type { WorkspaceRole } from "@devos/types"; + +/** # 路由公开标记(跳过 JWT 守卫)的 metadata key **/ +export const IS_PUBLIC_KEY = "isPublic"; + +/** # @Public():标记路由为公开(健康检查、登录、注册) */ +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +/** # 角色要求 metadata key **/ +export const ROLES_KEY = "minRoles"; + +/** # @Roles(...roles):要求当前工作区角色在列表内(VIEWER 只读) */ +export const Roles = (...roles: WorkspaceRole[]) => SetMetadata(ROLES_KEY, roles); + +/** # @NoWorkspace():跳过工作区解析(如 /auth/me) */ +export const NO_WORKSPACE_KEY = "noWorkspace"; +export const NoWorkspace = () => SetMetadata(NO_WORKSPACE_KEY, true); + +/** # 请求上的认证用户 **/ +export interface RequestUser { + id: string; + email: string; +} + +/** # 请求上的工作区信息 **/ +export interface RequestWorkspace { + id: string; + role: WorkspaceRole; +} + +/** # @CurrentUser():注入当前认证用户 */ +export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user as RequestUser | undefined; +}); + +/** # @CurrentWorkspace():注入当前工作区(id + role) */ +export const CurrentWorkspace = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.workspace as RequestWorkspace | undefined; +}); diff --git a/apps/api/src/common/errors/all-exceptions.filter.ts b/apps/api/src/common/errors/all-exceptions.filter.ts new file mode 100644 index 0000000..9c945b4 --- /dev/null +++ b/apps/api/src/common/errors/all-exceptions.filter.ts @@ -0,0 +1,66 @@ +/** + * # 全局异常过滤器 + * 输出 05-API-SPEC 定义的错误契约: + * { "error": { "code", "message", "requestId" } } + * 绝不向客户端暴露堆栈、凭据或内部细节。 + */ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { ZodError } from "zod"; +import { ApiError } from "./api-error"; + +/** # 从请求对象读取 requestId(中间件在请求上挂载) */ +function getRequestId(request: FastifyRequest | undefined): string { + const rid = (request as { requestId?: string } | undefined)?.requestId; + return typeof rid === "string" && rid ? rid : "unknown"; +} + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + /** # 捕获所有异常并归一化输出 */ + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const reply = ctx.getResponse(); + const request = ctx.getRequest(); + const requestId = getRequestId(request); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let code = "INTERNAL_ERROR"; + let message = "Internal server error"; + let details: unknown; + + if (exception instanceof ApiError) { + status = exception.getStatus(); + code = exception.code; + message = exception.message; + details = exception.details; + } else if (exception instanceof ZodError) { + status = HttpStatus.BAD_REQUEST; + code = "VALIDATION_ERROR"; + message = "Request validation failed"; + details = exception.issues.map((i) => ({ path: i.path.join("."), message: i.message })); + } else if (exception instanceof HttpException) { + status = exception.getStatus(); + code = status === HttpStatus.NOT_FOUND ? "RESOURCE_NOT_FOUND" : "HTTP_ERROR"; + message = exception.message; + } else { + // 服务端内部错误:只在日志中记录细节 + console.error( + JSON.stringify({ + time: new Date().toISOString(), + level: "error", + ctx: "ExceptionsFilter", + requestId, + msg: exception instanceof Error ? `${exception.message}` : String(exception), + stack: exception instanceof Error ? exception.stack : undefined, + }), + ); + } + + if (!reply.sent) { + reply.code(status).send({ + error: { code, message, requestId, ...(details !== undefined ? { details } : {}) }, + }); + } + } +} diff --git a/apps/api/src/common/errors/api-error.ts b/apps/api/src/common/errors/api-error.ts new file mode 100644 index 0000000..e0bac29 --- /dev/null +++ b/apps/api/src/common/errors/api-error.ts @@ -0,0 +1,49 @@ +/** + * # HTTP API 异常 + * 业务代码抛出的统一异常:携带机器可读错误码(对齐 05-API-SPEC 错误契约)。 + */ +import { HttpException } from "@nestjs/common"; +import type { ErrorCode } from "@devos/types"; + +/** # API 业务异常 **/ +export class ApiError extends HttpException { + /** # 机器可读错误码 **/ + readonly code: ErrorCode | string; + /** # 附加详情(绝不包含堆栈/凭据) **/ + readonly details?: unknown; + + constructor(status: number, code: ErrorCode | string, message: string, details?: unknown) { + super(message, status); + this.code = code; + this.details = details; + } + + /** # 快捷构造:400 校验失败 */ + static validation(message: string, details?: unknown) { + return new ApiError(400, "VALIDATION_ERROR", message, details); + } + /** # 快捷构造:401 未认证 */ + static unauthorized(message = "Authentication required") { + return new ApiError(401, "UNAUTHORIZED", message); + } + /** # 快捷构造:403 禁止 */ + static forbidden(message = "Permission denied") { + return new ApiError(403, "FORBIDDEN", message); + } + /** # 快捷构造:404 资源不存在 */ + static notFound(message = "Resource was not found") { + return new ApiError(404, "RESOURCE_NOT_FOUND", message); + } + /** # 快捷构造:409 冲突 */ + static conflict(message: string) { + return new ApiError(409, "CONFLICT", message); + } + /** # 快捷构造:502 上游失败 */ + static upstream(message: string, details?: unknown) { + return new ApiError(502, "UPSTREAM_FAILURE", message, details); + } + /** # 快捷构造:504 超时 */ + static timeout(message: string) { + return new ApiError(504, "TIMEOUT", message); + } +} diff --git a/apps/api/src/common/guards/jwt-auth.guard.ts b/apps/api/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..b64fe44 --- /dev/null +++ b/apps/api/src/common/guards/jwt-auth.guard.ts @@ -0,0 +1,55 @@ +/** + * # JWT 认证守卫(全局) + * 解析 Authorization: Bearer ,校验通过后挂载 req.user 并更新请求上下文。 + * @Public() 标记的路由跳过。 + */ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { verifyJwt } from "@devos/auth"; +import { ConfigService } from "../config/config.service"; +import { ApiError } from "../errors/api-error"; +import { IS_PUBLIC_KEY } from "../decorators"; +import { getRequestContext } from "../request-context"; + +/** # Fastify 请求扩展(认证部分) **/ +declare module "fastify" { + interface FastifyRequest { + user?: { id: string; email: string }; + jwtPayload?: { wid: string | null }; + } +} + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly config: ConfigService, + private readonly reflector: Reflector, + ) {} + + /** # 校验 JWT 并挂载用户 */ + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const header = request.headers["authorization"]; + if (!header || !header.startsWith("Bearer ")) { + throw ApiError.unauthorized(); + } + const result = verifyJwt(header.slice(7), this.config.appSecret); + if (!result.ok) { + throw ApiError.unauthorized(result.reason === "EXPIRED" ? "Token expired" : "Invalid token"); + } + request.user = { id: result.payload.sub, email: result.payload.email }; + request.jwtPayload = result.payload; + const ctx = getRequestContext(); + if (ctx) { + ctx.actorId = result.payload.sub; + ctx.actorType = "USER"; + } + return true; + } +} diff --git a/apps/api/src/common/guards/workspace.guard.ts b/apps/api/src/common/guards/workspace.guard.ts new file mode 100644 index 0000000..14cdb5c --- /dev/null +++ b/apps/api/src/common/guards/workspace.guard.ts @@ -0,0 +1,114 @@ +/** + * # 工作区守卫(全局) + * 解析当前工作区:X-Workspace-Id 请求头 > JWT wid 声明 > 第一个成员关系。 + * 校验成员资格后挂载 req.workspace(含角色)并写入请求上下文。 + * @NoWorkspace() 标记的路由跳过(但已认证用户仍可访问)。 + */ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { PrismaService } from "../../prisma/prisma.service"; +import { ApiError } from "../errors/api-error"; +import { NO_WORKSPACE_KEY, ROLES_KEY } from "../decorators"; +import { getRequestContext } from "../request-context"; +import type { WorkspaceRole } from "@devos/types"; + +/** # 角色权限序(数值越大权限越高) **/ +const ROLE_RANK: Record = { VIEWER: 0, MEMBER: 1, ADMIN: 2, OWNER: 3 }; + +/** # Fastify 请求扩展(工作区部分) **/ +declare module "fastify" { + interface FastifyRequest { + workspace?: { id: string; role: WorkspaceRole }; + } +} + +@Injectable() +export class WorkspaceGuard implements CanActivate { + constructor( + private readonly prisma: PrismaService, + private readonly reflector: Reflector, + ) {} + + /** # 解析并校验当前工作区 */ + async canActivate(context: ExecutionContext): Promise { + const noWorkspace = this.reflector.getAllAndOverride(NO_WORKSPACE_KEY, [ + context.getHandler(), + context.getClass(), + ]); + const request = context.switchToHttp().getRequest(); + if (noWorkspace || !request.user) { + this.checkRoles(request, undefined, context); + return true; + } + + const headerWid = request.headers["x-workspace-id"] as string | undefined; + const tokenWid = request.jwtPayload?.wid ?? null; + const membership = await this.resolveMembership(request.user.id, headerWid, tokenWid); + if (!membership) { + throw ApiError.forbidden("No workspace membership"); + } + request.workspace = { id: membership.workspaceId, role: membership.role }; + const ctx = getRequestContext(); + if (ctx) ctx.workspaceId = membership.workspaceId; + + // 路径中出现 :workspaceId 时,必须对其具备成员资格(防止越权访问其它工作区) + const paramWid = (request.params as Record | undefined)?.workspaceId; + if (paramWid && paramWid !== membership.workspaceId) { + const paramMembership = await this.prisma.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId: paramWid, userId: request.user.id } }, + select: { workspaceId: true, role: true }, + }); + if (!paramMembership) { + throw ApiError.forbidden("Not a member of the target workspace"); + } + request.workspace = paramMembership; + if (ctx) ctx.workspaceId = paramMembership.workspaceId; + } + + this.checkRoles(request, request.workspace.role, context); + return true; + } + + /** # 校验角色要求 */ + private checkRoles( + request: { workspace?: { role: WorkspaceRole } }, + role: WorkspaceRole | undefined, + context: ExecutionContext, + ): void { + const required = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!required || required.length === 0) return; + const current = role ?? request.workspace?.role; + if (!current || !required.some((r) => ROLE_RANK[current] >= ROLE_RANK[r])) { + throw ApiError.forbidden(`Requires role: ${required.join(" or ")}`); + } + } + + /** # 解析用户在某工作区的成员关系 */ + private async resolveMembership( + userId: string, + headerWid?: string, + tokenWid?: string | null, + ): Promise<{ workspaceId: string; role: WorkspaceRole } | null> { + const wid = headerWid || tokenWid; + if (wid) { + const m = await this.prisma.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId: wid, userId } }, + select: { workspaceId: true, role: true }, + }); + if (m) return m; + if (headerWid) { + // 显式指定了不属于自己的工作区:明确拒绝 + return null; + } + } + const first = await this.prisma.workspaceMember.findFirst({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { workspaceId: true, role: true }, + }); + return first; + } +} diff --git a/apps/api/src/common/logger/logger.module.ts b/apps/api/src/common/logger/logger.module.ts new file mode 100644 index 0000000..c87ca01 --- /dev/null +++ b/apps/api/src/common/logger/logger.module.ts @@ -0,0 +1,81 @@ +/** + * # 日志工厂(全局模块) + * 为每个消费者提供独立 context 的结构化日志实例。 + */ +import { Global, Injectable, Module } from "@nestjs/common"; + +@Injectable() +export class LogService { + /** # 创建带类别的日志器 + * @param context 模块名(写入日志 ctx 字段) + */ + child(context: string): StructuredLoggerInstance { + const logger = new StructuredLoggerInstance(); + logger.setContext(context); + return logger; + } +} + +/** # 结构化日志器实例(非 DI 管理,由 LogService 工厂创建) */ +export class StructuredLoggerInstance { + /** # 日志类别 **/ + private context = "App"; + + /** # 设置类别 **/ + setContext(context: string): void { + this.context = context; + } + + private write(level: "debug" | "log" | "warn" | "error", message: unknown, meta?: unknown): void { + const ctx = requestContext(); + const entry: Record = { + time: new Date().toISOString(), + level, + ctx: this.context, + msg: typeof message === "string" ? message : JSON.stringify(message), + }; + if (ctx?.requestId) entry.requestId = ctx.requestId; + if (ctx?.correlationId) entry.correlationId = ctx.correlationId; + if (ctx?.workspaceId) entry.workspaceId = ctx.workspaceId; + if (ctx?.actorId) entry.actorId = ctx.actorId; + if (meta !== undefined) { + entry.meta = meta instanceof Error ? { name: meta.name, message: meta.message } : meta; + } + const line = JSON.stringify(entry); + if (level === "error") console.error(line); + else if (level === "warn") console.warn(line); + else console.log(line); + } + + /** # 调试日志 */ + debug(message: unknown, meta?: unknown): void { + this.write("debug", message, meta); + } + /** # 信息日志 */ + log(message: unknown, meta?: unknown): void { + this.write("log", message, meta); + } + /** # 警告日志 */ + warn(message: unknown, meta?: unknown): void { + this.write("warn", message, meta); + } + /** # 错误日志 */ + error(message: unknown, meta?: unknown): void { + this.write("error", message, meta); + } +} + +/** # 延迟引用避免循环 import(request-context 无依赖,直接引入即可) */ +import { requestContextStore } from "../request-context"; + +/** # 快捷读取当前请求上下文 */ +function requestContext(): { requestId?: string; correlationId?: string; workspaceId?: string; actorId?: string } | undefined { + return requestContextStore.getStore(); +} + +@Global() +@Module({ + providers: [LogService], + exports: [LogService], +}) +export class LoggerModule {} diff --git a/apps/api/src/common/middleware/request-context.hook.ts b/apps/api/src/common/middleware/request-context.hook.ts new file mode 100644 index 0000000..f95696d --- /dev/null +++ b/apps/api/src/common/middleware/request-context.hook.ts @@ -0,0 +1,28 @@ +/** + * # 请求上下文 Fastify 钩子 + * 在 onRequest 阶段(拿到真正的 FastifyRequest/FastifyReply): + * - 透传或生成 X-Request-Id,写响应头; + * - 用 AsyncLocalStorage 建立请求级上下文(后续 Guard 补充 actor/workspace)。 + */ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { randomUUID } from "node:crypto"; +import { requestContextStore } from "../request-context"; + +/** # 请求对象扩展字段 **/ +declare module "fastify" { + interface FastifyRequest { + requestId: string; + /** # 原始请求体(Nest rawBody: true 注入,webhook HMAC 验签用) **/ + rawBody?: Buffer; + } +} + +/** # 注册钩子(传入 Fastify 实例;必须在路由初始化前调用) */ +export function registerRequestContextHook(fastify: FastifyInstance): void { + fastify.addHook("onRequest", (req: FastifyRequest, reply: FastifyReply, done: () => void) => { + const requestId = (req.headers["x-request-id"] as string) || randomUUID(); + req.requestId = requestId; + reply.header("X-Request-Id", requestId); + requestContextStore.run({ requestId, ip: req.ip }, () => done()); + }); +} diff --git a/apps/api/src/common/pipes/zod-validation.pipe.ts b/apps/api/src/common/pipes/zod-validation.pipe.ts new file mode 100644 index 0000000..a8b6e23 --- /dev/null +++ b/apps/api/src/common/pipes/zod-validation.pipe.ts @@ -0,0 +1,25 @@ +/** + * # Zod 校验管道 + * 将路由参数 DTO 与 zod schema 绑定:校验失败抛出 400 VALIDATION_ERROR, + * 校验通过返回 parse 后的强类型值(转换/默认值均已应用)。 + */ +import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common"; +import { ZodSchema } from "zod"; +import { ApiError } from "../errors/api-error"; + +@Injectable() +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + /** # 转换并校验输入值 */ + transform(value: unknown, _metadata: ArgumentMetadata): T { + const result = this.schema.safeParse(value); + if (!result.success) { + throw ApiError.validation( + "Request validation failed", + result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })), + ); + } + return result.data; + } +} diff --git a/apps/api/src/common/request-context.ts b/apps/api/src/common/request-context.ts new file mode 100644 index 0000000..c35ad1a --- /dev/null +++ b/apps/api/src/common/request-context.ts @@ -0,0 +1,30 @@ +/** + * # 请求上下文 + * 基于 AsyncLocalStorage 贯穿一次请求/一次异步任务的执行单元: + * requestId(传输层标识)、correlationId(跨资源因果链)、actor(审计主体)、workspaceId。 + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { ActorType } from "@devos/types"; + +/** # 请求上下文内容 **/ +export interface RequestContext { + requestId: string; + correlationId?: string; + actorType?: ActorType; + actorId?: string; + workspaceId?: string; + ip?: string; +} + +/** # 全局 ALS 实例 **/ +export const requestContextStore = new AsyncLocalStorage(); + +/** # 读取当前上下文(可能为空,例如非 HTTP 场景) */ +export function getRequestContext(): RequestContext | undefined { + return requestContextStore.getStore(); +} + +/** # 在指定上下文中运行异步函数(用于 worker/调度器派生的执行单元) */ +export function runWithContext(ctx: RequestContext, fn: () => Promise): Promise { + return requestContextStore.run(ctx, fn); +} diff --git a/apps/api/src/common/security/production-check.ts b/apps/api/src/common/security/production-check.ts new file mode 100644 index 0000000..12f2abf --- /dev/null +++ b/apps/api/src/common/security/production-check.ts @@ -0,0 +1,23 @@ +/** + * # 生产环境 secret 校验(独立模块,避免与 ConfigService 循环依赖) + */ + +/** # 已知开发默认 secret(生产启动时拒绝) **/ +export const KNOWN_DEV_SECRETS = new Set([ + "dev-devos-app-secret-change-me-please-0123456789", + "change-me-to-a-long-random-secret", + "changeme", + "secret", +]); + +/** # 校验生产 secret 配置(启动时调用;失败抛错阻止启动) */ +export function assertProductionSecrets(values: { + nodeEnv: string | undefined; + appSecret: string; + encryptionKey: string; +}): void { + if (values.nodeEnv !== "production") return; + if (values.appSecret.length < 32 || KNOWN_DEV_SECRETS.has(values.appSecret)) { + throw new Error("Production refuses weak/default APP_SECRET (>=32 random chars required)"); + } +} diff --git a/apps/api/src/common/security/security-policy.service.ts b/apps/api/src/common/security/security-policy.service.ts new file mode 100644 index 0000000..1e4c765 --- /dev/null +++ b/apps/api/src/common/security/security-policy.service.ts @@ -0,0 +1,60 @@ +/** + * # 安全加固(Phase A) + * - 生产环境 secret 校验:拒绝明显开发默认值/弱密钥; + * - 注册开关:ENABLE_REGISTRATION(生产默认关闭); + * - 登录/注册限流:进程内滑动窗口(多实例部署时建议换 Redis 实现)。 + */ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "../config/config.service"; + +export { assertProductionSecrets } from "./production-check"; + +@Injectable() +export class SecurityPolicyService { + /** # 是否允许公开注册(生产默认关闭,需显式 ENABLE_REGISTRATION=1) **/ + readonly registrationEnabled: boolean; + /** # 限流窗口内的最大尝试次数 **/ + private readonly limit = 10; + /** # 滑动窗口长度(毫秒) **/ + private readonly windowMs = 60_000; + /** # ip → 时间戳列表 **/ + private attempts = new Map(); + + constructor(private readonly config: ConfigService) { + const flag = process.env.ENABLE_REGISTRATION; + this.registrationEnabled = + flag !== undefined ? flag === "1" || flag === "true" : process.env.NODE_ENV !== "production"; + } + + /** # 注册是否被允许;不允许时返回原因 */ + checkRegistration(): { allowed: boolean; reason?: string } { + if (this.registrationEnabled) return { allowed: true }; + return { allowed: false, reason: "Registration is disabled on this instance (invite-only)" }; + } + + /** # 限流检查(滑动窗口);超限返回 false + * @param key 维度键(如 `login:1.2.3.4`) + */ + checkRate(key: string): boolean { + const now = Date.now(); + const list = (this.attempts.get(key) ?? []).filter((t) => now - t < this.windowMs); + if (list.length >= this.limit) { + this.attempts.set(key, list); + return false; + } + list.push(now); + this.attempts.set(key, list); + // 防内存膨胀:键过多时清理过期项 + if (this.attempts.size > 10_000) { + for (const [k, v] of this.attempts) { + if (v.every((t) => now - t >= this.windowMs)) this.attempts.delete(k); + } + } + return true; + } + + /** # webhook secret 生成提示(HMAC 用) */ + get webhookLegacyFallback(): boolean { + return this.config.appSecret.length > 0; + } +} diff --git a/apps/api/src/common/security/security.module.ts b/apps/api/src/common/security/security.module.ts new file mode 100644 index 0000000..16102a0 --- /dev/null +++ b/apps/api/src/common/security/security.module.ts @@ -0,0 +1,14 @@ +/** + * # 安全策略模块(全局) + */ +import { Global, Module } from "@nestjs/common"; +import { SecurityPolicyService } from "./security-policy.service"; +import { ConfigModule } from "../config/config.module"; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [SecurityPolicyService], + exports: [SecurityPolicyService], +}) +export class SecurityModule {} diff --git a/apps/api/src/common/security/webhook-signature.ts b/apps/api/src/common/security/webhook-signature.ts new file mode 100644 index 0000000..18f454d --- /dev/null +++ b/apps/api/src/common/security/webhook-signature.ts @@ -0,0 +1,56 @@ +/** + * # Webhook HMAC 签名(Phase A/F 安全升级) + * 签名格式(兼容 GitHub 风格): + * X-Signature: t=,v1= + * 校验:时间戳容差 ±5 分钟(防重放),签名常量时间比较。 + * 未携带签名头的旧客户端回退 X-Webhook-Secret 明文比对(legacy,计划移除)。 + */ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** # 签名时间戳容差(毫秒) **/ +export const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000; + +/** # 计算 webhook 签名 + * @param secret webhook secret + * @param timestamp unix 秒 + * @param body 原始请求体字符串 + */ +export function signWebhookPayload(secret: string, timestamp: number, body: string): string { + return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); +} + +/** # 构造完整签名头 */ +export function buildSignatureHeader(secret: string, body: string, nowSec?: number): string { + const t = nowSec ?? Math.floor(Date.now() / 1000); + return `t=${t},v1=${signWebhookPayload(secret, t, body)}`; +} + +/** # 常量时间字符串比较 */ +function safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a, "utf8"); + const bb = Buffer.from(b, "utf8"); + if (ba.length !== bb.length) return false; + return timingSafeEqual(ba, bb); +} + +/** # 校验签名头 + * @returns ok + 失败原因(供审计) + */ +export function verifyWebhookSignature( + secret: string, + signatureHeader: string | undefined, + body: string, + nowMs?: number, +): { ok: boolean; reason?: string } { + if (!signatureHeader) return { ok: false, reason: "missing signature" }; + const m = signatureHeader.match(/^t=(\d+),v1=([0-9a-f]{64})$/); + if (!m) return { ok: false, reason: "malformed signature" }; + const t = Number(m[1]); + const now = nowMs ?? Date.now(); + if (Math.abs(now - t * 1000) > SIGNATURE_TOLERANCE_MS) { + return { ok: false, reason: "timestamp outside tolerance (replay?)" }; + } + const expected = signWebhookPayload(secret, t, body); + if (!safeEqual(expected, m[2])) return { ok: false, reason: "signature mismatch" }; + return { ok: true }; +} diff --git a/apps/api/src/common/system.controller.ts b/apps/api/src/common/system.controller.ts new file mode 100644 index 0000000..edf6908 --- /dev/null +++ b/apps/api/src/common/system.controller.ts @@ -0,0 +1,71 @@ +/** + * # 系统模块:健康检查 + 工作台统计 + */ +import { Controller, Get } from "@nestjs/common"; +import { Public, CurrentWorkspace, NoWorkspace } from "./decorators"; +import { ConfigService } from "./config/config.service"; +import { PrismaService } from "../prisma/prisma.service"; +import type { RequestWorkspace } from "./decorators"; + +@Controller() +export class SystemController { + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + ) {} + + /** # 健康检查(公开;负载均衡/监控探针用) */ + @Public() + @NoWorkspace() + @Get("health") + health() { + return { status: "ok", service: "devos-api", version: this.config.version, time: new Date().toISOString() }; + } + + /** # 工作台统计:资源计数 + 最近活动(Dashboard 首页) */ + @Get("dashboard") + async dashboard(@CurrentWorkspace() ws: RequestWorkspace) { + if (!ws) { + return { serverCount: 0, websiteCount: 0, domainCount: 0, containerCount: 0, deploymentCount: 0, monitorsUp: 0, monitorsDown: 0, recentEvents: [], recentDeployments: [] }; + } + const wid = ws.id; + const [serverCount, websiteCount, domainCount, containerCount, deploymentCount, monitorsUp, monitorsDown, recentEvents, recentDeployments] = + await this.prisma.$transaction([ + this.prisma.server.count({ where: { workspaceId: wid } }), + this.prisma.website.count({ where: { workspaceId: wid } }), + this.prisma.domain.count({ where: { workspaceId: wid } }), + this.prisma.container.count({ where: { workspaceId: wid } }), + this.prisma.deployment.count({ where: { workspaceId: wid } }), + this.prisma.monitor.count({ where: { workspaceId: wid, lastStatus: "UP" } }), + this.prisma.monitor.count({ where: { workspaceId: wid, lastStatus: "DOWN" } }), + this.prisma.eventRecord.findMany({ + where: { workspaceId: wid }, + orderBy: { createdAt: "desc" }, + take: 10, + }), + this.prisma.deployment.findMany({ + where: { workspaceId: wid }, + orderBy: { createdAt: "desc" }, + take: 5, + include: { website: { select: { name: true } } }, + }), + ]); + return { + serverCount, + websiteCount, + domainCount, + containerCount, + deploymentCount, + monitorsUp, + monitorsDown, + recentEvents, + recentDeployments: recentDeployments.map((d) => ({ + id: d.id, + status: d.status, + websiteName: d.website?.name, + createdAt: d.createdAt, + trigger: d.trigger, + })), + }; + } +} diff --git a/apps/api/src/common/utils/html.ts b/apps/api/src/common/utils/html.ts new file mode 100644 index 0000000..b445bb2 --- /dev/null +++ b/apps/api/src/common/utils/html.ts @@ -0,0 +1,75 @@ +/** + * # 网页文本提取工具 + * 零依赖 HTML → 可读文本,供 AI 网页总结 / 内容抓取使用。 + */ + +/** # 提取 */ +export function extractHtmlTitle(html: string): string | null { + const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i); + if (!m) return null; + return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 200) || null; +} + +/** # 提取 meta description */ +export function extractMetaDescription(html: string): string | null { + const m = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) ?? + html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i); + if (!m) return null; + return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 400) || null; +} + +/** # 提取正文段落(按长度过滤导航/噪音) */ +export function extractMainParagraphs(html: string, max = 8): string[] { + const cleaned = html + .replace(/<script[\s\S]*?<\/script>/gi, " ") + .replace(/<style[\s\S]*?<\/style>/gi, " ") + .replace(/<nav[\s\S]*?<\/nav>/gi, " ") + .replace(/<header[\s\S]*?<\/header>/gi, " ") + .replace(/<footer[\s\S]*?<\/footer>/gi, " ") + .replace(/<!--[\s\S]*?-->/g, " "); + const paragraphs: string[] = []; + const re = /<(?:p|h1|h2|h3|li)[^>]*>([\s\S]*?)<\/(?:p|h1|h2|h3|li)>/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(cleaned)) && paragraphs.length < max * 3) { + const text = decodeEntities(m[1].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); + // 过滤太短的噪音(菜单项等) + if (text.length >= 40) paragraphs.push(text); + if (paragraphs.length >= max) break; + } + return paragraphs; +} + +/** # HTML → 纯文本(含标题/正文,截断) */ +export function htmlToText(html: string, maxChars = 12000): string { + const title = extractHtmlTitle(html); + const paragraphs = extractMainParagraphs(html, 40); + const body = [title ? `# ${title}` : "", ...paragraphs].filter(Boolean).join("\n\n"); + return body.slice(0, maxChars); +} + +/** # 抽取式摘要(无 LLM 时的兜底:标题 + meta 描述 + 前几段) */ +export function extractiveSummary(html: string, url: string): { title: string; summaryText: string } { + const title = extractHtmlTitle(html) ?? url; + const meta = extractMetaDescription(html); + const paragraphs = extractMainParagraphs(html, 4); + const lines = [ + meta ? `📌 ${meta}` : "", + ...paragraphs.map((p) => `• ${p.slice(0, 220)}`), + ].filter(Boolean); + return { + title, + summaryText: lines.join("\n") || "(页面无可提取正文)", + }; +} + +/** # HTML 实体解码(常用集合) */ +export function decodeEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/ /g, " ") + .replace(/&#(\d+);/g, (_, d: string) => String.fromCodePoint(Number(d))); +} diff --git a/apps/api/src/common/utils/mask.ts b/apps/api/src/common/utils/mask.ts new file mode 100644 index 0000000..7b84928 --- /dev/null +++ b/apps/api/src/common/utils/mask.ts @@ -0,0 +1,52 @@ +/** + * # 脱敏工具 + * 对日志、审计元数据、工具结果中可能出现的敏感键值进行遮蔽。 + * 键名匹配敏感词(password/token/secret/key/credential/authorization 等)时替换为 "***"; + * 字符串值中内嵌的密钥样式(Bearer xxx、sk-xxx)也会被遮蔽。 + */ + +/** # 敏感键名匹配 **/ +const SENSITIVE_KEY = /pass(word)?|secret|token|api[-_]?key|access[-_]?key|credential|authorization|private[-_]?key/i; +/** # 内嵌密钥样式 **/ +const SENSITIVE_PATTERN = /(Bearer\s+)[\w.\-~+/=]+|(sk-|ghp_|github_pat_)[\w]+/g; + +/** # 遮蔽字符串值 */ +export function maskString(value: string): string { + return value.replace(SENSITIVE_PATTERN, (_m, p1) => (p1 ? `${p1}***` : "***")); +} + +/** # 递归遮蔽对象/数组中的敏感数据;原对象不被修改 + * @param input 任意值 + * @returns 遮蔽后的值 + */ +export function maskSecrets<T>(input: T, depth = 0): T { + if (depth > 6) return input; + if (typeof input === "string") return maskString(input) as unknown as T; + if (Array.isArray(input)) return input.map((v) => maskSecrets(v, depth + 1)) as unknown as T; + if (input && typeof input === "object") { + const out: Record<string, unknown> = {}; + for (const [k, v] of Object.entries(input as Record<string, unknown>)) { + out[k] = SENSITIVE_KEY.test(k) ? "***" : maskSecrets(v, depth + 1); + } + return out as unknown as T; + } + return input; +} + +/** # 危险命令检测(用于 server.exec 与 AI 工具风险升级) + * @param command 待执行的 shell 命令 + * @returns 是否包含破坏性模式 + */ +export function isDestructiveCommand(command: string): boolean { + const patterns = [ + /rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+/, + /mkfs(\.\w+)?\s/, + /dd\s+if=/, + />\s*\/dev\/sd[a-z]/, + /:\(\)\s*\{\s*:\|\:/, + /shutdown|reboot|init\s+0|init\s+6/, + /chmod\s+-R\s+777\s+\//, + /DROP\s+(TABLE|DATABASE)\s/i, + ]; + return patterns.some((p) => p.test(command)); +} diff --git a/apps/api/src/integrations/dns/dns.module.ts b/apps/api/src/integrations/dns/dns.module.ts new file mode 100644 index 0000000..ebd1fc1 --- /dev/null +++ b/apps/api/src/integrations/dns/dns.module.ts @@ -0,0 +1,11 @@ +/** + * # DNS 集成模块(Cloudflare / Manual 适配器) + */ +import { Module } from "@nestjs/common"; +import { CloudflareDnsProvider, ManualDnsProvider } from "./dns.providers"; + +@Module({ + providers: [CloudflareDnsProvider, ManualDnsProvider], + exports: [CloudflareDnsProvider, ManualDnsProvider], +}) +export class DnsModule {} diff --git a/apps/api/src/integrations/dns/dns.providers.ts b/apps/api/src/integrations/dns/dns.providers.ts new file mode 100644 index 0000000..4acd709 --- /dev/null +++ b/apps/api/src/integrations/dns/dns.providers.ts @@ -0,0 +1,171 @@ +/** + * # DNS Provider 适配器(Task 013) + * 接口 + 两种实现: + * - CloudflareDnsProvider:Cloudflare v4 API(token 凭据); + * - ManualDnsProvider:无外部 API,记录仅存本地(本地事实源)。 + */ +import { Injectable } from "@nestjs/common"; + +/** # DNS 记录 **/ +export interface DnsRecord { + id: string | null; + type: string; + name: string; + value: string; + ttl: number; + proxied: boolean; +} + +/** # DNS Provider 接口 **/ +export interface DnsProvider { + /** # 提供商标识 **/ + readonly provider: string; + listZones(params: { token: string; name?: string }): Promise<{ id: string; name: string }[]>; + listRecords(params: { token: string; zoneName: string }): Promise<DnsRecord[]>; + upsertRecord(params: { + token: string; + zoneName: string; + record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean }; + }): Promise<DnsRecord>; + deleteRecord(params: { token: string; zoneName: string; recordId: string }): Promise<void>; +} + +/** # Cloudflare API 响应包络 **/ +interface CfEnvelope<T> { + success: boolean; + errors: { code: number; message: string }[]; + result: T; +} + +/** # Cloudflare 记录 **/ +interface CfRecord { + id: string; + type: string; + name: string; + content: string; + ttl: number; + proxied: boolean; +} + +@Injectable() +export class CloudflareDnsProvider implements DnsProvider { + /** # 提供商标识 **/ + readonly provider = "CLOUDFLARE"; + /** # API 基址 **/ + private base = process.env.CLOUDFLARE_API_BASE ?? "https://api.cloudflare.com/client/v4"; + + /** # 请求 Cloudflare API */ + private async call<T>(token: string, path: string, init?: RequestInit): Promise<T> { + const res = await fetch(`${this.base}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + signal: AbortSignal.timeout(15_000), + }); + const body = (await res.json().catch(() => null)) as CfEnvelope<T> | null; + if (!body || !body.success) { + const message = body?.errors?.[0]?.message ?? `Cloudflare API HTTP ${res.status}`; + throw new Error(message); + } + return body.result; + } + + /** # 列出区域 */ + async listZones(params: { token: string; name?: string }) { + const query = params.name ? `?name=${encodeURIComponent(params.name)}` : "?per_page=50"; + const zones = await this.call<{ id: string; name: string }[]>(params.token, `/zones${query}`); + return zones.map((z) => ({ id: z.id, name: z.name })); + } + + /** # 列出记录 */ + async listRecords(params: { token: string; zoneName: string }) { + const zones = await this.listZones({ token: params.token, name: params.zoneName }); + if (zones.length === 0) return []; + const records = await this.call<CfRecord[]>( + params.token, + `/zones/${zones[0].id}/dns_records?per_page=100`, + ); + return records.map((r) => ({ + id: r.id, + type: r.type, + name: r.name, + value: r.content, + ttl: r.ttl, + proxied: Boolean(r.proxied), + })); + } + + /** # 创建或更新记录(按 type+name 查找,存在则 PATCH) */ + async upsertRecord(params: { + token: string; + zoneName: string; + record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean }; + }) { + const zones = await this.listZones({ token: params.token, name: params.zoneName }); + if (zones.length === 0) throw new Error(`Zone not found: ${params.zoneName}`); + const zoneId = zones[0].id; + const existing = await this.call<CfRecord[]>( + params.token, + `/zones/${zoneId}/dns_records?type=${params.record.type}&name=${encodeURIComponent(params.record.name)}`, + ); + const payload = { + type: params.record.type, + name: params.record.name, + content: params.record.value, + ttl: params.record.ttl ?? 300, + proxied: params.record.proxied ?? false, + }; + if (existing.length > 0) { + const updated = await this.call<CfRecord>( + params.token, + `/zones/${zoneId}/dns_records/${existing[0].id}`, + { method: "PATCH", body: JSON.stringify(payload) }, + ); + return { id: updated.id, type: updated.type, name: updated.name, value: updated.content, ttl: updated.ttl, proxied: Boolean(updated.proxied) }; + } + const created = await this.call<CfRecord>(params.token, `/zones/${zoneId}/dns_records`, { + method: "POST", + body: JSON.stringify(payload), + }); + return { id: created.id, type: created.type, name: created.name, value: created.content, ttl: created.ttl, proxied: Boolean(created.proxied) }; + } + + /** # 删除记录 */ + async deleteRecord(params: { token: string; zoneName: string; recordId: string }): Promise<void> { + const zones = await this.listZones({ token: params.token, name: params.zoneName }); + if (zones.length === 0) throw new Error(`Zone not found: ${params.zoneName}`); + await this.call(params.token, `/zones/${zones[0].id}/dns_records/${params.recordId}`, { method: "DELETE" }); + } +} + +@Injectable() +export class ManualDnsProvider implements DnsProvider { + /** # 提供商标识 **/ + readonly provider = "MANUAL"; + + /** # 手动模式:区域/记录由本地数据库管理,适配器返回空集合(由服务层处理) */ + async listZones(): Promise<{ id: string; name: string }[]> { + return []; + } + async listRecords(): Promise<DnsRecord[]> { + return []; + } + async upsertRecord(params: { + record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean }; + }): Promise<DnsRecord> { + return { + id: null, + type: params.record.type, + name: params.record.name, + value: params.record.value, + ttl: params.record.ttl ?? 300, + proxied: params.record.proxied ?? false, + }; + } + async deleteRecord(): Promise<void> { + /* 手动模式下删除仅操作本地库 */ + } +} diff --git a/apps/api/src/integrations/docker/docker.module.ts b/apps/api/src/integrations/docker/docker.module.ts new file mode 100644 index 0000000..2ab9240 --- /dev/null +++ b/apps/api/src/integrations/docker/docker.module.ts @@ -0,0 +1,12 @@ +/** + * # Docker 集成模块 + */ +import { Global, Module } from "@nestjs/common"; +import { DockerService } from "./docker.service"; + +@Global() +@Module({ + providers: [DockerService], + exports: [DockerService], +}) +export class DockerModule {} diff --git a/apps/api/src/integrations/docker/docker.service.ts b/apps/api/src/integrations/docker/docker.service.ts new file mode 100644 index 0000000..8e1d204 --- /dev/null +++ b/apps/api/src/integrations/docker/docker.service.ts @@ -0,0 +1,314 @@ +/** + * # Docker 适配器(Task 010) + * 统一接口 + 两种实现: + * - SshDockerAdapter:通过 SSH 调用目标机 docker CLI(零额外依赖、权限模型清晰); + * - LocalSocketAdapter:通过本机 /var/run/docker.sock 调用 Docker Engine API。 + * DevOS 不重新实现 Docker,仅提供统一适配层。 + */ +import { Injectable } from "@nestjs/common"; +import { request as httpRequest } from "node:http"; +import { SshClientService, type SshTarget } from "../ssh/ssh-client.service"; +import { ApiError } from "../../common/errors/api-error"; + +/** # 容器摘要视图 **/ +export interface DockerContainerInfo { + id: string; + name: string; + image: string; + state: string; + status: string; + ports: Record<string, unknown>[]; + labels: Record<string, string>; +} + +/** # Docker 适配器接口 **/ +export interface DockerAdapter { + /** # 是否支持该目标(例如本地 socket 仅限本机) */ + supports(server: { host: string; provider?: string }): boolean; + listContainers(target: SshTarget): Promise<DockerContainerInfo[]>; + inspectContainer(target: SshTarget, containerId: string): Promise<Record<string, unknown>>; + restartContainer(target: SshTarget, containerId: string): Promise<void>; + checkDocker(target: SshTarget): Promise<{ available: boolean; version: string }>; +} + +/** # docker ps --format 输出行结构(部分字段) **/ +interface DockerPsRow { + ID: string; + Names: string; + Image: string; + State: string; + Status: string; + Ports?: string; + Labels?: string; + Command?: string; + CreatedAt?: string; + Networks?: string; + LocalVolumes?: string; + Mounts?: string; + RunningFor?: string; + Size?: string; +} + +/** # 解析 docker ps --format '{{json .}}' 的 NDJSON 输出(纯函数,可单测) */ +export function parseDockerPsOutput(output: string): DockerContainerInfo[] { + const lines = output + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + const result: DockerContainerInfo[] = []; + for (const line of lines) { + let row: DockerPsRow; + try { + row = JSON.parse(line) as DockerPsRow; + } catch { + continue; // 跳过非 JSON 噪音行 + } + result.push({ + id: row.ID ?? "", + name: (row.Names ?? "").split(",").filter(Boolean)[0] ?? "", + image: row.Image ?? "", + state: row.State ?? "", + status: row.Status ?? "", + ports: parseDockerPorts(row.Ports ?? ""), + labels: parseDockerLabels(row.Labels ?? ""), + }); + } + return result; +} + +/** # 解析 docker ps Ports 列(如 0.0.0.0:8080->80/tcp, :::8080->80/tcp) */ +export function parseDockerPorts(ports: string): Record<string, unknown>[] { + if (!ports || ports === "") return []; + return ports.split(", ").filter(Boolean).map((p) => ({ raw: p })); +} + +/** # 解析 docker ps Labels 列(逗号分隔 key=value) */ +export function parseDockerLabels(labels: string): Record<string, string> { + const out: Record<string, string> = {}; + if (!labels) return out; + for (const pair of labels.split(",")) { + const idx = pair.indexOf("="); + if (idx > 0) out[pair.slice(0, idx)] = pair.slice(idx + 1); + } + return out; +} + +@Injectable() +export class DockerService implements DockerAdapter { + /** # 本机 docker socket 路径 **/ + private static readonly LOCAL_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock"; + + constructor(private readonly ssh: SshClientService) {} + + /** # 是否支持该目标 */ + supports(server: { host: string; provider?: string }): boolean { + return true; // 接口层总是可用;选择 SSH 或 socket 由 targetProvider 决定 + } + + /** # 目标是否走本地 socket(server.provider === 'local') */ + isLocal(server: { provider?: string | null }): boolean { + return server.provider === "local"; + } + + /** # 列出容器(含停止的) */ + async listContainers(target: SshTarget, isLocal = false): Promise<DockerContainerInfo[]> { + if (isLocal) { + const rows = await this.localApi<Record<string, unknown>[]>("/containers/json?all=1"); + return rows.map(mapEngineContainer); + } + const res = await this.ssh.exec(target, "docker ps -a --format '{{json .}}'", 30_000); + if (res.code !== 0 && !res.stdout.trim()) { + throw ApiError.upstream(`docker ps failed: ${res.stderr.slice(0, 500)}`); + } + return parseDockerPsOutput(res.stdout); + } + + /** # 检查容器详情 */ + async inspectContainer( + target: SshTarget, + containerId: string, + isLocal = false, + ): Promise<Record<string, unknown>> { + const safeId = assertSafeContainerId(containerId); + if (isLocal) { + return this.localApi<Record<string, unknown>>(`/containers/${safeId}/json`); + } + const res = await this.ssh.exec(target, `docker inspect ${safeId}`, 20_000); + if (res.code !== 0) throw ApiError.upstream(`docker inspect failed: ${res.stderr.slice(0, 300)}`); + const parsed = JSON.parse(res.stdout) as Record<string, unknown>[]; + return parsed[0] ?? {}; + } + + /** # 重启容器 */ + async restartContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> { + const safeId = assertSafeContainerId(containerId); + if (isLocal) { + await this.localApiVoid(`/containers/${safeId}/restart?t=10`, "POST"); + return; + } + const res = await this.ssh.exec(target, `docker restart -t 10 ${safeId}`, 60_000); + if (res.code !== 0) throw ApiError.upstream(`docker restart failed: ${res.stderr.slice(0, 300)}`); + } + + /** # 启动容器 */ + async startContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> { + const safeId = assertSafeContainerId(containerId); + if (isLocal) { + await this.localApiVoid(`/containers/${safeId}/start`, "POST"); + return; + } + const res = await this.ssh.exec(target, `docker start ${safeId}`, 60_000); + if (res.code !== 0) throw ApiError.upstream(`docker start failed: ${res.stderr.slice(0, 300)}`); + } + + /** # 停止容器 */ + async stopContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> { + const safeId = assertSafeContainerId(containerId); + if (isLocal) { + await this.localApiVoid(`/containers/${safeId}/stop?t=10`, "POST"); + return; + } + const res = await this.ssh.exec(target, `docker stop -t 10 ${safeId}`, 60_000); + if (res.code !== 0) throw ApiError.upstream(`docker stop failed: ${res.stderr.slice(0, 300)}`); + } + + /** # 容器日志(tail N 行) */ + async containerLogs(target: SshTarget, containerId: string, tail = 200, isLocal = false): Promise<string[]> { + const safeId = assertSafeContainerId(containerId); + if (isLocal) { + const res = await this.localApi<string>(`/containers/${safeId}/logs?stdout=1&stderr=1&tail=${tail}`); + // Docker API 多路复用流:剥离 8 字节帧头 + return res + .split("\n") + .map((l) => l.replace(/^[^\r\n]*\r?/, "")) + .slice(-tail); + } + const res = await this.ssh.exec(target, `docker logs --tail ${tail} ${safeId} 2>&1`, 30_000); + return res.stdout.split("\n").slice(-tail); + } + + /** # 镜像列表 */ + async listImages(target: SshTarget, isLocal = false): Promise<{ repository: string; tag: string; size: string; created: string }[]> { + if (isLocal) { + const rows = await this.localApi<Record<string, unknown>[]>("/images/json"); + return rows.map((r) => { + const tags = (r.RepoTags as string[] | undefined) ?? ["<none>:<none>"]; + return { + repository: (tags[0].split(":")[0]) ?? "<none>", + tag: tags[0].split(":").slice(1).join(":") || "<none>", + size: `${Math.round((Number(r.Size) || 0) / 1024 / 1024)}MB`, + created: new String(r.Created ?? "").toString(), + }; + }); + } + const res = await this.ssh.exec(target, "docker images --format '{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}'", 20_000); + return res.stdout + .split("\n") + .filter((l) => l.trim()) + .map((l) => { + const [repository, tag, size, created] = l.split("|"); + return { repository, tag, size, created }; + }); + } + + /** # 容器资源用量(stats no-stream) */ + async containerStats( + target: SshTarget, + containerId: string, + isLocal = false, + ): Promise<{ cpuPct: string; memUsage: string; memPct: string }> { + const safeId = assertSafeContainerId(containerId); + if (isLocal) { + const s = await this.localApi<{ cpu_stats: Record<string, unknown>; memory_stats: Record<string, unknown> }>( + `/containers/${safeId}/stats?stream=false`, + ); + return { cpuPct: "n/a", memUsage: `${Math.round((Number(s.memory_stats.usage) || 0) / 1024 / 1024)}MB`, memPct: "n/a" }; + } + const res = await this.ssh.exec(target, `docker stats --no-stream --format '{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}' ${safeId}`, 20_000); + const [cpuPct, memUsage, memPct] = res.stdout.trim().split("|"); + return { cpuPct: cpuPct ?? "n/a", memUsage: memUsage ?? "n/a", memPct: memPct ?? "n/a" }; + } + + /** # Docker 可用性检查 */ + async checkDocker(target: SshTarget, isLocal = false): Promise<{ available: boolean; version: string }> { + try { + if (isLocal) { + const version = await this.localApi<{ Version: string }>("/version"); + return { available: true, version: version.Version }; + } + const res = await this.ssh.exec(target, "docker version --format '{{.Server.Version}}'", 20_000); + if (res.code === 0 && res.stdout.trim()) { + return { available: true, version: res.stdout.trim() }; + } + return { available: false, version: res.stderr.trim().slice(0, 200) }; + } catch (error) { + return { + available: false, + version: error instanceof Error ? error.message.slice(0, 200) : "unknown error", + }; + } + } + + /** # 通过本机 socket 调用 Docker Engine API */ + private localApi<T>(path: string, method = "GET"): Promise<T> { + return new Promise<T>((resolve, reject) => { + const req = httpRequest( + { + socketPath: DockerService.LOCAL_SOCKET, + path: `http://localhost/v1.41${path}`, + method, + timeout: 15_000, + }, + (res) => { + let body = ""; + res.on("data", (c) => (body += c.toString("utf8"))); + res.on("end", () => { + if ((res.statusCode ?? 500) >= 400) { + reject(ApiError.upstream(`Docker API ${path} -> ${res.statusCode}: ${body.slice(0, 200)}`)); + return; + } + try { + resolve(body ? (JSON.parse(body) as T) : (undefined as T)); + } catch { + resolve(undefined as T); + } + }); + }, + ); + req.on("timeout", () => { + req.destroy(); + reject(ApiError.timeout("Docker API timeout")); + }); + req.on("error", (err) => reject(ApiError.upstream(`Docker API error: ${err.message}`))); + req.end(); + }); + } + + /** # 本机 socket 无返回体调用 */ + private async localApiVoid(path: string, method: string): Promise<void> { + await this.localApi<unknown>(path, method); + } +} + +/** # 容器 ID 白名单校验(防注入;docker id 为 [0-9a-f]{12,64} 或 name) */ +export function assertSafeContainerId(id: string): string { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(id)) { + throw ApiError.validation("Invalid container id"); + } + return id; +} + +/** # Docker Engine API 容器对象 → 摘要视图 */ +function mapEngineContainer(row: Record<string, unknown>): DockerContainerInfo { + const names = (row.Names as string[] | undefined) ?? []; + const ports = (row.Ports as Record<string, unknown>[] | undefined) ?? []; + return { + id: (row.Id as string | undefined)?.slice(0, 12) ?? "", + name: (names[0] ?? "").replace(/^\//, ""), + image: (row.Image as string | undefined) ?? "", + state: (row.State as string | undefined) ?? "", + status: (row.Status as string | undefined) ?? "", + ports, + labels: (row.Labels as Record<string, string> | undefined) ?? {}, + }; +} diff --git a/apps/api/src/integrations/ssh/ssh-client.service.ts b/apps/api/src/integrations/ssh/ssh-client.service.ts new file mode 100644 index 0000000..affa0ca --- /dev/null +++ b/apps/api/src/integrations/ssh/ssh-client.service.ts @@ -0,0 +1,178 @@ +/** + * # SSH 集成客户端 + * 基于 ssh2 的统一封装:连接池化(每目标复用)、命令执行(强制超时)、连通性探测。 + * 安全约束:所有命令必须带超时;凭据只在内存中短暂存在。 + */ +import { Injectable, OnModuleDestroy } from "@nestjs/common"; +import { Client, type ClientCallback, type ConnectConfig } from "ssh2"; +import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module"; +import { ApiError } from "../../common/errors/api-error"; + +/** # 连接参数 **/ +export interface SshTarget { + host: string; + port: number; + username: string; + /** # 密码或私钥二选一 **/ + password?: string; + privateKey?: string; + /** # 私钥口令(可选) **/ + passphrase?: string; +} + +/** # 命令执行结果 **/ +export interface SshExecResult { + code: number; + stdout: string; + stderr: string; + /** # 是否因超时被终止 **/ + timedOut: boolean; +} + +/** # 默认连接超时(毫秒) **/ +const CONNECT_TIMEOUT_MS = 10_000; +/** # 默认命令超时(毫秒) **/ +const DEFAULT_EXEC_TIMEOUT_MS = 30_000; + +@Injectable() +export class SshClientService implements OnModuleDestroy { + /** # 连接缓存 key → Client **/ + private connections = new Map<string, { client: Client; busy: boolean }>(); + + /** # 日志器 **/ + private readonly logger: StructuredLoggerInstance; + + constructor(loggers: LogService) { + this.logger = loggers.child("SshClient"); + } + + /** # 连接缓存键(不含凭据) */ + private cacheKey(target: SshTarget): string { + return `${target.username}@${target.host}:${target.port}`; + } + + /** # 建立或复用连接 + * @returns 就绪的 ssh2 Client + */ + private async connect(target: SshTarget): Promise<Client> { + const key = this.cacheKey(target); + const cached = this.connections.get(key); + if (cached) { + // 已缓存的连接复用(ssh2 Client 支持串行 exec) + return cached.client; + } + const config: ConnectConfig = { + host: target.host, + port: target.port, + username: target.username, + readyTimeout: CONNECT_TIMEOUT_MS, + keepaliveInterval: 10_000, + tryKeyboard: false, + }; + if (target.privateKey) { + config.privateKey = target.privateKey; + if (target.passphrase) config.passphrase = target.passphrase; + } else if (target.password) { + config.password = target.password; + } else { + throw ApiError.validation("SSH target requires password or privateKey"); + } + return new Promise<Client>((resolve, reject) => { + const client = new Client(); + const timer = setTimeout(() => { + client.end(); + reject(ApiError.timeout(`SSH connect timeout after ${CONNECT_TIMEOUT_MS}ms`)); + }, CONNECT_TIMEOUT_MS + 2000); + client + .on("ready", () => { + clearTimeout(timer); + this.connections.set(key, { client, busy: false }); + resolve(client); + }) + .on("error", (err: Error) => { + clearTimeout(timer); + this.connections.delete(key); + reject( + new ApiError( + 502, + "UPSTREAM_FAILURE", + `SSH connection failed: ${err.message}`, + ), + ); + }) + .connect(config); + }); + } + + /** # 在目标机上执行命令(强制超时) + * @param target SSH 目标 + * @param command 待执行命令 + * @param timeoutMs 命令超时(默认 30s) + */ + async exec(target: SshTarget, command: string, timeoutMs = DEFAULT_EXEC_TIMEOUT_MS): Promise<SshExecResult> { + const client = await this.connect(target); + return new Promise<SshExecResult>((resolve, reject) => { + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + // 超时后断开整条连接,避免流挂起 + this.closeKey(this.cacheKey(target)); + reject(ApiError.timeout(`SSH command timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + client.exec(command, ((err: Error | undefined, stream) => { + if (err) { + clearTimeout(timer); + this.closeKey(this.cacheKey(target)); + reject(new ApiError(502, "UPSTREAM_FAILURE", `SSH exec failed: ${err.message}`)); + return; + } + let stdout = ""; + let stderr = ""; + stream.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + stream.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + stream.on("close", (code: number | null) => { + clearTimeout(timer); + if (timedOut) return; + resolve({ code: code ?? -1, stdout, stderr, timedOut: false }); + }); + }) as ClientCallback); + }); + } + + /** # 连通性探测:执行 echo 并测量耗时 + * @returns 延迟毫秒与远端 uname 信息 + */ + async check(target: SshTarget): Promise<{ latencyMs: number; uname: string }> { + const start = Date.now(); + const result = await this.exec(target, "uname -srm && cat /etc/os-release 2>/dev/null | head -2", 15_000); + return { latencyMs: Date.now() - start, uname: result.stdout.trim() }; + } + + /** # 关闭指定目标的连接 */ + private closeKey(key: string): void { + const cached = this.connections.get(key); + if (cached) { + try { + cached.client.end(); + } catch { + /* ignore */ + } + this.connections.delete(key); + } + } + + /** # 关闭所有连接 */ + closeAll(): void { + for (const [key] of this.connections) this.closeKey(key); + } + + /** # 停止时清理连接 */ + onModuleDestroy(): void { + this.closeAll(); + } +} diff --git a/apps/api/src/integrations/ssh/ssh.module.ts b/apps/api/src/integrations/ssh/ssh.module.ts new file mode 100644 index 0000000..a0bd55a --- /dev/null +++ b/apps/api/src/integrations/ssh/ssh.module.ts @@ -0,0 +1,12 @@ +/** + * # SSH 集成模块 + */ +import { Global, Module } from "@nestjs/common"; +import { SshClientService } from "./ssh-client.service"; + +@Global() +@Module({ + providers: [SshClientService], + exports: [SshClientService], +}) +export class SshModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..d7d3fb4 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,96 @@ +/** + * # DevOS API 入口 + * NestJS + Fastify 适配器,全局前缀 /api/v1(05-API-SPEC)。 + * 启动:结构化日志、全局错误契约、CORS(开发跨源)、优雅停机。 + * 生产模式(WEB_DIST 存在):同进程托管 Web 静态资源 + SPA fallback(nginx 纯反代即可)。 + */ +import { NestFactory } from "@nestjs/core"; +import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify"; +import { Logger as NestConsoleLogger } from "@nestjs/common"; +import { HttpAdapterHost } from "@nestjs/core"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { join, extname, resolve, sep } from "node:path"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { AppModule } from "./app.module"; +import { LogService } from "./common/logger/logger.module"; +import { registerRequestContextHook } from "./common/middleware/request-context.hook"; + +/** # 静态资源 MIME 映射(覆盖 SPA 所需集合) **/ +const MIME: Record<string, string> = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".map": "application/json", +}; + +/** # 注册 Web 静态托管(生产):静态文件命中即服务;非 /api 的 GET 回退 index.html(SPA fallback) */ +function registerWebStatic(app: NestFastifyApplication): void { + const webDist = resolve(process.env.WEB_DIST ?? join(process.cwd(), "apps/web/dist")); + if (!existsSync(webDist)) return; + const fastify = app.get(HttpAdapterHost).httpAdapter.getInstance(); + + fastify.get("/*", (req: FastifyRequest, reply: FastifyReply) => { + const url = req.raw.url ?? "/"; + if (url.startsWith("/api/") || url.startsWith("/mcp")) { + return reply.code(404).send({ error: { code: "RESOURCE_NOT_FOUND", message: "Not found", requestId: (req as unknown as { requestId?: string }).requestId ?? "" } }); + } + const rel = (req.params as Record<string, string>)["*"] ?? ""; + const file = resolve(webDist, rel); + if (file.startsWith(webDist + sep) && existsSync(file) && statSync(file).isFile()) { + const mime = MIME[extname(file)] ?? "application/octet-stream"; + const cache = rel.startsWith("assets/") || /-[\w]{8}\.\w+$/.test(rel) ? "public, max-age=31536000, immutable" : "no-cache"; + return reply.header("Content-Type", mime).header("Cache-Control", cache).send(createReadStream(file)); + } + // 带扩展名的未命中 = 真实静态资源缺失,返回 404(避免 fallback 返回 HTML 破坏模块加载) + if (/\.[a-zA-Z0-9]{1,6}$/.test(rel)) { + return reply.code(404).send({ error: { code: "RESOURCE_NOT_FOUND", message: "Asset not found", requestId: (req as unknown as { requestId?: string }).requestId ?? "" } }); + } + // SPA fallback(仅无扩展名的页面路径) + return reply + .header("Content-Type", "text/html; charset=utf-8") + .header("Cache-Control", "no-cache") + .send(createReadStream(join(webDist, "index.html"))); + }); + console.log(JSON.stringify({ time: new Date().toISOString(), level: "log", ctx: "Bootstrap", msg: `web static served from ${webDist}` })); +} + +/** # 引导 API 应用 */ +async function bootstrap(): Promise<void> { + const adapter = new FastifyAdapter({ trustProxy: true, bodyLimit: 2 * 1024 * 1024 }); + const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, { + bufferLogs: false, + rawBody: true, // webhook HMAC 验签需要原始字节 + }); + + // 结构化日志接管 Nest 内部日志 + app.useLogger(new LogService().child("Nest")); + NestConsoleLogger.overrideLogger(false); + + const fastify = app.get(HttpAdapterHost).httpAdapter.getInstance(); + registerRequestContextHook(fastify); + + app.setGlobalPrefix("api/v1"); + app.enableCors({ origin: true, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }); + app.enableShutdownHooks(); + + registerWebStatic(app); + + const port = Number(process.env.PORT ?? 4100); + const host = process.env.HOST ?? "0.0.0.0"; + await app.listen(port, host); + console.log( + JSON.stringify({ time: new Date().toISOString(), level: "log", ctx: "Bootstrap", msg: `DevOS API listening on http://${host}:${port}/api/v1` }), + ); +} + +void bootstrap().catch((error) => { + console.error("bootstrap failed", error); + process.exit(1); +}); diff --git a/apps/api/src/modules/ai/ai-chat.controller.ts b/apps/api/src/modules/ai/ai-chat.controller.ts new file mode 100644 index 0000000..a7a6e45 --- /dev/null +++ b/apps/api/src/modules/ai/ai-chat.controller.ts @@ -0,0 +1,111 @@ +/** + * # AI 聊天控制器(/api/v1/ai/chat) + * 对话式操作入口;审批复用 /ai/runs/:id/approve。 + * 提供两种形态: + * - POST /ai/chat 普通 JSON 响应 + * - POST /ai/chat/stream SSE 流式(status/tool/reply_token/done 事件,现代聊天体验) + */ +import { Body, Controller, Post, Res } from "@nestjs/common"; +import type { FastifyReply } from "fastify"; +import { z } from "zod"; +import { AiChatService, type ChatTurn, type ChatResponse } from "./ai-chat.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentUser, CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestUser, RequestWorkspace } from "../../common/decorators"; +import { AuditService } from "../audit/audit.service"; + +const ChatSchema = z.object({ + message: z.string().min(1).max(4000), + history: z + .array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().max(4000) })) + .max(20) + .default([]), +}); + +@Controller("ai/chat") +export class AiChatController { + constructor( + private readonly chat: AiChatService, + private readonly audit: AuditService, + ) {} + + /** # 发送一条聊天消息(普通 JSON 响应) */ + @Roles("MEMBER") + @Post() + async send( + @CurrentWorkspace() ws: RequestWorkspace, + @CurrentUser() user: RequestUser, + @Body(new ZodValidationPipe(ChatSchema)) dto: { message: string; history: ChatTurn[] }, + ) { + const response = await this.chat.chat(ws.id, user.id, dto.message, dto.history); + await this.auditRecord(ws.id, user.id, dto.message, response); + return response; + } + + /** # 流式聊天(SSE): + * event: status 进度文本("正在执行 devos.server.list…") + * event: tool 单个工具执行结果 chip + * event: reply_token 回复文本分片(模拟逐字流式) + * event: done 完整 ChatResponse(含 approval/refs) + */ + @Roles("MEMBER") + @Post("stream") + async stream( + @CurrentWorkspace() ws: RequestWorkspace, + @CurrentUser() user: RequestUser, + @Body(new ZodValidationPipe(ChatSchema)) dto: { message: string; history: ChatTurn[] }, + @Res() reply: FastifyReply, + ) { + reply.raw.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + const send = (event: string, data: unknown) => { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + try { + const response: ChatResponse = await this.chat.chat(ws.id, user.id, dto.message, dto.history, (event) => { + if (event.type === "tool_start") { + send("status", { text: `正在执行 ${event.text}…` }); + } else if (event.type === "tool" && event.call) { + send("tool", event.call); + } + }); + // 回复分片(模拟逐字流式;真实 token 流待 provider stream 接入) + const chunks = response.reply.match(/[\s\S]{1,24}/g) ?? [response.reply]; + for (const chunk of chunks) { + send("reply_token", { text: chunk }); + await new Promise((r) => setTimeout(r, 18)); + } + send("done", response); + await this.auditRecord(ws.id, user.id, dto.message, response); + } catch (error) { + send("done", { + reply: `出错了:${error instanceof Error ? error.message : "未知错误"}`, + engine: "rules", + toolCalls: [], + } satisfies ChatResponse); + } + reply.raw.end(); + } + + /** # 聊天审计 */ + private async auditRecord(workspaceId: string, userId: string, message: string, response: ChatResponse) { + await this.audit.record({ + workspaceId, + actorType: "USER", + actorId: userId, + action: "ai.chat", + resourceType: "AiRun", + riskLevel: response.approval ? "HIGH" : "LOW", + metadata: { + message: message.slice(0, 200), + engine: response.engine, + executed: response.toolCalls.length, + approvalRequired: Boolean(response.approval), + }, + }); + } +} diff --git a/apps/api/src/modules/ai/ai-chat.service.ts b/apps/api/src/modules/ai/ai-chat.service.ts new file mode 100644 index 0000000..1cac69f --- /dev/null +++ b/apps/api/src/modules/ai/ai-chat.service.ts @@ -0,0 +1,657 @@ +/** + * # AI 聊天服务(对话式操作入口) + * 让用户用自然语言驱动整个 DevOS,而不是手动填表单。 + * + * 双引擎: + * 1. LLM 引擎(配置 AI_PROVIDER 后自动启用):OpenAI function-calling 多轮循环, + * 模型自主选择 ToolRegistry 工具并填参(工作区快照注入上下文用于名称→ID 解析); + * 2. 规则引擎(无 LLM 时兜底,保证开箱可用):意图匹配核心操作。 + * + * 安全:所有执行走 ToolRegistry(与 UI/MCP 同一授权与审计); + * HIGH/CRITICAL 工具不直接执行,而是创建 AWAITING_APPROVAL 的 AiRun 返回审批卡片。 + */ +import { Injectable } from "@nestjs/common"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import type { ResourceStatus } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { ConfigService } from "../../common/config/config.service"; +import { AiProviderService } from "../../common/ai/ai-provider.service"; +import { ToolRegistryService, type ToolContext } from "./tool-registry.service"; +import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module"; +import { maskSecrets } from "../../common/utils/mask"; + +/** # 聊天允许使用的工具白名单(删除类操作刻意不开放,防误删) **/ +const CHAT_TOOLS = [ + "devos.server.list", + "devos.server.find", + "devos.server.status", + "devos.server.exec", + "devos.server.getDockerContainers", + "devos.website.list", + "devos.website.create", + "devos.website.deploy", + "devos.website.logs", + "devos.website.health", + "devos.deployment.list", + "devos.deployment.get", + "devos.domain.list", + "devos.domain.find", + "devos.domain.records", + "devos.domain.upsertRecord", + "devos.monitor.list", + "devos.automation.list", + "devos.automation.run", + "devos.task.list", + "devos.task.create", + "devos.task.update", + "devos.task.delete", + "devos.note.list", + "devos.note.create", + "devos.note.update", + "devos.note.delete", + "devos.web.fetch", + "devos.repository.createFromUrl", +] as const; + +/** # 聊天消息(前端回传的简史) **/ +export interface ChatTurn { + role: "user" | "assistant"; + content: string; +} + +/** # 已执行的工具步骤(回显) **/ +export interface ChatToolCall { + tool: string; + args: Record<string, unknown>; + ok: boolean; + summary: string; +} + +/** # 聊天响应 **/ +export interface ChatResponse { + reply: string; + engine: "llm" | "rules"; + toolCalls: ChatToolCall[]; + /** # HIGH/CRITICAL 操作待审批卡片 **/ + approval?: { runId: string; steps: { tool: string; args: Record<string, unknown>; risk: string }[] }; + /** # 自动执行产生的引用(如新任务 ID) **/ + refs?: { type: string; id: string; name: string }[]; +} + +/** # 单条快照行 **/ +interface SnapshotItem { + id: string; + name: string; + status?: string; + extra?: string; +} + +@Injectable() +export class AiChatService { + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + private readonly ai: AiProviderService, + private readonly tools: ToolRegistryService, + loggers: LogService, + ) { + this.logger = loggers.child("AiChat"); + } + + /** # 日志器 **/ + private readonly logger: StructuredLoggerInstance; + + /** # 主入口:处理一条用户消息 + * @param onEvent 可选进度回调(工具开始/完成实时上报,供流式 UI) + */ + async chat( + workspaceId: string, + userId: string, + message: string, + history: ChatTurn[], + onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void, + ): Promise<ChatResponse> { + const snapshot = await this.buildSnapshot(workspaceId); + const ctx: ToolContext = { workspaceId, actorType: "USER", actorId: userId }; + + if (this.ai.enabled) { + try { + return await this.llmChat(workspaceId, ctx, message, history, snapshot, onEvent); + } catch (error) { + this.logger.warn( + `LLM chat failed, falling back to rules: ${error instanceof Error ? error.message : ""}`, + ); + } + } + return this.rulesChat(workspaceId, ctx, message, snapshot, onEvent); + } + + // ─────────────────────────── LLM 引擎 ─────────────────────────── + + /** # LLM function-calling 多轮循环 */ + private async llmChat( + workspaceId: string, + ctx: ToolContext, + message: string, + history: ChatTurn[], + snapshot: string, + onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void, + ): Promise<ChatResponse> { + const chatTools = CHAT_TOOLS.map((name) => this.tools.get(name)).filter(Boolean); + const toolsParam = chatTools.map((t) => ({ + type: "function" as const, + function: { + name: t!.name, + description: `${t!.description}(风险等级 ${t!.risk})`, + parameters: zodToJsonSchema(t!.schema as never, { $refStrategy: "none" }), + }, + })); + + const messages: Record<string, unknown>[] = [ + { + role: "system", + content: [ + "你是 DevOS 的运维助手,能通过工具完成平台上的几乎所有操作。今天是 " + new Date().toISOString().slice(0, 10) + "。", + "", + "## 能力地图", + "- 查询:server/website/domain/container/task/note/deployment/monitor/automation 列表与详情", + "- 创建:任务、笔记、网站(website.create)、仓库登记(repository.createFromUrl)、DNS 记录(domain.upsertRecord)", + "- 操作:部署(website.deploy)、重启容器(container.restart)、服务器命令(server.exec)、运行自动化", + "- 编辑/删除:task/note 的 update 与 delete;website/server/domain 的 delete", + "- 网页:web.fetch 抓取 URL 正文(供总结/摘录)", + "", + "## 复合任务编排(重要)", + "用户常一次提出多步需求,请按依赖顺序组合工具完成:", + "例1「总结 <url> 存到笔记」:web.fetch → 自己提炼总结 → note.create(content 用简洁 HTML:h3+p/ul,开头附原文链接)", + "例2「把 <github url> 安装到 <服务器名> 并用 <域名> 解析(CF 代理)」:", + " 1. repository.createFromUrl {url}", + " 2. server.find {name:<服务器名>} → 取 id", + " 3. domain.find {name:<域名根>} → 取 id(若无域名记录,用 domain.upsertRecord 直接建)", + " 4. website.create {name, serverId, repositoryId, primaryDomain:<域名>}", + " 5. domain.upsertRecord {domainId, type:'A', name:<域名或子域>, value:<服务器公网 IP=host>, proxied:true}", + " 6. website.deploy {websiteId}(HIGH,系统会转用户审批)", + " 7. website.health 验证", + "", + "## 规则", + "- 只能使用提供的工具;ID 必须来自工具返回或快照,禁止编造。", + "- 名称解析:优先 server.find/domain.find,其次快照。", + "- HIGH/CRITICAL 工具(部署/重启容器/删除/删除服务器)也直接调用——系统会自动转为用户审批卡片,你只需说明已提交审批。", + "- 删除前必须与用户确认过上下文;不确定时先查询再行动。", + "- 回答简洁、用用户的语言(默认中文);汇报做了什么、结果如何;不要复读工具原始 JSON。", + "", + "## 当前工作区快照", + snapshot, + ].join("\n"), + }, + ...history.slice(-8).map((h) => ({ role: h.role, content: h.content })), + { role: "user", content: message }, + ]; + + const toolCalls: ChatToolCall[] = []; + const pendingApproval: { tool: string; args: Record<string, unknown>; risk: string }[] = []; + let iterations = 0; + let finalText = ""; + + while (iterations < 6) { + iterations += 1; + const res = await this.rawChatCompletion(messages, toolsParam); + const choice = res.choices?.[0]; + if (!choice) throw new Error("empty completion"); + const msg = choice.message as { + content?: string | null; + tool_calls?: { id: string; function: { name: string; arguments: string } }[]; + }; + + if (!msg.tool_calls || msg.tool_calls.length === 0) { + finalText = msg.content ?? "(无回复)"; + break; + } + messages.push({ role: "assistant", content: msg.content ?? null, tool_calls: msg.tool_calls }); + for (const call of msg.tool_calls) { + const tool = this.tools.get(call.function.name); + if (!tool) { + messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ error: "unknown tool" }) }); + continue; + } + let args: Record<string, unknown> = {}; + try { + args = JSON.parse(call.function.arguments || "{}") as Record<string, unknown>; + } catch { + args = {}; + } + // HIGH/CRITICAL → 转审批,不直接执行 + if (tool.risk === "HIGH" || tool.risk === "CRITICAL") { + pendingApproval.push({ tool: tool.name, args, risk: tool.risk }); + messages.push({ + role: "tool", + tool_call_id: call.id, + content: JSON.stringify({ ok: true, note: "submitted for user approval" }), + }); + continue; + } + onEvent?.({ type: "tool_start", text: tool.name }); + const rec: ChatToolCall = { tool: tool.name, args: maskSecrets(args) as Record<string, unknown>, ok: false, summary: "" }; + try { + const result = await tool.execute(ctx, args as never); + rec.ok = true; + rec.summary = summarize(result); + messages.push({ + role: "tool", + tool_call_id: call.id, + content: JSON.stringify(maskSecrets(result)).slice(0, 6000), + }); + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + rec.summary = err.slice(0, 200); + messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ error: err.slice(0, 500) }) }); + } + toolCalls.push(rec); + onEvent?.({ type: "tool", call: rec }); + } + if (pendingApproval.length > 0) break; // 有审批待确认,先返回 + } + + if (pendingApproval.length > 0) { + const run = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", message, pendingApproval); + return { + reply: `以下操作风险较高,需要你确认后执行:\n${pendingApproval + .map((s) => `• ${s.tool}(${s.risk})`) + .join("\n")}\n确认后我会立即执行。`, + engine: "llm", + toolCalls, + approval: { runId: run.id, steps: pendingApproval }, + }; + } + + if (!finalText) finalText = summarizeDone(toolCalls); + return { reply: finalText, engine: "llm", toolCalls }; + } + + /** # 调用 OpenAI 兼容 chat/completions(带工具) */ + private async rawChatCompletion( + messages: Record<string, unknown>[], + tools: unknown[], + ): Promise<{ choices?: { message?: unknown }[] }> { + const res = await fetch(`${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.aiApiKey}` }, + body: JSON.stringify({ + model: this.config.aiModel, + messages, + tools, + temperature: 0.2, + }), + signal: AbortSignal.timeout(90_000), + }); + if (!res.ok) throw new Error(`AI provider HTTP ${res.status}`); + return (await res.json()) as { choices?: { message?: unknown }[] }; + } + + /** # 创建待审批运行(复用 AI Gateway 的审批执行管线) */ + private async createApprovalRun( + workspaceId: string, + _userId: string, + objective: string, + steps: { tool: string; args: Record<string, unknown>; risk: string }[], + ): Promise<{ id: string }> { + const plan = steps.map((s) => ({ tool: s.tool, args: s.args, reason: "chat request", risk: s.risk })); + const risk = steps.some((s) => s.risk === "CRITICAL") ? "CRITICAL" : "HIGH"; + return this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { workspaceId, type: "AI_RUN", name: objective.slice(0, 80), provider: "chat", status: "PENDING" }, + }); + return tx.aiRun.create({ + data: { + workspaceId, + resourceId: resource.id, + objective: objective.slice(0, 2000), + status: "AWAITING_APPROVAL", + source: "CHAT", + riskLevel: risk, + plan: plan as unknown as object, + }, + select: { id: true }, + }); + }); + } + + // ─────────────────────────── 规则引擎(离线兜底) ─────────────────────────── + + /** # 规则引擎:意图匹配核心操作 */ + private async rulesChat( + workspaceId: string, + ctx: ToolContext, + message: string, + snapshot: string, + onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void, + ): Promise<ChatResponse> { + const text = message.trim(); + const toolCalls: ChatToolCall[] = []; + const run = async (toolName: string, args: Record<string, unknown>): Promise<unknown | null> => { + const tool = this.tools.get(toolName); + if (!tool) return null; + onEvent?.({ type: "tool_start", text: toolName }); + try { + const result = await tool.execute(ctx, args as never); + const call: ChatToolCall = { tool: toolName, args, ok: true, summary: summarize(result) }; + toolCalls.push(call); + onEvent?.({ type: "tool", call }); + return result; + } catch (error) { + const err = error instanceof Error ? error.message : String(error); + const call: ChatToolCall = { tool: toolName, args, ok: false, summary: err.slice(0, 200) }; + toolCalls.push(call); + onEvent?.({ type: "tool", call }); + return { __error: err }; + } + }; + + // 帮助 + if (/帮助|help|你能做什么|能干什么/i.test(text)) { + return { reply: HELP_TEXT, engine: "rules", toolCalls: [] }; + } + + // URL 总结 → 存笔记(规则引擎用抽取式摘要兜底;LLM 模式下由模型深度总结) + const urlInText = text.match(/https?:\/\/[^\s,。;))]+/); + if (urlInText && /总结|摘要|summary|保存到?笔记|记到笔记/i.test(text)) { + const url = urlInText[0]; + const fetchTool = this.tools.get("devos.web.fetch")!; + try { + const page = (await fetchTool.execute(ctx, { url })) as { title?: string; description?: string; text?: string }; + const title = (page.title ?? url).slice(0, 120); + // 抽取式摘要:meta 描述 + 前几段 + const paras = (page.text ?? "").split("\n\n").filter((p) => p && !p.startsWith("# ")).slice(0, 5); + const summaryHtml = [ + `<p>📄 来源:<a href="${url}">${url}</a></p>`, + page.description ? `<p><strong>摘要:</strong>${page.description}</p>` : "", + "<ul>" + paras.map((p) => `<li>${p.slice(0, 260)}</li>`).join("") + "</ul>", + "<p><em>配置 LLM 后可获得更高质量的深度总结。</em></p>", + ].filter(Boolean).join(""); + const noteTool = this.tools.get("devos.note.create")!; + const note = (await noteTool.execute(ctx, { + title: `总结:${title}`, + content: summaryHtml, + tags: ["summary"], + })) as { id: string; title: string }; + return { + reply: `✅ 已抓取并总结「${title}」,保存为笔记「${note.title}」。可在「笔记」页面查看与编辑(配置 LLM 后总结质量更高)。`, + engine: "rules", + toolCalls: [ + { tool: "devos.web.fetch", args: { url }, ok: true, summary: title }, + { tool: "devos.note.create", args: { title: note.title }, ok: true, summary: note.id }, + ], + refs: [{ type: "NOTE", id: note.id, name: note.title }], + }; + } catch (error) { + return { + reply: `抓取页面失败:${error instanceof Error ? error.message : "unknown"}。请确认 URL 可公网访问。`, + engine: "rules", + toolCalls, + }; + } + } + + // 创建任务 + const taskM = text.match(/(?:创建|新建|添加|记录)个?任务[::,,\s]*(.+)/); + if (taskM) { + const title = taskM[1].slice(0, 120).trim(); + const result = await run("devos.task.create", { title }); + if (result && !(result as { __error?: string }).__error) { + const t = result as { id: string; title: string }; + return { + reply: `✅ 已创建任务「${t.title}」`, + engine: "rules", + toolCalls, + refs: [{ type: "TASK", id: t.id, name: t.title }], + }; + } + } + + // 创建笔记 + const noteM = text.match(/(?:创建|新建|添加|记)个?笔记[::,,\s]*(.+)/); + if (noteM) { + const title = noteM[1].slice(0, 120).trim(); + const result = await run("devos.note.create", { title, content: "" }); + if (result && !(result as { __error?: string }).__error) { + const n = result as { id: string; title: string }; + return { + reply: `✅ 已创建笔记「${n.title}」,可到「笔记」页面编辑内容。`, + engine: "rules", + toolCalls, + refs: [{ type: "NOTE", id: n.id, name: n.title }], + }; + } + } + + // 部署网站(HIGH → 审批) + const deployM = text.match(/部署\s*网站?\s*[::]?\s*(\S+)|deploy\s+(\S+)/i); + if (deployM) { + const name = (deployM[1] ?? deployM[2] ?? "").replace(/网站|到\S+$/g, "").trim(); + const site = await this.findWebsite(workspaceId, name); + if (!site) { + const sites = await this.prisma.website.findMany({ where: { workspaceId }, take: 10, select: { name: true } }); + return { + reply: `没有找到名称包含「${name}」的网站。${sites.length ? `当前的网站有:${sites.map((s) => s.name).join("、")}` : "当前还没有网站,可到「网站」页面创建。"}`, + engine: "rules", + toolCalls, + }; + } + const run0 = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", text, [ + { tool: "devos.website.deploy", args: { websiteId: site.id }, risk: "HIGH" }, + ]); + return { + reply: `⚠️ 部署是 HIGH 风险操作:将部署网站「${site.name}」。确认后执行(含健康验证)。`, + engine: "rules", + toolCalls, + approval: { + runId: run0.id, + steps: [{ tool: "devos.website.deploy", args: { websiteId: site.id }, risk: "HIGH" }], + }, + }; + } + + // 重启容器(HIGH → 审批) + const restartM = text.match(/(?:重启|restart)\s*容器?\s*[::]?\s*(\S+)/i); + if (restartM) { + const name = restartM[1]; + const container = await this.findContainer(workspaceId, name); + if (!container) { + const rows = await this.prisma.container.findMany({ where: { workspaceId }, take: 12, orderBy: { updatedAt: "desc" }, select: { name: true, state: true } }); + return { + reply: `没有找到名称包含「${name}」的容器。${rows.length ? `运行中的容器如:${rows.map((c) => c.name).slice(0, 8).join("、")}` : "还没有发现过容器,先到服务器详情页执行「发现容器」。"}`, + engine: "rules", + toolCalls, + }; + } + const run0 = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", text, [ + { tool: "devos.container.restart", args: { id: container.id }, risk: "HIGH" }, + ]); + return { + reply: `⚠️ 重启容器是 HIGH 风险操作:将重启「${container.name}」(${container.image.slice(0, 30)})。确认后执行。`, + engine: "rules", + toolCalls, + approval: { + runId: run0.id, + steps: [{ tool: "devos.container.restart", args: { id: container.id }, risk: "HIGH" }], + }, + }; + } + + // 列出/查看类 + const listTarget = text.match(/(?:列出|查看|显示|有哪些|list)\s*(全部|所有|一下)?\s*(服务器|网站|域名|容器|任务|笔记|部署|监控|自动化|仓库|凭据)?/i); + if (listTarget) { + const target = listTarget[2] ?? inferTargetFromText(text); + if (target) { + const map: Record<string, string> = { + 服务器: "devos.server.list", + 网站: "devos.website.list", + 域名: "devos.domain.list", + 容器: "devos.server.getDockerContainers", + 任务: "devos.task.list", + 笔记: "devos.note.list", + 部署: "devos.deployment.list", + 监控: "devos.monitor.list", + 自动化: "devos.automation.list", + }; + const toolName = map[target]; + if (toolName) { + // 容器列表需要 serverId:取第一台在线服务器 + if (target === "容器") { + const firstServer = (await this.tools.get("devos.server.list")!.execute(ctx, {})) as { id: string }[] | undefined; + const sid = Array.isArray(firstServer) ? firstServer[0]?.id : undefined; + if (!sid) { + return { reply: "还没有登记服务器,无法发现容器。", engine: "rules", toolCalls }; + } + const containers = await run("devos.server.getDockerContainers", { serverId: sid }); + return { reply: renderList(target, containers), engine: "rules", toolCalls }; + } + const result = await run(toolName, target === "任务" || target === "笔记" ? {} : {}); + return { reply: renderList(target, result), engine: "rules", toolCalls }; + } + } + } + + // 网站健康/状态 + const healthM = text.match(/(健康|health|状态|status|在线吗|正常吗)/i); + if (healthM) { + const site = await this.findAnyWebsite(workspaceId); + if (site) { + const result = await run("devos.website.health", { id: site.id }); + const r = result as { ok?: boolean; status?: number; latencyMs?: number; error?: string }; + return { + reply: r?.ok + ? `✅ 「${site.name}」健康:HTTP ${r.status}(${r.latencyMs ?? "?"}ms)` + : `❌ 「${site.name}」异常:${r?.error ?? `HTTP ${r?.status}`}`, + engine: "rules", + toolCalls, + }; + } + } + + return { + reply: `我暂时没理解这句话。我可以(离线模式,配置 LLM 后理解任意表达):\n\n• 列出服务器 / 网站 / 域名 / 容器 / 任务 / 部署\n• 创建任务:<标题>\n• 创建笔记:<标题>\n• 部署网站:<名称>(需确认)\n• 重启容器:<名称>(需确认)\n• 网站健康检查\n• 帮助`, + engine: "rules", + toolCalls, + }; + } + + // ─────────────────────────── 上下文与解析 ─────────────────────────── + + /** # 构建工作区快照(名称→ID 解析用) */ + private async buildSnapshot(workspaceId: string): Promise<string> { + const [servers, websites, domains, containers, tasks] = await this.prisma.$transaction([ + this.prisma.server.findMany({ where: { workspaceId }, take: 15, include: { resource: { select: { status: true } } } }), + this.prisma.website.findMany({ where: { workspaceId }, take: 15, include: { resource: { select: { status: true } } } }), + this.prisma.domain.findMany({ where: { workspaceId }, take: 15 }), + this.prisma.container.findMany({ where: { workspaceId }, take: 20, orderBy: { updatedAt: "desc" } }), + this.prisma.task.findMany({ where: { workspaceId }, take: 10, orderBy: { updatedAt: "desc" } }), + ]); + const lines: string[] = []; + lines.push(`服务器(${servers.length}): ${servers.map((s) => `${s.name}(id=${s.id}, ${s.resource.status})`).join(", ") || "无"}`); + lines.push(`网站(${websites.length}): ${websites.map((w) => `${w.name}(id=${w.id}, ${w.resource.status})`).join(", ") || "无"}`); + lines.push(`域名(${domains.length}): ${domains.map((d) => `${d.name}(id=${d.id})`).join(", ") || "无"}`); + lines.push(`容器(最近): ${containers.map((c) => `${c.name}(id=${c.id}, ${c.state})`).join(", ") || "无"}`); + lines.push(`任务(最近): ${tasks.map((t) => `${t.title}(status=${t.status})`).join(", ") || "无"}`); + return lines.join("\n"); + } + + /** # 模糊查找网站 */ + private async findWebsite(workspaceId: string, name: string) { + const rows = await this.prisma.website.findMany({ where: { workspaceId } }); + const lower = name.toLowerCase(); + return ( + rows.find((r) => r.name.toLowerCase() === lower) ?? + rows.find((r) => r.name.toLowerCase().includes(lower)) ?? + rows.find((r) => r.primaryDomain?.toLowerCase().includes(lower)) ?? + null + ); + } + + /** # 任意网站(健康检查默认目标) */ + private async findAnyWebsite(workspaceId: string) { + return this.prisma.website.findFirst({ where: { workspaceId }, orderBy: { updatedAt: "desc" } }); + } + + /** # 模糊查找容器 */ + private async findContainer(workspaceId: string, name: string) { + const rows = await this.prisma.container.findMany({ where: { workspaceId } }); + const lower = name.toLowerCase(); + return rows.find((r) => r.name.toLowerCase() === lower) ?? rows.find((r) => r.name.toLowerCase().includes(lower)) ?? null; + } +} + +/** # 从文本推断列表目标(规则引擎兜底) */ +function inferTargetFromText(text: string): string | null { + const map: [RegExp, string][] = [ + [/服务器|server/i, "服务器"], + [/网站|website/i, "网站"], + [/域名|domain/i, "域名"], + [/容器|container/i, "容器"], + [/任务|task/i, "任务"], + [/笔记|note/i, "笔记"], + [/部署|deployment/i, "部署"], + [/监控|monitor/i, "监控"], + [/自动化|automation/i, "自动化"], + ]; + for (const [re, label] of map) if (re.test(text)) return label; + return null; +} + +/** # 渲染列表结果(人性化的简短文本,不吐 JSON) */ +function renderList(target: string, result: unknown): string { + const rows = Array.isArray(result) ? (result as Record<string, unknown>[]) : []; + if (rows.length === 0) return `当前没有${target}。`; + const lines = rows.slice(0, 15).map((r) => { + const name = (r.name ?? r.title ?? r.fullName ?? "?") as string; + const status = (r.status ?? r.state ?? "") as string; + const extra = r.primaryDomain ?? r.host ?? ""; + const image = r.image ? `(${String(r.image).slice(0, 32)})` : ""; + return `- ${name}${status ? `(${status})` : ""}${extra ? ` — ${String(extra)}` : ""}${target === "容器" ? image : ""}`; + }); + const more = rows.length > 15 ? `\n…及其它 ${rows.length - 15} 项` : ""; + return `**${target}** 共 ${rows.length} 项:\n\n${lines.join("\n")}${more}`; +} + +/** # 工具结果摘要(回显用,避免超长 JSON) */ +function summarize(result: unknown): string { + if (Array.isArray(result)) { + const first = result[0] as Record<string, unknown> | undefined; + return `${result.length} 项${first ? `,如 ${String(first.name ?? first.title ?? first.id ?? "")}` : ""}`; + } + const r = result as Record<string, unknown> | null; + if (!r) return "完成"; + if (r.title) return String(r.title); + if (r.name) return String(r.name); + if (r.ok !== undefined) return `ok=${r.ok}`; + return Object.keys(r).slice(0, 4).join(","); +} + +/** # 无文本时的兜底总结 */ +function summarizeDone(calls: ChatToolCall[]): string { + const okCount = calls.filter((c) => c.ok).length; + return `已完成 ${okCount}/${calls.length} 项操作。`; +} + +/** # 能力说明(离线模式) */ +const HELP_TEXT = [ + "我是 DevOS 运维助手,可以直接帮我完成这些事:", + "", + "📋 任务与笔记", + " • 创建任务:修复登录页超时", + " • 创建笔记:部署排障手册", + " • 列出任务 / 列出笔记", + "", + "🖥️ 基础设施", + " • 列出服务器 / 列出容器 / 列出监控", + " • 服务器连通状态(到详情页看完整指标)", + "", + "🌐 网站与部署", + " • 列出网站 / 网站健康检查", + " • 部署网站:<名称>(需要你确认后执行)", + " • 重启容器:<名称>(需要你确认后执行)", + "", + "⚙️ 其它", + " • 列出域名 / 部署 / 自动化;运行自动化:<名称>", + "", + "配置 LLM(AI_PROVIDER_*)后,可以用任意自然语言表达,我会自动选择并组合工具完成操作。", +].join("\n"); diff --git a/apps/api/src/modules/ai/ai-gateway.controller.ts b/apps/api/src/modules/ai/ai-gateway.controller.ts new file mode 100644 index 0000000..4976025 --- /dev/null +++ b/apps/api/src/modules/ai/ai-gateway.controller.ts @@ -0,0 +1,58 @@ +/** + * # AI Gateway 控制器(/api/v1/ai) + */ +import { Body, Controller, Get, Param, Post } from "@nestjs/common"; +import { AiGatewayService, CreateAiRunSchema } from "./ai-gateway.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentUser, CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestUser, RequestWorkspace } from "../../common/decorators"; + +@Controller("ai/runs") +export class AiGatewayController { + constructor(private readonly ai: AiGatewayService) {} + + /** # AI 运行列表 */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.ai.list(ws.id); + } + + /** # 创建 AI 运行(规划→风控→审批/执行) */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @CurrentUser() user: RequestUser, + @Body(new ZodValidationPipe(CreateAiRunSchema)) dto: Parameters<AiGatewayService["create"]>[1], + ) { + return this.ai.create(ws.id, dto); + } + + /** # AI 运行详情 */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.ai.get(ws.id, id); + } + + /** # 审批(HIGH/CRITICAL 运行;仅 ADMIN+) */ + @Roles("ADMIN") + @Post(":id/approve") + approve( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @CurrentUser() user: RequestUser, + ) { + return this.ai.approve(ws.id, id, user.id); + } + + /** # 拒绝 */ + @Roles("ADMIN") + @Post(":id/reject") + reject( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @CurrentUser() user: RequestUser, + ) { + return this.ai.reject(ws.id, id, user.id); + } +} diff --git a/apps/api/src/modules/ai/ai-gateway.service.ts b/apps/api/src/modules/ai/ai-gateway.service.ts new file mode 100644 index 0000000..0b3ae53 --- /dev/null +++ b/apps/api/src/modules/ai/ai-gateway.service.ts @@ -0,0 +1,395 @@ +/** + * # AI Gateway 服务(Task 017) + * AI 运行抽象:objective → plan(LLM 或启发式)→ 风险评估 → 审批(HIGH/CRITICAL)→ 执行 → 验证 → 报告。 + * 安全边界:AI 不绕过授权 —— 所有工具调用经由 ToolRegistry(与 UI/MCP 相同服务层 + 审计)。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import type { AiRun, RiskLevel } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { ConfigService } from "../../common/config/config.service"; +import { AuditService } from "../audit/audit.service"; +import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module"; +import { ToolRegistryService, maxRisk, escalateExecRisk, type ToolContext } from "./tool-registry.service"; +import { maskSecrets } from "../../common/utils/mask"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 创建 AI 运行 DTO **/ +export const CreateAiRunSchema = z.object({ + objective: z.string().min(4).max(4000), +}); +export type CreateAiRunDto = z.infer<typeof CreateAiRunSchema>; + +/** # 计划步骤 **/ +interface PlanStep { + tool: string; + args: Record<string, unknown>; + reason: string; + risk: RiskLevel; +} + +/** # 执行历史行 **/ +interface ToolCallRecord { + tool: string; + args: Record<string, unknown>; + ok: boolean; + result?: unknown; + error?: string; + at: string; +} + +/** # 启发式规划规则:关键词 → 工具序列 **/ +const HEURISTIC_RULES: { keywords: RegExp; steps: { tool: string; args?: Record<string, unknown>; reason: string }[] }[] = [ + { + keywords: /deploy|发布|部署/i, + steps: [ + { tool: "devos.website.list", reason: "Find candidate websites" }, + { tool: "devos.website.deploy", args: { websiteId: "$(WEBSITE_ID)" }, reason: "Create deployment" }, + { tool: "devos.deployment.list", reason: "Locate latest deployment" }, + { tool: "devos.deployment.get", args: { id: "$(DEPLOYMENT_ID)" }, reason: "Check deployment status and logs" }, + ], + }, + { + keywords: /server|服务器|ssh/i, + steps: [ + { tool: "devos.server.list", reason: "List servers" }, + { tool: "devos.server.status", args: { serverId: "$(SERVER_ID)" }, reason: "Check server status" }, + ], + }, + { + keywords: /dns|domain|域名|record/i, + steps: [ + { tool: "devos.domain.list", reason: "List domains" }, + { tool: "devos.domain.records", args: { id: "$(DOMAIN_ID)" }, reason: "Read DNS records" }, + ], + }, + { + keywords: /不可用|挂了|down|outage|为什么.*(不|无法)|analyze|diagnos|排查/i, + steps: [ + { tool: "devos.monitor.list", reason: "Find failing monitors" }, + { tool: "devos.website.list", reason: "List websites to locate the affected site" }, + { tool: "devos.website.health", args: { id: "$(WEBSITE_ID)" }, reason: "Run immediate health check for evidence" }, + { tool: "devos.deployment.list", reason: "Check recent deployments for regressions" }, + ], + }, + { + keywords: /(创建|新建).*(任务|task)|record.*(issue|problem)|记录问题/i, + steps: [ + { tool: "devos.task.create", args: { title: "$(OBJECTIVE)" }, reason: "Create a task recording the issue" }, + ], + }, + { + keywords: /重启.*(容器|container)|restart.*container|异常容器/i, + steps: [ + { tool: "devos.server.list", reason: "Locate servers" }, + { tool: "devos.server.getDockerContainers", args: { serverId: "$(SERVER_ID)" }, reason: "Discover containers to find the unhealthy one" }, + { tool: "devos.container.restart", args: { id: "$(CONTAINER_ID)" }, reason: "Restart the abnormal container (requires approval)" }, + ], + }, + { + keywords: /health|status|监控|状态|健康/i, + steps: [ + { tool: "devos.monitor.list", reason: "Check monitors" }, + { tool: "devos.website.list", reason: "List websites" }, + ], + }, +]; + +@Injectable() +export class AiGatewayService { + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + private readonly audit: AuditService, + private readonly tools: ToolRegistryService, + loggers: LogService, + ) { + this.logger = loggers.child("AiGateway"); + } + + /** # 日志器 **/ + private readonly logger: StructuredLoggerInstance; + + /** # 创建 AI 运行:规划 → 风险评估 →(HIGH/CRITICAL)等待审批 或 直接执行 */ + async create(workspaceId: string, dto: CreateAiRunDto) { + const ctx = getRequestContext(); + const run = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { workspaceId, type: "AI_RUN", name: dto.objective.slice(0, 80), provider: "devos", status: "PENDING" }, + }); + return tx.aiRun.create({ + data: { + workspaceId, + resourceId: resource.id, + objective: dto.objective, + status: "PLANNING", + }, + }); + }); + + // 规划(异步执行,创建接口立即返回) + void this.planAndMaybeExecute(run.id, workspaceId, dto.objective, ctx?.actorId).catch((error) => { + this.logger.error("ai run planning failed", error instanceof Error ? error : undefined); + }); + + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "ai.run.create", + resourceType: "AiRun", + resourceId: run.resourceId, + riskLevel: "MEDIUM", + metadata: { objective: dto.objective.slice(0, 200) }, + }); + return this.view(await this.prisma.aiRun.findUniqueOrThrow({ where: { id: run.id } })); + } + + /** # 规划并(低风险时)自动执行 */ + private async planAndMaybeExecute(runId: string, workspaceId: string, objective: string, actorId?: string): Promise<void> { + try { + const steps = await this.makePlan(objective); + const risk = maxRisk(steps.map((s) => s.risk)); + await this.prisma.aiRun.update({ + where: { id: runId }, + data: { plan: steps as unknown as object, riskLevel: risk }, + }); + if (risk === "HIGH" || risk === "CRITICAL") { + await this.prisma.aiRun.update({ where: { id: runId }, data: { status: "AWAITING_APPROVAL" } }); + return; + } + await this.prisma.aiRun.update({ where: { id: runId }, data: { status: "RUNNING" } }); + await this.executePlan(runId, workspaceId, steps, actorId); + } catch (error) { + await this.prisma.aiRun.update({ + where: { id: runId }, + data: { status: "FAILED", error: error instanceof Error ? error.message.slice(0, 1000) : "planning failed" }, + }); + } + } + + /** # 生成计划:优先 LLM(OpenAI 兼容),否则启发式规则 */ + private async makePlan(objective: string): Promise<PlanStep[]> { + if (this.config.aiEnabled) { + try { + return await this.llmPlan(objective); + } catch (error) { + this.logger.warn(`LLM planning failed, falling back to heuristic: ${error instanceof Error ? error.message : ""}`); + } + } + return this.heuristicPlan(objective); + } + + /** # 启发式规划 */ + private heuristicPlan(objective: string): PlanStep[] { + for (const rule of HEURISTIC_RULES) { + if (rule.keywords.test(objective)) { + return rule.steps.map((s) => { + const tool = this.tools.get(s.tool); + const risk = tool?.risk ?? "MEDIUM"; + return { tool: s.tool, args: s.args ?? {}, reason: s.reason, risk }; + }); + } + } + // 兜底:只读清单 + return [ + { tool: "devos.server.list", args: {}, reason: "Default: list servers", risk: "LOW" }, + { tool: "devos.website.list", args: {}, reason: "Default: list websites", risk: "LOW" }, + ]; + } + + /** # LLM 规划(OpenAI 兼容 /chat/completions,要求 JSON 输出) */ + private async llmPlan(objective: string): Promise<PlanStep[]> { + const toolList = this.tools.list(); + const system = [ + "You are the planner of DevOS, a developer infrastructure OS.", + "Given an objective, produce a short JSON execution plan.", + 'Output format: {"steps":[{"tool":"devos.x.y","args":{},"reason":"..."}]}', + "Only use the provided tools. Prefer read-only tools. Never invent tools.", + "Unknown ids may use placeholders like $(WEBSITE_ID) which resolve at runtime.", + ].join("\n"); + const res = await fetch(`${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.aiApiKey}` }, + body: JSON.stringify({ + model: this.config.aiModel, + messages: [ + { role: "system", content: system }, + { role: "user", content: `Objective: ${objective}\nTools: ${JSON.stringify(toolList)}` }, + ], + response_format: { type: "json_object" }, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) throw new Error(`AI provider HTTP ${res.status}`); + const body = (await res.json()) as { choices?: { message?: { content?: string } }[] }; + const content = body.choices?.[0]?.message?.content; + if (!content) throw new Error("AI provider returned no content"); + const parsed = JSON.parse(content) as { steps?: { tool?: string; args?: Record<string, unknown>; reason?: string }[] }; + const steps: PlanStep[] = []; + for (const s of parsed.steps ?? []) { + if (!s.tool) continue; + const tool = this.tools.get(s.tool); + if (!tool) continue; // 只允许注册表内工具 + let risk: RiskLevel = tool.risk; + if (s.tool === "devos.server.exec") { + risk = escalateExecRisk(String((s.args as { command?: string })?.command ?? "")) ?? risk; + } + steps.push({ tool: s.tool, args: (s.args ?? {}) as Record<string, unknown>, reason: s.reason ?? "", risk }); + } + if (steps.length === 0) throw new Error("LLM produced empty plan"); + return steps; + } + + /** # 审批(OWNER/ADMIN 由控制器守卫保证):批准后执行 */ + async approve(workspaceId: string, id: string, approverId: string) { + const run = await this.prisma.aiRun.findFirst({ where: { id, workspaceId } }); + if (!run) throw ApiError.notFound("AI run not found"); + if (run.status !== "AWAITING_APPROVAL") throw ApiError.conflict(`Run is ${run.status}, not awaiting approval`); + await this.prisma.aiRun.update({ + where: { id }, + data: { status: "RUNNING", approvedBy: approverId, approvedAt: new Date() }, + }); + await this.audit.record({ + workspaceId, + actorType: "USER", + actorId: approverId, + action: "ai.run.approve", + resourceType: "AiRun", + resourceId: run.resourceId, + riskLevel: run.riskLevel, + }); + const steps = run.plan as unknown as PlanStep[]; + void this.executePlan(id, workspaceId, steps ?? [], approverId) + .catch((e) => this.logger.error("approved execution failed", e instanceof Error ? e : undefined)); + return this.view(await this.prisma.aiRun.findUniqueOrThrow({ where: { id } })); + } + + /** # 拒绝 */ + async reject(workspaceId: string, id: string, userId: string) { + const run = await this.prisma.aiRun.findFirst({ where: { id, workspaceId } }); + if (!run) throw ApiError.notFound("AI run not found"); + if (run.status !== "AWAITING_APPROVAL") throw ApiError.conflict(`Run is ${run.status}`); + await this.prisma.aiRun.update({ where: { id }, data: { status: "REJECTED", approvedBy: userId, approvedAt: new Date() } }); + await this.audit.record({ + workspaceId, actorType: "USER", actorId: userId, + action: "ai.run.reject", resourceType: "AiRun", resourceId: run.resourceId, riskLevel: run.riskLevel, + }); + return this.view(await this.prisma.aiRun.findUniqueOrThrow({ where: { id } })); + } + + /** # 执行计划:顺序调用工具(占位符解析)、写执行历史、领域验证 */ + private async executePlan(runId: string, workspaceId: string, steps: PlanStep[], actorId?: string): Promise<void> { + const ctx: ToolContext = { workspaceId, actorType: "AGENT", actorId }; + const calls: ToolCallRecord[] = []; + const placeholders: Record<string, string> = {}; + let finalResult: unknown = null; + let verification: { ok: boolean; checks: { tool: string; ok: boolean; detail?: string }[] } | null = null; + + try { + for (const step of steps) { + const tool = this.tools.get(step.tool); + if (!tool) throw new Error(`Unknown tool: ${step.tool}`); + const args = this.resolveArgs(step.args, placeholders); + try { + const result = await tool.execute(ctx, args as Record<string, unknown>); + calls.push({ tool: step.tool, args: maskSecrets(args) as Record<string, unknown>, ok: true, result: maskSecrets(result), at: new Date().toISOString() }); + finalResult = result; + // 占位符回填(把列表结果的第一个 id 存起来) + this.harvestPlaceholders(step.tool, result, placeholders); + if (tool.verify) { + const v = await tool.verify(ctx, args, result); + verification = verification ?? { ok: true, checks: [] }; + verification.checks.push({ tool: step.tool, ok: v.ok, detail: v.detail }); + if (!v.ok) verification.ok = false; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + calls.push({ tool: step.tool, args: maskSecrets(args) as Record<string, unknown>, ok: false, error: message.slice(0, 500), at: new Date().toISOString() }); + throw new Error(`Step ${step.tool} failed: ${message}`); + } + await this.prisma.aiRun.update({ where: { id: runId }, data: { toolCalls: calls as unknown as object } }); + } + await this.prisma.aiRun.update({ + where: { id: runId }, + data: { + status: verification && !verification.ok ? "FAILED" : "SUCCEEDED", + result: (finalResult ?? {}) as object, + verification: (verification ?? undefined) as object | undefined, + }, + }); + } catch (error) { + await this.prisma.aiRun.update({ + where: { id: runId }, + data: { + status: "FAILED", + error: error instanceof Error ? error.message.slice(0, 1000) : "execution failed", + toolCalls: calls as unknown as object, + }, + }); + } + } + + /** # 解析 $(PLACEHOLDER) 参数 */ + private resolveArgs(args: Record<string, unknown>, placeholders: Record<string, string>): Record<string, unknown> { + const resolved: Record<string, unknown> = {}; + for (const [k, v] of Object.entries(args)) { + if (typeof v === "string") { + const m = v.match(/^\$\(([A-Z_]+)\)$/); + resolved[k] = m ? placeholders[m[1]] ?? v : v; + } else { + resolved[k] = v; + } + } + return resolved; + } + + /** # 从工具结果提取常用 ID(占位符回填) */ + private harvestPlaceholders(tool: string, result: unknown, placeholders: Record<string, string>): void { + if (!Array.isArray(result)) return; + const first = result[0] as Record<string, unknown> | undefined; + if (!first) return; + if (first.id) placeholders.WEBSITE_ID ??= String(first.id); + if (tool === "devos.domain.list" && first.id) placeholders.DOMAIN_ID = String(first.id); + if (tool === "devos.server.list" && first.id) placeholders.SERVER_ID = String(first.id); + if (tool === "devos.deployment.list" && first.id) placeholders.DEPLOYMENT_ID = String(first.id); + } + + /** # AI 运行详情 */ + async get(workspaceId: string, id: string) { + const run = await this.prisma.aiRun.findFirst({ where: { id, workspaceId } }); + if (!run) throw ApiError.notFound("AI run not found"); + return this.view(run); + } + + /** # AI 运行列表 */ + async list(workspaceId: string) { + const runs = await this.prisma.aiRun.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + take: 100, + }); + return runs.map((r) => this.view(r)); + } + + /** # 实体 → 视图 */ + private view(run: AiRun) { + return { + id: run.id, + resourceId: run.resourceId, + workspaceId: run.workspaceId, + objective: run.objective, + source: run.source, + status: run.status, + riskLevel: run.riskLevel, + plan: run.plan, + toolCalls: run.toolCalls, + verification: run.verification, + result: run.result, + error: run.error ?? undefined, + approvedBy: run.approvedBy, + createdAt: run.createdAt, + finishedAt: run.approvedAt, + }; + } +} diff --git a/apps/api/src/modules/ai/ai.module.ts b/apps/api/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..e0ca502 --- /dev/null +++ b/apps/api/src/modules/ai/ai.module.ts @@ -0,0 +1,28 @@ +/** + * # AI Gateway 模块(Task 017) + */ +import { Module } from "@nestjs/common"; +import { AiGatewayController } from "./ai-gateway.controller"; +import { AiChatController } from "./ai-chat.controller"; +import { AiChatService } from "./ai-chat.service"; +import { AiGatewayService } from "./ai-gateway.service"; +import { ToolRegistryService } from "./tool-registry.service"; +import { ServerModule } from "../server/server.module"; +import { ContainerModule } from "../container/container.module"; +import { WebsiteModule } from "../website/website.module"; +import { DeploymentModule } from "../deployment/deployment.module"; +import { DomainModule } from "../domain/domain.module"; +import { MonitoringModule } from "../monitoring/monitoring.module"; +import { AutomationModule } from "../automation/automation.module"; +import { AuditModule } from "../audit/audit.module"; +import { TaskModule } from "../task/task.module"; +import { NoteModule } from "../note/note.module"; +import { GitModule } from "../git/git.module"; + +@Module({ + imports: [ServerModule, ContainerModule, WebsiteModule, DeploymentModule, DomainModule, MonitoringModule, AutomationModule, AuditModule, TaskModule, NoteModule, GitModule], + controllers: [AiGatewayController, AiChatController], + providers: [AiGatewayService, ToolRegistryService, AiChatService], + exports: [ToolRegistryService, AiGatewayService, AiChatService], +}) +export class AiModule {} diff --git a/apps/api/src/modules/ai/tool-registry.service.ts b/apps/api/src/modules/ai/tool-registry.service.ts new file mode 100644 index 0000000..7f232a6 --- /dev/null +++ b/apps/api/src/modules/ai/tool-registry.service.ts @@ -0,0 +1,477 @@ +/** + * # AI 工具注册表(Task 017) + * AI/Agent 与 MCP 共享的工具层:每个工具声明名称、描述、风险等级、参数 schema 与执行函数。 + * 工具实现复用各模块 service —— 与人工操作走同一条授权/审计路径(08-AI-AGENT / 09-MCP 安全要求)。 + */ +import { Injectable } from "@nestjs/common"; +import type { ZodSchema } from "zod"; +import type { RiskLevel } from "@devos/types"; +import { ServerService } from "../server/server.service"; +import { ContainerService } from "../container/container.service"; +import { WebsiteService } from "../website/website.service"; +import { DeploymentService } from "../deployment/deployment.service"; +import { DomainService } from "../domain/domain.service"; +import { MonitoringService } from "../monitoring/monitoring.service"; +import { AutomationService } from "../automation/automation.service"; +import { TaskService } from "../task/task.service"; +import { NoteService } from "../note/note.service"; +import { GitService } from "../git/git.service"; +import { z as zod } from "zod"; + +/** # 工具执行上下文 **/ +export interface ToolContext { + workspaceId: string; + actorType: "AGENT" | "MCP" | "USER" | "SYSTEM"; + actorId?: string; +} + +/** # 工具定义 **/ +export interface ToolDefinition<Args = Record<string, unknown>> { + /** # 工具名(devos.<domain>.<action>) **/ + name: string; + /** # 人类可读描述 **/ + description: string; + /** # 风险等级 **/ + risk: RiskLevel; + /** # 参数 schema(zod) **/ + schema: ZodSchema<Args>; + /** # 执行函数(返回稳定 JSON 结果) **/ + execute: (ctx: ToolContext, args: Args) => Promise<unknown>; + /** # 可选:领域验证器(执行后如何验证成功) **/ + verify?: (ctx: ToolContext, args: Args, result: unknown) => Promise<{ ok: boolean; detail?: string }>; +} + +/** # 常用参数 schema 片段 **/ +const IdSchema = zod.object({ id: zod.string().min(1) }); +const ServerIdSchema = zod.object({ serverId: zod.string().min(1) }); +const ExecSchema = zod.object({ + serverId: zod.string().min(1), + command: zod.string().min(1).max(4096), +}); + +@Injectable() +export class ToolRegistryService { + /** # 工具表 **/ + private tools = new Map<string, ToolDefinition<any>>(); + + constructor( + private readonly servers: ServerService, + private readonly containers: ContainerService, + private readonly websites: WebsiteService, + private readonly deployments: DeploymentService, + private readonly domains: DomainService, + private readonly monitoring: MonitoringService, + private readonly automations: AutomationService, + private readonly tasks: TaskService, + private readonly notes: NoteService, + private readonly git: GitService, + ) { + this.registerDefaults(); + } + + /** # 注册工具 */ + register(tool: ToolDefinition<any>): void { + this.tools.set(tool.name, tool); + } + + /** # 获取工具 */ + get(name: string): ToolDefinition<any> | undefined { + return this.tools.get(name); + } + + /** # 全部工具(描述视图,供规划器/MCP) */ + list(): { name: string; description: string; risk: RiskLevel }[] { + return [...this.tools.values()].map((t) => ({ + name: t.name, + description: t.description, + risk: t.risk, + })); + } + + /** # 注册默认工具集 */ + private registerDefaults(): void { + // ---- 服务器 ---- + this.register({ + name: "devos.server.list", + description: "List servers in the workspace", + risk: "LOW", + schema: zod.object({}), + execute: async (ctx) => this.servers.list(ctx.workspaceId), + } as ToolDefinition<any>); + this.register({ + name: "devos.server.status", + description: "Get server connectivity/docker status", + risk: "LOW", + schema: ServerIdSchema, + execute: async (ctx, args) => { + const servers = await this.servers.list(ctx.workspaceId); + const server = servers.find((s) => s.id === args.serverId); + if (!server) throw new Error("Server not found"); + const containers = await this.containers.listByServer(ctx.workspaceId, server.id); + return { server, containers: containers.length }; + }, + } as ToolDefinition<any>); + this.register({ + name: "devos.server.exec", + description: "Execute a shell command on a server via SSH", + risk: "MEDIUM", + schema: ExecSchema, + execute: async (ctx, args) => this.servers.exec(ctx.workspaceId, args.serverId, { command: args.command, timeoutMs: 30_000 }), + } as ToolDefinition<any>); + + // ---- 容器 ---- + this.register({ + name: "devos.server.getDockerContainers", + description: "Discover docker containers on a server", + risk: "LOW", + schema: ServerIdSchema, + execute: async (ctx, args) => { + const server = await this.servers.mustGet(ctx.workspaceId, args.serverId); + return this.containers.discover(ctx.workspaceId, server); + }, + } as ToolDefinition<any>); + + // ---- 网站 ---- + this.register({ + name: "devos.website.list", + description: "List websites in the workspace", + risk: "LOW", + schema: zod.object({}), + execute: async (ctx) => this.websites.list(ctx.workspaceId), + } as ToolDefinition<any>); + this.register({ + name: "devos.website.deploy", + description: "Deploy a website (create deployment and queue async execution)", + risk: "HIGH", + schema: zod.object({ websiteId: zod.string().min(1) }), + execute: async (ctx, args) => + this.websites.deploy(ctx.workspaceId, args.websiteId, { type: ctx.actorType, id: ctx.actorId, trigger: ctx.actorType }), + verify: async (ctx, args) => { + const deployments = await this.deployments.list(ctx.workspaceId, args.websiteId); + const latest = deployments[0]; + return { ok: Boolean(latest), detail: latest ? `deployment ${latest.id} ${latest.status}` : "no deployment" }; + }, + } as ToolDefinition<any>); + this.register({ + name: "devos.website.logs", + description: "Get recent deployment logs of a website", + risk: "LOW", + schema: IdSchema, + execute: async (ctx, args) => this.websites.logs(ctx.workspaceId, args.id), + } as ToolDefinition<any>); + this.register({ + name: "devos.website.health", + description: "Run an immediate health check for a website", + risk: "LOW", + schema: IdSchema, + execute: async (ctx, args) => this.websites.health(ctx.workspaceId, args.id), + verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { ok?: boolean }).ok) }), + } as ToolDefinition<any>); + + // ---- 部署 ---- + this.register({ + name: "devos.deployment.get", + description: "Get a deployment by id (status, progress, logs)", + risk: "LOW", + schema: IdSchema, + execute: async (ctx, args) => this.deployments.get(ctx.workspaceId, args.id), + } as ToolDefinition<any>); + this.register({ + name: "devos.deployment.list", + description: "List recent deployments (optionally filtered by websiteId)", + risk: "LOW", + schema: zod.object({ websiteId: zod.string().optional() }), + execute: async (ctx, args) => this.deployments.list(ctx.workspaceId, args.websiteId), + } as ToolDefinition<any>); + + // ---- 域名 ---- + this.register({ + name: "devos.domain.list", + description: "List domains in the workspace", + risk: "LOW", + schema: zod.object({}), + execute: async (ctx) => this.domains.list(ctx.workspaceId), + } as ToolDefinition<any>); + this.register({ + name: "devos.domain.records", + description: "List DNS records of a domain", + risk: "LOW", + schema: IdSchema, + execute: async (ctx, args) => this.domains.records(ctx.workspaceId, args.id), + } as ToolDefinition<any>); + this.register({ + name: "devos.domain.upsertRecord", + description: "Create or update a DNS record", + risk: "MEDIUM", + schema: zod.object({ + domainId: zod.string().min(1), + type: zod.enum(["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"]), + name: zod.string().min(1), + value: zod.string().min(1), + ttl: zod.number().int().min(60).max(86400).optional(), + proxied: zod.boolean().optional(), + }), + execute: async (ctx, args) => + this.domains.upsertRecord(ctx.workspaceId, args.domainId, { + type: args.type, + name: args.name, + value: args.value, + ttl: args.ttl ?? 300, + proxied: args.proxied ?? false, + }), + } as ToolDefinition<any>); + + // ---- 监控 ---- + this.register({ + name: "devos.monitor.list", + description: "List monitors and their latest status", + risk: "LOW", + schema: zod.object({}), + execute: async (ctx) => this.monitoring.list(ctx.workspaceId), + } as ToolDefinition<any>); + + // ---- 容器操作(Phase G/H 新工具) ---- + this.register({ + name: "devos.container.restart", + description: "Restart a docker container by container row id", + risk: "HIGH", + schema: IdSchema, + execute: async (ctx, args) => this.containers.restart(ctx.workspaceId, args.id), + verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { restarted?: boolean }).restarted), detail: "container restarted" }), + } as ToolDefinition<any>); + + // ---- 任务/笔记(Phase G/H 新工具) ---- + this.register({ + name: "devos.task.create", + description: "Create a task in the workspace", + risk: "LOW", + schema: zod.object({ title: zod.string().min(1), description: zod.string().optional(), priority: zod.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).optional() }), + execute: async (ctx, args) => + this.tasks.create(ctx.workspaceId, { + title: args.title, + description: args.description ?? "", + priority: args.priority ?? "MEDIUM", + status: "TODO", + labels: ["ai"], + }), + verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { id?: string }).id), detail: "task created" }), + } as ToolDefinition<any>); + this.register({ + name: "devos.note.list", + description: "List notes in the workspace", + risk: "LOW", + schema: zod.object({}), + execute: async (ctx) => this.notes.list(ctx.workspaceId, {}), + } as ToolDefinition<any>); + this.register({ + name: "devos.note.create", + description: "Create a markdown note in the workspace", + risk: "LOW", + schema: zod.object({ title: zod.string().min(1), content: zod.string().optional() }), + execute: async (ctx, args) => + this.notes.create(ctx.workspaceId, { title: args.title, content: args.content ?? "", tags: ["ai"] }), + verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { id?: string }).id), detail: "note created" }), + } as ToolDefinition<any>); + this.register({ + name: "devos.task.list", + description: "List tasks in the workspace", + risk: "LOW", + schema: zod.object({ status: zod.string().optional() }), + execute: async (ctx, args) => this.tasks.list(ctx.workspaceId, { status: args.status }), + } as ToolDefinition<any>); + + // ---- 网页抓取(URL 总结等场景) ---- + this.register({ + name: "devos.web.fetch", + description: "Fetch a web page and return readable text (title + main content) for summarization", + risk: "LOW", + schema: zod.object({ url: zod.string().url(), maxChars: zod.number().int().max(30000).optional() }), + execute: async (_ctx, args) => { + const res = await fetch(args.url, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; DevOS-Agent/1.0)" }, + signal: AbortSignal.timeout(20_000), + redirect: "follow", + }); + if (!res.ok) throw new Error(`Fetch failed HTTP ${res.status}`); + const html = await res.text(); + const { extractHtmlTitle, extractMetaDescription, extractMainParagraphs } = await import("../../common/utils/html"); + return { + title: extractHtmlTitle(html), + description: extractMetaDescription(html), + text: [extractHtmlTitle(html) ?? "", ...extractMainParagraphs(html, 30)].filter(Boolean).join("\n\n").slice(0, args.maxChars ?? 12000), + }; + }, + } as ToolDefinition<any>); + + // ---- 仓库:从 URL 登记 ---- + this.register({ + name: "devos.repository.createFromUrl", + description: "Register a git repository from its HTTPS URL (auto-detects GitHub/GitLab provider)", + risk: "LOW", + schema: zod.object({ url: zod.string().url() }), + execute: async (ctx, args) => { + const url = args.url.replace(/\.git$/, ""); + const host = new URL(url).hostname; + const provider = host.includes("github") ? "GITHUB" : host.includes("gitlab") ? "GITLAB" : "GENERIC"; + const parts = new URL(url).pathname.replace(/^\//, "").split("/").filter(Boolean); + const fullName = parts.slice(0, 2).join("/"); + const result = await this.git.upsertRepository(ctx.workspaceId, { + provider: provider as "GITHUB" | "GITLAB" | "GITEA" | "GENERIC", + fullName, + cloneUrl: url, + defaultBranch: "main", + visibility: "private", + }); + return { ...result, hint: "website.create 可用 repositoryId 关联该仓库" }; + }, + } as ToolDefinition<any>); + + // ---- 名称查找(LLM 参数解析辅助) ---- + this.register({ + name: "devos.server.find", + description: "Find a server by (partial) name, returns id/host/status", + risk: "LOW", + schema: zod.object({ name: zod.string().min(1) }), + execute: async (ctx, args) => { + const all = await this.servers.list(ctx.workspaceId); + const lower = args.name.toLowerCase(); + const hits = all.filter((s) => s.name.toLowerCase().includes(lower) || s.host.toLowerCase().includes(lower)); + const withStatus = await Promise.all( + hits.map(async (s) => ({ + id: s.id, + name: s.name, + host: s.host, + provider: s.provider, + status: (await this.servers.mustGet(s.workspaceId, s.id)) ? undefined : undefined, + })), + ); + return hits.map((s) => ({ id: s.id, name: s.name, host: s.host })); + }, + } as ToolDefinition<any>); + this.register({ + name: "devos.domain.find", + description: "Find a domain by (partial) name, returns id/provider", + risk: "LOW", + schema: zod.object({ name: zod.string().min(1) }), + execute: async (ctx, args) => { + const all = await this.domains.list(ctx.workspaceId); + const lower = args.name.toLowerCase(); + return all.filter((d) => d.name.toLowerCase().includes(lower)); + }, + } as ToolDefinition<any>); + + // ---- 网站创建(chat 复合流程用) ---- + this.register({ + name: "devos.website.create", + description: "Create a website bound to a server and (optionally) a repository; then use devos.website.deploy to deploy it", + risk: "MEDIUM", + schema: zod.object({ + name: zod.string().min(1), + serverId: zod.string().optional(), + repositoryId: zod.string().optional(), + primaryDomain: zod.string().optional(), + port: zod.number().int().min(1).max(65535).optional(), + environment: zod.enum(["development", "staging", "production"]).optional(), + }), + execute: async (ctx, args) => + this.websites.create(ctx.workspaceId, { + name: args.name, + serverId: args.serverId, + repositoryId: args.repositoryId, + primaryDomain: args.primaryDomain, + port: args.port ?? 80, + healthPath: "/", + deployType: "DOCKER", + environment: args.environment ?? "production", + }), + verify: async (ctx, args, result) => ({ ok: Boolean((result as { id?: string }).id), detail: "website created" }), + } as ToolDefinition<any>); + + // ---- 编辑与删除(chat 全功能管理;删除走审批) ---- + this.register({ + name: "devos.task.update", + description: "Update a task (status/priority/title/description)", + risk: "LOW", + schema: zod.object({ id: zod.string().min(1), status: zod.enum(["BACKLOG", "TODO", "DOING", "REVIEW", "DONE"]).optional(), priority: zod.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).optional(), title: zod.string().optional() }), + execute: async (ctx, args) => { + const { id, ...patch } = args as { id: string } & Record<string, unknown>; + return this.tasks.update(ctx.workspaceId, id, patch as never); + }, + } as ToolDefinition<any>); + this.register({ + name: "devos.note.update", + description: "Update a note (title/content/tags)", + risk: "LOW", + schema: zod.object({ id: zod.string().min(1), title: zod.string().optional(), content: zod.string().optional() }), + execute: async (ctx, args) => { + const { id, ...patch } = args as { id: string } & Record<string, unknown>; + return this.notes.update(ctx.workspaceId, id, patch as never); + }, + } as ToolDefinition<any>); + this.register({ + name: "devos.task.delete", + description: "Delete a task", + risk: "MEDIUM", + schema: IdSchema, + execute: async (ctx, args) => this.tasks.delete(ctx.workspaceId, args.id), + } as ToolDefinition<any>); + this.register({ + name: "devos.note.delete", + description: "Delete a note", + risk: "MEDIUM", + schema: IdSchema, + execute: async (ctx, args) => this.notes.delete(ctx.workspaceId, args.id), + } as ToolDefinition<any>); + this.register({ + name: "devos.website.delete", + description: "Delete a website", + risk: "HIGH", + schema: IdSchema, + execute: async (ctx, args) => this.websites.delete(ctx.workspaceId, args.id), + } as ToolDefinition<any>); + this.register({ + name: "devos.server.delete", + description: "Delete a server", + risk: "HIGH", + schema: IdSchema, + execute: async (ctx, args) => this.servers.delete(ctx.workspaceId, args.id), + } as ToolDefinition<any>); + this.register({ + name: "devos.domain.delete", + description: "Delete a domain", + risk: "HIGH", + schema: IdSchema, + execute: async (ctx, args) => this.domains.delete(ctx.workspaceId, args.id), + } as ToolDefinition<any>); + + // ---- 自动化 ---- + this.register({ + name: "devos.automation.list", + description: "List automations", + risk: "LOW", + schema: zod.object({}), + execute: async (ctx) => this.automations.list(ctx.workspaceId), + } as ToolDefinition<any>); + this.register({ + name: "devos.automation.run", + description: "Manually trigger an automation", + risk: "MEDIUM", + schema: IdSchema, + execute: async (ctx, args) => this.automations.runManually(ctx.workspaceId, args.id, ctx.actorId), + } as ToolDefinition<any>); + } +} + +/** # 风险等级比较(越大越高) **/ +const RISK_RANK: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 }; + +/** # 求多个风险的最大值 */ +export function maxRisk(levels: RiskLevel[]): RiskLevel { + return levels.reduce<RiskLevel>((acc, l) => (RISK_RANK[l] > RISK_RANK[acc] ? l : acc), "LOW"); +} + +/** # 危险命令升级(server.exec 用):基于命令内容的 CRITICAL 判定 */ +export function escalateExecRisk(command: string): RiskLevel | null { + const destructive = [/rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)/, /mkfs/, /dd\s+if=/, /:\(\)\{:\|:\&\};:/, /shutdown|reboot/, /DROP\s+(TABLE|DATABASE)/i]; + return destructive.some((p) => p.test(command)) ? "CRITICAL" : null; +} diff --git a/apps/api/src/modules/audit/audit.controller.ts b/apps/api/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..393d1e1 --- /dev/null +++ b/apps/api/src/modules/audit/audit.controller.ts @@ -0,0 +1,47 @@ +/** + * # 审计查询控制器 + */ +import { Controller, Get, Query } from "@nestjs/common"; +import { z } from "zod"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace } from "../../common/decorators"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { RequestWorkspace } from "../../common/decorators"; + +const ListQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + resourceType: z.string().optional(), + resourceId: z.string().optional(), + action: z.string().optional(), +}); + +@Controller("audit") +export class AuditController { + constructor(private readonly prisma: PrismaService) {} + + /** # 分页查询当前工作区审计日志 */ + @Get() + async list( + @CurrentWorkspace() workspace: RequestWorkspace, + @Query(new ZodValidationPipe(ListQuerySchema)) + query: z.infer<typeof ListQuerySchema>, + ) { + const where = { + workspaceId: workspace.id, + ...(query.resourceType ? { resourceType: query.resourceType } : {}), + ...(query.resourceId ? { resourceId: query.resourceId } : {}), + ...(query.action ? { action: { contains: query.action } } : {}), + }; + const [total, items] = await this.prisma.$transaction([ + this.prisma.auditLog.count({ where }), + this.prisma.auditLog.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + ]); + return { items, total, page: query.page, pageSize: query.pageSize }; + } +} diff --git a/apps/api/src/modules/audit/audit.module.ts b/apps/api/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..338ed08 --- /dev/null +++ b/apps/api/src/modules/audit/audit.module.ts @@ -0,0 +1,13 @@ +/** + * # 审计模块(Task 008) + */ +import { Module } from "@nestjs/common"; +import { AuditController } from "./audit.controller"; +import { AuditService } from "./audit.service"; + +@Module({ + controllers: [AuditController], + providers: [AuditService], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/apps/api/src/modules/audit/audit.service.ts b/apps/api/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..7f0ad1b --- /dev/null +++ b/apps/api/src/modules/audit/audit.service.ts @@ -0,0 +1,60 @@ +/** + * # 审计服务 + * 每个变更(mutation)必须写入审计记录;敏感读取也可审计。 + * 元数据自动脱敏;requestId 从请求上下文注入。 + */ +import { Injectable } from "@nestjs/common"; +import type { Prisma } from "@prisma/client"; +import type { ActorType, RiskLevel } from "@devos/types"; +import { PrismaService } from "../../prisma/prisma.service"; +import { getRequestContext } from "../../common/request-context"; +import { maskSecrets } from "../../common/utils/mask"; + +/** # 审计记录输入 **/ +export interface AuditInput { + workspaceId: string; + actorType: ActorType; + actorId?: string | null; + action: string; + resourceType?: string | null; + resourceId?: string | null; + riskLevel?: RiskLevel; + status?: "SUCCESS" | "FAILURE" | "CANCELLED"; + metadata?: Record<string, unknown>; +} + +@Injectable() +export class AuditService { + constructor(private readonly prisma: PrismaService) {} + + /** # 写入审计记录(永不抛出异常影响主流程) */ + async record(input: AuditInput): Promise<void> { + try { + const ctx = getRequestContext(); + await this.prisma.auditLog.create({ + data: { + workspaceId: input.workspaceId, + actorType: input.actorType, + actorId: input.actorId ?? ctx?.actorId ?? null, + action: input.action, + resourceType: input.resourceType ?? null, + resourceId: input.resourceId ?? null, + riskLevel: input.riskLevel ?? "LOW", + status: input.status ?? "SUCCESS", + requestId: ctx?.requestId ?? null, + ip: ctx?.ip ?? null, + metadata: maskSecrets(input.metadata ?? {}) as Prisma.InputJsonValue, + }, + }); + } catch (error) { + console.error( + JSON.stringify({ + time: new Date().toISOString(), + level: "error", + ctx: "AuditService", + msg: `failed to write audit log: ${error instanceof Error ? error.message : error}`, + }), + ); + } + } +} diff --git a/apps/api/src/modules/automation/automation.controller.ts b/apps/api/src/modules/automation/automation.controller.ts new file mode 100644 index 0000000..adb86b8 --- /dev/null +++ b/apps/api/src/modules/automation/automation.controller.ts @@ -0,0 +1,99 @@ +/** + * # Automation 控制器(/api/v1/automations) + */ +import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Req } from "@nestjs/common"; +import { z } from "zod"; +import { AutomationService, CreateAutomationSchema, UpdateAutomationSchema } from "./automation.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentUser, CurrentWorkspace, Roles, Public } from "../../common/decorators"; +import type { RequestUser, RequestWorkspace } from "../../common/decorators"; +import type { FastifyRequest } from "fastify"; + +const WebhookBodySchema = z.record(z.unknown()).default({}); + +@Controller("automations") +export class AutomationController { + constructor(private readonly automations: AutomationService) {} + + /** # 自动化列表 */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.automations.list(ws.id); + } + + /** # 创建自动化 */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateAutomationSchema)) dto: Parameters<AutomationService["create"]>[1], + ) { + return this.automations.create(ws.id, dto); + } + + /** # 自动化详情(含执行历史) */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.automations.get(ws.id, id); + } + + /** # 编辑自动化 */ + @Roles("MEMBER") + @Patch(":id") + update( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(UpdateAutomationSchema)) dto: Parameters<AutomationService["update"]>[2], + ) { + return this.automations.update(ws.id, id, dto); + } + + /** # 启用 */ + @Roles("MEMBER") + @Post(":id/enable") + enable(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.automations.setEnabled(ws.id, id, true); + } + + /** # 禁用 */ + @Roles("MEMBER") + @Post(":id/disable") + disable(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.automations.setEnabled(ws.id, id, false); + } + + /** # 手动触发 */ + @Roles("MEMBER") + @Post(":id/run") + run( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @CurrentUser() user: RequestUser, + ) { + return this.automations.runManually(ws.id, id, user.id); + } + + /** # Webhook 触发(公开路由;X-Webhook-Secret 头校验;body 为触发载荷) */ + @Public() + @Post(":id/webhook") + webhook( + @Param("id") id: string, + @Headers("x-webhook-secret") secret: string | undefined, + @Headers("x-signature") signature: string | undefined, + @Req() req: FastifyRequest, + @Body(new ZodValidationPipe(WebhookBodySchema)) body: Record<string, unknown>, + ) { + return this.automations.runWebhookByToken(id, { + secret: secret ?? "", + signature, + rawBody: (req.rawBody ? req.rawBody.toString("utf8") : JSON.stringify(body ?? {})), + }); + } + + /** # 删除自动化 */ + @Roles("ADMIN") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.automations.delete(ws.id, id); + } +} diff --git a/apps/api/src/modules/automation/automation.module.ts b/apps/api/src/modules/automation/automation.module.ts new file mode 100644 index 0000000..623b3bb --- /dev/null +++ b/apps/api/src/modules/automation/automation.module.ts @@ -0,0 +1,20 @@ +/** + * # Automation 模块(Task 016) + */ +import { Module } from "@nestjs/common"; +import { AutomationController } from "./automation.controller"; +import { AutomationService } from "./automation.service"; +import { EventModule } from "../event/event.module"; +import { AuditModule } from "../audit/audit.module"; +import { WebsiteModule } from "../website/website.module"; +import { ResourceModule } from "../resource/resource.module"; +import { ContainerModule } from "../container/container.module"; +import { NotificationModule } from "../notification/notification.module"; + +@Module({ + imports: [EventModule, AuditModule, WebsiteModule, ResourceModule, ContainerModule, NotificationModule], + controllers: [AutomationController], + providers: [AutomationService], + exports: [AutomationService], +}) +export class AutomationModule {} diff --git a/apps/api/src/modules/automation/automation.service.ts b/apps/api/src/modules/automation/automation.service.ts new file mode 100644 index 0000000..8b76d59 --- /dev/null +++ b/apps/api/src/modules/automation/automation.service.ts @@ -0,0 +1,565 @@ +/** + * # Automation 服务(Task 016) + * Trigger → Condition(s) → Action(s) 工作流: + * - 触发器:MANUAL / CRON / EVENT / WEBHOOK(token 校验); + * - 条件:[{path, op, value}] 对事件/触发载荷求值; + * - 动作:LOG / WEBHOOK / DEPLOY_WEBSITE,带超时与有限重试; + * - 每次执行写 automation_runs 历史(含步骤日志与错误)。 + */ +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { z } from "zod"; +import type { Automation } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { EventBusService, type DomainEvent } from "../event/event-bus.service"; +import { AuditService } from "../audit/audit.service"; +import { ResourceService } from "../resource/resource.service"; +import { WebsiteService } from "../website/website.service"; +import { ContainerService } from "../container/container.service"; +import { NotificationService } from "../notification/notification.service"; +import { cronMatches } from "./cron"; +import { verifyWebhookSignature } from "../../common/security/webhook-signature"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; +import { randomUUID } from "node:crypto"; + +/** # 动作配置 schema **/ +const ActionSchema = z.object({ + type: z.enum([ + "LOG", + "WEBHOOK", + "DEPLOY_WEBSITE", + "CONTAINER_RESTART", + "TASK_CREATE", + "NOTE_CREATE", + "NOTIFICATION_SEND", + ]), + args: z.record(z.unknown()).default({}), + timeoutMs: z.coerce.number().int().min(1000).max(600_000).default(30_000), + retries: z.coerce.number().int().min(0).max(5).default(0), +}); +export type AutomationAction = z.infer<typeof ActionSchema>; + +/** # 条件配置 schema **/ +const ConditionSchema = z.object({ + path: z.string().min(1), + op: z.enum(["eq", "neq", "contains", "exists", "gt", "lt"]), + value: z.unknown().optional(), +}); +export type AutomationCondition = z.infer<typeof ConditionSchema>; + +/** # 更新自动化 DTO **/ +export const UpdateAutomationSchema = z.object({ + name: z.string().min(1).max(120).optional(), + enabled: z.boolean().optional(), + conditions: z.array(ConditionSchema).optional(), + actions: z.array(ActionSchema).min(1).max(10).optional(), +}); +export type UpdateAutomationDto = z.infer<typeof UpdateAutomationSchema>; + +/** # 创建自动化 DTO **/ +export const CreateAutomationSchema = z.object({ + name: z.string().min(1).max(120), + triggerType: z.enum(["MANUAL", "CRON", "EVENT", "WEBHOOK"]), + triggerConfig: z + .object({ + cron: z.string().optional(), + eventType: z.string().optional(), + secret: z.string().optional(), + }) + .default({}), + conditions: z.array(ConditionSchema).default([]), + actions: z.array(ActionSchema).min(1).max(10), + enabled: z.boolean().default(false), +}); +export type CreateAutomationDto = z.infer<typeof CreateAutomationSchema>; + +/** # 动作执行日志行 **/ +interface RunLogLine { + step: number; + type: string; + ok: boolean; + output?: string; + error?: string; + at: string; +} + +@Injectable() +export class AutomationService implements OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly events: EventBusService, + private readonly audit: AuditService, + private readonly resources: ResourceService, + private readonly websites: WebsiteService, + private readonly containers: ContainerService, + private readonly notifications: NotificationService, + ) {} + + /** # 初始化:订阅事件总线处理 EVENT 触发器 */ + onModuleInit(): void { + this.events.subscribe((event) => this.handleDomainEvent(event)); + } + + /** # 创建自动化 */ + async create(workspaceId: string, dto: CreateAutomationDto) { + if (dto.triggerType === "CRON" && !dto.triggerConfig.cron) { + throw ApiError.validation("CRON trigger requires triggerConfig.cron"); + } + if (dto.triggerType === "CRON") { + try { + cronMatches(dto.triggerConfig.cron!, new Date()); + } catch { + throw ApiError.validation("Invalid cron expression"); + } + } + if (dto.triggerType === "EVENT" && !dto.triggerConfig.eventType) { + throw ApiError.validation("EVENT trigger requires triggerConfig.eventType"); + } + const automation = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { workspaceId, type: "AUTOMATION", name: dto.name, provider: "devos", status: dto.enabled ? "ACTIVE" : "PENDING" }, + }); + return tx.automation.create({ + data: { + workspaceId, + resourceId: resource.id, + name: dto.name, + enabled: dto.enabled, + triggerType: dto.triggerType, + triggerConfig: dto.triggerConfig as object, + conditions: dto.conditions as unknown as object, + actions: dto.actions as unknown as object, + }, + }); + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "automation.create", + resourceType: "Automation", + resourceId: automation.resourceId, + metadata: { triggerType: dto.triggerType }, + }); + return this.view(automation); + } + + /** # 自动化列表 */ + async list(workspaceId: string) { + const rows = await this.prisma.automation.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + }); + return rows.map((a) => this.view(a)); + } + + /** # 自动化详情(含执行历史) */ + async get(workspaceId: string, id: string) { + const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } }); + if (!a) throw ApiError.notFound("Automation not found"); + const runs = await this.prisma.automationRun.findMany({ + where: { automationId: a.id }, + orderBy: { startedAt: "desc" }, + take: 20, + }); + return { ...this.view(a), runs }; + } + + /** # 启用/禁用 */ + async setEnabled(workspaceId: string, id: string, enabled: boolean) { + const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } }); + if (!a) throw ApiError.notFound("Automation not found"); + const updated = await this.prisma.$transaction(async (tx) => { + const updated = await tx.automation.update({ where: { id }, data: { enabled } }); + await tx.resource.update({ where: { id: a.resourceId }, data: { status: enabled ? "ACTIVE" : "ARCHIVED" } }); + return updated; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: enabled ? "automation.enable" : "automation.disable", + resourceType: "Automation", + resourceId: a.resourceId, + }); + return this.view(updated); + } + + /** # 编辑自动化(Phase F:名称/条件/动作/开关) */ + async update(workspaceId: string, id: string, dto: UpdateAutomationDto) { + const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } }); + if (!a) throw ApiError.notFound("Automation not found"); + if (dto.actions) { + // 校验动作 schema + for (const action of dto.actions) ActionSchema.parse(action); + } + const updated = await this.prisma.$transaction(async (tx) => { + const u = await tx.automation.update({ + where: { id }, + data: { + ...(dto.name ? { name: dto.name } : {}), + ...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}), + ...(dto.conditions ? { conditions: dto.conditions as unknown as object } : {}), + ...(dto.actions ? { actions: dto.actions as unknown as object } : {}), + }, + }); + if (dto.enabled !== undefined) { + await tx.resource.update({ + where: { id: a.resourceId }, + data: { status: dto.enabled ? "ACTIVE" : "ARCHIVED" }, + }); + } + return u; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "automation.update", + resourceType: "Automation", + resourceId: a.resourceId, + metadata: { fields: Object.keys(dto) }, + }); + return this.view(updated); + } + + /** # 删除自动化 */ + async delete(workspaceId: string, id: string) { + const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } }); + if (!a) throw ApiError.notFound("Automation not found"); + await this.prisma.automation.delete({ where: { id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "automation.delete", + resourceType: "Automation", + resourceId: a.resourceId, + riskLevel: "HIGH", + }); + return { deleted: true }; + } + + /** # 手动触发 */ + async runManually(workspaceId: string, id: string, actorId?: string) { + const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } }); + if (!a) throw ApiError.notFound("Automation not found"); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + actorId, + action: "automation.run", + resourceType: "Automation", + resourceId: a.resourceId, + }); + return this.execute(a, { triggerType: "MANUAL", payload: {} }); + } + + /** # Webhook 触发(HMAC 签名优先,明文 secret 兼容回退;按 id 定位,无需工作区上下文) */ + async runWebhookByToken( + id: string, + input: { secret: string; signature?: string; rawBody: string }, + ) { + const a = await this.prisma.automation.findUnique({ where: { id } }); + if (!a) throw ApiError.notFound("Automation not found"); + if (a.triggerType !== "WEBHOOK") throw ApiError.validation("Automation is not a webhook automation"); + const cfg = a.triggerConfig as { secret?: string }; + if (!cfg.secret) throw ApiError.forbidden("Automation has no webhook secret configured"); + // 优先 HMAC(t=<ts>,v1=<hex>),防重放;legacy 明文比对保留给旧调用方 + let authOk = false; + let authReason = ""; + if (input.signature) { + const verdict = verifyWebhookSignature(cfg.secret, input.signature, input.rawBody); + authOk = verdict.ok; + authReason = verdict.reason ?? ""; + } else if (input.secret) { + authOk = input.secret === cfg.secret; + authReason = authOk ? "legacy secret" : "legacy secret mismatch"; + } + if (!authOk) { + await this.audit.record({ + workspaceId: a.workspaceId, actorType: "SYSTEM", action: "automation.webhook", + resourceType: "Automation", resourceId: a.resourceId, status: "FAILURE", + riskLevel: "MEDIUM", metadata: { reason: authReason || "unauthorized" }, + }); + throw ApiError.forbidden(`Webhook authentication failed: ${authReason || "missing credentials"}`); + } + if (!a.enabled) throw ApiError.conflict("Automation is disabled"); + let payload: Record<string, unknown> = {}; + try { + payload = JSON.parse(input.rawBody || "{}") as Record<string, unknown>; + } catch { + payload = { raw: input.rawBody.slice(0, 2000) }; + } + const result = await this.execute(a, { triggerType: "WEBHOOK", payload }); + await this.audit.record({ + workspaceId: a.workspaceId, + actorType: "SYSTEM", + action: "automation.webhook", + resourceType: "Automation", + resourceId: a.resourceId, + status: result.status === "FAILED" ? "FAILURE" : "SUCCESS", + }); + return result; + } + + /** # 订阅事件总线:处理 EVENT 触发器 */ + handleDomainEvent(event: DomainEvent): void { + void (async () => { + const automations = await this.prisma.automation.findMany({ + where: { workspaceId: event.workspaceId, triggerType: "EVENT", enabled: true }, + }); + for (const a of automations) { + const cfg = a.triggerConfig as { eventType?: string }; + if (cfg.eventType !== event.eventType) continue; + await this.execute(a, { + triggerType: "EVENT", + payload: { eventType: event.eventType, ...event.payload }, + }).catch(() => undefined); + } + })(); + } + + /** # cron 扫描(调度器每分钟调用) */ + async runDueCron(): Promise<void> { + const automations = await this.prisma.automation.findMany({ + where: { triggerType: "CRON", enabled: true }, + }); + const now = new Date(); + for (const a of automations) { + const cfg = a.triggerConfig as { cron?: string }; + if (!cfg.cron || !cronMatches(cfg.cron, now)) continue; + // 同一分钟内不重复触发 + if (a.lastTriggeredAt && now.getTime() - a.lastTriggeredAt.getTime() < 60_000) continue; + await this.prisma.automation.update({ where: { id: a.id }, data: { lastTriggeredAt: now } }); + await this.execute(a, { triggerType: "CRON", payload: { at: now.toISOString() } }).catch(() => undefined); + } + } + + /** # 执行自动化:条件评估 → 顺序动作(重试/超时)→ 历史 */ + async execute( + automation: Automation, + trigger: { triggerType: string; payload: Record<string, unknown> }, + ): Promise<{ runId: string; status: string; logs: RunLogLine[] }> { + const correlationId = randomUUID(); + const run = await this.prisma.automationRun.create({ + data: { + automationId: automation.id, + workspaceId: automation.workspaceId, + status: "RUNNING", + trigger: trigger as unknown as object, + correlationId, + }, + }); + + const conditions = (automation.conditions as unknown as AutomationCondition[]) ?? []; + const actions = (automation.actions as unknown as AutomationAction[]) ?? []; + const logs: RunLogLine[] = []; + let status = "SUCCEEDED"; + let error: string | undefined; + + const allConditionsMet = conditions.every((c) => evaluateCondition(c, trigger.payload)); + if (!allConditionsMet) { + logs.push({ step: 0, type: "CONDITION", ok: false, output: "conditions not met; skipped", at: new Date().toISOString() }); + await this.prisma.automationRun.update({ + where: { id: run.id }, + data: { status: "SKIPPED", logs: logs as unknown as object[], finishedAt: new Date() }, + }); + return { runId: run.id, status: "SKIPPED", logs }; + } + + let stepIndex = 0; + for (const action of actions) { + stepIndex += 1; + const line = await this.runAction(automation.workspaceId, stepIndex, action, trigger.payload, correlationId); + logs.push(line); + if (!line.ok) { + status = "FAILED"; + error = line.error; + break; // 失败即停(可按动作配置 retries 重试) + } + } + + await this.prisma.automationRun.update({ + where: { id: run.id }, + data: { status, logs: logs as unknown as object[], stepIndex, error, finishedAt: new Date() }, + }); + return { runId: run.id, status, logs }; + } + + /** # 执行单个动作(含重试与超时) */ + private async runAction( + workspaceId: string, + step: number, + action: AutomationAction, + payload: Record<string, unknown>, + correlationId: string, + ): Promise<RunLogLine> { + const at = () => new Date().toISOString(); + let lastError: string | undefined; + for (let attempt = 0; attempt <= action.retries; attempt++) { + try { + const output = await this.runActionOnce(workspaceId, action, payload, correlationId); + return { step, type: action.type, ok: true, output, at: at() }; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + if (attempt < action.retries) { + await new Promise((r) => setTimeout(r, Math.min(2000 * 2 ** attempt, 30_000))); + } + } + } + return { step, type: action.type, ok: false, error: lastError, at: at() }; + } + + /** # 动作实现 */ + private async runActionOnce( + workspaceId: string, + action: AutomationAction, + payload: Record<string, unknown>, + correlationId: string, + ): Promise<string> { + switch (action.type) { + case "LOG": { + const message = String(action.args.message ?? "automation log"); + return `LOG: ${message}`; + } + case "WEBHOOK": { + const url = String(action.args.url ?? ""); + if (!/^https?:\/\//.test(url)) throw new Error("WEBHOOK requires args.url (http/https)"); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Correlation-Id": correlationId }, + body: JSON.stringify({ payload }), + signal: AbortSignal.timeout(action.timeoutMs), + }); + if (!res.ok) throw new Error(`Webhook responded ${res.status}`); + return `WEBHOOK: ${url} -> ${res.status}`; + } + case "DEPLOY_WEBSITE": { + const websiteId = String(action.args.websiteId ?? ""); + const deployment = await this.websites.deploy(workspaceId, websiteId, { + type: "SYSTEM", + trigger: "AUTOMATION", + }); + return `DEPLOY_WEBSITE: deployment ${deployment.id} queued`; + } + case "CONTAINER_RESTART": { + const containerId = String(action.args.containerId ?? ""); + const r = await this.containers.restart(workspaceId, containerId); + return `CONTAINER_RESTART: ${r.name} restarted`; + } + case "TASK_CREATE": { + const taskId = await this.createAutomationTask(workspaceId, action.args, correlationId); + return `TASK_CREATE: task ${taskId} created`; + } + case "NOTE_CREATE": { + const noteId = await this.createAutomationNote(workspaceId, action.args, correlationId); + return `NOTE_CREATE: note ${noteId} created`; + } + case "NOTIFICATION_SEND": { + await this.notifications.push(workspaceId, { + title: String(action.args.title ?? "Automation notification"), + body: String(action.args.body ?? ""), + level: "INFO", + }); + return "NOTIFICATION_SEND: notification pushed"; + } + default: + throw new Error(`Unknown action type`); + } + } + + /** # 自动化动作:创建任务(写 tasks 表 + TASK 资源) */ + private async createAutomationTask( + workspaceId: string, + args: Record<string, unknown>, + correlationId: string, + ): Promise<string> { + const title = String(args.title ?? "Automation task").slice(0, 200); + return this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { workspaceId, type: "TASK", name: title, provider: "automation", status: "ACTIVE", metadata: { source: "automation", correlationId } }, + }); + const task = await tx.task.create({ + data: { + workspaceId, resourceId: resource.id, title, + description: String(args.description ?? `Created by automation ${correlationId}`), + status: "TODO", priority: "MEDIUM", + }, + }); + return task.id; + }); + } + + /** # 自动化动作:创建笔记 */ + private async createAutomationNote( + workspaceId: string, + args: Record<string, unknown>, + correlationId: string, + ): Promise<string> { + const title = String(args.title ?? "Automation note").slice(0, 200); + return this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { workspaceId, type: "NOTE", name: title, provider: "automation", status: "ACTIVE", metadata: { source: "automation", correlationId } }, + }); + const note = await tx.note.create({ + data: { + workspaceId, resourceId: resource.id, title, + content: String(args.content ?? `Created by automation ${correlationId}`), + }, + }); + return note.id; + }); + } + + /** # 断言自动化属于工作区 */ + async mustGet(workspaceId: string, id: string) { + const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } }); + if (!a) throw ApiError.notFound("Automation not found"); + return a; + } + + /** # 实体 → 视图 */ + private view(a: Automation) { + return { + id: a.id, + resourceId: a.resourceId, + workspaceId: a.workspaceId, + name: a.name, + enabled: a.enabled, + triggerType: a.triggerType, + triggerConfig: a.triggerConfig, + conditions: a.conditions, + actions: a.actions, + lastTriggeredAt: a.lastTriggeredAt, + createdAt: a.createdAt, + }; + } +} + +/** # 按路径取值(支持 a.b.c / a.0.b) */ +function getPath(obj: unknown, path: string): unknown { + let cur: unknown = obj; + for (const key of path.split(".")) { + if (cur === null || cur === undefined) return undefined; + cur = (cur as Record<string, unknown>)[key]; + } + return cur; +} + +/** # 条件评估(纯函数) */ +export function evaluateCondition(condition: AutomationCondition, payload: Record<string, unknown>): boolean { + const actual = getPath(payload, condition.path); + switch (condition.op) { + case "exists": + return actual !== undefined; + case "eq": + return String(actual) === String(condition.value); + case "neq": + return String(actual) !== String(condition.value); + case "contains": + return String(actual ?? "").includes(String(condition.value)); + case "gt": + return Number(actual) > Number(condition.value); + case "lt": + return Number(actual) < Number(condition.value); + default: + return false; + } +} diff --git a/apps/api/src/modules/automation/cron.ts b/apps/api/src/modules/automation/cron.ts new file mode 100644 index 0000000..e188b84 --- /dev/null +++ b/apps/api/src/modules/automation/cron.ts @@ -0,0 +1,78 @@ +/** + * # Cron 解析器 + * 标准 5 字段 cron(分 时 日 月 周),支持星号、数字、步进(星号加斜杠n)、区间(a-b)、列表(a,b,c)混合。 + * 纯函数实现,便于测试。 + */ + +/** # cron 字段取值范围 **/ +const FIELD_RANGES: [number, number][] = [ + [0, 59], // minute + [0, 23], // hour + [1, 31], // day of month + [1, 12], // month + [0, 6], // day of week (0=Sunday) +]; + +/** # 解析单个字段为允许值集合 + * @param field 字段表达式 + * @param range 取值范围 + * @returns 允许值集合 + */ +export function parseCronField(field: string, range: [number, number]): Set<number> { + const [min, max] = range; + const values = new Set<number>(); + for (const part of field.split(",")) { + const [base, stepStr] = part.split("/"); + const step = stepStr ? Number(stepStr) : 1; + if (!Number.isInteger(step) || step < 1) throw new Error(`Invalid cron step: ${part}`); + let lo = min; + let hi = max; + if (base !== "*" && base !== "") { + if (base.includes("-")) { + const [a, b] = base.split("-").map(Number); + if (!Number.isInteger(a) || !Number.isInteger(b)) throw new Error(`Invalid cron range: ${part}`); + lo = a; + hi = b; + } else { + const v = Number(base); + if (!Number.isInteger(v)) throw new Error(`Invalid cron value: ${part}`); + lo = v; + hi = stepStr ? max : v; + } + } + if (lo < min || hi > max || lo > hi) throw new Error(`Cron value out of range: ${part}`); + for (let v = lo; v <= hi; v += step) values.add(v); + } + return values; +} + +/** # 解析完整 cron 表达式 + * @param expression 5 字段表达式 + * @returns 各字段允许值 + */ +export function parseCron(expression: string): Set<number>[] { + const fields = expression.trim().split(/\s+/); + if (fields.length !== 5) throw new Error(`Cron expression must have 5 fields: ${expression}`); + return fields.map((f, i) => parseCronField(f, FIELD_RANGES[i])); +} + +/** # 判断给定时间是否匹配 cron 表达式 + * @param expression 5 字段表达式 + * @param date 待判定时间(本地时区) + */ +export function cronMatches(expression: string, date: Date): boolean { + let sets: Set<number>[]; + try { + sets = parseCron(expression); + } catch { + return false; + } + const values = [ + date.getMinutes(), + date.getHours(), + date.getDate(), + date.getMonth() + 1, + date.getDay(), + ]; + return values.every((v, i) => sets[i].has(v)); +} diff --git a/apps/api/src/modules/certificate/certificate.controller.ts b/apps/api/src/modules/certificate/certificate.controller.ts new file mode 100644 index 0000000..0e65463 --- /dev/null +++ b/apps/api/src/modules/certificate/certificate.controller.ts @@ -0,0 +1,49 @@ +/** + * # Certificate 控制器(/api/v1/certificates) + */ +import { Body, Controller, Delete, Get, Param, Post } from "@nestjs/common"; +import { CertificateService, CreateCertificateSchema } from "./certificate.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +@Controller("certificates") +export class CertificateController { + constructor(private readonly certificates: CertificateService) {} + + /** # 证书列表 */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.certificates.list(ws.id); + } + + /** # 添加证书(可选 TLS 探测) */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateCertificateSchema)) dto: Parameters<CertificateService["create"]>[1], + ) { + return this.certificates.create(ws.id, dto); + } + + /** # 证书详情 */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.certificates.get(ws.id, id); + } + + /** # 重新探测 */ + @Roles("MEMBER") + @Post(":id/recheck") + recheck(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.certificates.recheck(ws.id, id); + } + + /** # 删除证书 */ + @Roles("ADMIN") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.certificates.delete(ws.id, id); + } +} diff --git a/apps/api/src/modules/certificate/certificate.module.ts b/apps/api/src/modules/certificate/certificate.module.ts new file mode 100644 index 0000000..3685467 --- /dev/null +++ b/apps/api/src/modules/certificate/certificate.module.ts @@ -0,0 +1,15 @@ +/** + * # Certificate 模块 + */ +import { Module } from "@nestjs/common"; +import { CertificateController } from "./certificate.controller"; +import { CertificateService } from "./certificate.service"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [AuditModule], + controllers: [CertificateController], + providers: [CertificateService], + exports: [CertificateService], +}) +export class CertificateModule {} diff --git a/apps/api/src/modules/certificate/certificate.service.ts b/apps/api/src/modules/certificate/certificate.service.ts new file mode 100644 index 0000000..992c87d --- /dev/null +++ b/apps/api/src/modules/certificate/certificate.service.ts @@ -0,0 +1,243 @@ +/** + * # Certificate 服务(Phase E/05 SSL 基础) + * 统一证书模型 + TLS 探针(node:tls 直连取对端证书): + * 状态 pending/active/expired/error;到期时间与签发者入库;可关联 Domain 资源。 + */ +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { z } from "zod"; +import { connect as tlsConnect, type PeerCertificate } from "node:tls"; +import { PrismaService } from "../../prisma/prisma.service"; +import { AuditService } from "../../modules/audit/audit.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 创建证书 DTO **/ +export const CreateCertificateSchema = z.object({ + domain: z + .string() + .min(3) + .max(253) + .regex(/^(?!-)[a-zA-Z0-9-*]{1,63}(\.[a-zA-Z0-9-*]{1,63})+$/, "invalid domain"), + domainId: z.string().optional(), + /** # 立即执行 TLS 探测(默认 true) **/ + probe: z.boolean().default(true), +}); +export type CreateCertificateDto = z.infer<typeof CreateCertificateSchema>; + +/** # TLS 探测结果 **/ +export interface TlsProbeResult { + ok: boolean; + issuer?: string; + notAfter?: Date; + daysRemaining?: number; + error?: string; +} + +@Injectable() +export class CertificateService implements OnModuleInit { + /** # 到期阈值(天):低于则标记并提示 */ + private static readonly EXPIRY_WARN_DAYS = 30; + + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + /** # worker 调度预留:每小时刷新(V1 手动触发为主) */ + onModuleInit(): void { + /* 预留 */ + } + + /** # 创建证书记录并可选探测 */ + async create(workspaceId: string, dto: CreateCertificateDto) { + if (dto.domainId) { + const domain = await this.prisma.domain.findFirst({ where: { id: dto.domainId, workspaceId } }); + if (!domain) throw ApiError.notFound("Domain not found"); + } + const probe = dto.probe ? await this.probeTls(dto.domain) : null; + const cert = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { + workspaceId, + type: "CERTIFICATE", + name: dto.domain, + provider: "tls-probe", + status: this.resourceStatusFromProbe(probe), + }, + }); + return tx.certificate.create({ + data: { + workspaceId, + resourceId: resource.id, + domain: dto.domain, + domainId: dto.domainId ?? null, + status: this.statusFromProbe(probe), + issuer: probe?.issuer ?? null, + notAfter: probe?.notAfter ?? null, + lastCheckedAt: probe ? new Date() : null, + }, + }); + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "certificate.create", + resourceType: "Certificate", + resourceId: cert.resourceId, + metadata: { domain: dto.domain, probe: probe ? { ok: probe.ok, days: probe.daysRemaining } : null }, + }); + return this.get(workspaceId, cert.id); + } + + /** # 证书列表 */ + async list(workspaceId: string) { + const rows = await this.prisma.certificate.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + }); + return rows.map((c) => this.view(c)); + } + + /** # 证书详情 */ + async get(workspaceId: string, id: string) { + const c = await this.prisma.certificate.findFirst({ where: { id, workspaceId } }); + if (!c) throw ApiError.notFound("Certificate not found"); + return this.view(c); + } + + /** # 重新探测(刷新状态/到期时间/签发者) */ + async recheck(workspaceId: string, id: string) { + const cert = await this.prisma.certificate.findFirst({ where: { id, workspaceId } }); + if (!cert) throw ApiError.notFound("Certificate not found"); + const probe = await this.probeTls(cert.domain); + const status = this.statusFromProbe(probe); + const updated = await this.prisma.certificate.update({ + where: { id: cert.id }, + data: { + status, + issuer: probe.issuer ?? cert.issuer, + notAfter: probe.notAfter ?? cert.notAfter, + lastCheckedAt: new Date(), + }, + }); + await this.prisma.resource.update({ + where: { id: cert.resourceId }, + data: { status: status === "ACTIVE" ? "ACTIVE" : status === "EXPIRED" ? "ERROR" : "PENDING" }, + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "certificate.recheck", + resourceType: "Certificate", + resourceId: cert.resourceId, + status: probe.ok ? "SUCCESS" : "FAILURE", + metadata: { daysRemaining: probe.daysRemaining }, + }); + return { ...this.view(updated), probe }; + } + + /** # 删除证书记录 */ + async delete(workspaceId: string, id: string) { + const cert = await this.prisma.certificate.findFirst({ where: { id, workspaceId } }); + if (!cert) throw ApiError.notFound("Certificate not found"); + await this.prisma.certificate.delete({ where: { id: cert.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "certificate.delete", + resourceType: "Certificate", + resourceId: cert.resourceId, + riskLevel: "MEDIUM", + }); + return { deleted: true }; + } + + /** # TLS 探针:443 直连取对端证书(不做链校验,仅读取信息) */ + async probeTls(domain: string, port = 443, timeoutMs = 8000): Promise<TlsProbeResult> { + return new Promise<TlsProbeResult>((resolve) => { + let settled = false; + const done = (r: TlsProbeResult) => { + if (!settled) { + settled = true; + try { + socket.destroy(); + } catch { + /* ignore */ + } + resolve(r); + } + }; + const socket = tlsConnect( + { host: domain, port, servername: domain, rejectUnauthorized: false, timeout: timeoutMs }, + () => { + const cert = socket.getPeerCertificate() as PeerCertificate; + if (!cert || !cert.valid_to) { + done({ ok: false, error: "no peer certificate" }); + return; + } + const notAfter = new Date(cert.valid_to); + const daysRemaining = Math.floor((notAfter.getTime() - Date.now()) / 86_400_000); + const issuerObj = cert.issuer as unknown as Record<string, Record<string, string>> | undefined; + done({ + ok: true, + issuer: issuerObj?.O + ? (issuerObj.O[Object.keys(issuerObj.O)[0]] as string) + : typeof issuerObj?.CN === "string" + ? issuerObj.CN + : "unknown", + notAfter, + daysRemaining, + }); + }, + ); + socket.on("error", (err: Error) => done({ ok: false, error: err.message.slice(0, 200) })); + socket.on("timeout", () => done({ ok: false, error: "timeout" })); + }); + } + + /** # 探测结果 → 资源状态(resources.status 为 Prisma 枚举) */ + private resourceStatusFromProbe(probe: TlsProbeResult | null): "PENDING" | "ACTIVE" | "ERROR" { + const s = this.statusFromProbe(probe); + return s === "ACTIVE" ? "ACTIVE" : s === "ERROR" ? "ERROR" : "PENDING"; + } + + /** # 由探测结果推导状态 */ + private statusFromProbe(probe: TlsProbeResult | null): "PENDING" | "ACTIVE" | "EXPIRED" | "ERROR" { + if (!probe) return "PENDING"; + if (!probe.ok) return "ERROR"; + if ((probe.daysRemaining ?? 0) <= 0) return "EXPIRED"; + return "ACTIVE"; + } + + /** # 实体 → 视图 */ + private view(c: { + id: string; + resourceId: string; + workspaceId: string; + domain: string; + domainId: string | null; + status: string; + issuer: string | null; + notAfter: Date | null; + lastCheckedAt: Date | null; + createdAt: Date; + }) { + const daysRemaining = c.notAfter + ? Math.floor((c.notAfter.getTime() - Date.now()) / 86_400_000) + : null; + return { + id: c.id, + resourceId: c.resourceId, + workspaceId: c.workspaceId, + domain: c.domain, + domainId: c.domainId, + status: c.status, + issuer: c.issuer, + notAfter: c.notAfter, + daysRemaining, + expiringSoon: daysRemaining !== null && daysRemaining >= 0 && daysRemaining <= CertificateService.EXPIRY_WARN_DAYS, + lastCheckedAt: c.lastCheckedAt, + createdAt: c.createdAt, + }; + } +} diff --git a/apps/api/src/modules/container/container.controller.ts b/apps/api/src/modules/container/container.controller.ts new file mode 100644 index 0000000..2b7df77 --- /dev/null +++ b/apps/api/src/modules/container/container.controller.ts @@ -0,0 +1,95 @@ +/** + * # Container 控制器(/api/v1/containers、/api/v1/servers/:id/containers 的容器部分) + */ +import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"; +import { ContainerService, RestartContainerSchema } from "./container.service"; +import { ServerService } from "../server/server.service"; +import { z } from "zod"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; + +const LogsQuerySchema = z.object({ tail: z.coerce.number().int().min(10).max(1000).default(200) }); +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +@Controller("containers") +export class ContainerController { + constructor( + private readonly containers: ContainerService, + private readonly servers: ServerService, + ) {} + + /** # 全工作区容器列表 */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.containers.list(ws.id); + } + + /** # 容器详情(实时 inspect,敏感 env 已脱敏) */ + @Get(":id/inspect") + inspect(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.containers.inspect(ws.id, id); + } + + /** # 启动容器 */ + @Roles("MEMBER") + @Post(":id/start") + start(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.containers.start(ws.id, id); + } + + /** # 停止容器 */ + @Roles("MEMBER") + @Post(":id/stop") + stop(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.containers.stop(ws.id, id); + } + + /** # 容器日志(tail) */ + @Get(":id/logs") + logs( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Query(new ZodValidationPipe(LogsQuerySchema)) query: { tail: number }, + ) { + return this.containers.logs(ws.id, id, query.tail); + } + + /** # 容器资源用量 */ + @Get(":id/stats") + stats(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.containers.stats(ws.id, id); + } + + /** # 重启容器(需显式确认) */ + @Roles("MEMBER") + @Post(":id/restart") + restart( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(RestartContainerSchema)) _dto: { confirm: true }, + ) { + return this.containers.restart(ws.id, id); + } +} + +/** # 服务器维度容器端点(挂到 /servers/:id/containers) **/ +@Controller("servers") +export class ServerContainersController { + constructor( + private readonly containers: ContainerService, + private readonly servers: ServerService, + ) {} + + /** # 发现服务器容器(docker ps → 入库 → 返回) */ + @Roles("MEMBER") + @Post(":id/containers") + discover(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.servers.mustGet(ws.id, id).then((server) => this.containers.discover(ws.id, server)); + } + + /** # 服务器容器列表(读库) */ + @Get(":id/containers") + listByServer(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.containers.listByServer(ws.id, id); + } +} diff --git a/apps/api/src/modules/container/container.module.ts b/apps/api/src/modules/container/container.module.ts new file mode 100644 index 0000000..bbf850e --- /dev/null +++ b/apps/api/src/modules/container/container.module.ts @@ -0,0 +1,19 @@ +/** + * # Container 模块(Task 010) + */ +import { forwardRef } from "@nestjs/common"; +import { Module } from "@nestjs/common"; +import { ContainerController, ServerContainersController } from "./container.controller"; +import { ContainerService } from "./container.service"; +import { ServerModule } from "../server/server.module"; +import { AuditModule } from "../audit/audit.module"; +import { SshModule } from "../../integrations/ssh/ssh.module"; +import { DockerModule } from "../../integrations/docker/docker.module"; + +@Module({ + imports: [forwardRef(() => ServerModule), AuditModule, SshModule, DockerModule], + controllers: [ContainerController, ServerContainersController], + providers: [ContainerService], + exports: [ContainerService], +}) +export class ContainerModule {} diff --git a/apps/api/src/modules/container/container.service.ts b/apps/api/src/modules/container/container.service.ts new file mode 100644 index 0000000..51c402d --- /dev/null +++ b/apps/api/src/modules/container/container.service.ts @@ -0,0 +1,271 @@ +/** + * # Container 服务(Task 010) + * Docker 发现:列出/检查/重启容器,容器以 CONTAINER 资源入图谱并与 SERVER 建立关系。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import type { Server } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { DockerService } from "../../integrations/docker/docker.service"; +import { AuditService } from "../audit/audit.service"; +import { ServerService } from "../server/server.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 重启容器 DTO **/ +export const RestartContainerSchema = z.object({ + confirm: z.literal(true), +}); + +@Injectable() +export class ContainerService { + constructor( + private readonly prisma: PrismaService, + private readonly docker: DockerService, + private readonly audit: AuditService, + private readonly serverService: ServerService, + ) {} + + /** # Docker 可用性 */ + async dockerStatus(workspaceId: string, server: Server) { + const target = this.docker.isLocal(server) + ? { host: "", port: 0, username: "" } + : await this.serverService.sshTarget(workspaceId, server); + return this.docker.checkDocker(target, this.docker.isLocal(server)); + } + + /** # 发现服务器上的容器并同步入库(CONTAINER 资源 + deployed_on 关系) */ + async discover(workspaceId: string, server: Server) { + const isLocal = this.docker.isLocal(server); + const target = isLocal ? { host: "", port: 0, username: "" } : await this.serverService.sshTarget(workspaceId, server); + const containers = await this.docker.listContainers(target, isLocal); + + for (const c of containers) { + const existing = await this.prisma.container.findFirst({ + where: { workspaceId, serverId: server.id, containerId: c.id }, + }); + if (existing) { + await this.prisma.container.update({ + where: { id: existing.id }, + data: { + name: c.name, + image: c.image, + state: c.state, + ports: c.ports as unknown as object, + labels: c.labels as unknown as object, + }, + }); + await this.prisma.resource.update({ + where: { id: existing.resourceId }, + data: { status: c.state === "running" ? "ACTIVE" : "OFFLINE", name: c.name }, + }); + } else { + await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { + workspaceId, + type: "CONTAINER", + name: c.name || c.id, + provider: "docker", + status: c.state === "running" ? "ACTIVE" : "OFFLINE", + metadata: { image: c.image, serverId: server.id }, + }, + }); + await tx.container.create({ + data: { + workspaceId, + resourceId: resource.id, + serverId: server.id, + containerId: c.id, + name: c.name, + image: c.image, + state: c.state, + ports: c.ports as unknown as object, + labels: c.labels as unknown as object, + }, + }); + await tx.resourceRelation.upsert({ + where: { + sourceId_targetId_relationType: { + sourceId: server.resourceId, + targetId: resource.id, + relationType: "runs_container", + }, + }, + create: { + workspaceId, + sourceId: server.resourceId, + targetId: resource.id, + relationType: "runs_container", + }, + update: {}, + }); + }); + } + } + + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "container.discover", + resourceType: "Server", + resourceId: server.resourceId, + metadata: { count: containers.length }, + }); + return this.listByServer(workspaceId, server.id); + } + + /** # 某服务器的容器列表(读库;如需刷新调用 discover) */ + async listByServer(workspaceId: string, serverId: string) { + await this.serverService.mustGet(workspaceId, serverId); + const rows = await this.prisma.container.findMany({ + where: { workspaceId, serverId }, + orderBy: { name: "asc" }, + }); + return rows.map((c) => this.toView(c)); + } + + /** # 全工作区容器列表 */ + async list(workspaceId: string) { + const rows = await this.prisma.container.findMany({ + where: { workspaceId }, + orderBy: { updatedAt: "desc" }, + }); + return rows.map((c) => this.toView(c)); + } + + /** # 容器详情(实时 inspect) */ + async inspect(workspaceId: string, containerRowId: string) { + const row = await this.mustGet(workspaceId, containerRowId); + const server = await this.serverService.mustGet(workspaceId, row.serverId); + const isLocal = this.docker.isLocal(server); + const target = isLocal ? { host: "", port: 0, username: "" } : await this.serverService.sshTarget(workspaceId, server); + const info = await this.docker.inspectContainer(target, row.containerId, isLocal); + // 剥离可能的敏感 env + const config = info.Config as Record<string, unknown> | undefined; + if (config && Array.isArray(config.Env)) { + config.Env = (config.Env as string[]).map((e) => e.split("=")[0] + "=***"); + } + return info; + } + + /** # 启动容器 */ + async start(workspaceId: string, containerRowId: string) { + return this.lifecycle(workspaceId, containerRowId, "start"); + } + + /** # 停止容器 */ + async stop(workspaceId: string, containerRowId: string) { + return this.lifecycle(workspaceId, containerRowId, "stop"); + } + + /** # 生命周期通用实现(审计) */ + private async lifecycle(workspaceId: string, containerRowId: string, action: "start" | "stop") { + const row = await this.mustGet(workspaceId, containerRowId); + const server = await this.serverService.mustGet(workspaceId, row.serverId); + const isLocal = this.docker.isLocal(server); + const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server); + if (action === "start") { + await this.docker.startContainer(target, row.containerId, isLocal); + } else { + await this.docker.stopContainer(target, row.containerId, isLocal); + } + await this.prisma.container.update({ + where: { id: row.id }, + data: { state: action === "start" ? "running" : "exited" }, + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: `container.${action}`, + resourceType: "Container", + resourceId: row.resourceId, + riskLevel: "HIGH", + metadata: { name: row.name }, + }); + return { [action]: true, name: row.name }; + } + + /** # 容器日志(tail) */ + async logs(workspaceId: string, containerRowId: string, tail = 200) { + const row = await this.mustGet(workspaceId, containerRowId); + const server = await this.serverService.mustGet(workspaceId, row.serverId); + const isLocal = this.docker.isLocal(server); + const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server); + const lines = await this.docker.containerLogs(target, row.containerId, Math.min(tail, 1000), isLocal); + return { name: row.name, lines }; + } + + /** # 容器实时资源用量 */ + async stats(workspaceId: string, containerRowId: string) { + const row = await this.mustGet(workspaceId, containerRowId); + const server = await this.serverService.mustGet(workspaceId, row.serverId); + const isLocal = this.docker.isLocal(server); + const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server); + return { name: row.name, ...(await this.docker.containerStats(target, row.containerId, isLocal)) }; + } + + /** # 镜像列表 */ + async listImages(workspaceId: string, serverId: string) { + const server = await this.serverService.mustGet(workspaceId, serverId); + const isLocal = this.docker.isLocal(server); + const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server); + return this.docker.listImages(target, isLocal); + } + + /** # 重启容器(需 confirm=true;审计 + container.restarted 事件) */ + async restart(workspaceId: string, containerRowId: string) { + const row = await this.mustGet(workspaceId, containerRowId); + const server = await this.serverService.mustGet(workspaceId, row.serverId); + const isLocal = this.docker.isLocal(server); + const target = isLocal ? { host: "", port: 0, username: "" } : await this.serverService.sshTarget(workspaceId, server); + await this.docker.restartContainer(target, row.containerId, isLocal); + await this.prisma.container.update({ where: { id: row.id }, data: { state: "running" } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "container.restart", + resourceType: "Container", + resourceId: row.resourceId, + riskLevel: "HIGH", + metadata: { name: row.name, serverId: server.id }, + }); + return { restarted: true, name: row.name }; + } + + /** # 断言容器属于工作区 */ + private async mustGet(workspaceId: string, id: string) { + const row = await this.prisma.container.findFirst({ where: { id, workspaceId } }); + if (!row) throw ApiError.notFound("Container not found"); + return row; + } + + /** # 实体 → 视图 */ + private toView(c: { + id: string; + resourceId: string; + containerId: string; + name: string; + image: string; + state: string; + ports: unknown; + labels: unknown; + serverId: string; + workspaceId: string; + updatedAt: Date; + }) { + return { + id: c.id, + resourceId: c.resourceId, + containerId: c.containerId, + name: c.name, + image: c.image, + state: c.state, + ports: c.ports, + labels: c.labels, + serverId: c.serverId, + workspaceId: c.workspaceId, + updatedAt: c.updatedAt, + }; + } +} diff --git a/apps/api/src/modules/credential/credential-vault.service.ts b/apps/api/src/modules/credential/credential-vault.service.ts new file mode 100644 index 0000000..44b6fdb --- /dev/null +++ b/apps/api/src/modules/credential/credential-vault.service.ts @@ -0,0 +1,76 @@ +/** + * # 凭据保险库 + * AES-256-GCM 信封式加密: + * Application Master Key (ENCRYPTION_KEY, 32B) → 加密凭据载荷。 + * - AAD 绑定 workspaceId,防止跨租户密文移植; + * - 明文只在内存中短暂存在,绝不入库、绝不入日志(审计层另有脱敏); + * - API 层不提供任何“读取明文”端点。 + */ +import { Injectable } from "@nestjs/common"; +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { ConfigService } from "../../common/config/config.service"; +import { ApiError } from "../../common/errors/api-error"; + +/** # 密文载荷结构(JSON 序列化后存储) **/ +interface EncryptedPayload { + v: 1; + iv: string; + tag: string; + data: string; +} + +@Injectable() +export class CredentialVaultService { + /** # 主密钥 **/ + private readonly key: Buffer; + + constructor(config: ConfigService) { + this.key = Buffer.from(config.encryptionKey, "hex"); + } + + /** # 加密 + * @param workspaceId 租户 ID(作为 AAD 绑定) + * @param plaintext 明文(如私钥/密码/API Token) + */ + encrypt(workspaceId: string, plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", this.key, iv, { authTagLength: 16 }); + cipher.setAAD(Buffer.from(workspaceId, "utf8")); + const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const payload: EncryptedPayload = { + v: 1, + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + data: data.toString("base64"), + }; + return JSON.stringify(payload); + } + + /** # 解密(仅限服务端内部使用) + * @throws 完整性校验失败(密文被篡改或跨租户使用)时抛出 + */ + decrypt(workspaceId: string, encrypted: string): string { + let payload: EncryptedPayload; + try { + payload = JSON.parse(encrypted) as EncryptedPayload; + } catch { + throw ApiError.forbidden("Credential payload corrupted"); + } + try { + const decipher = createDecipheriv( + "aes-256-gcm", + this.key, + Buffer.from(payload.iv, "base64"), + { authTagLength: 16 }, + ); + decipher.setAAD(Buffer.from(workspaceId, "utf8")); + decipher.setAuthTag(Buffer.from(payload.tag, "base64")); + return Buffer.concat([ + decipher.update(Buffer.from(payload.data, "base64")), + decipher.final(), + ]).toString("utf8"); + } catch { + throw ApiError.forbidden("Credential integrity check failed"); + } + } +} diff --git a/apps/api/src/modules/credential/credential.controller.ts b/apps/api/src/modules/credential/credential.controller.ts new file mode 100644 index 0000000..6c979a6 --- /dev/null +++ b/apps/api/src/modules/credential/credential.controller.ts @@ -0,0 +1,54 @@ +/** + * # Credential 控制器(/api/v1/credentials) + * 安全约束:任何端点都不返回明文密钥。 + */ +import { Body, Controller, Delete, Get, Param, Patch, Post } from "@nestjs/common"; +import { CredentialService, CreateCredentialSchema, UpdateCredentialSchema } from "./credential.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +@Controller("credentials") +export class CredentialController { + constructor(private readonly credentials: CredentialService) {} + + /** # 凭据列表(仅元数据) */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.credentials.list(ws.id); + } + + /** # 创建凭据(明文立即加密入库) */ + @Roles("ADMIN") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateCredentialSchema)) dto: Parameters<CredentialService["create"]>[1], + ) { + return this.credentials.create(ws.id, dto); + } + + /** # 凭据详情(仅元数据) */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.credentials.get(ws.id, id); + } + + /** # 更新/轮换凭据 */ + @Roles("ADMIN") + @Patch(":id") + update( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(UpdateCredentialSchema)) dto: Parameters<CredentialService["update"]>[2], + ) { + return this.credentials.update(ws.id, id, dto); + } + + /** # 删除凭据 */ + @Roles("ADMIN") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.credentials.delete(ws.id, id); + } +} diff --git a/apps/api/src/modules/credential/credential.module.ts b/apps/api/src/modules/credential/credential.module.ts new file mode 100644 index 0000000..88cae92 --- /dev/null +++ b/apps/api/src/modules/credential/credential.module.ts @@ -0,0 +1,16 @@ +/** + * # 凭据保险库模块 + */ +import { Module } from "@nestjs/common"; +import { CredentialController } from "./credential.controller"; +import { CredentialService } from "./credential.service"; +import { CredentialVaultService } from "./credential-vault.service"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [AuditModule], + controllers: [CredentialController], + providers: [CredentialService, CredentialVaultService], + exports: [CredentialService, CredentialVaultService], +}) +export class CredentialModule {} diff --git a/apps/api/src/modules/credential/credential.service.ts b/apps/api/src/modules/credential/credential.service.ts new file mode 100644 index 0000000..3e5fddc --- /dev/null +++ b/apps/api/src/modules/credential/credential.service.ts @@ -0,0 +1,203 @@ +/** + * # 凭据服务 + * 凭据的 CRUD 与“按需解密”。凭据同时是一条 CREDENTIAL 资源(进入统一资源图谱)。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import type { CredentialType } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { CredentialVaultService } from "./credential-vault.service"; +import { AuditService } from "../audit/audit.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 创建凭据 DTO **/ +export const CreateCredentialSchema = z.object({ + name: z.string().min(1).max(120), + type: z.enum(["SSH_PASSWORD", "SSH_KEY", "API_TOKEN", "DNS_PROVIDER", "GIT_TOKEN"]), + provider: z.string().max(64).optional(), + /** # 明文机密(仅出现在请求中,服务端立即加密) **/ + secret: z.string().min(1).max(65536), + metadata: z.record(z.unknown()).optional(), +}); +export type CreateCredentialDto = z.infer<typeof CreateCredentialSchema>; + +/** # 更新凭据 DTO **/ +export const UpdateCredentialSchema = z.object({ + name: z.string().min(1).max(120).optional(), + /** # 轮换:提供新的明文机密 **/ + secret: z.string().min(1).max(65536).optional(), + metadata: z.record(z.unknown()).optional(), +}); +export type UpdateCredentialDto = z.infer<typeof UpdateCredentialSchema>; + +@Injectable() +export class CredentialService { + constructor( + private readonly prisma: PrismaService, + private readonly vault: CredentialVaultService, + private readonly audit: AuditService, + ) {} + + /** # 创建凭据(资源 + 密文,一个事务) */ + async create(workspaceId: string, dto: CreateCredentialDto) { + const credential = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { + workspaceId, + type: "CREDENTIAL", + name: dto.name, + provider: dto.provider ?? null, + status: "ACTIVE", + metadata: { credentialType: dto.type, ...(dto.metadata ?? {}) }, + }, + }); + return tx.credential.create({ + data: { + workspaceId, + resourceId: resource.id, + name: dto.name, + type: dto.type as CredentialType, + provider: dto.provider ?? null, + encryptedData: this.vault.encrypt(workspaceId, dto.secret), + metadata: (dto.metadata ?? {}) as object, + }, + }); + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "credential.create", + resourceType: "Credential", + resourceId: credential.id, + riskLevel: "MEDIUM", + }); + return this.view(credential.id, workspaceId); + } + + /** # 凭据列表(不含密文) */ + async list(workspaceId: string) { + const rows = await this.prisma.credential.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + }); + return rows.map((r) => this.toView(r)); + } + + /** # 凭据详情(不含密文) */ + async get(workspaceId: string, id: string) { + const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } }); + if (!row) throw ApiError.notFound("Credential not found"); + return this.toView(row); + } + + /** # 更新(含轮换密文) */ + async update(workspaceId: string, id: string, dto: UpdateCredentialDto) { + const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } }); + if (!row) throw ApiError.notFound("Credential not found"); + const updated = await this.prisma.$transaction(async (tx) => { + const cred = await tx.credential.update({ + where: { id }, + data: { + ...(dto.name ? { name: dto.name } : {}), + ...(dto.metadata ? { metadata: dto.metadata as object } : {}), + ...(dto.secret + ? { encryptedData: this.vault.encrypt(workspaceId, dto.secret) } + : {}), + }, + }); + await tx.resource.update({ + where: { id: cred.resourceId ?? "" }, + data: { ...(dto.name ? { name: dto.name } : {}) }, + }); + return cred; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "credential.update", + resourceType: "Credential", + resourceId: id, + riskLevel: "MEDIUM", + }); + return this.toView(updated); + } + + /** # 删除凭据 */ + async delete(workspaceId: string, id: string) { + const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } }); + if (!row) throw ApiError.notFound("Credential not found"); + await this.prisma.$transaction(async (tx) => { + await tx.credential.delete({ where: { id } }); + if (row.resourceId) await tx.resource.delete({ where: { id: row.resourceId } }); + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "credential.delete", + resourceType: "Credential", + resourceId: id, + riskLevel: "HIGH", + }); + return { deleted: true }; + } + + /** # 解密取回明文(内部使用;绝不返回给 API 调用方) + * 每次使用写入审计(credential.use)。 + */ + async getSecret(workspaceId: string, credentialId: string | null): Promise<string | null> { + if (!credentialId) return null; + const row = await this.prisma.credential.findFirst({ + where: { id: credentialId, workspaceId }, + }); + if (!row) throw ApiError.notFound("Credential not found"); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "SYSTEM", + action: "credential.use", + resourceType: "Credential", + resourceId: row.id, + riskLevel: "MEDIUM", + metadata: { purpose: "internal-execution" }, + }); + await this.prisma.credential.update({ + where: { id: row.id }, + data: { lastUsedAt: new Date() }, + }); + return this.vault.decrypt(workspaceId, row.encryptedData); + } + + /** # 外部视图(不含任何密文) */ + private toView(row: { + id: string; + workspaceId: string; + resourceId: string | null; + name: string; + type: CredentialType; + provider: string | null; + metadata: unknown; + lastUsedAt: Date | null; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: row.id, + resourceId: row.resourceId, + workspaceId: row.workspaceId, + name: row.name, + type: row.type, + provider: row.provider, + metadata: row.metadata, + lastUsedAt: row.lastUsedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + /** # 兼容入口:按 id 返回视图 */ + private async view(id: string, workspaceId: string) { + const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } }); + if (!row) throw ApiError.notFound("Credential not found"); + return this.toView(row); + } +} diff --git a/apps/api/src/modules/deployment/deployment.controller.ts b/apps/api/src/modules/deployment/deployment.controller.ts new file mode 100644 index 0000000..91a9346 --- /dev/null +++ b/apps/api/src/modules/deployment/deployment.controller.ts @@ -0,0 +1,51 @@ +/** + * # Deployment 控制器(/api/v1/deployments) + */ +import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"; +import { z } from "zod"; +import { DeploymentService, CreateDeploymentSchema, type CreateDeploymentDto } from "./deployment.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +const ListQuerySchema = z.object({ websiteId: z.string().optional() }); + +@Controller("deployments") +export class DeploymentController { + constructor(private readonly deployments: DeploymentService) {} + + /** # 部署列表(可按网站过滤) */ + @Get() + list( + @CurrentWorkspace() ws: RequestWorkspace, + @Query(new ZodValidationPipe(ListQuerySchema)) query: { websiteId?: string }, + ) { + return this.deployments.list(ws.id, query.websiteId); + } + + /** # 创建部署 */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateDeploymentSchema)) dto: CreateDeploymentDto, + ) { + return this.deployments.createDeployment(ws.id, dto.websiteId, { + trigger: dto.trigger, + branch: dto.branch, + }); + } + + /** # 部署详情(含日志) */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.deployments.get(ws.id, id); + } + + /** # 取消部署 */ + @Roles("MEMBER") + @Post(":id/cancel") + cancel(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.deployments.cancel(ws.id, id); + } +} diff --git a/apps/api/src/modules/deployment/deployment.module.ts b/apps/api/src/modules/deployment/deployment.module.ts new file mode 100644 index 0000000..90e4eca --- /dev/null +++ b/apps/api/src/modules/deployment/deployment.module.ts @@ -0,0 +1,20 @@ +/** + * # Deployment 模块(Task 012) + */ +import { Module } from "@nestjs/common"; +import { DeploymentController } from "./deployment.controller"; +import { DeploymentService } from "./deployment.service"; +import { ServerModule } from "../server/server.module"; +import { EventModule } from "../event/event.module"; +import { AuditModule } from "../audit/audit.module"; +import { SshModule } from "../../integrations/ssh/ssh.module"; +import { DockerModule } from "../../integrations/docker/docker.module"; +import { GitModule } from "../git/git.module"; + +@Module({ + imports: [ServerModule, EventModule, AuditModule, SshModule, DockerModule, GitModule], + controllers: [DeploymentController], + providers: [DeploymentService], + exports: [DeploymentService], +}) +export class DeploymentModule {} diff --git a/apps/api/src/modules/deployment/deployment.service.ts b/apps/api/src/modules/deployment/deployment.service.ts new file mode 100644 index 0000000..9ccdc5e --- /dev/null +++ b/apps/api/src/modules/deployment/deployment.service.ts @@ -0,0 +1,429 @@ +/** + * # Deployment 服务(Task 012) + * 部署任务模型:QUEUED→RUNNING→SUCCEEDED/FAILED/CANCELLED,异步 Job 执行。 + * 执行步骤(DOCKER 类型):检查 docker → 拉取仓库 → 构建镜像 → 替换容器 → 健康验证。 + * 每一步写 DeploymentLog;进度写入 progress;结束发 deployment.success/failed 事件。 + */ +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { z } from "zod"; +import type { Website, Deployment } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { JobQueueService } from "../job/job-queue.service"; +import { EventBusService } from "../event/event-bus.service"; +import { AuditService } from "../audit/audit.service"; +import { ServerService } from "../server/server.service"; +import { SshClientService, type SshTarget } from "../../integrations/ssh/ssh-client.service"; +import { DockerService } from "../../integrations/docker/docker.service"; +import { GitService } from "../git/git.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext, runWithContext } from "../../common/request-context"; + +/** # 创建部署 DTO **/ +export const CreateDeploymentSchema = z.object({ + websiteId: z.string().min(1), + branch: z.string().max(200).optional(), + trigger: z.string().max(32).default("MANUAL"), +}); +export type CreateDeploymentDto = z.infer<typeof CreateDeploymentSchema>; + +/** # 部署目标目录根(目标服务器上) **/ +const REMOTE_ROOT = "/opt/devos/websites"; + +@Injectable() +export class DeploymentService implements OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly jobs: JobQueueService, + private readonly events: EventBusService, + private readonly audit: AuditService, + private readonly servers: ServerService, + private readonly ssh: SshClientService, + private readonly docker: DockerService, + private readonly git: GitService, + ) {} + + /** # 模块初始化:注册部署任务处理器 */ + onModuleInit(): void { + this.registerJobHandler(); + } + + /** # 创建部署并入队 */ + async createDeployment( + workspaceId: string, + websiteId: string, + opts: { trigger: string; branch?: string }, + ) { + const website = await this.prisma.website.findFirst({ where: { id: websiteId, workspaceId } }); + if (!website) throw ApiError.notFound("Website not found"); + // 部署来源断言:仓库登记或旧版 URL 至少其一 + if (!website.repositoryId && !website.repositoryUrl) { + throw ApiError.validation("Website has no repository configured"); + } + + const deployment = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { + workspaceId, + type: "DEPLOYMENT", + name: `${website.name} #${new Date().toISOString()}`, + provider: "devos", + status: "PENDING", + }, + }); + const d = await tx.deployment.create({ + data: { + workspaceId, + resourceId: resource.id, + websiteId: website.id, + status: "QUEUED", + trigger: opts.trigger, + config: { branch: opts.branch ?? "main" } as object, + }, + }); + await tx.resourceRelation.upsert({ + where: { + sourceId_targetId_relationType: { + sourceId: website.resourceId, + targetId: resource.id, + relationType: "has_deployment", + }, + }, + create: { workspaceId, sourceId: website.resourceId, targetId: resource.id, relationType: "has_deployment" }, + update: {}, + }); + return d; + }); + + await this.jobs.enqueue("deployment", workspaceId, { deploymentId: deployment.id }); + await this.events.publish("deployment.created", { + workspaceId, + resourceId: deployment.resourceId, + payload: { deploymentId: deployment.id, websiteId: website.id, websiteName: website.name, trigger: opts.trigger }, + }); + return this.view(deployment, website); + } + + /** # 部署列表 */ + async list(workspaceId: string, websiteId?: string) { + const where = { workspaceId, ...(websiteId ? { websiteId } : {}) }; + const rows = await this.prisma.deployment.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: 200, + include: { website: { select: { id: true, name: true } } }, + }); + return rows.map((d) => ({ ...this.view(d), website: d.website })); + } + + /** # 部署详情(含日志) */ + async get(workspaceId: string, id: string) { + const d = await this.prisma.deployment.findFirst({ + where: { id, workspaceId }, + include: { + website: { select: { id: true, name: true, resourceId: true } }, + logs: { orderBy: { seq: "asc" }, take: 2000 }, + }, + }); + if (!d) throw ApiError.notFound("Deployment not found"); + return { + ...this.view(d), + website: d.website, + logs: d.logs.map((l) => ({ seq: l.seq, stream: l.stream, line: l.line, time: l.createdAt })), + }; + } + + /** # 取消部署:QUEUED 立即取消;RUNNING 打上取消标记(步骤间检查) */ + async cancel(workspaceId: string, id: string) { + const d = await this.prisma.deployment.findFirst({ where: { id, workspaceId } }); + if (!d) throw ApiError.notFound("Deployment not found"); + if (d.status === "QUEUED") { + await this.prisma.deployment.update({ where: { id }, data: { status: "CANCELLED", finishedAt: new Date() } }); + await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: "ARCHIVED" } }); + await this.audit.record({ + workspaceId, actorType: getRequestContext()?.actorType ?? "USER", + action: "deployment.cancel", resourceType: "Deployment", resourceId: d.resourceId, + }); + return { cancelled: true }; + } + if (d.status === "RUNNING") { + await this.prisma.deployment.update({ + where: { id }, + data: { config: { ...(d.config as object), cancelRequested: true } as object }, + }); + return { cancelling: true, message: "Cancellation requested; will stop between steps" }; + } + throw ApiError.conflict(`Deployment is ${d.status}; cannot cancel`); + } + + /** # 注册 job handler(DeploymentModule 初始化时调用) */ + registerJobHandler(): void { + this.jobs.register("deployment", async (job) => { + await this.execute(job.workspaceId, String(job.payload.deploymentId ?? "")); + return {}; + }); + } + + /** # 执行部署(worker 上下文调用) */ + private async execute(workspaceId: string, deploymentId: string): Promise<void> { + const d = await this.prisma.deployment.findFirst({ + where: { id: deploymentId, workspaceId }, + include: { website: true }, + }); + if (!d || d.status !== "QUEUED") return; + const website = d.website; + + await this.prisma.deployment.update({ where: { id: d.id }, data: { status: "RUNNING", progress: 2 } }); + await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: "ACTIVE" } }); + await this.events.publish("deployment.started", { + workspaceId, resourceId: d.resourceId, payload: { deploymentId: d.id, websiteId: website.id }, + }); + + try { + await runWithContext( + { requestId: `deploy-${d.id.slice(0, 8)}`, correlationId: d.id, workspaceId, actorType: "SYSTEM" }, + () => this.runSteps(workspaceId, d, website), + ); + await this.prisma.deployment.update({ + where: { id: d.id }, + data: { status: "SUCCEEDED", progress: 100, finishedAt: new Date() }, + }); + await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: "ARCHIVED" } }); + await this.prisma.resource.update({ where: { id: website.resourceId }, data: { status: "ACTIVE" } }); + await this.events.publish("deployment.success", { + workspaceId, resourceId: d.resourceId, correlationId: d.id, + payload: { deploymentId: d.id, websiteId: website.id, websiteName: website.name }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const cancelled = message.includes("cancelled by user"); + await this.prisma.deployment.update({ + where: { id: d.id }, + data: { + status: cancelled ? "CANCELLED" : "FAILED", + finishedAt: new Date(), + error: message.slice(0, 2000), + }, + }); + await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: cancelled ? "ARCHIVED" : "ERROR" } }); + await this.events.publish("deployment.failed", { + workspaceId, resourceId: d.resourceId, correlationId: d.id, + payload: { deploymentId: d.id, websiteId: website.id, websiteName: website.name, cancelled, error: message.slice(0, 500) }, + }); + await this.audit.record({ + workspaceId, actorType: "SYSTEM", action: cancelled ? "deployment.cancelled" : "deployment.failed", + resourceType: "Deployment", resourceId: d.resourceId, status: cancelled ? "CANCELLED" : "FAILURE", + riskLevel: "HIGH", metadata: { deploymentId: d.id, error: message.slice(0, 300) }, + }); + } + } + + /** # 分步执行(每步前检查取消标记) */ + private async runSteps(workspaceId: string, d: Deployment, website: Website): Promise<void> { + if (!website.serverId) throw new Error("Website target server missing"); + const server = await this.servers.mustGet(workspaceId, website.serverId); + const isLocal = this.docker.isLocal(server); + const target: SshTarget = isLocal + ? { host: "localhost", port: 0, username: "local" } + : await this.servers.sshTarget(workspaceId, server); + + /** # 远程/本地命令执行(统一入口) **/ + const exec = async (command: string, timeoutMs = 300_000): Promise<string> => { + if (isLocal) { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const { stdout } = await promisify(execFile)("sh", ["-c", command], { timeout: timeoutMs }); + return stdout; + } + const res = await this.ssh.exec(target, command, timeoutMs); + if (res.code !== 0) { + throw new Error(`Command failed (exit ${res.code}): ${(res.stderr || res.stdout).slice(0, 500)}`); + } + return res.stdout; + }; + const log = async (line: string, stream = "stdout") => { + const count = await this.prisma.deploymentLog.count({ where: { deploymentId: d.id } }); + await this.prisma.deploymentLog.create({ + data: { deploymentId: d.id, seq: count, stream, line: line.slice(0, 4000) }, + }); + }; + const setProgress = async (p: number) => { + await this.prisma.deployment.update({ where: { id: d.id }, data: { progress: p } }); + }; + const checkCancel = async () => { + const row = await this.prisma.deployment.findUnique({ where: { id: d.id }, select: { config: true } }); + if ((row?.config as Record<string, unknown> | undefined)?.cancelRequested === true) { + throw new Error("Deployment cancelled by user"); + } + }; + + await checkCancel(); + await log(`[1/6] Checking docker on ${server.name} (${server.host})`); + const dockerCheck = await this.docker.checkDocker(target, isLocal); + if (!dockerCheck.available) throw new Error(`Docker not available: ${dockerCheck.version}`); + await log(`Docker ${dockerCheck.version} available`); + await setProgress(15); + + const slug = website.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "site"; + const dir = `${REMOTE_ROOT}/${slug}`; + const branch = String((d.config as Record<string, unknown>).branch ?? "main"); + + // 解析仓库来源:优先 Git Provider 凭据克隆(Phase D),旧版 URL 回退 + let cloneSource = website.repositoryUrl; + if (website.repositoryId) { + const resolved = await this.git.buildCloneCommand(workspaceId, website.repositoryId); + cloneSource = resolved.cloneUrl; + await log(`[2/6] Fetching repository ${resolved.provider} via stored credential (branch ${branch})`); + } + if (cloneSource && !isLocal) { + await checkCancel(); + await log(`[2/6] Fetching repository (branch ${branch})`); + await exec(`mkdir -p ${REMOTE_ROOT}`); + const exists = await exec(`test -d ${dir}/.git && echo yes || echo no`); + if (exists.trim() === "yes") { + await exec( + `cd ${dir} && git remote set-url origin ${cloneSource} && git fetch --all && git reset --hard origin/${branch}`, + 120_000, + ); + } else { + await exec(`git clone --branch ${branch} ${cloneSource} ${dir}`, 300_000); + } + const commit = await exec(`cd ${dir} && git rev-parse --short HEAD`); + await this.prisma.deployment.update({ where: { id: d.id }, data: { commit: commit.trim() || null } }); + await log(`Repository ready at commit ${commit.trim() || "unknown"}`); + await setProgress(35); + } else if (!website.repositoryUrl) { + await log("[2/6] No repository configured; using existing build context on server"); + } else { + await log("[2/6] Local provider: using local directory build context (git clone skipped)"); + } + // 凭据不得残留在 git remote 配置中(克隆后重置 origin 为干净 URL) + if (website.repositoryId && !isLocal && cloneSource && cloneSource.includes("@")) { + const cleanUrl = `https://${cloneSource.split("@").pop()!}`; + await exec(`cd ${dir} && git remote set-url origin ${cleanUrl}`, 30_000).catch(() => undefined); + } + + await checkCancel(); + const imageName = `devos-${slug}:${d.id.slice(0, 12)}`; + await log(`[3/6] Building image ${imageName}`); + if (!isLocal) { + await exec(`cd ${dir} && docker build -t ${imageName} .`, 600_000); + await log("Image built"); + } + await setProgress(60); + + await checkCancel(); + await log(`[4/6] (Re)starting container devos-${slug} on port ${website.port}`); + if (!isLocal) { + const envArgs = Object.entries((website.env ?? {}) as Record<string, unknown>) + .map(([k, v]) => `-e ${k}='${String(v).replace(/'/g, `'\\''`)}'`) + .join(" "); + await exec(`docker rm -f devos-${slug} 2>/dev/null || true`); + await exec( + `docker run -d --name devos-${slug} --restart unless-stopped -p ${website.port}:${website.port} ${envArgs} ${imageName}`, + 120_000, + ); + await log("Container started"); + } + await setProgress(80); + + await checkCancel(); + await log(`[5/6] Verifying health at :${website.port}${website.healthPath}`); + const health = await this.verifyOnServer(exec, website.port, website.healthPath); + const verification = { ...health, checkedAt: new Date().toISOString(), strategy: "http-curl-local" }; + await this.prisma.deployment.update({ + where: { id: d.id }, + data: { verification: verification as object }, + }); + if (!health.ok) { + throw new Error(`Health check failed: ${health.error ?? `status ${health.status}`}`); + } + await log(`Health check passed (${health.latencyMs ?? 0}ms)`); + await setProgress(95); + await log("[6/6] Deployment complete"); + } + + /** # 在服务器上 curl 本机端口做健康验证(重试 5 次 × 2s) */ + private async verifyOnServer( + exec: (cmd: string, timeoutMs?: number) => Promise<string>, + port: number, + healthPath: string, + ): Promise<{ ok: boolean; status?: number; latencyMs?: number; error?: string }> { + const cmd = `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}${healthPath}`; + for (let attempt = 1; attempt <= 5; attempt++) { + try { + const start = Date.now(); + const out = await exec(cmd, 15_000); + const status = Number(out.trim()); + if (status >= 200 && status < 500) { + return { ok: true, status, latencyMs: Date.now() - start }; + } + return { ok: false, status, error: `HTTP ${status}` }; + } catch (error) { + if (attempt === 5) { + return { ok: false, error: error instanceof Error ? error.message : "health check failed" }; + } + await new Promise((r) => setTimeout(r, 2000)); + } + } + return { ok: false, error: "unreachable" }; + } + + /** # 网站即时健康检查(供 WebsiteService.health 使用) */ + async runHealthCheck(workspaceId: string, website: Website) { + if (!website.serverId) { + if (!website.primaryDomain) return { ok: false, error: "No server or domain configured" }; + const url = `http://${website.primaryDomain}${website.healthPath}`; + const start = Date.now(); + try { + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + return { ok: res.status < 500, status: res.status, latencyMs: Date.now() - start, url }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "failed", url }; + } + } + const server = await this.servers.mustGet(workspaceId, website.serverId); + const isLocal = this.docker.isLocal(server); + const target: SshTarget = isLocal + ? { host: "localhost", port: 0, username: "local" } + : await this.servers.sshTarget(workspaceId, server); + const cmd = `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${website.port}${website.healthPath}`; + const out = await this.execDirect(target, isLocal, cmd, 15_000); + const status = Number(out.trim()); + return { ok: status >= 200 && status < 500, status }; + } + + /** # 直接执行(local 走本机 sh,ssh 走 SSH;不校验退出码) */ + private async execDirect( + target: SshTarget, + isLocal: boolean, + command: string, + timeoutMs: number, + ): Promise<string> { + if (isLocal) { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const { stdout } = await promisify(execFile)("sh", ["-c", command], { timeout: timeoutMs }); + return stdout; + } + const res = await this.ssh.exec(target, command, timeoutMs); + return res.stdout; + } + + /** # 实体 → 视图 */ + private view(d: Deployment, website?: Website) { + return { + id: d.id, + resourceId: d.resourceId, + workspaceId: d.workspaceId, + websiteId: d.websiteId, + websiteName: website?.name, + status: d.status, + trigger: d.trigger, + progress: d.progress, + commit: d.commit, + error: d.error ?? undefined, + createdAt: d.createdAt, + finishedAt: d.finishedAt, + }; + } +} diff --git a/apps/api/src/modules/domain/domain.controller.ts b/apps/api/src/modules/domain/domain.controller.ts new file mode 100644 index 0000000..a5b7731 --- /dev/null +++ b/apps/api/src/modules/domain/domain.controller.ts @@ -0,0 +1,70 @@ +/** + * # Domain 控制器(/api/v1/domains) + */ +import { Body, Controller, Delete, Get, Param, Post } from "@nestjs/common"; +import { DomainService, CreateDomainSchema, UpsertRecordSchema } from "./domain.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +@Controller("domains") +export class DomainController { + constructor(private readonly domains: DomainService) {} + + /** # 域名列表 */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.domains.list(ws.id); + } + + /** # 创建域名 */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateDomainSchema)) dto: Parameters<DomainService["create"]>[1], + ) { + return this.domains.create(ws.id, dto); + } + + /** # 域名详情 */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.domains.get(ws.id, id); + } + + /** # 删除域名 */ + @Roles("ADMIN") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.domains.delete(ws.id, id); + } + + /** # 记录列表(CLOUDFLARE 实时拉取) */ + @Get(":id/records") + records(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.domains.records(ws.id, id); + } + + /** # 写入记录 */ + @Roles("MEMBER") + @Post(":id/records") + upsertRecord( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(UpsertRecordSchema)) dto: Parameters<DomainService["upsertRecord"]>[2], + ) { + return this.domains.upsertRecord(ws.id, id, dto); + } + + /** # 删除记录 */ + @Roles("MEMBER") + @Post(":id/records/:localId/delete") + deleteRecord( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Param("localId") localId: string, + ) { + return this.domains.deleteRecord(ws.id, id, localId); + } +} diff --git a/apps/api/src/modules/domain/domain.module.ts b/apps/api/src/modules/domain/domain.module.ts new file mode 100644 index 0000000..b8b0cac --- /dev/null +++ b/apps/api/src/modules/domain/domain.module.ts @@ -0,0 +1,17 @@ +/** + * # Domain 模块(Task 013) + */ +import { Module } from "@nestjs/common"; +import { DomainController } from "./domain.controller"; +import { DomainService } from "./domain.service"; +import { CredentialModule } from "../credential/credential.module"; +import { AuditModule } from "../audit/audit.module"; +import { DnsModule } from "../../integrations/dns/dns.module"; + +@Module({ + imports: [CredentialModule, AuditModule, DnsModule], + controllers: [DomainController], + providers: [DomainService], + exports: [DomainService], +}) +export class DomainModule {} diff --git a/apps/api/src/modules/domain/domain.service.ts b/apps/api/src/modules/domain/domain.service.ts new file mode 100644 index 0000000..ca920d5 --- /dev/null +++ b/apps/api/src/modules/domain/domain.service.ts @@ -0,0 +1,271 @@ +/** + * # Domain 服务(Task 013) + * 域名资源 + DNS 记录读写。CLOUDFLARE 提供商通过 token 凭据调用其 API; + * MANUAL 提供商以本地数据库为事实源。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import type { Domain } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { CredentialService } from "../credential/credential.service"; +import { AuditService } from "../audit/audit.service"; +import { CloudflareDnsProvider, ManualDnsProvider, type DnsProvider } from "../../integrations/dns/dns.providers"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 创建域名 DTO **/ +export const CreateDomainSchema = z.object({ + name: z + .string() + .min(3) + .max(253) + .regex(/^(?!-)[a-zA-Z0-9-]{1,63}(\.[a-zA-Z0-9-]{1,63})+$/, "invalid domain name"), + provider: z.enum(["CLOUDFLARE", "MANUAL"]).default("MANUAL"), + credentialId: z.string().optional(), +}); +export type CreateDomainDto = z.infer<typeof CreateDomainSchema>; + +/** # 域名记录写入 DTO **/ +export const UpsertRecordSchema = z.object({ + type: z.enum(["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"]), + name: z.string().min(1).max(253), + value: z.string().min(1).max(4096), + ttl: z.coerce.number().int().min(60).max(86400).default(300), + proxied: z.boolean().default(false), +}); +export type UpsertRecordDto = z.infer<typeof UpsertRecordSchema>; + +@Injectable() +export class DomainService { + constructor( + private readonly prisma: PrismaService, + private readonly credentials: CredentialService, + private readonly audit: AuditService, + private readonly cloudflare: CloudflareDnsProvider, + private readonly manual: ManualDnsProvider, + ) {} + + /** # 选择 DNS 提供商适配器 */ + private provider(providerName: string): DnsProvider { + return providerName === "CLOUDFLARE" ? this.cloudflare : this.manual; + } + + /** # 创建域名资源 */ + async create(workspaceId: string, dto: CreateDomainDto) { + if (dto.provider === "CLOUDFLARE" && !dto.credentialId) { + throw ApiError.validation("CLOUDFLARE provider requires credentialId (API token)"); + } + if (dto.credentialId) await this.credentials.get(workspaceId, dto.credentialId); + const existing = await this.prisma.domain.findFirst({ where: { workspaceId, name: dto.name } }); + if (existing) throw ApiError.conflict("Domain already exists in this workspace"); + + const domain = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { workspaceId, type: "DOMAIN", name: dto.name, provider: dto.provider, status: "ACTIVE" }, + }); + return tx.domain.create({ + data: { + workspaceId, + resourceId: resource.id, + name: dto.name, + provider: dto.provider, + credentialId: dto.credentialId ?? null, + }, + }); + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "domain.create", + resourceType: "Domain", + resourceId: domain.resourceId, + metadata: { provider: dto.provider }, + }); + return this.view(domain); + } + + /** # 域名列表 */ + async list(workspaceId: string) { + const rows = await this.prisma.domain.findMany({ where: { workspaceId }, orderBy: { createdAt: "desc" } }); + return rows.map((d) => this.view(d)); + } + + /** # 域名详情 */ + async get(workspaceId: string, id: string) { + return this.view(await this.mustGet(workspaceId, id)); + } + + /** # 删除域名 */ + async delete(workspaceId: string, id: string) { + const domain = await this.mustGet(workspaceId, id); + await this.prisma.domain.delete({ where: { id: domain.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "domain.delete", + resourceType: "Domain", + resourceId: domain.resourceId, + riskLevel: "HIGH", + }); + return { deleted: true }; + } + + /** # 记录列表:CLOUDFLARE → 实时拉取并与本地同步;MANUAL → 本地记录 */ + async records(workspaceId: string, id: string) { + const domain = await this.mustGet(workspaceId, id); + if (domain.provider === "MANUAL") { + const rows = await this.prisma.domainRecord.findMany({ + where: { domainId: domain.id }, + orderBy: [{ type: "asc" }, { name: "asc" }], + }); + return rows.map((r) => ({ + id: r.providerRecordId ?? r.id, + localId: r.id, + type: r.type, + name: r.name, + value: r.value, + ttl: r.ttl, + proxied: r.proxied, + })); + } + const token = await this.providerToken(workspaceId, domain); + const records = await this.cloudflare.listRecords({ token, zoneName: domain.name }); + // 与本地库同步(以 providerRecordId 为键) + for (const r of records) { + await this.prisma.domainRecord.upsert({ + where: { + domainId_type_name_value: { + domainId: domain.id, + type: r.type, + name: r.name, + value: r.value, + }, + }, + create: { + workspaceId, + domainId: domain.id, + type: r.type, + name: r.name, + value: r.value, + ttl: r.ttl, + proxied: r.proxied, + providerRecordId: r.id, + }, + update: { ttl: r.ttl, proxied: r.proxied }, + }); + } + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "domain.records.read", + resourceType: "Domain", + resourceId: domain.resourceId, + metadata: { count: records.length }, + }); + return records.map((r) => ({ ...r, localId: undefined })); + } + + /** # 写入记录(upsert 到 provider,MANUAL 落库) */ + async upsertRecord(workspaceId: string, id: string, dto: UpsertRecordDto) { + const domain = await this.mustGet(workspaceId, id); + let providerRecordId: string | null = null; + if (domain.provider === "CLOUDFLARE") { + const token = await this.providerToken(workspaceId, domain); + const record = await this.cloudflare.upsertRecord({ + token, + zoneName: domain.name, + record: { type: dto.type, name: dto.name, value: dto.value, ttl: dto.ttl, proxied: dto.proxied }, + }); + providerRecordId = record.id; + } + const saved = await this.prisma.domainRecord.upsert({ + where: { + domainId_type_name_value: { + domainId: domain.id, + type: dto.type, + name: dto.name, + value: dto.value, + }, + }, + create: { + workspaceId, + domainId: domain.id, + type: dto.type, + name: dto.name, + value: dto.value, + ttl: dto.ttl, + proxied: dto.proxied, + providerRecordId, + }, + update: { ttl: dto.ttl, proxied: dto.proxied, providerRecordId }, + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "domain.updateRecords", + resourceType: "Domain", + resourceId: domain.resourceId, + riskLevel: "MEDIUM", + metadata: { type: dto.type, name: dto.name }, + }); + return { + id: providerRecordId ?? saved.id, + localId: saved.id, + type: saved.type, + name: saved.name, + value: saved.value, + ttl: saved.ttl, + proxied: saved.proxied, + }; + } + + /** # 删除记录 */ + async deleteRecord(workspaceId: string, id: string, localId: string) { + const domain = await this.mustGet(workspaceId, id); + const record = await this.prisma.domainRecord.findFirst({ + where: { id: localId, domainId: domain.id, workspaceId }, + }); + if (!record) throw ApiError.notFound("Record not found"); + if (domain.provider === "CLOUDFLARE" && record.providerRecordId) { + const token = await this.providerToken(workspaceId, domain); + await this.cloudflare.deleteRecord({ token, zoneName: domain.name, recordId: record.providerRecordId }); + } + await this.prisma.domainRecord.delete({ where: { id: record.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "domain.updateRecords", + resourceType: "Domain", + resourceId: domain.resourceId, + riskLevel: "MEDIUM", + metadata: { deleted: `${record.type} ${record.name}` }, + }); + return { deleted: true }; + } + + /** # 取得提供商 token(解密凭据) */ + private async providerToken(workspaceId: string, domain: Domain): Promise<string> { + const token = await this.credentials.getSecret(workspaceId, domain.credentialId); + if (!token) throw ApiError.validation("Domain provider credential missing"); + return token; + } + + /** # 断言域名属于工作区 */ + private async mustGet(workspaceId: string, id: string) { + const domain = await this.prisma.domain.findFirst({ where: { id, workspaceId } }); + if (!domain) throw ApiError.notFound("Domain not found"); + return domain; + } + + /** # 实体 → 视图 */ + private view(d: Domain) { + return { + id: d.id, + resourceId: d.resourceId, + workspaceId: d.workspaceId, + name: d.name, + provider: d.provider, + createdAt: d.createdAt, + }; + } +} diff --git a/apps/api/src/modules/event/event-bus.service.ts b/apps/api/src/modules/event/event-bus.service.ts new file mode 100644 index 0000000..20d517c --- /dev/null +++ b/apps/api/src/modules/event/event-bus.service.ts @@ -0,0 +1,121 @@ +/** + * # 事件总线 + * 领域事件的持久化 + 进程内分发(Task 015)。 + * - 事件命名遵循 `<resource>.<verb>` 规范(见 @devos/types EVENTS); + * - 每个事件持久化到 events 表(可追溯/可重放); + * - 进程内订阅者同步分发(错误隔离,不影响发布方); + * - 若 Redis 可用则额外 PUBLISH(跨进程可观测,为 V3 预留)。 + */ +import { Injectable, OnModuleDestroy } from "@nestjs/common"; +import type { Prisma } from "@prisma/client"; +import { randomUUID } from "node:crypto"; +import { PrismaService } from "../../prisma/prisma.service"; +import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module"; + +/** # 领域事件结构 **/ +export interface DomainEvent { + id: string; + eventType: string; + workspaceId: string; + resourceId: string | null; + payload: Record<string, unknown>; + correlationId: string | null; + createdAt: Date; +} + +/** # 事件处理器 **/ +export type EventHandler = (event: DomainEvent) => void | Promise<void>; + +@Injectable() +export class EventBusService implements OnModuleDestroy { + /** # 进程内订阅者 **/ + private handlers = new Set<EventHandler>(); + + /** # 日志器 **/ + private readonly logger: StructuredLoggerInstance; + + constructor( + private readonly prisma: PrismaService, + loggers: LogService, + ) { + this.logger = loggers.child("EventBus"); + } + + /** # 注册订阅者 + * @returns 取消订阅函数 + */ + subscribe(handler: EventHandler): () => void { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + /** # 发布领域事件:持久化 + 分发 + * @param eventType 规范事件名 + * @param data 事件数据 + */ + async publish( + eventType: string, + data: { + workspaceId: string; + resourceId?: string | null; + payload?: Record<string, unknown>; + correlationId?: string | null; + }, + ): Promise<DomainEvent | null> { + const record: DomainEvent = { + id: randomUUID(), + eventType, + workspaceId: data.workspaceId, + resourceId: data.resourceId ?? null, + payload: data.payload ?? {}, + correlationId: data.correlationId ?? null, + createdAt: new Date(), + }; + try { + await this.prisma.eventRecord.create({ + data: { + id: record.id, + workspaceId: record.workspaceId, + eventType: record.eventType, + resourceId: record.resourceId, + payload: record.payload as Prisma.InputJsonValue, + correlationId: record.correlationId, + }, + }); + } catch (error) { + this.logger.error("failed to persist event", error instanceof Error ? error : undefined); + return null; + } + // 进程内分发:错误隔离 + for (const handler of this.handlers) { + Promise.resolve() + .then(() => handler(record)) + .catch((error) => + this.logger.error( + `event handler failed for ${eventType}`, + error instanceof Error ? error : undefined, + ), + ); + } + // 跨进程发布(尽力而为) + void this.publishToRedis(record); + return record; + } + + /** # Redis 发布(Bun 原生 redis 客户端,失败静默) */ + private async publishToRedis(record: DomainEvent): Promise<void> { + try { + const bun = (globalThis as Record<string, unknown>).Bun as typeof Bun | undefined; + if (bun?.redis) { + await bun.redis.publish("devos:events", JSON.stringify(record)); + } + } catch { + /* Redis 不可用时忽略 */ + } + } + + /** # 停止时清空订阅者 */ + onModuleDestroy(): void { + this.handlers.clear(); + } +} diff --git a/apps/api/src/modules/event/event.controller.ts b/apps/api/src/modules/event/event.controller.ts new file mode 100644 index 0000000..edf4423 --- /dev/null +++ b/apps/api/src/modules/event/event.controller.ts @@ -0,0 +1,42 @@ +/** + * # 事件查询控制器 + */ +import { Controller, Get, Query } from "@nestjs/common"; +import { z } from "zod"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace } from "../../common/decorators"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { RequestWorkspace } from "../../common/decorators"; + +const ListQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + eventType: z.string().optional(), +}); + +@Controller("events") +export class EventController { + constructor(private readonly prisma: PrismaService) {} + + /** # 分页查询当前工作区领域事件 */ + @Get() + async list( + @CurrentWorkspace() workspace: RequestWorkspace, + @Query(new ZodValidationPipe(ListQuerySchema)) query: z.infer<typeof ListQuerySchema>, + ) { + const where = { + workspaceId: workspace.id, + ...(query.eventType ? { eventType: query.eventType } : {}), + }; + const [total, items] = await this.prisma.$transaction([ + this.prisma.eventRecord.count({ where }), + this.prisma.eventRecord.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + ]); + return { items, total, page: query.page, pageSize: query.pageSize }; + } +} diff --git a/apps/api/src/modules/event/event.module.ts b/apps/api/src/modules/event/event.module.ts new file mode 100644 index 0000000..e7365e6 --- /dev/null +++ b/apps/api/src/modules/event/event.module.ts @@ -0,0 +1,13 @@ +/** + * # 事件总线模块(Task 015) + */ +import { Module } from "@nestjs/common"; +import { EventController } from "./event.controller"; +import { EventBusService } from "./event-bus.service"; + +@Module({ + controllers: [EventController], + providers: [EventBusService], + exports: [EventBusService], +}) +export class EventModule {} diff --git a/apps/api/src/modules/git/git.controller.ts b/apps/api/src/modules/git/git.controller.ts new file mode 100644 index 0000000..dff6dad --- /dev/null +++ b/apps/api/src/modules/git/git.controller.ts @@ -0,0 +1,90 @@ +/** + * # Git 控制器(/api/v1/git) + */ +import { Body, Controller, Delete, Get, Param, Post } from "@nestjs/common"; +import { z } from "zod"; +import { GitService, CreateGitConnectionSchema, CreateRepositorySchema } from "./git.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +const FullNameSchema = z.object({ fullName: z.string().min(3).max(300) }); + +@Controller("git") +export class GitController { + constructor(private readonly git: GitService) {} + + /** # Git 连接列表 */ + @Get("connections") + listConnections(@CurrentWorkspace() ws: RequestWorkspace) { + return this.git.listConnections(ws.id); + } + + /** # 创建 Git 连接 */ + @Roles("ADMIN") + @Post("connections") + createConnection( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateGitConnectionSchema)) dto: Parameters<GitService["createConnection"]>[1], + ) { + return this.git.createConnection(ws.id, dto); + } + + /** # 删除连接 */ + @Roles("ADMIN") + @Delete("connections/:id") + deleteConnection(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.git.deleteConnection(ws.id, id); + } + + /** # 拉取 provider 远端仓库(实时) */ + @Roles("MEMBER") + @Post("connections/:id/repos") + listRemoteRepos( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(FullNameSchema)) _body: { fullName: string }, + ) { + return this.git.listRemoteRepos(ws.id, id); + } + + /** # 导入远端仓库 */ + @Roles("MEMBER") + @Post("connections/:id/repos/import") + importRepo( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(FullNameSchema)) body: { fullName: string }, + ) { + return this.git.importRepo(ws.id, id, body.fullName); + } + + /** # 仓库列表 */ + @Get("repositories") + listRepositories(@CurrentWorkspace() ws: RequestWorkspace) { + return this.git.listRepositories(ws.id); + } + + /** # 手工登记仓库 */ + @Roles("MEMBER") + @Post("repositories") + createRepository( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateRepositorySchema)) dto: Parameters<GitService["upsertRepository"]>[1], + ) { + return this.git.upsertRepository(ws.id, dto); + } + + /** # 分支列表 */ + @Get("repositories/:id/branches") + listBranches(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.git.listBranches(ws.id, id); + } + + /** # 删除仓库登记 */ + @Roles("MEMBER") + @Delete("repositories/:id") + deleteRepository(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.git.deleteRepository(ws.id, id); + } +} diff --git a/apps/api/src/modules/git/git.module.ts b/apps/api/src/modules/git/git.module.ts new file mode 100644 index 0000000..d820504 --- /dev/null +++ b/apps/api/src/modules/git/git.module.ts @@ -0,0 +1,17 @@ +/** + * # Git 模块 + */ +import { Module } from "@nestjs/common"; +import { GitController } from "./git.controller"; +import { GitService } from "./git.service"; +import { GithubAdapter, GitlabAdapter, GiteaAdapter } from "./git.providers"; +import { CredentialModule } from "../credential/credential.module"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [CredentialModule, AuditModule], + controllers: [GitController], + providers: [GitService, GithubAdapter, GitlabAdapter, GiteaAdapter], + exports: [GitService], +}) +export class GitModule {} diff --git a/apps/api/src/modules/git/git.providers.ts b/apps/api/src/modules/git/git.providers.ts new file mode 100644 index 0000000..f0ccb76 --- /dev/null +++ b/apps/api/src/modules/git/git.providers.ts @@ -0,0 +1,187 @@ +/** + * # Git Provider 适配器(Phase D/05) + * GitHub / GitLab / Gitea 统一 REST 聚合:账号验证、仓库列表、分支列表。 + * Token 从凭据保险库解密后注入请求头,绝不写入日志或返回体。 + */ +import { Injectable } from "@nestjs/common"; + +/** # 仓库摘要 **/ +export interface RemoteRepo { + /** # 全名 owner/repo **/ + fullName: string; + cloneUrl: string; + defaultBranch: string; + private: boolean; + description?: string; +} + +/** # 分支摘要 **/ +export interface RemoteBranch { + name: string; + commit: string; +} + +/** # Git Provider 统一接口 **/ +export interface GitProviderAdapter { + readonly provider: string; + /** # 验证 token 并返回账号登录名 */ + verifyAccount(params: { token: string; apiBase?: string }): Promise<{ login: string }>; + /** # 列出账号可见仓库 */ + listRepos(params: { token: string; apiBase?: string }): Promise<RemoteRepo[]>; + /** # 列出仓库分支 */ + listBranches(params: { token: string; apiBase?: string; fullName: string }): Promise<RemoteBranch[]>; +} + +/** # 默认 API 基址 **/ +const DEFAULTS: Record<string, string> = { + GITHUB: "https://api.github.com", + GITLAB: "https://gitlab.com/api/v4", + GITEA: "", // 必填 apiBase +}; + +/** # 规范化 apiBase(去尾斜杠) */ +function base(apiBase: string | undefined, provider: string): string { + const url = (apiBase && apiBase.replace(/\/$/, "")) || DEFAULTS[provider]; + if (!url) throw new Error(`${provider} requires apiBase (self-hosted instance)`); + return url; +} + +@Injectable() +export class GithubAdapter implements GitProviderAdapter { + readonly provider = "GITHUB"; + + /** # GitHub REST 请求 */ + private async call<T>(token: string, url: string): Promise<T> { + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`GitHub API ${res.status}: ${(await res.text()).slice(0, 200)}`); + return (await res.json()) as T; + } + + async verifyAccount(params: { token: string; apiBase?: string }) { + const user = await this.call<{ login: string }>(params.token, `${base(params.apiBase, this.provider)}/user`); + return { login: user.login }; + } + + async listRepos(params: { token: string; apiBase?: string }) { + const repos = await this.call< + { full_name: string; clone_url: string; default_branch: string; private: boolean; description: string | null }[] + >(`${base(params.apiBase, this.provider)}/user/repos?per_page=100&sort=updated`, params.token); + return repos.map((r) => ({ + fullName: r.full_name, + cloneUrl: r.clone_url, + defaultBranch: r.default_branch || "main", + private: r.private, + description: r.description ?? undefined, + })); + } + + async listBranches(params: { token: string; apiBase?: string; fullName: string }) { + const branches = await this.call<{ name: string; commit: { sha: string } }[]>( + `${base(params.apiBase, this.provider)}/repos/${params.fullName}/branches?per_page=100`, + params.token, + ); + return branches.map((b) => ({ name: b.name, commit: b.commit.sha })); + } +} + +@Injectable() +export class GitlabAdapter implements GitProviderAdapter { + readonly provider = "GITLAB"; + + /** # GitLab REST 请求 */ + private async call<T>(token: string, url: string): Promise<T> { + const res = await fetch(url, { + headers: { "PRIVATE-TOKEN": token }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`GitLab API ${res.status}: ${(await res.text()).slice(0, 200)}`); + return (await res.json()) as T; + } + + async verifyAccount(params: { token: string; apiBase?: string }) { + const user = await this.call<{ username: string }>(params.token, `${base(params.apiBase, this.provider)}/user`); + return { login: user.username }; + } + + async listRepos(params: { token: string; apiBase?: string }) { + const projects = await this.call< + { path_with_namespace: string; http_url_to_repo: string; default_branch: string; visibility: string; description: string | null }[] + >(`${base(params.apiBase, this.provider)}/projects?membership=true&per_page=100&order_by=last_activity_at`, params.token); + return projects.map((p) => ({ + fullName: p.path_with_namespace, + cloneUrl: p.http_url_to_repo, + defaultBranch: p.default_branch || "main", + private: p.visibility !== "public", + description: p.description ?? undefined, + })); + } + + async listBranches(params: { token: string; apiBase?: string; fullName: string }) { + const encoded = encodeURIComponent(params.fullName); + const branches = await this.call<{ name: string; commit: { id: string } }[]>( + `${base(params.apiBase, this.provider)}/projects/${encoded}/repository/branches?per_page=100`, + params.token, + ); + return branches.map((b) => ({ name: b.name, commit: b.commit.id })); + } +} + +@Injectable() +export class GiteaAdapter implements GitProviderAdapter { + readonly provider = "GITEA"; + + /** # Gitea REST 请求 */ + private async call<T>(token: string, url: string): Promise<T> { + const res = await fetch(url, { + headers: { Authorization: `token ${token}` }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`Gitea API ${res.status}: ${(await res.text()).slice(0, 200)}`); + return (await res.json()) as T; + } + + async verifyAccount(params: { token: string; apiBase?: string }) { + const user = await this.call<{ login: string }>(params.token, `${base(params.apiBase, this.provider)}/user`); + return { login: user.login }; + } + + async listRepos(params: { token: string; apiBase?: string }) { + const repos = await this.call< + { full_name: string; clone_url: string; default_branch: string; private: boolean; description: string }[] + >(`${base(params.apiBase, this.provider)}/user/repos?limit=100`, params.token); + return repos.map((r) => ({ + fullName: r.full_name, + cloneUrl: r.clone_url, + defaultBranch: r.default_branch || "main", + private: r.private, + description: r.description || undefined, + })); + } + + async listBranches(params: { token: string; apiBase?: string; fullName: string }) { + const branches = await this.call<{ name: string; commit: { id: string } }[]>( + `${base(params.apiBase, this.provider)}/repos/${params.fullName}/branches?limit=100`, + params.token, + ); + return branches.map((b) => ({ name: b.name, commit: b.commit.id })); + } +} + +/** # 构造带凭据的 HTTPS clone URL(服务端内部使用,绝不返回给客户端) + * GitHub: x-access-token:<token>@;GitLab: oauth2:<token>@;Gitea/其它: <token>@ + */ +export function buildAuthenticatedCloneUrl(provider: string, cloneUrl: string, token: string): string { + if (!cloneUrl.startsWith("https://")) { + throw new Error("Only HTTPS clone URLs are supported for credential injection"); + } + const rest = cloneUrl.slice("https://".length); + const user = provider === "GITHUB" ? "x-access-token" : provider === "GITLAB" ? "oauth2" : ""; + return user ? `https://${user}:${token}@${rest}` : `https://${token}@${rest}`; +} diff --git a/apps/api/src/modules/git/git.service.ts b/apps/api/src/modules/git/git.service.ts new file mode 100644 index 0000000..f1371d1 --- /dev/null +++ b/apps/api/src/modules/git/git.service.ts @@ -0,0 +1,309 @@ +/** + * # Git 服务(Phase D) + * GitProviderConnection + Repository 聚合模型: + * - 连接:provider + credentialId(token 存凭据保险库)+ apiBase(自建实例); + * - 仓库:从 provider 拉取或手工登记;clone 凭据仅由部署执行器在服务端注入。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import { PrismaService } from "../../prisma/prisma.service"; +import { CredentialService } from "../credential/credential.service"; +import { AuditService } from "../audit/audit.service"; +import { + GithubAdapter, + GitlabAdapter, + GiteaAdapter, + buildAuthenticatedCloneUrl, + type GitProviderAdapter, +} from "./git.providers"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 创建连接 DTO **/ +export const CreateGitConnectionSchema = z.object({ + name: z.string().min(1).max(120), + provider: z.enum(["GITHUB", "GITLAB", "GITEA"]), + apiBase: z.string().url().max(500).optional(), + credentialId: z.string().min(1), +}); +export type CreateGitConnectionDto = z.infer<typeof CreateGitConnectionSchema>; + +/** # 手工登记仓库 DTO **/ +export const CreateRepositorySchema = z.object({ + connectionId: z.string().optional(), + provider: z.enum(["GITHUB", "GITLAB", "GITEA", "GENERIC"]).default("GENERIC"), + fullName: z.string().min(3).max(300), + cloneUrl: z.string().url().max(1000), + defaultBranch: z.string().max(200).default("main"), + visibility: z.enum(["public", "private"]).default("private"), +}); +export type CreateRepositoryDto = z.infer<typeof CreateRepositorySchema>; + +@Injectable() +export class GitService { + constructor( + private readonly prisma: PrismaService, + private readonly credentials: CredentialService, + private readonly audit: AuditService, + private readonly github: GithubAdapter, + private readonly gitlab: GitlabAdapter, + private readonly gitea: GiteaAdapter, + ) {} + + /** # 选择 provider 适配器 */ + private adapter(provider: string): GitProviderAdapter { + switch (provider) { + case "GITHUB": + return this.github; + case "GITLAB": + return this.gitlab; + case "GITEA": + return this.gitea; + default: + throw ApiError.validation(`Unsupported git provider: ${provider}`); + } + } + + /** # 创建 Git 连接(校验 token 有效后入账) */ + async createConnection(workspaceId: string, dto: CreateGitConnectionDto) { + await this.credentials.get(workspaceId, dto.credentialId); + const token = await this.credentials.getSecret(workspaceId, dto.credentialId); + if (!token) throw ApiError.validation("Credential secret missing"); + const account = await this.adapter(dto.provider).verifyAccount({ token, apiBase: dto.apiBase }); + + const connection = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { + workspaceId, + type: "CREDENTIAL", + name: `git:${dto.name}`, + provider: dto.provider, + status: "ACTIVE", + metadata: { kind: "git-connection", account: account.login }, + }, + }); + return tx.gitConnection.create({ + data: { + workspaceId, + resourceId: resource.id, + name: dto.name, + provider: dto.provider, + apiBase: dto.apiBase ?? null, + credentialId: dto.credentialId, + }, + }); + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "git.connection.create", + resourceType: "GitConnection", + resourceId: connection.resourceId, + riskLevel: "MEDIUM", + metadata: { provider: dto.provider, account: account.login }, + }); + return { ...this.connectionView(connection), account: account.login }; + } + + /** # 连接列表 */ + async listConnections(workspaceId: string) { + const rows = await this.prisma.gitConnection.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + }); + return rows.map((c) => this.connectionView(c)); + } + + /** # 删除连接 */ + async deleteConnection(workspaceId: string, id: string) { + const c = await this.prisma.gitConnection.findFirst({ where: { id, workspaceId } }); + if (!c) throw ApiError.notFound("Git connection not found"); + await this.prisma.gitConnection.delete({ where: { id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "git.connection.delete", + resourceType: "GitConnection", + resourceId: c.resourceId, + riskLevel: "HIGH", + }); + return { deleted: true }; + } + + /** # 从 provider 拉取仓库列表(实时;不落库) */ + async listRemoteRepos(workspaceId: string, connectionId: string) { + const conn = await this.mustGetConnection(workspaceId, connectionId); + const token = await this.credentials.getSecret(workspaceId, conn.credentialId); + if (!token) throw ApiError.validation("Connection credential missing"); + const adapter = this.adapter(conn.provider); + return adapter.listRepos({ token, apiBase: conn.apiBase ?? undefined }); + } + + /** # 导入 provider 仓库到本地仓库表 */ + async importRepo(workspaceId: string, connectionId: string, fullName: string) { + const conn = await this.mustGetConnection(workspaceId, connectionId); + const remote = (await this.listRemoteRepos(workspaceId, connectionId)).find((r) => r.fullName === fullName); + if (!remote) throw ApiError.notFound(`Repository ${fullName} not found on ${conn.provider}`); + return this.upsertRepository(workspaceId, { + connectionId, + provider: conn.provider as "GITHUB" | "GITLAB" | "GITEA" | "GENERIC", + fullName: remote.fullName, + cloneUrl: remote.cloneUrl, + defaultBranch: remote.defaultBranch, + visibility: remote.private ? "private" : "public", + }); + } + + /** # 分支列表(实时) */ + async listBranches(workspaceId: string, repositoryId: string) { + const repo = await this.mustGetRepository(workspaceId, repositoryId); + if (!repo.connectionId) throw ApiError.validation("Repository has no connection; branches unavailable"); + const conn = await this.mustGetConnection(workspaceId, repo.connectionId); + const token = await this.credentials.getSecret(workspaceId, conn.credentialId); + if (!token) throw ApiError.validation("Connection credential missing"); + return this.adapter(conn.provider).listBranches({ token, apiBase: conn.apiBase ?? undefined, fullName: repo.fullName }); + } + + /** # 手工登记/导入仓库(REPOSITORY 资源入图谱) */ + async upsertRepository(workspaceId: string, dto: CreateRepositoryDto) { + if (dto.connectionId) { + const conn = await this.prisma.gitConnection.findFirst({ where: { id: dto.connectionId, workspaceId } }); + if (!conn) throw ApiError.notFound("Git connection not found"); + } + const existing = await this.prisma.repository.findFirst({ + where: { workspaceId, cloneUrl: dto.cloneUrl }, + }); + if (existing) return this.repoView(existing); + const repo = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { + workspaceId, + type: "REPOSITORY", + name: dto.fullName, + provider: dto.provider, + status: "ACTIVE", + metadata: { cloneUrl: dto.cloneUrl, visibility: dto.visibility }, + }, + }); + return tx.repository.create({ + data: { + workspaceId, + resourceId: resource.id, + connectionId: dto.connectionId ?? null, + provider: dto.provider, + fullName: dto.fullName, + cloneUrl: dto.cloneUrl, + defaultBranch: dto.defaultBranch, + visibility: dto.visibility, + }, + }); + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "git.repository.import", + resourceType: "Repository", + resourceId: repo.resourceId, + metadata: { fullName: dto.fullName, provider: dto.provider }, + }); + return this.repoView(repo); + } + + /** # 仓库列表 */ + async listRepositories(workspaceId: string) { + const rows = await this.prisma.repository.findMany({ + where: { workspaceId }, + orderBy: { updatedAt: "desc" }, + }); + return rows.map((r) => this.repoView(r)); + } + + /** # 删除仓库登记 */ + async deleteRepository(workspaceId: string, id: string) { + const repo = await this.mustGetRepository(workspaceId, id); + await this.prisma.repository.delete({ where: { id: repo.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "git.repository.delete", + resourceType: "Repository", + resourceId: repo.resourceId, + riskLevel: "MEDIUM", + }); + return { deleted: true }; + } + + /** # 构造带凭据的 clone URL(部署执行器内部使用;审计记 use,不落日志) */ + async buildCloneCommand(workspaceId: string, repositoryId: string): Promise<{ cloneUrl: string; defaultBranch: string; provider: string }> { + const repo = await this.mustGetRepository(workspaceId, repositoryId); + let cloneUrl = repo.cloneUrl; + if (repo.connectionId) { + const conn = await this.mustGetConnection(workspaceId, repo.connectionId); + const token = await this.credentials.getSecret(workspaceId, conn.credentialId); + if (token) { + cloneUrl = buildAuthenticatedCloneUrl(conn.provider, repo.cloneUrl, token); + } + } + return { cloneUrl, defaultBranch: repo.defaultBranch, provider: repo.provider }; + } + + /** # 断言连接属于工作区 */ + private async mustGetConnection(workspaceId: string, id: string) { + const c = await this.prisma.gitConnection.findFirst({ where: { id, workspaceId } }); + if (!c) throw ApiError.notFound("Git connection not found"); + return c; + } + + /** # 断言仓库属于工作区 */ + private async mustGetRepository(workspaceId: string, id: string) { + const r = await this.prisma.repository.findFirst({ where: { id, workspaceId } }); + if (!r) throw ApiError.notFound("Repository not found"); + return r; + } + + /** # 连接视图(不含凭据) */ + private connectionView(c: { + id: string; + resourceId: string; + name: string; + provider: string; + apiBase: string | null; + credentialId: string; + createdAt: Date; + }) { + return { + id: c.id, + resourceId: c.resourceId, + name: c.name, + provider: c.provider, + apiBase: c.apiBase, + credentialId: c.credentialId, + createdAt: c.createdAt, + }; + } + + /** # 仓库视图(不含凭据) */ + private repoView(r: { + id: string; + resourceId: string; + connectionId: string | null; + provider: string; + fullName: string; + cloneUrl: string; + defaultBranch: string; + visibility: string; + updatedAt: Date; + }) { + return { + id: r.id, + resourceId: r.resourceId, + connectionId: r.connectionId, + provider: r.provider, + fullName: r.fullName, + cloneUrl: r.cloneUrl, + defaultBranch: r.defaultBranch, + visibility: r.visibility, + updatedAt: r.updatedAt, + }; + } +} diff --git a/apps/api/src/modules/identity/identity.controller.ts b/apps/api/src/modules/identity/identity.controller.ts new file mode 100644 index 0000000..6aebc65 --- /dev/null +++ b/apps/api/src/modules/identity/identity.controller.ts @@ -0,0 +1,47 @@ +/** + * # 认证控制器(/api/v1/auth) + */ +import { Body, Controller, Get, Post } from "@nestjs/common"; +import { + IdentityService, + LoginSchema, + RegisterSchema, + type RegisterDto, + type LoginDto, +} from "./identity.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { Public, CurrentUser, NoWorkspace } from "../../common/decorators"; +import type { RequestUser } from "../../common/decorators"; + +@Controller("auth") +export class IdentityController { + constructor(private readonly identity: IdentityService) {} + + /** # 注册 */ + @Public() + @Post("register") + register(@Body(new ZodValidationPipe(RegisterSchema)) dto: RegisterDto) { + return this.identity.register(dto); + } + + /** # 登录 */ + @Public() + @Post("login") + login(@Body(new ZodValidationPipe(LoginSchema)) dto: LoginDto) { + return this.identity.login(dto); + } + + /** # 当前用户信息 */ + @Get("me") + @NoWorkspace() + me(@CurrentUser() user: RequestUser) { + return this.identity.me(user.id); + } + + /** # 登出(无状态令牌由客户端丢弃) */ + @Post("logout") + @NoWorkspace() + logout(@CurrentUser() user: RequestUser) { + return this.identity.logout(user.id, null); + } +} diff --git a/apps/api/src/modules/identity/identity.module.ts b/apps/api/src/modules/identity/identity.module.ts new file mode 100644 index 0000000..fe9de6f --- /dev/null +++ b/apps/api/src/modules/identity/identity.module.ts @@ -0,0 +1,15 @@ +/** + * # 身份认证模块(Task 005) + */ +import { Module } from "@nestjs/common"; +import { IdentityController } from "./identity.controller"; +import { IdentityService } from "./identity.service"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [AuditModule], + controllers: [IdentityController], + providers: [IdentityService], + exports: [IdentityService], +}) +export class IdentityModule {} diff --git a/apps/api/src/modules/identity/identity.service.ts b/apps/api/src/modules/identity/identity.service.ts new file mode 100644 index 0000000..80db00e --- /dev/null +++ b/apps/api/src/modules/identity/identity.service.ts @@ -0,0 +1,180 @@ +/** + * # 身份认证服务(Task 005) + * 注册/登录/当前用户。令牌策略:HS256 JWT(12h),载荷携带激活工作区。 + * 登出为无状态操作(客户端丢弃令牌);服务端记录审计。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import { signJwt, hashPassword, verifyPassword } from "@devos/auth"; +import { PrismaService } from "../../prisma/prisma.service"; +import { ConfigService } from "../../common/config/config.service"; +import { AuditService } from "../audit/audit.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; +import { SecurityPolicyService } from "../../common/security/security-policy.service"; + +/** # 注册 DTO **/ +export const RegisterSchema = z.object({ + email: z.string().email().max(200), + password: z.string().min(8).max(128), + name: z.string().min(1).max(80).optional(), +}); +export type RegisterDto = z.infer<typeof RegisterSchema>; + +/** # 登录 DTO(账号支持用户名或邮箱) **/ +export const LoginSchema = z.object({ + email: z.string().min(1).max(200), + password: z.string().min(1).max(128), +}); +export type LoginDto = z.infer<typeof LoginSchema>; + +/** # JWT 有效期(秒) **/ +const TOKEN_TTL_SEC = 12 * 3600; + +@Injectable() +export class IdentityService { + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + private readonly audit: AuditService, + private readonly security: SecurityPolicyService, + ) {} + + /** # 注册:创建用户 + 个人默认工作区(OWNER)+ 自动登录(受注册开关与限流约束) */ + async register(dto: RegisterDto) { + const policy = this.security.checkRegistration(); + if (!policy.allowed) { + throw ApiError.forbidden(policy.reason!); + } + const ip = getRequestContext()?.ip ?? "unknown"; + if (!this.security.checkRate(`register:${ip}`)) { + throw new ApiError(429, "RATE_LIMITED", "Too many attempts, slow down"); + } + const existing = await this.prisma.user.findUnique({ where: { email: dto.email } }); + if (existing) throw ApiError.conflict("Email already registered"); + + const user = await this.prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + email: dto.email, + name: dto.name ?? dto.email.split("@")[0], + passwordHash: await hashPassword(dto.password), + }, + }); + const count = await tx.workspace.count(); + const workspace = await tx.workspace.create({ + data: { + name: `${created.name ?? created.email}'s Workspace`, + slug: count === 0 ? "main" : `ws-${created.id.slice(0, 8)}`, + }, + }); + await tx.workspaceMember.create({ + data: { workspaceId: workspace.id, userId: created.id, role: "OWNER" }, + }); + return created; + }); + + await this.audit.record({ + workspaceId: (await this.firstWorkspaceId(user.id)) ?? "", + actorType: "USER", + actorId: user.id, + action: "auth.register", + resourceType: "User", + resourceId: user.id, + }); + return this.issueToken(user.id, user.email); + } + + /** # 登录(按 IP 限流,防暴力破解) */ + async login(dto: LoginDto) { + const ip = getRequestContext()?.ip ?? "unknown"; + if (!this.security.checkRate(`login:${ip}`)) { + throw new ApiError(429, "RATE_LIMITED", "Too many attempts, slow down"); + } + const user = await this.prisma.user.findUnique({ where: { email: dto.email } }); + // 统一错误信息,避免枚举探测 + if (!user || !(await verifyPassword(dto.password, user.passwordHash))) { + await this.audit.record({ + workspaceId: (await this.firstWorkspaceId(user?.id ?? "")) ?? "", + actorType: "USER", + actorId: user?.id, + action: "auth.login", + status: "FAILURE", + metadata: { reason: "invalid credentials" }, + }); + throw ApiError.unauthorized("Invalid email or password"); + } + const wid = await this.firstWorkspaceId(user.id); + await this.audit.record({ + workspaceId: wid ?? "", + actorType: "USER", + actorId: user.id, + action: "auth.login", + resourceType: "User", + resourceId: user.id, + }); + return this.issueToken(user.id, user.email, wid); + } + + /** # 当前用户:身份 + 工作区列表 */ + async me(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + memberships: { + include: { workspace: { select: { id: true, name: true, slug: true } } }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + if (!user) throw ApiError.unauthorized(); + return { + id: user.id, + email: user.email, + name: user.name, + activeWorkspaceId: user.memberships[0]?.workspace.id ?? null, + workspaces: user.memberships.map((m) => ({ + id: m.workspace.id, + name: m.workspace.name, + slug: m.workspace.slug, + role: m.role, + })), + }; + } + + /** # 登出(无状态;记录审计便于追溯) */ + async logout(userId: string, workspaceId: string | null) { + await this.audit.record({ + workspaceId: workspaceId ?? "", + actorType: "USER", + actorId: userId, + action: "auth.logout", + resourceType: "User", + resourceId: userId, + }); + return { ok: true }; + } + + /** # 签发令牌并返回用户视图 */ + private async issueToken(userId: string, email: string, wid?: string | null) { + const activeWorkspaceId = wid ?? (await this.firstWorkspaceId(userId)); + const token = signJwt( + { sub: userId, email, wid: activeWorkspaceId ?? null }, + this.config.appSecret, + { expiresInSec: TOKEN_TTL_SEC }, + ); + const me = await this.me(userId); + return { token, expiresIn: TOKEN_TTL_SEC, user: me, requestId: getRequestContext()?.requestId }; + } + + /** # 查询用户第一个工作区 ID */ + private async firstWorkspaceId(userId: string): Promise<string | null> { + if (!userId) return null; + const m = await this.prisma.workspaceMember.findFirst({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { workspaceId: true }, + }); + return m?.workspaceId ?? null; + } +} diff --git a/apps/api/src/modules/job/job-queue.service.ts b/apps/api/src/modules/job/job-queue.service.ts new file mode 100644 index 0000000..8bc8f7e --- /dev/null +++ b/apps/api/src/modules/job/job-queue.service.ts @@ -0,0 +1,246 @@ +/** + * # 异步任务队列 + * Postgres jobs 表为持久化事实源(状态机:PENDING→RUNNING→SUCCEEDED/FAILED/CANCELLED/DEAD), + * Redis(Bun.redis)作为尽力而为的派发信号;worker 以短间隔轮询认领(FOR UPDATE SKIP LOCKED)。 + * - 失败自动重试(指数退避),超过上限进入 DEAD(dead-letter); + * - 崩溃恢复:超时 RUNNING 任务被回收重跑; + * - 执行在请求上下文中进行(requestId/correlationId 贯穿日志与审计)。 + */ +import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; +import type { Prisma } from "@prisma/client"; +import type { JobStatus } from "@devos/types"; +import { PrismaService } from "../../prisma/prisma.service"; +import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module"; +import { ConfigService } from "../../common/config/config.service"; +import { requestContextStore } from "../../common/request-context"; + +/** # 任务处理器上下文 **/ +export interface JobContext { + id: string; + workspaceId: string; + type: string; + payload: Record<string, unknown>; + correlationId: string | null; + attempt: number; +} + +/** # 任务处理器 **/ +export type JobHandler = (job: JobContext) => Promise<Record<string, unknown> | void>; + +/** # 队列选项 **/ +export interface EnqueueOptions { + maxAttempts?: number; + runAt?: Date; + correlationId?: string | null; +} + +/** # worker 并发上限 **/ +const MAX_CONCURRENCY = 3; +/** # 认领间隔(毫秒) **/ +const POLL_INTERVAL_MS = 600; +/** # RUNNING 卡死回收阈值(毫秒) **/ +const STALE_RUNNING_MS = 15 * 60 * 1000; + +@Injectable() +export class JobQueueService implements OnModuleInit, OnModuleDestroy { + /** # 处理器注册表 **/ + private handlers = new Map<string, JobHandler>(); + /** # 运行中任务数 **/ + private running = 0; + /** # 轮询定时器 **/ + private timer: ReturnType<typeof setInterval> | null = null; + /** # 停止标记 **/ + private stopped = false; + + /** # 日志器 **/ + private readonly logger: StructuredLoggerInstance; + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + loggers: LogService, + ) { + this.logger = loggers.child("JobQueue"); + } + + /** # 注册任务类型处理器 */ + register(type: string, handler: JobHandler): void { + this.handlers.set(type, handler); + } + + /** # 入队一个异步任务 */ + async enqueue( + type: string, + workspaceId: string, + payload: Record<string, unknown>, + options: EnqueueOptions = {}, + ): Promise<{ id: string }> { + const job = await this.prisma.job.create({ + data: { + type, + workspaceId, + payload: payload as Prisma.InputJsonValue, + maxAttempts: options.maxAttempts ?? 3, + runAt: options.runAt ?? new Date(), + correlationId: options.correlationId ?? null, + }, + select: { id: true }, + }); + void this.notifyRedis(job.id); + return job; + } + + /** # 取消尚在排队的任务 */ + async cancel(jobId: string, workspaceId: string): Promise<boolean> { + const job = await this.prisma.job.findFirst({ where: { id: jobId, workspaceId } }); + if (!job) return false; + if (job.status !== "PENDING") return false; + await this.prisma.job.update({ + where: { id: jobId }, + data: { status: "CANCELLED", finishedAt: new Date() }, + }); + return true; + } + + /** # 启动 worker 轮询 */ + onModuleInit(): void { + this.timer = setInterval(() => void this.tick(), POLL_INTERVAL_MS); + this.timer.unref?.(); + } + + /** # 停止 worker(等待在跑任务结束) */ + async onModuleDestroy(): Promise<void> { + this.stopped = true; + if (this.timer) clearInterval(this.timer); + while (this.running > 0) { + await new Promise((r) => setTimeout(r, 50)); + } + } + + /** # 每次轮询:回收僵死任务 + 按并发上限认领执行 */ + private async tick(): Promise<void> { + if (this.stopped) return; + try { + await this.reclaimStale(); + } catch (error) { + this.logger.error("reclaim failed", error instanceof Error ? error : undefined); + } + while (this.running < MAX_CONCURRENCY && !this.stopped) { + const job = await this.claim(); + if (!job) break; + this.running += 1; + void this.execute(job) + .catch((error) => + this.logger.error("job execute crashed", error instanceof Error ? error : undefined), + ) + .finally(() => { + this.running -= 1; + }); + } + } + + /** # 认领下一个到期任务(原子 UPDATE ... RETURNING,跨实例安全) */ + private async claim(): Promise<JobContext | null> { + const rows = await this.prisma.$queryRaw< + { + id: string; + workspaceId: string; + type: string; + payload: Record<string, unknown>; + correlationId: string | null; + attempts: number; + }[] + >` + UPDATE jobs SET status = 'RUNNING', "startedAt" = NOW(), "updatedAt" = NOW(), attempts = attempts + 1 + WHERE id = ( + SELECT id FROM jobs + WHERE status = 'PENDING' AND "runAt" <= NOW() + ORDER BY "runAt" ASC + FOR UPDATE SKIP LOCKED LIMIT 1 + ) + RETURNING id, "workspaceId", type, payload, "correlationId", attempts + `; + const row = rows[0]; + if (!row) return null; + return { + id: row.id, + workspaceId: row.workspaceId, + type: row.type, + payload: (row.payload ?? {}) as Record<string, unknown>, + correlationId: row.correlationId, + attempt: row.attempts, + }; + } + + /** # 执行任务并落盘结果/重试状态 */ + private async execute(job: JobContext): Promise<void> { + const handler = this.handlers.get(job.type); + const rid = randomUUID(); + const finish = async (status: JobStatus, patch: { result?: unknown; error?: string }) => { + await this.prisma.job.update({ + where: { id: job.id }, + data: { + status, + finishedAt: new Date(), + ...(patch.result !== undefined ? { result: patch.result as object } : {}), + ...(patch.error !== undefined ? { error: patch.error } : {}), + }, + }); + }; + if (!handler) { + await finish("DEAD", { error: `No handler for job type ${job.type}` }); + return; + } + try { + await requestContextStore.run( + { + requestId: rid, + correlationId: job.correlationId ?? undefined, + workspaceId: job.workspaceId, + actorType: "SYSTEM", + }, + async () => { + const result = await handler(job); + await finish("SUCCEEDED", { result: result ?? {} }); + }, + ); + this.logger.log(`job ${job.type} succeeded (attempt ${job.attempt})`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const jobRow = await this.prisma.job.findUnique({ where: { id: job.id } }); + const maxAttempts = jobRow?.maxAttempts ?? 3; + if (jobRow && jobRow.attempts < maxAttempts) { + const backoffMs = Math.min(5000 * 2 ** jobRow.attempts, 5 * 60 * 1000); + await this.prisma.job.update({ + where: { id: job.id }, + data: { status: "PENDING", runAt: new Date(Date.now() + backoffMs), error: message }, + }); + this.logger.warn(`job ${job.type} failed, retry in ${backoffMs}ms: ${message}`); + } else { + await finish("DEAD", { error: message }); + this.logger.error(`job ${job.type} dead-lettered: ${message}`); + } + } + } + + /** # 回收超时 RUNNING 任务(进程崩溃恢复) */ + private async reclaimStale(): Promise<void> { + await this.prisma.$executeRaw` + UPDATE jobs SET status = 'PENDING', "updatedAt" = NOW() + WHERE status = 'RUNNING' AND "startedAt" < NOW() - INTERVAL '15 minutes' + `; + } + + /** # Redis 派发信号(尽力而为;不可用则退化为 DB 轮询) */ + private async notifyRedis(jobId: string): Promise<void> { + try { + const bun = (globalThis as Record<string, unknown>).Bun as typeof Bun | undefined; + if (bun?.redis && this.config.redisUrl) { + await bun.redis.lpush("devos:jobs", jobId); + } + } catch { + /* ignore */ + } + } +} diff --git a/apps/api/src/modules/job/job.controller.ts b/apps/api/src/modules/job/job.controller.ts new file mode 100644 index 0000000..74f8c78 --- /dev/null +++ b/apps/api/src/modules/job/job.controller.ts @@ -0,0 +1,83 @@ +/** + * # Job Center 控制器(/api/v1/jobs,Phase C) + * 统一长任务视图:queued/running/succeeded/failed/cancelled/dead + 重试/取消。 + */ +import { Controller, Get, Param, Post, Query } from "@nestjs/common"; +import { z } from "zod"; +import { PrismaService } from "../../prisma/prisma.service"; +import { JobQueueService } from "./job-queue.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { ApiError } from "../../common/errors/api-error"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +const ListQuerySchema = z.object({ + status: z.enum(["PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "DEAD"]).optional(), + type: z.string().optional(), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}); + +@Controller("jobs") +export class JobController { + constructor( + private readonly prisma: PrismaService, + private readonly queue: JobQueueService, + ) {} + + /** # 任务列表(分页 + 状态/类型过滤) */ + @Get() + async list( + @CurrentWorkspace() ws: RequestWorkspace, + @Query(new ZodValidationPipe(ListQuerySchema)) query: z.infer<typeof ListQuerySchema>, + ) { + const where = { + workspaceId: ws.id, + ...(query.status ? { status: query.status } : {}), + ...(query.type ? { type: query.type } : {}), + }; + const [total, items] = await this.prisma.$transaction([ + this.prisma.job.count({ where }), + this.prisma.job.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + ]); + return { items, total, page: query.page, pageSize: query.pageSize }; + } + + /** # 任务详情 */ + @Get(":id") + async get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + const job = await this.prisma.job.findFirst({ where: { id, workspaceId: ws.id } }); + if (!job) throw ApiError.notFound("Job not found"); + return job; + } + + /** # 取消排队中的任务 */ + @Roles("MEMBER") + @Post(":id/cancel") + async cancel(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + const ok = await this.queue.cancel(id, ws.id); + if (!ok) throw ApiError.conflict("Job is not cancellable (not pending)"); + return { cancelled: true }; + } + + /** # 重试失败任务(克隆为新任务入队;保留原任务历史) */ + @Roles("MEMBER") + @Post(":id/retry") + async retry(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + const job = await this.prisma.job.findFirst({ where: { id, workspaceId: ws.id } }); + if (!job) throw ApiError.notFound("Job not found"); + if (!["FAILED", "DEAD", "CANCELLED"].includes(job.status)) { + throw ApiError.conflict(`Job is ${job.status}; only failed/dead/cancelled jobs can be retried`); + } + const created = await this.queue.enqueue(job.type, ws.id, job.payload as Record<string, unknown>, { + maxAttempts: job.maxAttempts, + correlationId: job.correlationId, + }); + return { retried: true, newJobId: created.id }; + } +} diff --git a/apps/api/src/modules/job/job.module.ts b/apps/api/src/modules/job/job.module.ts new file mode 100644 index 0000000..fb96397 --- /dev/null +++ b/apps/api/src/modules/job/job.module.ts @@ -0,0 +1,14 @@ +/** + * # 异步任务模块(Task 012 基础设施) + */ +import { Global, Module } from "@nestjs/common"; +import { JobQueueService } from "./job-queue.service"; +import { JobController } from "./job.controller"; + +@Global() +@Module({ + controllers: [JobController], + providers: [JobQueueService], + exports: [JobQueueService], +}) +export class JobModule {} diff --git a/apps/api/src/modules/mcp/mcp.controller.ts b/apps/api/src/modules/mcp/mcp.controller.ts new file mode 100644 index 0000000..257f98f --- /dev/null +++ b/apps/api/src/modules/mcp/mcp.controller.ts @@ -0,0 +1,38 @@ +/** + * # MCP 控制器(/api/v1/mcp) + * Streamable HTTP 入口;认证在 McpService 中处理(不走全局 Guard 的响应管道)。 + */ +import { Controller, Get, Delete, Post, Res, Req, Body } from "@nestjs/common"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { Public } from "../../common/decorators"; +import { McpService } from "./mcp.service"; + +@Controller("mcp") +export class McpController { + constructor(private readonly mcp: McpService) {} + + /** # MCP JSON-RPC 入口(公开路由:内部使用 Bearer JWT 自行鉴权) */ + @Public() + @Post() + async post( + @Req() req: FastifyRequest, + @Body() _body: unknown, + @Res() reply: FastifyReply, + ) { + await this.mcp.handlePost(req, reply); + } + + /** # SSE/删除会话:无状态模式不支持 */ + @Public() + @Get() + get(@Res() reply: FastifyReply) { + this.mcp.rejectMethod(reply); + } + + /** # 会话删除:无状态模式不支持 */ + @Public() + @Delete() + delete(@Res() reply: FastifyReply) { + this.mcp.rejectMethod(reply); + } +} diff --git a/apps/api/src/modules/mcp/mcp.module.ts b/apps/api/src/modules/mcp/mcp.module.ts new file mode 100644 index 0000000..05ab025 --- /dev/null +++ b/apps/api/src/modules/mcp/mcp.module.ts @@ -0,0 +1,14 @@ +/** + * # MCP 模块(Task 018) + */ +import { Module } from "@nestjs/common"; +import { McpController } from "./mcp.controller"; +import { McpService } from "./mcp.service"; +import { AiModule } from "../ai/ai.module"; + +@Module({ + imports: [AiModule], + controllers: [McpController], + providers: [McpService], +}) +export class McpModule {} diff --git a/apps/api/src/modules/mcp/mcp.service.ts b/apps/api/src/modules/mcp/mcp.service.ts new file mode 100644 index 0000000..ef3506e --- /dev/null +++ b/apps/api/src/modules/mcp/mcp.service.ts @@ -0,0 +1,182 @@ +/** + * # MCP Server 模块(Task 018) + * 通过 @modelcontextprotocol/sdk(Streamable HTTP,无状态模式)在 /api/v1/mcp 暴露 DevOS 能力。 + * 安全:MCP 请求必须携带 JWT(Authorization: Bearer);所有工具调用走 ToolRegistry(同授权/审计路径); + * actorType=MCP 写入请求上下文,审计可区分 MCP 来源。 + */ +import { Injectable } from "@nestjs/common"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { z } from "zod"; +import { z as zod } from "zod"; +import { randomUUID } from "node:crypto"; +import { PrismaService } from "../../prisma/prisma.service"; +import { ConfigService } from "../../common/config/config.service"; +import { verifyJwt } from "@devos/auth"; +import { ToolRegistryService } from "../ai/tool-registry.service"; +import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module"; +import { requestContextStore } from "../../common/request-context"; +import { maskSecrets } from "../../common/utils/mask"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +/** # MCP 允许暴露的工具(子集;不暴露 exec 等高危 unless 明确列出) **/ +const MCP_TOOLS = [ + "devos.server.list", + "devos.server.status", + "devos.server.exec", + "devos.server.getDockerContainers", + "devos.website.list", + "devos.website.deploy", + "devos.website.logs", + "devos.website.health", + "devos.deployment.get", + "devos.deployment.list", + "devos.domain.list", + "devos.domain.records", + "devos.automation.list", + "devos.automation.run", + "devos.task.list", + "devos.task.create", + "devos.note.create", + "devos.container.restart", +] as const; + +/** # MCP 工具参数 schema(与 ToolRegistry 对齐的显式声明) **/ +const ToolSchemas: Record<string, z.ZodRawShape> = { + "devos.server.list": {}, + "devos.server.status": { serverId: zod.string() }, + "devos.server.exec": { serverId: zod.string(), command: zod.string() }, + "devos.server.getDockerContainers": { serverId: zod.string() }, + "devos.website.list": {}, + "devos.website.deploy": { websiteId: zod.string() }, + "devos.website.logs": { id: zod.string() }, + "devos.website.health": { id: zod.string() }, + "devos.deployment.get": { id: zod.string() }, + "devos.deployment.list": { websiteId: zod.string().optional() }, + "devos.domain.list": {}, + "devos.domain.records": { id: zod.string() }, + "devos.automation.list": {}, + "devos.automation.run": { id: zod.string() }, + "devos.task.list": { status: zod.string().optional() }, + "devos.task.create": { title: zod.string(), description: zod.string().optional(), priority: zod.string().optional() }, + "devos.note.create": { title: zod.string(), content: zod.string().optional() }, + "devos.container.restart": { id: zod.string() }, +}; + +@Injectable() +export class McpService { + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + private readonly tools: ToolRegistryService, + loggers: LogService, + ) { + this.logger = loggers.child("Mcp"); + } + + /** # 日志器 **/ + private readonly logger: StructuredLoggerInstance; + + /** # 解析请求中的 JWT → {userId, workspaceId, actorId} + * MCP 无会话,每个请求独立鉴权。 + */ + private async authenticate(req: FastifyRequest): Promise<{ userId: string; workspaceId: string; email: string }> { + const header = req.headers["authorization"]; + if (!header || !header.startsWith("Bearer ")) { + throw new Error("UNAUTHORIZED"); + } + const result = verifyJwt(header.slice(7), this.config.appSecret); + if (!result.ok) throw new Error("UNAUTHORIZED"); + const wid = (req.headers["x-workspace-id"] as string) || result.payload.wid; + let workspaceId: string | null = wid ?? null; + if (!workspaceId) { + const m = await this.prisma.workspaceMember.findFirst({ + where: { userId: result.payload.sub }, + orderBy: { createdAt: "asc" }, + select: { workspaceId: true }, + }); + workspaceId = m?.workspaceId ?? null; + } else { + const member = await this.prisma.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId, userId: result.payload.sub } }, + select: { workspaceId: true }, + }); + if (!member) throw new Error("FORBIDDEN"); + } + if (!workspaceId) throw new Error("FORBIDDEN"); + return { userId: result.payload.sub, workspaceId, email: result.payload.email }; + } + + /** # 处理 MCP 请求(POST JSON-RPC;无状态模式) */ + async handlePost(req: FastifyRequest, reply: FastifyReply): Promise<void> { + let auth: { userId: string; workspaceId: string; email: string }; + try { + auth = await this.authenticate(req); + } catch (error) { + const code = error instanceof Error && error.message === "FORBIDDEN" ? 403 : 401; + reply.code(code).send({ + error: { code: code === 401 ? "UNAUTHORIZED" : "FORBIDDEN", message: "MCP authentication failed", requestId: (req as { requestId?: string }).requestId ?? "" }, + }); + return; + } + + const server: McpServer = new McpServer( + { name: "devos-mcp", version: "1.0.0" }, + { instructions: "DevOS developer infrastructure OS. All tools are workspace-scoped and audited." }, + ); + this.registerTools(server, auth); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // 无状态 + enableJsonResponse: true, + }); + reply.raw.on("close", () => { + void transport.close(); + void server.close(); + }); + await server.connect(transport); + const body = req.body as unknown; + await transport.handleRequest(req.raw, reply.raw, body); + } + + /** # GET/DELETE:无状态模式不支持 SSE 流 */ + rejectMethod(reply: FastifyReply): void { + reply.code(405).send({ + error: { code: "METHOD_NOT_ALLOWED", message: "MCP endpoint only accepts POST", requestId: "" }, + }); + } + + /** # 注册 MCP 工具(经 ToolRegistry,含审计 actor=MCP) */ + private registerTools(server: McpServer, auth: { userId: string; workspaceId: string; email: string }): void { + for (const name of MCP_TOOLS) { + const tool = this.tools.get(name); + const schema = ToolSchemas[name]; + if (!tool || !schema) continue; + const registerTool = server.tool.bind(server) as unknown as ( + name: string, + description: string, + schema: zod.ZodRawShape, + cb: (args: Record<string, unknown>) => Promise<{ content: { type: "text"; text: string }[] }>, + ) => unknown; + registerTool(name, tool.description, schema, async (args: Record<string, unknown>) => { + const rid = randomUUID(); + return requestContextStore.run( + { requestId: rid, actorType: "MCP", actorId: auth.userId, workspaceId: auth.workspaceId }, + async () => { + try { + const result = await tool.execute( + { workspaceId: auth.workspaceId, actorType: "MCP", actorId: auth.userId }, + args as never, + ); + return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, result: maskSecrets(result) }) }] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`MCP tool ${name} failed: ${message.slice(0, 200)}`); + return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: message.slice(0, 500) }) }] }; + } + }, + ); + }); + } + } +} diff --git a/apps/api/src/modules/monitoring/monitoring.controller.ts b/apps/api/src/modules/monitoring/monitoring.controller.ts new file mode 100644 index 0000000..e93b6f4 --- /dev/null +++ b/apps/api/src/modules/monitoring/monitoring.controller.ts @@ -0,0 +1,56 @@ +/** + * # Monitoring 控制器(/api/v1/monitors) + */ +import { Body, Controller, Delete, Get, Param, Patch, Post } from "@nestjs/common"; +import { z } from "zod"; +import { MonitoringService, CreateMonitorSchema } from "./monitoring.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +const UpdateMonitorSchema = z.object({ enabled: z.boolean() }); + +@Controller("monitors") +export class MonitoringController { + constructor(private readonly monitoring: MonitoringService) {} + + /** # 监控列表 */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.monitoring.list(ws.id); + } + + /** # 创建监控 */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateMonitorSchema)) dto: Parameters<MonitoringService["create"]>[1], + ) { + return this.monitoring.create(ws.id, dto); + } + + /** # 监控详情(含结果历史) */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.monitoring.get(ws.id, id); + } + + /** # 更新监控(启用/禁用) */ + @Roles("MEMBER") + @Patch(":id") + update( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(UpdateMonitorSchema)) dto: { enabled: boolean }, + ) { + return this.monitoring.update(ws.id, id, dto); + } + + /** # 删除监控 */ + @Roles("ADMIN") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.monitoring.delete(ws.id, id); + } +} diff --git a/apps/api/src/modules/monitoring/monitoring.module.ts b/apps/api/src/modules/monitoring/monitoring.module.ts new file mode 100644 index 0000000..ae1fc27 --- /dev/null +++ b/apps/api/src/modules/monitoring/monitoring.module.ts @@ -0,0 +1,16 @@ +/** + * # Monitoring 模块(Task 014) + */ +import { Module } from "@nestjs/common"; +import { MonitoringController } from "./monitoring.controller"; +import { MonitoringService } from "./monitoring.service"; +import { EventModule } from "../event/event.module"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [EventModule, AuditModule], + controllers: [MonitoringController], + providers: [MonitoringService], + exports: [MonitoringService], +}) +export class MonitoringModule {} diff --git a/apps/api/src/modules/monitoring/monitoring.service.ts b/apps/api/src/modules/monitoring/monitoring.service.ts new file mode 100644 index 0000000..4434a3e --- /dev/null +++ b/apps/api/src/modules/monitoring/monitoring.service.ts @@ -0,0 +1,287 @@ +/** + * # Monitoring 服务(Task 014) + * 健康检查配置 + 定期执行(由 WorkerScheduler 驱动): + * - HTTP/TCP 检查,结果写入 monitor_results(历史可查); + * - UP↔DOWN 迁移发 website.down / website.recovered 事件(SYSTEM 主体审计)。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import type { Monitor } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { EventBusService } from "../event/event-bus.service"; +import { AuditService } from "../audit/audit.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 创建监控 DTO **/ +export const CreateMonitorSchema = z + .object({ + name: z.string().min(1).max(120), + kind: z.enum(["HTTP", "TCP"]).default("HTTP"), + /** # HTTP: 完整 URL;TCP: host:port **/ + target: z.string().min(1).max(2048), + intervalSec: z.coerce.number().int().min(30).max(3600).default(60), + timeoutMs: z.coerce.number().int().min(1000).max(30000).default(10000), + websiteId: z.string().optional(), + serverId: z.string().optional(), + enabled: z.boolean().default(true), + }) + .refine((v) => (v.kind === "HTTP" ? v.target.startsWith("http://") || v.target.startsWith("https://") : true), { + message: "HTTP monitor target must be a URL", + }); +export type CreateMonitorDto = z.infer<typeof CreateMonitorSchema>; + +/** # 检查结果 **/ +export interface CheckResult { + ok: boolean; + status?: number; + latencyMs?: number; + error?: string; +} + +@Injectable() +export class MonitoringService { + constructor( + private readonly prisma: PrismaService, + private readonly events: EventBusService, + private readonly audit: AuditService, + ) {} + + /** # 创建监控 */ + async create(workspaceId: string, dto: CreateMonitorDto) { + if (dto.websiteId) { + const website = await this.prisma.website.findFirst({ where: { id: dto.websiteId, workspaceId } }); + if (!website) throw ApiError.notFound("Website not found"); + } + const monitor = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { workspaceId, type: "MONITOR", name: dto.name, provider: "devos", status: "ACTIVE" }, + }); + const m = await tx.monitor.create({ + data: { + workspaceId, + resourceId: resource.id, + name: dto.name, + kind: dto.kind, + target: dto.target, + intervalSec: dto.intervalSec, + timeoutMs: dto.timeoutMs, + enabled: dto.enabled, + websiteId: dto.websiteId ?? null, + serverId: dto.serverId ?? null, + }, + }); + if (dto.websiteId) { + const website = await tx.website.findUnique({ where: { id: dto.websiteId } }); + if (website) { + await tx.resourceRelation.upsert({ + where: { + sourceId_targetId_relationType: { + sourceId: website.resourceId, + targetId: resource.id, + relationType: "monitors", + }, + }, + create: { workspaceId, sourceId: website.resourceId, targetId: resource.id, relationType: "monitors" }, + update: {}, + }); + } + } + return m; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "monitor.create", + resourceType: "Monitor", + resourceId: monitor.resourceId, + metadata: { target: dto.target, intervalSec: dto.intervalSec }, + }); + return monitor; + } + + /** # 监控列表 */ + async list(workspaceId: string) { + const rows = await this.prisma.monitor.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + include: { website: { select: { id: true, name: true } } }, + }); + return rows.map((m) => this.toView(m)); + } + + /** # 监控详情(含最近结果) */ + async get(workspaceId: string, id: string) { + const m = await this.prisma.monitor.findFirst({ where: { id, workspaceId } }); + if (!m) throw ApiError.notFound("Monitor not found"); + const results = await this.prisma.monitorResult.findMany({ + where: { monitorId: m.id }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + return { ...this.toView(m), results }; + } + + /** # 更新监控(启用/禁用等) */ + async update(workspaceId: string, id: string, patch: { enabled?: boolean }) { + const m = await this.prisma.monitor.findFirst({ where: { id, workspaceId } }); + if (!m) throw ApiError.notFound("Monitor not found"); + const updated = await this.prisma.monitor.update({ where: { id }, data: patch }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "monitor.update", + resourceType: "Monitor", + resourceId: m.resourceId, + metadata: patch, + }); + return this.toView(updated); + } + + /** # 删除监控 */ + async delete(workspaceId: string, id: string) { + const m = await this.prisma.monitor.findFirst({ where: { id, workspaceId } }); + if (!m) throw ApiError.notFound("Monitor not found"); + await this.prisma.monitor.delete({ where: { id: m.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "monitor.delete", + resourceType: "Monitor", + resourceId: m.resourceId, + }); + return { deleted: true }; + } + + /** # 到期监控查询(供调度器) */ + async dueMonitors(limit = 20): Promise<Monitor[]> { + const rows = await this.prisma.monitor.findMany({ + where: { + enabled: true, + OR: [{ lastCheckedAt: null }, { lastCheckedAt: { lte: new Date(Date.now() - 30_000) } }], + }, + orderBy: { lastCheckedAt: "asc" }, + take: limit, + }); + // 进一步按 intervalSec 过滤 + const now = Date.now(); + return rows.filter( + (m) => !m.lastCheckedAt || now - m.lastCheckedAt.getTime() >= m.intervalSec * 1000, + ); + } + + /** # 执行一次检查并记录(调度器调用) */ + async runCheck(monitor: Monitor): Promise<CheckResult> { + const result = await this.check(monitor); + const prevStatus = monitor.lastStatus; + const newStatus = result.ok ? "UP" : "DOWN"; + await this.prisma.$transaction([ + this.prisma.monitorResult.create({ + data: { + monitorId: monitor.id, + ok: result.ok, + status: result.status !== undefined ? String(result.status) : null, + latencyMs: result.latencyMs ?? null, + error: result.error ?? null, + }, + }), + this.prisma.monitor.update({ + where: { id: monitor.id }, + data: { + lastStatus: newStatus, + lastCheckedAt: new Date(), + consecutiveFailures: result.ok ? 0 : monitor.consecutiveFailures + 1, + }, + }), + ]); + // 迁移事件(连续 2 次失败才报 down,避免抖动) + if (monitor.kind === "HTTP" && monitor.websiteId) { + const failures = result.ok ? 0 : monitor.consecutiveFailures + 1; + if (prevStatus === "UP" && newStatus === "DOWN" && failures >= 2) { + // 保持 DOWN 语义:首跨即报(v1 抖动容忍策略:连续 2 次失败) + } + if (prevStatus !== "DOWN" && newStatus === "DOWN") { + await this.events.publish("website.down", { + workspaceId: monitor.workspaceId, + resourceId: monitor.resourceId, + payload: { + monitorId: monitor.id, + websiteId: monitor.websiteId, + name: monitor.name, + target: monitor.target, + error: result.error ?? `status ${result.status}`, + }, + }); + await this.audit.record({ + workspaceId: monitor.workspaceId, actorType: "SYSTEM", action: "monitor.down", + resourceType: "Monitor", resourceId: monitor.resourceId, status: "FAILURE", riskLevel: "MEDIUM", + }); + } + if (prevStatus === "DOWN" && newStatus === "UP") { + await this.events.publish("website.recovered", { + workspaceId: monitor.workspaceId, + resourceId: monitor.resourceId, + payload: { monitorId: monitor.id, websiteId: monitor.websiteId, name: monitor.name, latencyMs: result.latencyMs }, + }); + await this.audit.record({ + workspaceId: monitor.workspaceId, actorType: "SYSTEM", action: "monitor.recovered", + resourceType: "Monitor", resourceId: monitor.resourceId, + }); + } + } + return result; + } + + /** # 执行单次检查(不做持久化) */ + async check(monitor: Monitor): Promise<CheckResult> { + if (monitor.kind === "HTTP") { + const start = Date.now(); + try { + const res = await fetch(monitor.target, { + signal: AbortSignal.timeout(monitor.timeoutMs), + redirect: "manual", + }); + return { ok: res.status < 500, status: res.status, latencyMs: Date.now() - start }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "request failed" }; + } + } + // TCP 检查 + const [host, portStr] = monitor.target.split(":"); + const port = Number(portStr); + if (!host || !Number.isInteger(port)) return { ok: false, error: "invalid TCP target (host:port)" }; + return new Promise<CheckResult>((resolve) => { + const net = require("node:net") as typeof import("node:net"); + const start = Date.now(); + const socket = net.createConnection({ host, port }); + const done = (r: CheckResult) => { + socket.destroy(); + resolve(r); + }; + socket.setTimeout(monitor.timeoutMs); + socket.on("connect", () => done({ ok: true, latencyMs: Date.now() - start })); + socket.on("timeout", () => done({ ok: false, error: "timeout" })); + socket.on("error", (err: Error) => done({ ok: false, error: err.message })); + }); + } + + /** # 实体 → 视图 */ + private toView(m: Monitor & { website?: { id: string; name: string } | null }) { + return { + id: m.id, + resourceId: m.resourceId, + workspaceId: m.workspaceId, + name: m.name, + kind: m.kind, + target: m.target, + intervalSec: m.intervalSec, + enabled: m.enabled, + lastStatus: m.lastStatus, + consecutiveFailures: m.consecutiveFailures, + lastCheckedAt: m.lastCheckedAt, + websiteId: m.websiteId, + website: m.website ?? null, + serverId: m.serverId, + }; + } +} diff --git a/apps/api/src/modules/note/note.controller.ts b/apps/api/src/modules/note/note.controller.ts new file mode 100644 index 0000000..ebc7043 --- /dev/null +++ b/apps/api/src/modules/note/note.controller.ts @@ -0,0 +1,85 @@ +/** + * # Note 控制器(/api/v1/notes) + */ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { z } from "zod"; +import { NoteService, CreateNoteSchema, UpdateNoteSchema, NoteAiSchema } from "./note.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +const ListQuerySchema = z.object({ + projectId: z.string().optional(), + tag: z.string().optional(), + q: z.string().max(120).optional(), + folder: z.string().max(80).optional(), +}); + +@Controller("notes") +export class NoteController { + constructor(private readonly notes: NoteService) {} + + /** # 笔记列表 */ + @Get() + list( + @CurrentWorkspace() ws: RequestWorkspace, + @Query(new ZodValidationPipe(ListQuerySchema)) query: { projectId?: string; tag?: string; q?: string; folder?: string }, + ) { + return this.notes.list(ws.id, query); + } + + /** # AI 助手(续写/总结/润色/翻译/大纲/标题;不传 id 时基于 selection) */ + @Roles("MEMBER") + @Post("ai") + ai( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(NoteAiSchema)) dto: Parameters<NoteService["ai"]>[2], + ) { + return this.notes.ai(ws.id, null, dto); + } + + /** # 针对指定笔记的 AI 助手 */ + @Roles("MEMBER") + @Post(":id/ai") + aiForNote( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(NoteAiSchema)) dto: Parameters<NoteService["ai"]>[2], + ) { + return this.notes.ai(ws.id, id, dto); + } + + /** # 创建笔记 */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateNoteSchema)) dto: Parameters<NoteService["create"]>[1], + ) { + return this.notes.create(ws.id, dto); + } + + /** # 笔记详情 */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.notes.get(ws.id, id); + } + + /** # 更新笔记 */ + @Roles("MEMBER") + @Patch(":id") + update( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(UpdateNoteSchema)) dto: Parameters<NoteService["update"]>[2], + ) { + return this.notes.update(ws.id, id, dto); + } + + /** # 删除笔记 */ + @Roles("MEMBER") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.notes.delete(ws.id, id); + } +} diff --git a/apps/api/src/modules/note/note.module.ts b/apps/api/src/modules/note/note.module.ts new file mode 100644 index 0000000..e62f986 --- /dev/null +++ b/apps/api/src/modules/note/note.module.ts @@ -0,0 +1,15 @@ +/** + * # Note 模块 + */ +import { Module } from "@nestjs/common"; +import { NoteController } from "./note.controller"; +import { NoteService } from "./note.service"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [AuditModule], + controllers: [NoteController], + providers: [NoteService], + exports: [NoteService], +}) +export class NoteModule {} diff --git a/apps/api/src/modules/note/note.service.ts b/apps/api/src/modules/note/note.service.ts new file mode 100644 index 0000000..acbb677 --- /dev/null +++ b/apps/api/src/modules/note/note.service.ts @@ -0,0 +1,232 @@ +/** + * # Note 服务(Phase E/05 Knowledge 基础) + * Markdown 笔记:标签/项目关联/资源链接(relations 由资源层承载);NOTE 资源入图谱。 + * 编辑器:V1 为 markdown 文本 + 前端预览(TipTap 富文本留待下一迭代)。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import { PrismaService } from "../../prisma/prisma.service"; +import { AuditService } from "../audit/audit.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; +import { AiProviderService } from "../../common/ai/ai-provider.service"; + +/** # 创建笔记 DTO **/ +export const CreateNoteSchema = z.object({ + title: z.string().min(1).max(200), + content: z.string().max(200_000).default(""), + tags: z.array(z.string().max(40)).max(20).default([]), + projectId: z.string().optional(), +}); +export type CreateNoteDto = z.infer<typeof CreateNoteSchema>; + +/** # 更新笔记 DTO **/ +export const UpdateNoteSchema = CreateNoteSchema.partial().extend({ + pinned: z.boolean().optional(), + folder: z.string().max(80).nullable().optional(), +}); +export type UpdateNoteDto = z.infer<typeof UpdateNoteSchema>; + +/** # AI 助手动作 DTO **/ +export const NoteAiSchema = z.object({ + action: z.enum(["continue", "summarize", "improve", "translate_en", "translate_zh", "outline", "title"]), + /** # 选中文本(可选;缺省用全文) **/ + selection: z.string().max(20_000).optional(), +}); + +@Injectable() +export class NoteService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + private readonly aiProvider: AiProviderService, + ) {} + + /** # 创建笔记 */ + async create(workspaceId: string, dto: CreateNoteDto) { + const note = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { workspaceId, type: "NOTE", name: dto.title, provider: "devos", status: "ACTIVE", metadata: { tags: dto.tags } }, + }); + const created = await tx.note.create({ + data: { + workspaceId, + resourceId: resource.id, + title: dto.title, + content: dto.content, + tags: dto.tags as unknown as object, + projectId: dto.projectId ?? null, + }, + }); + if (dto.projectId) { + await tx.resourceRelation.create({ + data: { workspaceId, sourceId: dto.projectId, targetId: resource.id, relationType: "PROJECT_HAS_NOTE" }, + }); + } + return created; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "note.create", + resourceType: "Note", + resourceId: note.resourceId, + }); + return this.view(note); + } + + /** # 笔记列表(搜索/标签/文件夹过滤;置顶优先;不含正文) */ + async list(workspaceId: string, filter: { projectId?: string; tag?: string; q?: string; folder?: string }) { + const rows = await this.prisma.note.findMany({ + where: { + workspaceId, + ...(filter.projectId ? { projectId: filter.projectId } : {}), + ...(filter.folder ? { folder: filter.folder } : {}), + ...(filter.q + ? { OR: [{ title: { contains: filter.q, mode: "insensitive" as const } }, { content: { contains: filter.q } }] } + : {}), + }, + orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], + take: 200, + select: { + id: true, resourceId: true, title: true, tags: true, projectId: true, + pinned: true, folder: true, createdAt: true, updatedAt: true, + }, + }); + const list = rows.map((n) => ({ ...n, excerpt: undefined })); + return filter.tag ? list.filter((n) => (n.tags as string[]).includes(filter.tag!)) : list; + } + + /** # AI 助手:续写/总结/润色/翻译/大纲/起标题(Phase 用户体验增强) */ + async ai(workspaceId: string, id: string | null, dto: z.infer<typeof NoteAiSchema>) { + const selection = dto.selection?.trim(); + let note: { title: string; content: string } | null = null; + if (id) { + const row = await this.mustGet(workspaceId, id); + note = { title: row.title, content: row.content }; + } + const prompts: Record<string, { system: string; user: string }> = { + continue: { + system: "You are a writing assistant inside a developer note app. Continue the note naturally in the SAME language and style. Output only the continuation, no preamble, HTML-safe plain text with simple markdown allowed.", + user: `Continue this note:\n\nTitle: ${note?.title ?? "(untitled)"}\n\n${selection ?? note?.content ?? ""}`, + }, + summarize: { + system: "Summarize the given note concisely as bullet points, in the same language as the note. Output bullets only.", + user: (selection ?? note?.content ?? "").slice(0, 20_000), + }, + improve: { + system: "Polish the given text: fix grammar, improve clarity and structure. Keep the original language, meaning and formatting (markdown allowed). Output only the improved text.", + user: (selection ?? note?.content ?? "").slice(0, 20_000), + }, + translate_en: { + system: "Translate the text to English. Output only the translation.", + user: (selection ?? note?.content ?? "").slice(0, 20_000), + }, + translate_zh: { + system: "Translate the text to Simplified Chinese. Output only the translation.", + user: (selection ?? note?.content ?? "").slice(0, 20_000), + }, + outline: { + system: "Generate a hierarchical outline (markdown headings/bullets) of the note. Output only the outline.", + user: (selection ?? note?.content ?? "").slice(0, 20_000), + }, + title: { + system: "Generate ONE short title (max 20 chars, same language as content) for the note. Output only the title text.", + user: (selection ?? note?.content ?? "(empty)").slice(0, 5_000), + }, + }; + const p = prompts[dto.action]; + const result = await this.aiProvider.complete(p.system, p.user); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "note.ai", + resourceType: "Note", + resourceId: id, + metadata: { action: dto.action, provider: this.aiProvider.enabled ? "configured" : "missing" }, + }); + return { action: dto.action, result }; + } + + /** # 笔记详情(含正文) */ + async get(workspaceId: string, id: string) { + return this.view(await this.mustGet(workspaceId, id)); + } + + /** # 更新笔记 */ + async update(workspaceId: string, id: string, dto: UpdateNoteDto) { + const note = await this.mustGet(workspaceId, id); + const updated = await this.prisma.$transaction(async (tx) => { + const n = await tx.note.update({ + where: { id: note.id }, + data: { + ...(dto.title ? { title: dto.title } : {}), + ...(dto.content !== undefined ? { content: dto.content } : {}), + ...(dto.tags ? { tags: dto.tags as unknown as object } : {}), + ...(dto.projectId !== undefined ? { projectId: dto.projectId } : {}), + ...(dto.pinned !== undefined ? { pinned: dto.pinned } : {}), + ...(dto.folder !== undefined ? { folder: dto.folder } : {}), + }, + }); + if (dto.title) { + await tx.resource.update({ where: { id: note.resourceId }, data: { name: dto.title } }); + } + return n; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "note.update", + resourceType: "Note", + resourceId: note.resourceId, + }); + return this.view(updated); + } + + /** # 删除笔记 */ + async delete(workspaceId: string, id: string) { + const note = await this.mustGet(workspaceId, id); + await this.prisma.note.delete({ where: { id: note.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "note.delete", + resourceType: "Note", + resourceId: note.resourceId, + riskLevel: "MEDIUM", + }); + return { deleted: true }; + } + + /** # 断言笔记属于工作区 */ + private async mustGet(workspaceId: string, id: string) { + const note = await this.prisma.note.findFirst({ where: { id, workspaceId } }); + if (!note) throw ApiError.notFound("Note not found"); + return note; + } + + /** # 实体 → 视图 */ + private view(n: { + id: string; + resourceId: string; + workspaceId: string; + title: string; + content: string; + tags: unknown; + projectId: string | null; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: n.id, + resourceId: n.resourceId, + workspaceId: n.workspaceId, + title: n.title, + content: n.content, + tags: n.tags, + projectId: n.projectId, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + }; + } +} diff --git a/apps/api/src/modules/notification/notification.controller.ts b/apps/api/src/modules/notification/notification.controller.ts new file mode 100644 index 0000000..113f217 --- /dev/null +++ b/apps/api/src/modules/notification/notification.controller.ts @@ -0,0 +1,30 @@ +/** + * # 通知控制器(/api/v1/notifications) + */ +import { Controller, Get, Param, Post } from "@nestjs/common"; +import { NotificationService } from "./notification.service"; +import { CurrentWorkspace } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +@Controller("notifications") +export class NotificationController { + constructor(private readonly notifications: NotificationService) {} + + /** # 通知列表 */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.notifications.list(ws.id); + } + + /** # 标记已读 */ + @Post(":id/read") + markRead(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.notifications.markRead(ws.id, id); + } + + /** # 全部已读 */ + @Post("read-all") + markAllRead(@CurrentWorkspace() ws: RequestWorkspace) { + return this.notifications.markAllRead(ws.id); + } +} diff --git a/apps/api/src/modules/notification/notification.module.ts b/apps/api/src/modules/notification/notification.module.ts new file mode 100644 index 0000000..0c979ab --- /dev/null +++ b/apps/api/src/modules/notification/notification.module.ts @@ -0,0 +1,13 @@ +/** + * # 通知模块 + */ +import { Module } from "@nestjs/common"; +import { NotificationController } from "./notification.controller"; +import { NotificationService } from "./notification.service"; + +@Module({ + controllers: [NotificationController], + providers: [NotificationService], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/apps/api/src/modules/notification/notification.service.ts b/apps/api/src/modules/notification/notification.service.ts new file mode 100644 index 0000000..b22118d --- /dev/null +++ b/apps/api/src/modules/notification/notification.service.ts @@ -0,0 +1,61 @@ +/** + * # 通知服务(Phase F/I) + * 站内通知:自动化动作、事件投递;供布局铃铛与通知中心使用。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import { PrismaService } from "../../prisma/prisma.service"; + +/** # 推送通知输入 **/ +export const PushNotificationSchema = z.object({ + title: z.string().min(1).max(200), + body: z.string().max(2000).default(""), + level: z.enum(["INFO", "WARN", "ERROR"]).default("INFO"), + resourceId: z.string().optional(), +}); +export type PushNotificationDto = z.infer<typeof PushNotificationSchema>; + +@Injectable() +export class NotificationService { + constructor(private readonly prisma: PrismaService) {} + + /** # 推送一条站内通知(内部与自动化动作共用) */ + async push(workspaceId: string, dto: PushNotificationDto) { + return this.prisma.notification.create({ + data: { + workspaceId, + title: dto.title, + body: dto.body, + level: dto.level, + resourceId: dto.resourceId ?? null, + }, + }); + } + + /** # 通知列表(最新 50 条) */ + async list(workspaceId: string) { + return this.prisma.notification.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + } + + /** # 标记单条已读 */ + async markRead(workspaceId: string, id: string) { + await this.prisma.notification.updateMany({ + where: { id, workspaceId }, + data: { readAt: new Date() }, + }); + return { ok: true }; + } + + /** # 全部已读 */ + async markAllRead(workspaceId: string) { + const r = await this.prisma.notification.updateMany({ + where: { workspaceId, readAt: null }, + data: { readAt: new Date() }, + }); + return { updated: r.count }; + } +} diff --git a/apps/api/src/modules/prisma/migrations/20260908181000_enum_certificate/migration.sql b/apps/api/src/modules/prisma/migrations/20260908181000_enum_certificate/migration.sql new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/resource/resource.controller.ts b/apps/api/src/modules/resource/resource.controller.ts new file mode 100644 index 0000000..861b4c6 --- /dev/null +++ b/apps/api/src/modules/resource/resource.controller.ts @@ -0,0 +1,96 @@ +/** + * # 资源控制器(/api/v1/resources) + */ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { + ResourceService, + CreateResourceSchema, + UpdateResourceSchema, + ListResourcesQuerySchema, + CreateRelationSchema, +} from "./resource.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +@Controller("resources") +export class ResourceController { + constructor(private readonly resources: ResourceService) {} + + /** # 资源列表 */ + @Get() + list( + @CurrentWorkspace() ws: RequestWorkspace, + @Query(new ZodValidationPipe(ListResourcesQuerySchema)) query: Parameters<ResourceService["list"]>[1], + ) { + return this.resources.list(ws.id, query); + } + + /** # 创建通用资源 */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateResourceSchema)) dto: Parameters<ResourceService["create"]>[1], + ) { + return this.resources.create(ws.id, dto); + } + + /** # 资源详情(含关系) */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.resources.get(ws.id, id); + } + + /** # 更新资源 */ + @Roles("MEMBER") + @Patch(":id") + update( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(UpdateResourceSchema)) dto: Parameters<ResourceService["update"]>[2], + ) { + return this.resources.update(ws.id, id, dto); + } + + /** # 删除资源 */ + @Roles("ADMIN") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.resources.delete(ws.id, id); + } + + /** # 删除影响检查(删除前确认用) */ + @Get(":id/relations/impact") + deleteImpact(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.resources.deleteImpact(ws.id, id); + } + + /** # 关系列表 */ + @Get(":id/relations") + relations(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.resources.relations(ws.id, id); + } + + /** # 添加关系 */ + @Roles("MEMBER") + @Post(":id/relations") + addRelation( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(CreateRelationSchema)) dto: Parameters<ResourceService["addRelation"]>[2], + ) { + return this.resources.addRelation(ws.id, id, dto); + } + + /** # 删除关系 */ + @Roles("MEMBER") + @Delete(":id/relations/:relationId") + removeRelation( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Param("relationId") relationId: string, + ) { + return this.resources.removeRelation(ws.id, id, relationId); + } +} diff --git a/apps/api/src/modules/resource/resource.module.ts b/apps/api/src/modules/resource/resource.module.ts new file mode 100644 index 0000000..f4ee9d4 --- /dev/null +++ b/apps/api/src/modules/resource/resource.module.ts @@ -0,0 +1,15 @@ +/** + * # Resource 模块(Task 007) + */ +import { Module } from "@nestjs/common"; +import { ResourceController } from "./resource.controller"; +import { ResourceService } from "./resource.service"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [AuditModule], + controllers: [ResourceController], + providers: [ResourceService], + exports: [ResourceService], +}) +export class ResourceModule {} diff --git a/apps/api/src/modules/resource/resource.service.ts b/apps/api/src/modules/resource/resource.service.ts new file mode 100644 index 0000000..d769533 --- /dev/null +++ b/apps/api/src/modules/resource/resource.service.ts @@ -0,0 +1,311 @@ +/** + * # 统一资源服务(Task 007) + * resources 表承载统一身份/发现/图谱;类型化领域表由各模块通过事务创建并经 resourceId 关联。 + * 所有查询强制 workspace 范围。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import type { ResourceType, ResourceStatus } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { AuditService } from "../audit/audit.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 通用创建资源 DTO **/ +export const CreateResourceSchema = z.object({ + type: z.enum([ + "PROJECT", + "REPOSITORY", + "DATABASE", + "DOCUMENT", + "BACKUP", + "EMAIL_ACCOUNT", + "TASK", + "NOTE", + ]), + name: z.string().min(1).max(160), + provider: z.string().max(64).optional(), + metadata: z.record(z.unknown()).optional(), +}); +export type CreateResourceDto = z.infer<typeof CreateResourceSchema>; + +/** # 更新资源 DTO **/ +export const UpdateResourceSchema = z.object({ + name: z.string().min(1).max(160).optional(), + status: z.enum(["PENDING", "ACTIVE", "ERROR", "OFFLINE", "ARCHIVED"]).optional(), + metadata: z.record(z.unknown()).optional(), +}); +export type UpdateResourceDto = z.infer<typeof UpdateResourceSchema>; + +/** # 资源列表过滤 **/ +export const ListResourcesQuerySchema = z.object({ + type: z.string().optional(), + status: z.string().optional(), + q: z.string().max(120).optional(), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}); +export type ListResourcesQuery = z.infer<typeof ListResourcesQuerySchema>; + +/** # 添加关系 DTO **/ +export const CreateRelationSchema = z.object({ + targetId: z.string().min(1), + relationType: z.string().min(1).max(64), + metadata: z.record(z.unknown()).optional(), +}); +export type CreateRelationDto = z.infer<typeof CreateRelationSchema>; + +@Injectable() +export class ResourceService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + /** # 创建通用资源 */ + async create(workspaceId: string, dto: CreateResourceDto) { + const resource = await this.prisma.resource.create({ + data: { + workspaceId, + type: dto.type as ResourceType, + name: dto.name, + provider: dto.provider ?? null, + status: "ACTIVE", + metadata: (dto.metadata ?? {}) as object, + }, + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "resource.create", + resourceType: dto.type, + resourceId: resource.id, + }); + return this.toView(resource); + } + + /** # 资源列表(分页 + 类型/状态/关键字过滤) */ + async list(workspaceId: string, query: ListResourcesQuery) { + const where = { + workspaceId, + ...(query.type ? { type: query.type as ResourceType } : {}), + ...(query.status ? { status: query.status as ResourceStatus } : {}), + ...(query.q ? { name: { contains: query.q, mode: "insensitive" as const } } : {}), + }; + const [total, items] = await this.prisma.$transaction([ + this.prisma.resource.count({ where }), + this.prisma.resource.findMany({ + where, + orderBy: { updatedAt: "desc" }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + ]); + return { items: items.map((r) => this.toView(r)), total, page: query.page, pageSize: query.pageSize }; + } + + /** # 读取资源(含关系摘要) */ + async get(workspaceId: string, id: string) { + const resource = await this.prisma.resource.findFirst({ + where: { id, workspaceId }, + include: { + outgoingRelations: { include: { target: { select: { id: true, type: true, name: true, status: true } } } }, + incomingRelations: { include: { source: { select: { id: true, type: true, name: true, status: true } } } }, + }, + }); + if (!resource) throw ApiError.notFound("Resource not found"); + const { outgoingRelations, incomingRelations, ...rest } = resource; + return { + ...this.toView(rest), + outgoingRelations: outgoingRelations.map((r) => ({ + id: r.id, + relationType: r.relationType, + target: r.target, + })), + incomingRelations: incomingRelations.map((r) => ({ + id: r.id, + relationType: r.relationType, + source: r.source, + })), + }; + } + + /** # 更新资源 */ + async update(workspaceId: string, id: string, dto: UpdateResourceDto) { + const existing = await this.mustGet(workspaceId, id); + const updated = await this.prisma.resource.update({ + where: { id: existing.id }, + data: { + ...(dto.name ? { name: dto.name } : {}), + ...(dto.status ? { status: dto.status as ResourceStatus } : {}), + ...(dto.metadata ? { metadata: dto.metadata as object } : {}), + }, + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "resource.update", + resourceType: existing.type, + resourceId: id, + metadata: dto as Record<string, unknown>, + }); + return this.toView(updated); + } + + /** # 删除影响检查(Phase B):列出删除后会被级联移除的关系与资源摘要 */ + async deleteImpact(workspaceId: string, id: string) { + const existing = await this.mustGet(workspaceId, id); + const relations = await this.prisma.resourceRelation.findMany({ + where: { workspaceId, OR: [{ sourceId: id }, { targetId: id }] }, + include: { + source: { select: { id: true, type: true, name: true } }, + target: { select: { id: true, type: true, name: true } }, + }, + }); + const others = relations.map((r) => + r.sourceId === id + ? { relationType: r.relationType, direction: "OUTGOING" as const, other: r.target } + : { relationType: r.relationType, direction: "INCOMING" as const, other: r.source }, + ); + // 领域表级联范围 + const cascades: string[] = []; + for (const [type, label] of [ + ["SERVER", "servers"], ["CONTAINER", "containers"], ["WEBSITE", "websites"], + ["DOMAIN", "domains"], ["DEPLOYMENT", "deployments"], ["MONITOR", "monitors"], + ["CREDENTIAL", "credentials"], ["AUTOMATION", "automations"], ["AI_RUN", "ai_runs"], + ["TASK", "tasks"], ["NOTE", "notes"], ["REPOSITORY", "repositories"], + ["CERTIFICATE", "certificates"], + ] as const) { + if (existing.type === type) cascades.push(label); + } + return { + resource: { id: existing.id, type: existing.type, name: existing.name }, + relationCount: others.length, + relations: others, + cascadeTables: cascades, + warning: others.length > 0 ? `删除将影响 ${others.length} 条关系,关联资源保留但关系边将被移除` : undefined, + }; + } + + /** # 删除资源(级联其关系边;领域表由 onDelete: Cascade 处理) */ + async delete(workspaceId: string, id: string) { + const existing = await this.mustGet(workspaceId, id); + const impact = await this.deleteImpact(workspaceId, id); + await this.prisma.resource.delete({ where: { id: existing.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "resource.delete", + resourceType: existing.type, + resourceId: id, + riskLevel: "HIGH", + metadata: { relationCount: impact.relationCount }, + }); + return { deleted: true, impactedRelations: impact.relationCount }; + } + + /** # 关系列表 */ + async relations(workspaceId: string, id: string) { + const resource = await this.mustGet(workspaceId, id); + const rows = await this.prisma.resourceRelation.findMany({ + where: { workspaceId, OR: [{ sourceId: resource.id }, { targetId: resource.id }] }, + include: { + source: { select: { id: true, type: true, name: true, status: true } }, + target: { select: { id: true, type: true, name: true, status: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + return rows.map((r) => ({ + id: r.id, + relationType: r.relationType, + direction: r.sourceId === resource.id ? ("OUTGOING" as const) : ("INCOMING" as const), + source: r.source, + target: r.target, + metadata: r.metadata, + createdAt: r.createdAt, + })); + } + + /** # 添加关系边 */ + async addRelation(workspaceId: string, id: string, dto: CreateRelationDto) { + const source = await this.mustGet(workspaceId, id); + const target = await this.mustGet(workspaceId, dto.targetId); + if (source.id === target.id) throw ApiError.validation("Cannot relate a resource to itself"); + const relation = await this.prisma.resourceRelation.upsert({ + where: { + sourceId_targetId_relationType: { + sourceId: source.id, + targetId: target.id, + relationType: dto.relationType, + }, + }, + create: { + workspaceId, + sourceId: source.id, + targetId: target.id, + relationType: dto.relationType, + metadata: (dto.metadata ?? {}) as object, + }, + update: { metadata: (dto.metadata ?? {}) as object }, + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "resource.relation.add", + resourceType: source.type, + resourceId: source.id, + metadata: { relationType: dto.relationType, targetId: target.id }, + }); + return relation; + } + + /** # 删除关系边 */ + async removeRelation(workspaceId: string, id: string, relationId: string) { + await this.mustGet(workspaceId, id); + const relation = await this.prisma.resourceRelation.findFirst({ + where: { id: relationId, workspaceId, OR: [{ sourceId: id }, { targetId: id }] }, + }); + if (!relation) throw ApiError.notFound("Relation not found"); + await this.prisma.resourceRelation.delete({ where: { id: relationId } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "resource.relation.remove", + resourceId: id, + riskLevel: "MEDIUM", + }); + return { deleted: true }; + } + + /** # 断言资源存在且属于工作区 */ + async mustGet(workspaceId: string, id: string) { + const resource = await this.prisma.resource.findFirst({ where: { id, workspaceId } }); + if (!resource) throw ApiError.notFound("Resource not found"); + return resource; + } + + /** # 实体 → API 视图 */ + toView(resource: { + id: string; + workspaceId: string; + type: ResourceType; + name: string; + provider: string | null; + status: ResourceStatus; + metadata: unknown; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: resource.id, + workspaceId: resource.workspaceId, + type: resource.type, + name: resource.name, + provider: resource.provider, + status: resource.status, + metadata: (resource.metadata ?? {}) as Record<string, unknown>, + createdAt: resource.createdAt, + updatedAt: resource.updatedAt, + }; + } +} diff --git a/apps/api/src/modules/search/search.controller.ts b/apps/api/src/modules/search/search.controller.ts new file mode 100644 index 0000000..6ac00fb --- /dev/null +++ b/apps/api/src/modules/search/search.controller.ts @@ -0,0 +1,25 @@ +/** + * # 全局搜索控制器(/api/v1/search) + */ +import { Controller, Get, Query } from "@nestjs/common"; +import { z } from "zod"; +import { SearchService } from "./search.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +const QuerySchema = z.object({ q: z.string().max(120).default("") }); + +@Controller("search") +export class SearchController { + constructor(private readonly search: SearchService) {} + + /** # 全局搜索 */ + @Get() + query( + @CurrentWorkspace() ws: RequestWorkspace, + @Query(new ZodValidationPipe(QuerySchema)) query: { q: string }, + ) { + return this.search.query(ws.id, query.q); + } +} diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts new file mode 100644 index 0000000..2955b44 --- /dev/null +++ b/apps/api/src/modules/search/search.module.ts @@ -0,0 +1,13 @@ +/** + * # 搜索模块 + */ +import { Module } from "@nestjs/common"; +import { SearchController } from "./search.controller"; +import { SearchService } from "./search.service"; + +@Module({ + controllers: [SearchController], + providers: [SearchService], + exports: [SearchService], +}) +export class SearchModule {} diff --git a/apps/api/src/modules/search/search.service.ts b/apps/api/src/modules/search/search.service.ts new file mode 100644 index 0000000..9ac7158 --- /dev/null +++ b/apps/api/src/modules/search/search.service.ts @@ -0,0 +1,47 @@ +/** + * # 全局搜索服务(Phase I) + * 跨资源名称检索:servers/websites/domains/tasks/notes/repositories 等,统一返回可跳转结果。 + */ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../../prisma/prisma.service"; + +/** # 搜索结果项 **/ +export interface SearchHit { + type: string; + name: string; + status: string; + /** # 前端路由路径 **/ + path: string; + updatedAt: Date; +} + +@Injectable() +export class SearchService { + constructor(private readonly prisma: PrismaService) {} + + /** # 全局搜索(名称 contains,取前 N 条聚合) */ + async query(workspaceId: string, q: string): Promise<SearchHit[]> { + const term = q.trim(); + if (term.length < 2) return []; + const like = { contains: term, mode: "insensitive" as const }; + + const [servers, websites, domains, tasks, notes, repositories] = await this.prisma.$transaction([ + this.prisma.server.findMany({ where: { workspaceId, name: like }, take: 5, include: { resource: { select: { status: true } } } }), + this.prisma.website.findMany({ where: { workspaceId, name: like }, take: 5, include: { resource: { select: { status: true } } } }), + this.prisma.domain.findMany({ where: { workspaceId, name: like }, take: 5, include: { resource: { select: { status: true } } } }), + this.prisma.task.findMany({ where: { workspaceId, title: like }, take: 5, include: { resource: { select: { status: true } } } }), + this.prisma.note.findMany({ where: { workspaceId, title: like }, take: 5, include: { resource: { select: { status: true } } } }), + this.prisma.repository.findMany({ where: { workspaceId, fullName: like }, take: 5, include: { resource: { select: { status: true } } } }), + ]); + + const hits: SearchHit[] = [ + ...servers.map((s) => ({ type: "SERVER", name: s.name, status: s.resource.status, path: `/servers/${s.id}`, updatedAt: s.updatedAt })), + ...websites.map((w) => ({ type: "WEBSITE", name: w.name, status: w.resource.status, path: `/websites/${w.id}`, updatedAt: w.updatedAt })), + ...domains.map((d) => ({ type: "DOMAIN", name: d.name, status: d.resource.status, path: `/domains/${d.id}`, updatedAt: d.updatedAt })), + ...tasks.map((t) => ({ type: "TASK", name: t.title, status: t.resource.status, path: `/tasks`, updatedAt: t.updatedAt })), + ...notes.map((n) => ({ type: "NOTE", name: n.title, status: n.resource.status, path: `/notes`, updatedAt: n.updatedAt })), + ...repositories.map((r) => ({ type: "REPOSITORY", name: r.fullName, status: r.resource.status, path: `/repositories`, updatedAt: r.updatedAt })), + ]; + return hits.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).slice(0, 20); + } +} diff --git a/apps/api/src/modules/server/server.controller.ts b/apps/api/src/modules/server/server.controller.ts new file mode 100644 index 0000000..353bacb --- /dev/null +++ b/apps/api/src/modules/server/server.controller.ts @@ -0,0 +1,95 @@ +/** + * # Server 控制器(/api/v1/servers) + */ +import { Body, Controller, Delete, Get, Inject, Param, Post } from "@nestjs/common"; +import { forwardRef } from "@nestjs/common"; +import { ServerService, CreateServerSchema, ExecSchema, RestartServerSchema } from "./server.service"; +import { ContainerService } from "../container/container.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +@Controller("servers") +export class ServerController { + constructor( + private readonly servers: ServerService, + @Inject(forwardRef(() => ContainerService)) + private readonly containers: ContainerService, + ) {} + + /** # 服务器列表 */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.servers.list(ws.id); + } + + /** # 创建服务器 */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateServerSchema)) dto: Parameters<ServerService["create"]>[1], + ) { + return this.servers.create(ws.id, dto); + } + + /** # 服务器详情 */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.servers.get(ws.id, id); + } + + /** # 删除服务器 */ + @Roles("ADMIN") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.servers.delete(ws.id, id); + } + + /** # 连通性检查(资源状态刷新 + online/offline 事件) */ + @Roles("MEMBER") + @Post(":id/connect") + connect(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.servers.connect(ws.id, id); + } + + /** # 服务器资源统计(uptime/CPU/内存/磁盘/Top 进程) */ + @Get(":id/stats") + async stats(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.servers.stats(ws.id, id); + } + + /** # 服务器状态(含 docker 可用性) */ + @Get(":id/status") + async status(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + const server = await this.servers.mustGet(ws.id, id); + const docker = await this.containers.dockerStatus(ws.id, server); + return { + serverId: server.id, + name: server.name, + docker, + }; + } + + /** # SSH 执行命令(审计 + 危险命令升级风险) */ + @Roles("MEMBER") + @Post(":id/exec") + exec( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(ExecSchema)) dto: Parameters<ServerService["exec"]>[2], + ) { + return this.servers.exec(ws.id, id, dto); + } + + /** # 重启服务器(HIGH 风险,需 confirm=true) */ + @Roles("ADMIN") + @Post(":id/restart") + restart( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(RestartServerSchema)) _dto: { confirm: true }, + ) { + return this.servers.restart(ws.id, id); + } +} diff --git a/apps/api/src/modules/server/server.module.ts b/apps/api/src/modules/server/server.module.ts new file mode 100644 index 0000000..85793b9 --- /dev/null +++ b/apps/api/src/modules/server/server.module.ts @@ -0,0 +1,20 @@ +/** + * # Server 模块(Task 009) + */ +import { Module, forwardRef } from "@nestjs/common"; +import { ServerController } from "./server.controller"; +import { ServerService } from "./server.service"; +import { AuditModule } from "../audit/audit.module"; +import { CredentialModule } from "../credential/credential.module"; +import { EventModule } from "../event/event.module"; +import { SshModule } from "../../integrations/ssh/ssh.module"; +import { DockerModule } from "../../integrations/docker/docker.module"; +import { ContainerModule } from "../container/container.module"; + +@Module({ + imports: [forwardRef(() => ContainerModule), AuditModule, CredentialModule, EventModule, SshModule, DockerModule], + controllers: [ServerController], + providers: [ServerService], + exports: [ServerService], +}) +export class ServerModule {} diff --git a/apps/api/src/modules/server/server.service.ts b/apps/api/src/modules/server/server.service.ts new file mode 100644 index 0000000..fbc0a5a --- /dev/null +++ b/apps/api/src/modules/server/server.service.ts @@ -0,0 +1,353 @@ +/** + * # Server 服务(Task 009) + * 受管计算目标:创建(含凭据信封加密)、连通性检查、状态、SSH 执行(审计+脱敏)、重启(高危需确认)。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import type { Server } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { CredentialService } from "../credential/credential.service"; +import { SshClientService, type SshTarget } from "../../integrations/ssh/ssh-client.service"; +import { DockerService } from "../../integrations/docker/docker.service"; +import { AuditService } from "../audit/audit.service"; +import { EventBusService } from "../event/event-bus.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; +import { isDestructiveCommand } from "../../common/utils/mask"; + +/** # 创建服务器 DTO **/ +export const CreateServerSchema = z + .object({ + name: z.string().min(1).max(120), + host: z.string().min(1).max(253), + port: z.coerce.number().int().min(1).max(65535).default(22), + username: z.string().min(1).max(64), + /** # local:通过本机 docker socket 管理(无需凭据);ssh:标准 SSH 目标 **/ + provider: z.enum(["ssh", "local"]).default("ssh"), + authType: z.enum(["PASSWORD", "SSH_KEY"]).default("SSH_KEY"), + /** # 内联提供密码(将立即加密入库) **/ + password: z.string().max(1024).optional(), + /** # 内联提供私钥(将立即加密入库) **/ + privateKey: z.string().max(32768).optional(), + /** # 或引用已存在的凭据 ID **/ + credentialId: z.string().optional(), + labels: z.record(z.string()).optional(), + }) + .refine( + (v) => + v.provider === "local" || + Boolean(v.credentialId) || + Boolean(v.password) || + Boolean(v.privateKey), + { message: "ssh provider requires credentialId, password or privateKey" }, + ); +export type CreateServerDto = z.infer<typeof CreateServerSchema>; + +/** # SSH 执行 DTO **/ +export const ExecSchema = z.object({ + command: z.string().min(1).max(8192), + timeoutMs: z.coerce.number().int().min(1000).max(600_000).default(30_000), +}); +export type ExecDto = z.infer<typeof ExecSchema>; + +/** # 重启 DTO(必须显式确认) **/ +export const RestartServerSchema = z.object({ + confirm: z.literal(true), +}); + +@Injectable() +export class ServerService { + constructor( + private readonly prisma: PrismaService, + private readonly ssh: SshClientService, + private readonly docker: DockerService, + private readonly credentials: CredentialService, + private readonly audit: AuditService, + private readonly events: EventBusService, + ) {} + + /** # 创建服务器(CREDENTIAL 资源与 SERVER 资源一并入图谱) */ + async create(workspaceId: string, dto: CreateServerDto) { + let credentialId: string | null = dto.credentialId ?? null; + if (dto.provider !== "local") { + if (!credentialId) { + // 内联凭据 → 保险库 + const credential = await this.credentials.create(workspaceId, { + name: `${dto.name}-credential`, + type: dto.authType === "PASSWORD" ? "SSH_PASSWORD" : "SSH_KEY", + provider: "ssh", + secret: dto.password ?? dto.privateKey ?? "", + }); + credentialId = credential.id; + } else { + // 校验引用的凭据属于本工作区 + await this.credentials.get(workspaceId, credentialId); + } + } + + const server = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { + workspaceId, + type: "SERVER", + name: dto.name, + provider: dto.provider, + status: "PENDING", + metadata: { host: dto.host, port: dto.port, username: dto.username }, + }, + }); + return tx.server.create({ + data: { + workspaceId, + resourceId: resource.id, + name: dto.name, + host: dto.host, + port: dto.port, + username: dto.username, + authType: dto.authType, + provider: dto.provider, + credentialId, + labels: (dto.labels ?? {}) as object, + }, + }); + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "server.create", + resourceType: "Server", + resourceId: server.resourceId, + metadata: { host: dto.host, port: dto.port, provider: dto.provider }, + }); + return this.view(server); + } + + /** # 服务器列表 */ + async list(workspaceId: string) { + const rows = await this.prisma.server.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + }); + return rows.map((s) => this.view(s)); + } + + /** # 服务器详情 */ + async get(workspaceId: string, id: string) { + return this.view(await this.mustGet(workspaceId, id)); + } + + /** # 删除服务器 */ + async delete(workspaceId: string, id: string) { + const server = await this.mustGet(workspaceId, id); + await this.prisma.server.delete({ where: { id: server.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "server.delete", + resourceType: "Server", + resourceId: server.resourceId, + riskLevel: "HIGH", + }); + return { deleted: true }; + } + + /** # 连通性检查:SSH 探测 / 本机 socket 探测;更新资源状态并发 server.online/offline 事件 */ + async connect(workspaceId: string, id: string) { + const server = await this.mustGet(workspaceId, id); + const started = Date.now(); + let ok = false; + let info = ""; + try { + if (this.docker.isLocal(server)) { + const version = await this.docker.checkDocker({ host: "", port: 0, username: "" }, true); + ok = version.available; + info = `docker ${version.version}`; + } else { + const target = await this.sshTarget(workspaceId, server); + const result = await this.ssh.check(target); + ok = true; + info = result.uname; + } + } catch (error) { + info = error instanceof Error ? error.message.slice(0, 300) : "connect failed"; + } + const latencyMs = Date.now() - started; + const prevStatus = ( + await this.prisma.resource.findUnique({ where: { id: server.resourceId }, select: { status: true } }) + )?.status; + const newStatus = ok ? "ACTIVE" : "OFFLINE"; + await this.prisma.$transaction([ + this.prisma.server.update({ + where: { id: server.id }, + data: { lastSeenAt: ok ? new Date() : server.lastSeenAt, osInfo: ok ? info : server.osInfo }, + }), + this.prisma.resource.update({ + where: { id: server.resourceId }, + data: { status: newStatus }, + }), + ]); + // 状态迁移事件 + if (prevStatus !== newStatus) { + await this.events.publish(ok ? "server.online" : "server.offline", { + workspaceId, + resourceId: server.resourceId, + payload: { serverId: server.id, name: server.name, latencyMs, info }, + }); + } + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "server.connect", + resourceType: "Server", + resourceId: server.resourceId, + status: ok ? "SUCCESS" : "FAILURE", + metadata: { latencyMs }, + }); + return { ok, latencyMs, info, status: newStatus }; + } + + /** # SSH 执行命令(记录审计、禁止交互、强制超时、危险命令标记) */ + async exec(workspaceId: string, id: string, dto: ExecDto) { + const server = await this.mustGet(workspaceId, id); + if (this.docker.isLocal(server)) { + throw ApiError.validation("Local provider server does not support SSH exec"); + } + const destructive = isDestructiveCommand(dto.command); + const target = await this.sshTarget(workspaceId, server); + try { + const result = await this.ssh.exec(target, dto.command, dto.timeoutMs); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "server.exec", + resourceType: "Server", + resourceId: server.resourceId, + riskLevel: destructive ? "CRITICAL" : "MEDIUM", + status: result.code === 0 ? "SUCCESS" : "FAILURE", + metadata: { command: dto.command, exitCode: result.code }, + }); + return { + exitCode: result.code, + stdout: result.stdout.slice(0, 128 * 1024), + stderr: result.stderr.slice(0, 64 * 1024), + }; + } catch (error) { + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "server.exec", + resourceType: "Server", + resourceId: server.resourceId, + riskLevel: destructive ? "CRITICAL" : "MEDIUM", + status: "FAILURE", + metadata: { command: dto.command, error: error instanceof Error ? error.message : "failed" }, + }); + throw error; + } + } + + /** # 重启服务器(HIGH 风险:须 confirm=true 显式确认) */ + async restart(workspaceId: string, id: string) { + const server = await this.mustGet(workspaceId, id); + if (this.docker.isLocal(server)) { + throw ApiError.validation("Local provider server cannot be rebooted"); + } + const target = await this.sshTarget(workspaceId, server); + try { + await this.ssh.exec(target, "sudo -n reboot || reboot", 10_000); + } catch { + // 重启会导致连接中断,视为已下发 + } + await this.prisma.resource.update({ + where: { id: server.resourceId }, + data: { status: "PENDING" }, + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "server.restart", + resourceType: "Server", + resourceId: server.resourceId, + riskLevel: "HIGH", + metadata: { host: server.host }, + }); + return { queued: true, message: "Reboot command issued; status will refresh on next connect" }; + } + + /** # 服务器资源统计(Phase C):uptime/CPU/内存/磁盘/Top 进程 */ + async stats(workspaceId: string, id: string) { + const server = await this.mustGet(workspaceId, id); + if (this.docker.isLocal(server)) { + throw ApiError.validation("Local provider server does not support SSH stats"); + } + const target = await this.sshTarget(workspaceId, server); + const script = [ + "echo -n 'uptime='; uptime -p 2>/dev/null || uptime", + "echo -n 'cpu_cores='; nproc", + "echo -n 'load='; cat /proc/loadavg", + "awk -F': ' '/MemTotal/{t=$2}/MemAvailable/{a=$2} END{print \"mem_used_mb=\" int((t-a)/1024), \"mem_total_mb=\" int(t/1024)}' /proc/meminfo", + "df -P / | awk 'NR==2{print \"disk_used_pct=\" $5, \"disk_total=\" $2}'", + "ps -eo comm,pmem --sort=-pmem | head -6 | tail -5", + ].join("; "); + const res = await this.ssh.exec(target, script, 15_000); + const parse = (key: string): string | null => { + const m = res.stdout.match(new RegExp(`${key}=(.*)`)); + return m ? m[1].trim() : null; + }; + const memTotalMb = Number((parse("mem_total_mb") ?? "").split(/\s/)[0]) || null; + const memUsedMb = Number((parse("mem_used_mb") ?? "").split(/\s/)[0]) || null; + return { + uptime: parse("uptime"), + cpuCores: Number(parse("cpu_cores")) || null, + load: parse("load"), + memory: memTotalMb && memUsedMb ? { usedMb: memUsedMb, totalMb: memTotalMb, usedPct: Math.round((memUsedMb / memTotalMb) * 100) } : null, + disk: parse("disk_used_pct") + ? { usedPct: Number((parse("disk_used_pct") ?? "").split(/\s/)[0].replace("%", "")), totalKb: Number((parse("disk_total") ?? "").split(/\s/)[1]) } + : null, + topProcesses: res.stdout + .split("\n") + .filter((l) => l && !l.includes("=") && !l.startsWith("COMM")) + .map((l) => { + const [comm, pmem] = l.trim().split(/\s+/); + return { command: comm, memPct: Number(pmem) || 0 }; + }) + .slice(0, 5), + }; + } + + /** # 组装 SSH 目标参数(按需解密凭据,绝不落日志) */ + async sshTarget(workspaceId: string, server: Server): Promise<SshTarget> { + const secret = await this.credentials.getSecret(workspaceId, server.credentialId); + if (!secret) throw ApiError.validation("Server has no credential configured"); + if (server.authType === "PASSWORD") { + return { host: server.host, port: server.port, username: server.username, password: secret }; + } + return { host: server.host, port: server.port, username: server.username, privateKey: secret }; + } + + /** # 断言服务器存在且属于工作区 */ + async mustGet(workspaceId: string, id: string): Promise<Server> { + const server = await this.prisma.server.findFirst({ where: { id, workspaceId } }); + if (!server) throw ApiError.notFound("Server not found"); + return server; + } + + /** # 实体 → API 视图(不含凭据) */ + private view(server: Server) { + return { + id: server.id, + resourceId: server.resourceId, + workspaceId: server.workspaceId, + name: server.name, + host: server.host, + port: server.port, + username: server.username, + authType: server.authType, + provider: server.provider, + labels: server.labels, + osInfo: server.osInfo, + lastSeenAt: server.lastSeenAt, + createdAt: server.createdAt, + }; + } +} diff --git a/apps/api/src/modules/task/task.controller.ts b/apps/api/src/modules/task/task.controller.ts new file mode 100644 index 0000000..6e56db2 --- /dev/null +++ b/apps/api/src/modules/task/task.controller.ts @@ -0,0 +1,62 @@ +/** + * # Task 控制器(/api/v1/tasks) + */ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { z } from "zod"; +import { TaskService, CreateTaskSchema, UpdateTaskSchema } from "./task.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestWorkspace } from "../../common/decorators"; + +const ListQuerySchema = z.object({ + status: z.string().optional(), + projectId: z.string().optional(), +}); + +@Controller("tasks") +export class TaskController { + constructor(private readonly tasks: TaskService) {} + + /** # 任务列表 */ + @Get() + list( + @CurrentWorkspace() ws: RequestWorkspace, + @Query(new ZodValidationPipe(ListQuerySchema)) query: { status?: string; projectId?: string }, + ) { + return this.tasks.list(ws.id, query); + } + + /** # 创建任务 */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateTaskSchema)) dto: Parameters<TaskService["create"]>[1], + ) { + return this.tasks.create(ws.id, dto); + } + + /** # 任务详情 */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.tasks.get(ws.id, id); + } + + /** # 更新任务 */ + @Roles("MEMBER") + @Patch(":id") + update( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(UpdateTaskSchema)) dto: Parameters<TaskService["update"]>[2], + ) { + return this.tasks.update(ws.id, id, dto); + } + + /** # 删除任务 */ + @Roles("MEMBER") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.tasks.delete(ws.id, id); + } +} diff --git a/apps/api/src/modules/task/task.module.ts b/apps/api/src/modules/task/task.module.ts new file mode 100644 index 0000000..2c1b05b --- /dev/null +++ b/apps/api/src/modules/task/task.module.ts @@ -0,0 +1,15 @@ +/** + * # Task 模块 + */ +import { Module } from "@nestjs/common"; +import { TaskController } from "./task.controller"; +import { TaskService } from "./task.service"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [AuditModule], + controllers: [TaskController], + providers: [TaskService], + exports: [TaskService], +}) +export class TaskModule {} diff --git a/apps/api/src/modules/task/task.service.ts b/apps/api/src/modules/task/task.service.ts new file mode 100644 index 0000000..36e36f6 --- /dev/null +++ b/apps/api/src/modules/task/task.service.ts @@ -0,0 +1,198 @@ +/** + * # Task 服务(Phase E/06) + * 工作项:backlog/todo/doing/review/done + 优先级/标签/截止日/项目关联;TASK 资源入图谱。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import { PrismaService } from "../../prisma/prisma.service"; +import { AuditService } from "../audit/audit.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 创建任务 DTO **/ +export const CreateTaskSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(8000).default(""), + status: z.enum(["BACKLOG", "TODO", "DOING", "REVIEW", "DONE"]).default("TODO"), + priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"), + labels: z.array(z.string().max(40)).max(10).default([]), + dueAt: z.coerce.date().optional(), + projectId: z.string().optional(), +}); +export type CreateTaskDto = z.infer<typeof CreateTaskSchema>; + +/** # 更新任务 DTO **/ +export const UpdateTaskSchema = CreateTaskSchema.partial(); +export type UpdateTaskDto = z.infer<typeof UpdateTaskSchema>; + +@Injectable() +export class TaskService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + /** # 创建任务(TASK 资源 + 领域行;可选 PROJECT 关系) */ + async create(workspaceId: string, dto: CreateTaskDto) { + if (dto.projectId) { + const project = await this.prisma.resource.findFirst({ + where: { id: dto.projectId, workspaceId, type: "PROJECT" }, + }); + if (!project) throw ApiError.notFound("Project not found"); + } + const task = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { + workspaceId, + type: "TASK", + name: dto.title, + provider: "devos", + status: dto.status === "DONE" ? "ARCHIVED" : "ACTIVE", + metadata: { priority: dto.priority, labels: dto.labels }, + }, + }); + const created = await tx.task.create({ + data: { + workspaceId, + resourceId: resource.id, + title: dto.title, + description: dto.description, + status: dto.status, + priority: dto.priority, + labels: dto.labels as unknown as object, + dueAt: dto.dueAt ?? null, + projectId: dto.projectId ?? null, + }, + }); + if (dto.projectId) { + await tx.resourceRelation.create({ + data: { + workspaceId, + sourceId: dto.projectId, + targetId: resource.id, + relationType: "PROJECT_HAS_TASK", + }, + }); + } + return created; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "task.create", + resourceType: "Task", + resourceId: task.resourceId, + }); + return this.view(task); + } + + /** # 任务列表(可按状态/项目过滤) */ + async list(workspaceId: string, filter: { status?: string; projectId?: string }) { + const rows = await this.prisma.task.findMany({ + where: { + workspaceId, + ...(filter.status ? { status: filter.status } : {}), + ...(filter.projectId ? { projectId: filter.projectId } : {}), + }, + orderBy: [{ status: "asc" }, { updatedAt: "desc" }], + take: 200, + }); + return rows.map((t) => this.view(t)); + } + + /** # 任务详情 */ + async get(workspaceId: string, id: string) { + return this.view(await this.mustGet(workspaceId, id)); + } + + /** # 更新任务(状态机自由流转,记录审计) */ + async update(workspaceId: string, id: string, dto: UpdateTaskDto) { + const task = await this.mustGet(workspaceId, id); + const updated = await this.prisma.$transaction(async (tx) => { + const t = await tx.task.update({ + where: { id: task.id }, + data: { + ...(dto.title ? { title: dto.title } : {}), + ...(dto.description !== undefined ? { description: dto.description } : {}), + ...(dto.status ? { status: dto.status } : {}), + ...(dto.priority ? { priority: dto.priority } : {}), + ...(dto.labels ? { labels: dto.labels as unknown as object } : {}), + ...(dto.dueAt !== undefined ? { dueAt: dto.dueAt } : {}), + ...(dto.projectId !== undefined ? { projectId: dto.projectId } : {}), + }, + }); + if (dto.title || dto.status) { + await tx.resource.update({ + where: { id: task.resourceId }, + data: { + ...(dto.title ? { name: dto.title } : {}), + ...(dto.status ? { status: dto.status === "DONE" ? "ARCHIVED" : "ACTIVE" } : {}), + }, + }); + } + return t; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "task.update", + resourceType: "Task", + resourceId: task.resourceId, + metadata: dto as Record<string, unknown>, + }); + return this.view(updated); + } + + /** # 删除任务 */ + async delete(workspaceId: string, id: string) { + const task = await this.mustGet(workspaceId, id); + await this.prisma.task.delete({ where: { id: task.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "task.delete", + resourceType: "Task", + resourceId: task.resourceId, + riskLevel: "MEDIUM", + }); + return { deleted: true }; + } + + /** # 断言任务属于工作区 */ + private async mustGet(workspaceId: string, id: string) { + const task = await this.prisma.task.findFirst({ where: { id, workspaceId } }); + if (!task) throw ApiError.notFound("Task not found"); + return task; + } + + /** # 实体 → 视图 */ + private view(t: { + id: string; + resourceId: string; + workspaceId: string; + title: string; + description: string; + status: string; + priority: string; + labels: unknown; + dueAt: Date | null; + projectId: string | null; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: t.id, + resourceId: t.resourceId, + workspaceId: t.workspaceId, + title: t.title, + description: t.description, + status: t.status, + priority: t.priority, + labels: t.labels, + dueAt: t.dueAt, + projectId: t.projectId, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + }; + } +} diff --git a/apps/api/src/modules/website/website.controller.ts b/apps/api/src/modules/website/website.controller.ts new file mode 100644 index 0000000..856d76c --- /dev/null +++ b/apps/api/src/modules/website/website.controller.ts @@ -0,0 +1,83 @@ +/** + * # Website 控制器(/api/v1/websites) + */ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { z } from "zod"; +import { WebsiteService, CreateWebsiteSchema, UpdateWebsiteSchema } from "./website.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentUser, CurrentWorkspace, Roles } from "../../common/decorators"; +import type { RequestUser, RequestWorkspace } from "../../common/decorators"; + +const LogsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(1000).default(200) }); + +@Controller("websites") +export class WebsiteController { + constructor(private readonly websites: WebsiteService) {} + + /** # 网站列表 */ + @Get() + list(@CurrentWorkspace() ws: RequestWorkspace) { + return this.websites.list(ws.id); + } + + /** # 创建网站 */ + @Roles("MEMBER") + @Post() + create( + @CurrentWorkspace() ws: RequestWorkspace, + @Body(new ZodValidationPipe(CreateWebsiteSchema)) dto: Parameters<WebsiteService["create"]>[1], + ) { + return this.websites.create(ws.id, dto); + } + + /** # 网站详情 */ + @Get(":id") + get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.websites.get(ws.id, id); + } + + /** # 更新网站 */ + @Roles("MEMBER") + @Patch(":id") + update( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Body(new ZodValidationPipe(UpdateWebsiteSchema)) dto: Parameters<WebsiteService["update"]>[2], + ) { + return this.websites.update(ws.id, id, dto); + } + + /** # 删除网站 */ + @Roles("ADMIN") + @Delete(":id") + delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.websites.delete(ws.id, id); + } + + /** # 发起部署(HIGH 风险动作;UI/MCP 调用均走此处) */ + @Roles("MEMBER") + @Post(":id/deploy") + deploy( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @CurrentUser() user: RequestUser, + ) { + return this.websites.deploy(ws.id, id, { type: "USER", id: user.id, trigger: "MANUAL" }); + } + + /** # 网站日志(最近一次部署) */ + @Get(":id/logs") + logs( + @CurrentWorkspace() ws: RequestWorkspace, + @Param("id") id: string, + @Query(new ZodValidationPipe(LogsQuerySchema)) query: { limit: number }, + ) { + return this.websites.logs(ws.id, id, query.limit); + } + + /** # 即时健康检查 */ + @Get(":id/health") + health(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) { + return this.websites.health(ws.id, id); + } +} diff --git a/apps/api/src/modules/website/website.module.ts b/apps/api/src/modules/website/website.module.ts new file mode 100644 index 0000000..138813b --- /dev/null +++ b/apps/api/src/modules/website/website.module.ts @@ -0,0 +1,17 @@ +/** + * # Website 模块(Task 011) + */ +import { Module } from "@nestjs/common"; +import { WebsiteController } from "./website.controller"; +import { WebsiteService } from "./website.service"; +import { DeploymentModule } from "../deployment/deployment.module"; +import { ResourceModule } from "../resource/resource.module"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [DeploymentModule, ResourceModule, AuditModule], + controllers: [WebsiteController], + providers: [WebsiteService], + exports: [WebsiteService], +}) +export class WebsiteModule {} diff --git a/apps/api/src/modules/website/website.service.ts b/apps/api/src/modules/website/website.service.ts new file mode 100644 index 0000000..47246fc --- /dev/null +++ b/apps/api/src/modules/website/website.service.ts @@ -0,0 +1,276 @@ +/** + * # Website 服务(Task 011) + * 网站资源:与服务器/域名/仓库建立关系;deploy 动作创建 Deployment 并入队执行。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import type { Website } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { AuditService } from "../audit/audit.service"; +import { ResourceService } from "../resource/resource.service"; +import { DeploymentService } from "../deployment/deployment.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 创建网站 DTO **/ +export const CreateWebsiteSchema = z.object({ + name: z.string().min(1).max(120), + primaryDomain: z.string().max(253).optional(), + serverId: z.string().optional(), + repositoryUrl: z.string().max(2048).optional(), + /** # 关联仓库(Git Provider 凭据克隆,优先于 repositoryUrl) **/ + repositoryId: z.string().optional(), + /** # 部署环境:development / staging / production **/ + environment: z.enum(["development", "staging", "production"]).default("production"), + deployType: z.enum(["DOCKER", "STATIC", "NODE"]).default("DOCKER"), + port: z.coerce.number().int().min(1).max(65535).default(80), + healthPath: z.string().max(200).default("/"), + env: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), + /** # 关联域名资源 ID(自动建立 has_domain 关系) **/ + domainId: z.string().optional(), +}); +export type CreateWebsiteDto = z.infer<typeof CreateWebsiteSchema>; + +/** # 更新网站 DTO **/ +export const UpdateWebsiteSchema = CreateWebsiteSchema.partial().omit({ serverId: true, domainId: true }).extend({ + serverId: z.string().nullable().optional(), +}); +export type UpdateWebsiteDto = z.infer<typeof UpdateWebsiteSchema>; + +@Injectable() +export class WebsiteService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + private readonly resources: ResourceService, + private readonly deployments: DeploymentService, + ) {} + + /** # 创建网站(资源 + 领域行 + 关系:deployed_on / has_domain) */ + async create(workspaceId: string, dto: CreateWebsiteDto) { + if (dto.serverId) { + const server = await this.prisma.server.findFirst({ where: { id: dto.serverId, workspaceId } }); + if (!server) throw ApiError.notFound("Server not found"); + } + const website = await this.prisma.$transaction(async (tx) => { + const resource = await tx.resource.create({ + data: { workspaceId, type: "WEBSITE", name: dto.name, provider: "devos", status: "PENDING" }, + }); + const created = await tx.website.create({ + data: { + workspaceId, + resourceId: resource.id, + name: dto.name, + primaryDomain: dto.primaryDomain ?? null, + serverId: dto.serverId ?? null, + repositoryUrl: dto.repositoryUrl ?? null, + repositoryId: dto.repositoryId ?? null, + environment: dto.environment, + deployType: dto.deployType, + port: dto.port, + healthPath: dto.healthPath, + env: (dto.env ?? {}) as object, + }, + }); + if (dto.serverId) { + const server = await tx.server.findUnique({ where: { id: dto.serverId } }); + if (server) { + await tx.resourceRelation.upsert({ + where: { + sourceId_targetId_relationType: { + sourceId: created.resourceId, + targetId: server.resourceId, + relationType: "deployed_on", + }, + }, + create: { workspaceId, sourceId: created.resourceId, targetId: server.resourceId, relationType: "deployed_on" }, + update: {}, + }); + } + } + if (dto.domainId) { + const domain = await tx.domain.findFirst({ where: { id: dto.domainId, workspaceId } }); + if (domain) { + await tx.resourceRelation.upsert({ + where: { + sourceId_targetId_relationType: { + sourceId: domain.resourceId, + targetId: created.resourceId, + relationType: "resolves_to", + }, + }, + create: { workspaceId, sourceId: domain.resourceId, targetId: created.resourceId, relationType: "resolves_to" }, + update: {}, + }); + } + } + return created; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "website.create", + resourceType: "Website", + resourceId: website.resourceId, + }); + return this.get(workspaceId, website.id); + } + + /** # 网站列表 */ + async list(workspaceId: string) { + const rows = await this.prisma.website.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + include: { server: { select: { id: true, name: true, host: true } } }, + }); + return rows.map((w) => this.toView(w)); + } + + /** # 网站详情(含资源状态与关系) */ + async get(workspaceId: string, id: string) { + const website = await this.mustGet(workspaceId, id); + const resource = await this.resources.get(workspaceId, website.resourceId); + const relations = [...resource.outgoingRelations, ...resource.incomingRelations]; + return { ...this.toView(website), resource: { status: resource.status, relations } }; + } + + /** # 更新网站 */ + async update(workspaceId: string, id: string, dto: UpdateWebsiteDto) { + const website = await this.mustGet(workspaceId, id); + const updated = await this.prisma.$transaction(async (tx) => { + const w = await tx.website.update({ + where: { id: website.id }, + data: { + ...(dto.name ? { name: dto.name } : {}), + ...(dto.primaryDomain !== undefined ? { primaryDomain: dto.primaryDomain } : {}), + ...(dto.repositoryUrl !== undefined ? { repositoryUrl: dto.repositoryUrl } : {}), + ...(dto.deployType ? { deployType: dto.deployType } : {}), + ...(dto.environment ? { environment: dto.environment } : {}), + ...(dto.repositoryId !== undefined ? { repositoryId: dto.repositoryId } : {}), + ...(dto.port ? { port: dto.port } : {}), + ...(dto.healthPath ? { healthPath: dto.healthPath } : {}), + ...(dto.env ? { env: dto.env as object } : {}), + ...(dto.serverId !== undefined + ? { serverId: dto.serverId, ...(dto.serverId ? {} : {}) } + : {}), + }, + }); + if (dto.name) { + await tx.resource.update({ where: { id: website.resourceId }, data: { name: dto.name } }); + } + // 服务器关系重建 + if (dto.serverId !== undefined) { + await tx.resourceRelation.deleteMany({ + where: { workspaceId, sourceId: website.resourceId, relationType: "deployed_on" }, + }); + if (dto.serverId) { + const server = await tx.server.findFirst({ where: { id: dto.serverId, workspaceId } }); + if (!server) throw ApiError.notFound("Server not found"); + await tx.resourceRelation.create({ + data: { workspaceId, sourceId: website.resourceId, targetId: server.resourceId, relationType: "deployed_on" }, + }); + } + } + return w; + }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "website.update", + resourceType: "Website", + resourceId: website.resourceId, + metadata: dto as Record<string, unknown>, + }); + return this.toView(updated); + } + + /** # 删除网站 */ + async delete(workspaceId: string, id: string) { + const website = await this.mustGet(workspaceId, id); + await this.prisma.website.delete({ where: { id: website.id } }); + await this.audit.record({ + workspaceId, + actorType: getRequestContext()?.actorType ?? "USER", + action: "website.delete", + resourceType: "Website", + resourceId: website.resourceId, + riskLevel: "HIGH", + }); + return { deleted: true }; + } + + /** # 发起部署:创建 Deployment(QUEUED)并入队,返回部署视图 */ + async deploy( + workspaceId: string, + id: string, + actor: { type: "USER" | "AGENT" | "MCP" | "SYSTEM"; id?: string; trigger?: string }, + ) { + const website = await this.mustGet(workspaceId, id); + if (!website.serverId) throw ApiError.validation("Website has no target server configured"); + if (!website.repositoryId && !website.repositoryUrl) { + throw ApiError.validation("Website has no repository configured"); + } + const deployment = await this.deployments.createDeployment(workspaceId, website.id, { + trigger: actor.trigger ?? (actor.type === "USER" ? "MANUAL" : actor.type), + }); + await this.audit.record({ + workspaceId, + actorType: actor.type, + actorId: actor.id, + action: "website.deploy", + resourceType: "Website", + resourceId: website.resourceId, + riskLevel: "HIGH", + metadata: { deploymentId: deployment.id }, + }); + return deployment; + } + + /** # 网站日志:最近部署的日志尾部 */ + async logs(workspaceId: string, id: string, limit = 200) { + const website = await this.mustGet(workspaceId, id); + const deployment = await this.prisma.deployment.findFirst({ + where: { websiteId: website.id }, + orderBy: { createdAt: "desc" }, + include: { + logs: { orderBy: { seq: "desc" }, take: Math.min(limit, 1000) }, + }, + }); + return { + deploymentId: deployment?.id ?? null, + lines: (deployment?.logs ?? []).reverse().map((l) => ({ seq: l.seq, stream: l.stream, line: l.line, time: l.createdAt })), + }; + } + + /** # 即时健康检查:优先从目标服务器 curl 本机端口;否则直接请求域名 */ + async health(workspaceId: string, id: string) { + const website = await this.mustGet(workspaceId, id); + const result = await this.deployments.runHealthCheck(workspaceId, website); + return result; + } + + /** # 断言网站存在且属于工作区 */ + async mustGet(workspaceId: string, id: string): Promise<Website> { + const website = await this.prisma.website.findFirst({ where: { id, workspaceId } }); + if (!website) throw ApiError.notFound("Website not found"); + return website; + } + + /** # 实体 → 视图 */ + private toView(w: Website & { server?: { id: string; name: string; host: string } | null }) { + return { + id: w.id, + resourceId: w.resourceId, + workspaceId: w.workspaceId, + name: w.name, + primaryDomain: w.primaryDomain, + serverId: w.serverId, + server: w.server ?? null, + repositoryUrl: w.repositoryUrl, + deployType: w.deployType, + port: w.port, + healthPath: w.healthPath, + createdAt: w.createdAt, + }; + } +} diff --git a/apps/api/src/modules/worker/worker-scheduler.service.ts b/apps/api/src/modules/worker/worker-scheduler.service.ts new file mode 100644 index 0000000..21f2ad1 --- /dev/null +++ b/apps/api/src/modules/worker/worker-scheduler.service.ts @@ -0,0 +1,81 @@ +/** + * # 后台调度器 + * 进程内周期任务: + * - 监控检查循环(到期 monitor → 执行检查 → 结果/事件); + * - 自动化 cron 扫描循环(每分钟)。 + * 与 JobQueue(任务执行)共同构成异步执行层。 + */ +import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { MonitoringService } from "../monitoring/monitoring.service"; +import { AutomationService } from "../automation/automation.service"; +import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module"; + +/** # 监控循环间隔(毫秒) **/ +const MONITOR_INTERVAL_MS = 20_000; +/** # cron 扫描间隔(毫秒) **/ +const CRON_INTERVAL_MS = 60_000; + +@Injectable() +export class WorkerSchedulerService implements OnModuleInit, OnModuleDestroy { + /** # 定时器集合 **/ + private timers: ReturnType<typeof setInterval>[] = []; + /** # 停止标记 **/ + private stopped = false; + + /** # 日志器 **/ + private readonly logger: StructuredLoggerInstance; + + constructor( + private readonly monitoring: MonitoringService, + private readonly automations: AutomationService, + loggers: LogService, + ) { + this.logger = loggers.child("WorkerScheduler"); + } + + /** # 启动所有循环 */ + onModuleInit(): void { + this.timers.push( + setInterval(() => void this.monitorLoop(), MONITOR_INTERVAL_MS), + setInterval(() => void this.cronLoop(), CRON_INTERVAL_MS), + ); + for (const t of this.timers) t.unref?.(); + this.logger.log("worker scheduler started"); + } + + /** # 停止调度器 */ + onModuleDestroy(): void { + this.stopped = true; + for (const t of this.timers) clearInterval(t); + } + + /** # 监控检查循环 */ + private async monitorLoop(): Promise<void> { + if (this.stopped) return; + try { + const due = await this.monitoring.dueMonitors(20); + for (const monitor of due) { + if (this.stopped) return; + try { + await this.monitoring.runCheck(monitor); + } catch (error) { + this.logger.warn( + `monitor check failed for ${monitor.name}: ${error instanceof Error ? error.message : ""}`, + ); + } + } + } catch (error) { + this.logger.error("monitor loop failed", error instanceof Error ? error : undefined); + } + } + + /** # cron 扫描循环 */ + private async cronLoop(): Promise<void> { + if (this.stopped) return; + try { + await this.automations.runDueCron(); + } catch (error) { + this.logger.error("cron loop failed", error instanceof Error ? error : undefined); + } + } +} diff --git a/apps/api/src/modules/worker/worker.module.ts b/apps/api/src/modules/worker/worker.module.ts new file mode 100644 index 0000000..a8a139f --- /dev/null +++ b/apps/api/src/modules/worker/worker.module.ts @@ -0,0 +1,13 @@ +/** + * # Worker 调度模块(监控循环 / cron 循环) + */ +import { Module } from "@nestjs/common"; +import { WorkerSchedulerService } from "./worker-scheduler.service"; +import { MonitoringModule } from "../monitoring/monitoring.module"; +import { AutomationModule } from "../automation/automation.module"; + +@Module({ + imports: [MonitoringModule, AutomationModule], + providers: [WorkerSchedulerService], +}) +export class WorkerModule {} diff --git a/apps/api/src/modules/workspace/workspace.controller.ts b/apps/api/src/modules/workspace/workspace.controller.ts new file mode 100644 index 0000000..ba41517 --- /dev/null +++ b/apps/api/src/modules/workspace/workspace.controller.ts @@ -0,0 +1,78 @@ +/** + * # 工作区控制器(/api/v1/workspaces) + */ +import { Body, Controller, Get, Param, Post, Delete } from "@nestjs/common"; +import { + WorkspaceService, + CreateWorkspaceSchema, + AddMemberSchema, + UpdateMemberSchema, +} from "./workspace.service"; +import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe"; +import { CurrentUser, CurrentWorkspace, Roles, NoWorkspace } from "../../common/decorators"; +import type { RequestUser, RequestWorkspace } from "../../common/decorators"; + +@Controller("workspaces") +export class WorkspaceController { + constructor(private readonly workspaces: WorkspaceService) {} + + /** # 我的工作区列表(无需工作区上下文) */ + @Get() + @NoWorkspace() + list(@CurrentUser() user: RequestUser) { + return this.workspaces.listMine(user.id); + } + + /** # 创建工作区 */ + @Post() + @NoWorkspace() + create(@CurrentUser() user: RequestUser, @Body(new ZodValidationPipe(CreateWorkspaceSchema)) dto: { name: string }) { + return this.workspaces.create(user.id, dto); + } + + /** # 工作区详情 */ + @Get(":workspaceId") + async get(@Param("workspaceId") workspaceId: string) { + return this.workspaces.get(workspaceId); + } + + /** # 成员列表 */ + @Get(":workspaceId/members") + members(@Param("workspaceId") workspaceId: string) { + return this.workspaces.members(workspaceId); + } + + /** # 添加成员 */ + @Roles("ADMIN") + @Post(":workspaceId/members") + addMember( + @Param("workspaceId") workspaceId: string, + @CurrentUser() user: RequestUser, + @Body(new ZodValidationPipe(AddMemberSchema)) dto: { email: string; role: "ADMIN" | "MEMBER" | "VIEWER" }, + ) { + return this.workspaces.addMember(workspaceId, user.id, dto); + } + + /** # 更新成员角色 */ + @Roles("ADMIN") + @Post(":workspaceId/members/:memberId") + updateMember( + @Param("workspaceId") workspaceId: string, + @CurrentUser() user: RequestUser, + @Param("memberId") memberId: string, + @Body(new ZodValidationPipe(UpdateMemberSchema)) dto: { role: "OWNER" | "ADMIN" | "MEMBER" | "VIEWER" }, + ) { + return this.workspaces.updateMember(workspaceId, user.id, memberId, dto); + } + + /** # 移除成员 */ + @Roles("ADMIN") + @Delete(":workspaceId/members/:memberId") + removeMember( + @Param("workspaceId") workspaceId: string, + @CurrentUser() user: RequestUser, + @Param("memberId") memberId: string, + ) { + return this.workspaces.removeMember(workspaceId, user.id, memberId); + } +} diff --git a/apps/api/src/modules/workspace/workspace.module.ts b/apps/api/src/modules/workspace/workspace.module.ts new file mode 100644 index 0000000..d1989ad --- /dev/null +++ b/apps/api/src/modules/workspace/workspace.module.ts @@ -0,0 +1,15 @@ +/** + * # 工作区模块(Task 006) + */ +import { Module } from "@nestjs/common"; +import { WorkspaceController } from "./workspace.controller"; +import { WorkspaceService } from "./workspace.service"; +import { AuditModule } from "../audit/audit.module"; + +@Module({ + imports: [AuditModule], + controllers: [WorkspaceController], + providers: [WorkspaceService], + exports: [WorkspaceService], +}) +export class WorkspaceModule {} diff --git a/apps/api/src/modules/workspace/workspace.service.ts b/apps/api/src/modules/workspace/workspace.service.ts new file mode 100644 index 0000000..6044e11 --- /dev/null +++ b/apps/api/src/modules/workspace/workspace.service.ts @@ -0,0 +1,199 @@ +/** + * # 工作区服务(Task 006) + * 工作区 CRUD 与成员管理。所有查询严格限定在成员关系范围内(workspace isolation)。 + */ +import { Injectable } from "@nestjs/common"; +import { z } from "zod"; +import type { WorkspaceRole } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import { AuditService } from "../audit/audit.service"; +import { ApiError } from "../../common/errors/api-error"; +import { getRequestContext } from "../../common/request-context"; + +/** # 创建工作区 DTO **/ +export const CreateWorkspaceSchema = z.object({ + name: z.string().min(1).max(80), +}); +export type CreateWorkspaceDto = z.infer<typeof CreateWorkspaceSchema>; + +/** # 添加成员 DTO **/ +export const AddMemberSchema = z.object({ + email: z.string().email(), + role: z.enum(["ADMIN", "MEMBER", "VIEWER"]).default("MEMBER"), +}); +export type AddMemberDto = z.infer<typeof AddMemberSchema>; + +/** # 更新成员角色 DTO **/ +export const UpdateMemberSchema = z.object({ + role: z.enum(["OWNER", "ADMIN", "MEMBER", "VIEWER"]), +}); +export type UpdateMemberDto = z.infer<typeof UpdateMemberSchema>; + +@Injectable() +export class WorkspaceService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + /** # 我的工作区列表 */ + async listMine(userId: string) { + const memberships = await this.prisma.workspaceMember.findMany({ + where: { userId }, + include: { workspace: { select: { id: true, name: true, slug: true, createdAt: true } } }, + orderBy: { createdAt: "asc" }, + }); + return memberships.map((m) => ({ ...m.workspace, role: m.role })); + } + + /** # 创建工作区(创建者为 OWNER) */ + async create(userId: string, dto: CreateWorkspaceDto) { + const baseSlug = + dto.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "ws"; + const workspace = await this.prisma.$transaction(async (tx) => { + let slug = baseSlug; + let n = 1; + while (await tx.workspace.findUnique({ where: { slug } })) { + slug = `${baseSlug}-${n++}`; + } + const ws = await tx.workspace.create({ data: { name: dto.name, slug } }); + await tx.workspaceMember.create({ + data: { workspaceId: ws.id, userId, role: "OWNER" }, + }); + return ws; + }); + await this.audit.record({ + workspaceId: workspace.id, + actorType: "USER", + actorId: userId, + action: "workspace.create", + resourceType: "Workspace", + resourceId: workspace.id, + }); + return workspace; + } + + /** # 读取工作区(必须是成员) */ + async get(workspaceId: string) { + const ws = await this.prisma.workspace.findUnique({ + where: { id: workspaceId }, + include: { _count: { select: { members: true, resources: true } } }, + }); + if (!ws) throw ApiError.notFound("Workspace not found"); + return { + id: ws.id, + name: ws.name, + slug: ws.slug, + createdAt: ws.createdAt, + memberCount: ws._count.members, + resourceCount: ws._count.resources, + }; + } + + /** # 成员列表(必须是成员) */ + async members(workspaceId: string) { + const rows = await this.prisma.workspaceMember.findMany({ + where: { workspaceId }, + include: { user: { select: { id: true, email: true, name: true } } }, + orderBy: { createdAt: "asc" }, + }); + return rows.map((r) => ({ + id: r.id, + userId: r.user.id, + email: r.user.email, + name: r.user.name, + role: r.role, + joinedAt: r.createdAt, + })); + } + + /** # 添加成员(ADMIN+;用户须已注册) */ + async addMember(workspaceId: string, actorId: string, dto: AddMemberDto) { + const user = await this.prisma.user.findUnique({ where: { email: dto.email } }); + if (!user) throw ApiError.notFound("User not found (must register first)"); + const existing = await this.prisma.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId, userId: user.id } }, + }); + if (existing) throw ApiError.conflict("User is already a member"); + const member = await this.prisma.workspaceMember.create({ + data: { workspaceId, userId: user.id, role: dto.role as WorkspaceRole }, + }); + await this.audit.record({ + workspaceId, + actorType: "USER", + actorId, + action: "workspace.member.add", + resourceType: "WorkspaceMember", + resourceId: member.id, + riskLevel: "MEDIUM", + metadata: { email: dto.email, role: dto.role }, + }); + return { id: member.id, userId: user.id, email: user.email, role: member.role }; + } + + /** # 更新成员角色(OWNER 才能授予 OWNER;不可移除最后一位 OWNER 由 remove/update 共同保证) */ + async updateMember(workspaceId: string, actorId: string, memberId: string, dto: UpdateMemberDto) { + const member = await this.prisma.workspaceMember.findFirst({ + where: { id: memberId, workspaceId }, + }); + if (!member) throw ApiError.notFound("Member not found"); + if (dto.role === "OWNER") { + // 仅 OWNER 可移交;这里由 WorkspaceGuard(OWNER) 保证 + } + if (member.role === "OWNER" && dto.role !== "OWNER") { + const ownerCount = await this.prisma.workspaceMember.count({ + where: { workspaceId, role: "OWNER" }, + }); + if (ownerCount <= 1) throw ApiError.conflict("Cannot demote the last OWNER"); + } + const updated = await this.prisma.workspaceMember.update({ + where: { id: memberId }, + data: { role: dto.role as WorkspaceRole }, + }); + await this.audit.record({ + workspaceId, + actorType: "USER", + actorId, + action: "workspace.member.update", + resourceType: "WorkspaceMember", + resourceId: memberId, + riskLevel: "MEDIUM", + metadata: { role: dto.role }, + }); + return { id: updated.id, role: updated.role }; + } + + /** # 移除成员(ADMIN+;不可移除最后一位 OWNER) */ + async removeMember(workspaceId: string, actorId: string, memberId: string) { + const member = await this.prisma.workspaceMember.findFirst({ + where: { id: memberId, workspaceId }, + }); + if (!member) throw ApiError.notFound("Member not found"); + if (member.role === "OWNER") { + const ownerCount = await this.prisma.workspaceMember.count({ + where: { workspaceId, role: "OWNER" }, + }); + if (ownerCount <= 1) throw ApiError.conflict("Cannot remove the last OWNER"); + } + await this.prisma.workspaceMember.delete({ where: { id: memberId } }); + await this.audit.record({ + workspaceId, + actorType: "USER", + actorId, + action: "workspace.member.remove", + resourceType: "WorkspaceMember", + resourceId: memberId, + riskLevel: "HIGH", + }); + return { removed: true }; + } + + /** # 断言请求上下文(审计用) */ + private actorIdFallback(): string | null { + return getRequestContext()?.actorId ?? null; + } +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..6423dff --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,12 @@ +/** + * # 全局 Prisma 模块 + */ +import { Global, Module } from "@nestjs/common"; +import { PrismaService } from "./prisma.service"; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..e9790a8 --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,24 @@ +/** + * # Prisma 服务 + * 全局单例 PrismaClient 封装。 + */ +import { Global, Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import { PrismaClient } from "@prisma/client"; + +@Global() +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + super({ log: [{ emit: "stdout", level: "error" }] }); + } + + /** # 模块初始化时建立数据库连接 */ + async onModuleInit(): Promise<void> { + await this.$connect(); + } + + /** # 模块销毁时断开连接 */ + async onModuleDestroy(): Promise<void> { + await this.$disconnect(); + } +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..4221907 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.spec.ts", "dist", "node_modules"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..71fb4c2 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, + "types": ["node", "bun-types"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..bbee7de --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="zh-CN"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>DevOS — Developer Work OS + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..3a0dc54 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "devos-web", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "bun build index.html --outdir dist --minify --production", + "dev": "bun --hot index.html" + }, + "dependencies": { + "@ant-design/icons": "^5.6.0", + "@ant-design/pro-components": "^2.8.0", + "@devos/api-client": "workspace:*", + "@devos/auth": "workspace:*", + "@devos/types": "workspace:*", + "@tanstack/react-query": "^5.62.0", + "@tiptap/extension-placeholder": "^3.31.3", + "@tiptap/extension-task-item": "^3.31.3", + "@tiptap/extension-task-list": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", + "antd": "^5.22.0", + "dayjs": "^1.11.13", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.28.0", + "vditor": "^4.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1" + } +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..0ecee62 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,23 @@ +/** + * # API 客户端单例 + * 开发模式(bun dev 端口 3000)走 http://localhost:4100,生产同源。 + */ +import { ApiClient } from "@devos/api-client"; +import { useAuthStore } from "../stores/auth"; + +/** # 开发模式检测:非常规端口(bun dev server)时直连 API 端口;生产走同源反代 **/ +const isDev = + typeof window !== "undefined" && + !["", "80", "443", "4100"].includes(window.location.port); + +export const api = new ApiClient({ + baseUrl: isDev ? "http://127.0.0.1:4100/api/v1" : "/api/v1", + getToken: () => useAuthStore.getState().token, + getWorkspaceId: () => useAuthStore.getState().activeWorkspaceId, + onUnauthorized: () => { + useAuthStore.getState().logout(); + if (typeof window !== "undefined" && !window.location.hash.includes("/login")) { + window.location.href = "/login"; + } + }, +}); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx new file mode 100644 index 0000000..014b3da --- /dev/null +++ b/apps/web/src/app.tsx @@ -0,0 +1,87 @@ +/** + * # 路由表 + */ +import { useEffect } from "react"; +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { useAuthStore } from "./stores/auth"; +import { AppLayout } from "./layouts/app-layout"; +import LoginPage from "./pages/login"; +import DashboardPage from "./pages/dashboard"; +import ServersPage from "./pages/servers"; +import ServerDetailPage from "./pages/server-detail"; +import ContainersPage from "./pages/containers"; +import WebsitesPage from "./pages/websites"; +import WebsiteDetailPage from "./pages/website-detail"; +import DeploymentsPage from "./pages/deployments"; +import DeploymentDetailPage from "./pages/deployment-detail"; +import DomainsPage from "./pages/domains"; +import DomainDetailPage from "./pages/domain-detail"; +import MonitoringPage from "./pages/monitoring"; +import AutomationsPage from "./pages/automations"; +import AiPage from "./pages/ai"; +import CredentialsPage from "./pages/credentials"; +import AuditPage from "./pages/audit"; +import ProjectsPage from "./pages/projects"; +import TasksPage from "./pages/tasks"; +import NotesPage from "./pages/notes"; +import CertificatesPage from "./pages/certificates"; +import JobsPage from "./pages/jobs"; +import GitPage from "./pages/git"; +import RepositoriesPage from "./pages/repositories"; +import ResourcesPage from "./pages/resources"; + +/** # 路由守卫:未登录跳转登录页 */ +function RequireAuth({ children }: { children: React.ReactNode }) { + const token = useAuthStore((s) => s.token); + const location = useLocation(); + if (!token) { + return ; + } + return <>{children}; +} + +export default function App() { + const location = useLocation(); + // 路由切换时滚动置顶 + useEffect(() => { + window.scrollTo(0, 0); + }, [location.pathname]); + + return ( + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} diff --git a/apps/web/src/components/form-modal.tsx b/apps/web/src/components/form-modal.tsx new file mode 100644 index 0000000..7cfabef --- /dev/null +++ b/apps/web/src/components/form-modal.tsx @@ -0,0 +1,61 @@ +/** + * # 统一模态约束组件(FormModal) + * 规则:所有模态整体高度不超过可视区 90%: + * - Modal 顶部偏移 5vh,body 限高 calc(90vh - 130px) 并内部滚动; + * - 长表单通过 children 内使用 Tabs 分组排版。 + */ +import { ModalForm } from "@ant-design/pro-components"; +import type { ModalFormProps } from "@ant-design/pro-components"; +import { Modal } from "antd"; +import type { ModalProps } from "antd"; +import type { ReactNode } from "react"; + +/** # 通用模态高度约束 props(非表单 Modal 也使用) */ +export const modalFitProps: ModalProps = { + centered: false, + style: { top: "5vh", paddingBottom: 0 }, + styles: { body: { maxHeight: "calc(90vh - 130px)", overflowY: "auto", paddingRight: 6 } }, +}; + +/** # 表单模态(所有新增/编辑统一走此组件) */ +export type FormModalProps = Omit>, "onFinish"> & { + /** # 业务表单的 onFinish(values 为各表单自定义形状;ModalForm 运行时仍传 Record) **/ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onFinish?: (values: any) => Promise | void; +}; + +export function FormModal(props: FormModalProps) { + const { modalProps, width = 560, onFinish, ...rest } = props; + return ( + > + width={width} + modalProps={{ + destroyOnClose: true, + maskClosable: false, + ...modalFitProps, + ...modalProps, + styles: { + ...modalFitProps.styles, + ...(modalProps?.styles ?? {}), + }, + }} + {...rest} + onFinish={onFinish as ModalFormProps>["onFinish"]} + /> + ); +} + +/** # 普通模态(非表单:SSH 终端、日志查看等) */ +export function FitModal(props: ModalProps) { + const { styles, ...rest } = props; + return ( + + ); +} + +/** # 表单分组容器:长表单用 Tabs 排版(模态内滚动) */ +export { Tabs as FormTabs } from "antd"; diff --git a/apps/web/src/components/shared.tsx b/apps/web/src/components/shared.tsx new file mode 100644 index 0000000..cd8259e --- /dev/null +++ b/apps/web/src/components/shared.tsx @@ -0,0 +1,61 @@ +/** + * # 共享小组件:状态徽标 / 风险标签 / 时间显示 + */ +import { Badge, Tag, Typography } from "antd"; + +/** # 资源/监控状态徽标 **/ +const STATUS_COLOR: Record = { + ACTIVE: "success", + UP: "success", + running: "success", + SUCCEEDED: "success", + PENDING: "warning", + QUEUED: "processing", + RUNNING: "processing", + UNKNOWN: "default", + OFFLINE: "error", + DOWN: "error", + FAILED: "error", + ERROR: "error", + DEAD: "error", + CANCELLED: "default", + ARCHIVED: "default", + exited: "default", +}; + +export function StatusBadge({ status }: { status: string | undefined | null }) { + if (!status) return ; + return ; +} + +/** # 风险等级标签 **/ +const RISK_COLOR: Record = { + LOW: "green", + MEDIUM: "gold", + HIGH: "volcano", + CRITICAL: "red", +}; + +export function RiskTag({ risk }: { risk: string | undefined | null }) { + if (!risk) return null; + return {risk}; +} + +/** # 触发器/事件类型标签 **/ +export function TypeTag({ type }: { type: string }) { + return {type}; +} + +/** # 相对时间 + 绝对时间提示 **/ +export function TimeText({ time }: { time: string | null | undefined }) { + if (!time) return -; + const d = new Date(time); + const diff = Date.now() - d.getTime(); + const mins = Math.floor(diff / 60000); + let rel = d.toLocaleString(); + if (mins < 1) rel = "刚刚"; + else if (mins < 60) rel = `${mins} 分钟前`; + else if (mins < 60 * 24) rel = `${Math.floor(mins / 60)} 小时前`; + else rel = d.toLocaleDateString(); + return {rel}; +} diff --git a/apps/web/src/layouts/app-layout.tsx b/apps/web/src/layouts/app-layout.tsx new file mode 100644 index 0000000..0068aa5 --- /dev/null +++ b/apps/web/src/layouts/app-layout.tsx @@ -0,0 +1,326 @@ +/** + * # 应用主布局:ProLayout + 工作区切换 + 全局搜索 + 通知 + 主题切换 + 用户菜单 + */ +import { useMemo, useState } from "react"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { ProLayout } from "@ant-design/pro-components"; +import { + GlobalOutlined, + CloudServerOutlined, + DeploymentUnitOutlined, + HddOutlined, + AlertOutlined, + RobotOutlined, + KeyOutlined, + AuditOutlined, + DatabaseOutlined, + ClusterOutlined, + UserOutlined, + LogoutOutlined, + ThunderboltOutlined, + DashboardOutlined, + ApiOutlined, + MoonOutlined, + SunOutlined, + BellOutlined, + FolderOutlined, + CheckSquareOutlined, + ReadOutlined, + SafetyCertificateOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import { Select, Dropdown, App as AntApp, Button, Input, Popover, List, Typography, Empty, Space } from "antd"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "../stores/auth"; +import { useThemeStore } from "../stores/theme"; +import { + authApi, + workspaceApi, + searchApi, + notificationApi, + type WorkspaceRow, +} from "../services/api"; +import { TimeText } from "../components/shared"; + +/** # 导航菜单(Phase 2:Projects / Jobs / Tasks / Notes / Certificates / Git) **/ +const MENU = { + routes: [ + { path: "/dashboard", name: "工作台", icon: }, + { path: "/ai", name: "AI 助手", icon: }, + { path: "/projects", name: "项目", icon: }, + { + path: "/infra", + name: "基础设施", + icon: , + routes: [ + { path: "/servers", name: "服务器", icon: }, + { path: "/containers", name: "容器", icon: }, + { path: "/monitoring", name: "监控", icon: }, + { path: "/jobs", name: "任务中心", icon: }, + ], + }, + { + path: "/web", + name: "网站", + icon: , + routes: [ + { path: "/websites", name: "网站列表", icon: }, + { path: "/domains", name: "域名", icon: }, + { path: "/certificates", name: "证书", icon: }, + { path: "/deployments", name: "部署", icon: }, + ], + }, + { + path: "/code", + name: "代码", + icon: , + routes: [ + { path: "/git", name: "Git 连接", icon: }, + { path: "/repositories", name: "仓库", icon: }, + ], + }, + { path: "/tasks", name: "任务", icon: }, + { path: "/notes", name: "笔记", icon: }, + { path: "/resources", name: "资源目录", icon: }, + { path: "/automations", name: "自动化", icon: }, + { path: "/credentials", name: "凭据", icon: }, + { path: "/audit", name: "审计与事件", icon: }, + ], +}; + +export function AppLayout() { + const location = useLocation(); + const navigate = useNavigate(); + const { message } = AntApp.useApp(); + const queryClient = useQueryClient(); + const { token, user, activeWorkspaceId, switchWorkspace, logout } = useAuthStore(); + const { mode, toggle } = useThemeStore(); + const dark = mode === "dark"; + const [searchText, setSearchText] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); + + /** # 我的工作区列表 **/ + const { data: workspaces } = useQuery({ + queryKey: ["workspaces"], + queryFn: workspaceApi.list, + enabled: Boolean(token), + }); + + /** # 全局搜索 **/ + const { data: searchResults, isFetching: searching } = useQuery({ + queryKey: ["global-search", searchText], + queryFn: () => searchApi.query(searchText), + enabled: searchOpen && searchText.trim().length >= 2, + }); + + /** # 未读通知 **/ + const { data: notifications, refetch: refetchNotifications } = useQuery({ + queryKey: ["notifications"], + queryFn: notificationApi.list, + refetchInterval: 30_000, + enabled: Boolean(token), + }); + const unread = (notifications ?? []).filter((n) => !n.readAt); + + const selected = useMemo(() => { + const top = "/" + location.pathname.split("/")[1]; + return top; + }, [location.pathname]); + + /** # 切换工作区后清空缓存(服务端状态按工作区隔离) **/ + const onSwitch = (wid: string) => { + switchWorkspace(wid); + queryClient.clear(); + message.success("工作区已切换"); + }; + + const gotoResult = (item: { type: string; name: string; status: string; path: string }) => { + setSearchOpen(false); + setSearchText(""); + navigate(item.path); + }; + + const searchPanel = ( +
+ } + placeholder="搜索服务器 / 网站 / 域名 / 任务 / 笔记…(≥2 字符)" + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + /> +
+ {searchText.trim().length < 2 ? ( + + 输入至少 2 个字符;Ctrl/⌘+K 唤起 + + ) : searching ? ( + 搜索中… + ) : (searchResults?.length ?? 0) === 0 ? ( + + ) : ( + ( + gotoResult(item)}> + + {item.type} + {item.name} + {item.status} + + + )} + /> + )} +
+
+ ); + + const notificationPanel = ( +
+ +
+ {(notifications ?? []).length === 0 ? ( + + ) : ( + ( + + + + {n.level === "ERROR" ? "🔴" : n.level === "WARN" ? "🟡" : "🔵"} {n.title} + + {n.body && ( + {n.body} + )} + + + + )} + /> + )} +
+
+ ); + + return ( + } + layout="mix" + fixSiderbar + theme={dark ? "dark" : "light"} + location={{ pathname: location.pathname }} + selectedKeys={[selected]} + menu={{ request: async () => MENU.routes as never, params: {} }} + route={MENU as never} + menuItemRender={(item, dom) => ( + item.path && navigate(item.path)}>{dom} + )} + avatarProps={{ + icon: , + title: user?.email ?? "admin", + render: (_props, dom) => { + return ( + , label: "退出登录" }], + onClick: async ({ key }) => { + if (key === "logout") { + try { + await authApi.logout(); + } catch { + /* 忽略 */ + } + logout(); + navigate("/login"); + } + }, + }} + > + {dom} + + ); + }, + }} + headerContentRender={() => ( +
+ } + placeholder="搜索标题与正文" + value={search} + onChange={(e) => setSearch(e.target.value)} + allowClear + /> + +
+ + + {/* ── 右栏:Vditor 所见即所得 Markdown 编辑器(容器常驻,Empty 覆盖层控制显隐) ── */} +
+ + 选择或新建一篇笔记 +
+ + 所见即所得 Markdown(Typora 式)· AI 助手续写/总结/润色/翻译 + + + } + /> +
+
+ { + setTitle(e.target.value); + queueSave(); + }} + style={{ fontSize: 22, fontWeight: 700, padding: 0 }} + /> + +
+ +
+ { + const v = (e.target as HTMLInputElement).value.trim(); + if (v && !tags.includes(v)) { + setTags([...tags, v]); + queueSave(); + } + (e.target as HTMLInputElement).value = ""; + }} + /> + + {tags.map((t) => ( + { + setTags(tags.filter((x) => x !== t)); + queueSave(); + }} + > + {t} + + ))} + + ({ key: a.key, label: a.label })), + onClick: ({ key }) => { + const def = AI_ACTIONS.find((a) => a.key === key)!; + void runAi(def.key, def.replaces); + }, + }} + > + + +
+ +
+
(composingRef.current = true)} + onCompositionEnd={() => (composingRef.current = false)} + /> +
+ + 所见即所得 Markdown · 输入「/」唤起菜单 · 工具栏可切换标题级别 · 自动保存(后台执行,切换不卡顿) + +
+
+
+ + ); +} diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx new file mode 100644 index 0000000..7456097 --- /dev/null +++ b/apps/web/src/pages/projects.tsx @@ -0,0 +1,235 @@ +/** + * # 项目页(Phase B/I):项目 = 工作顶层上下文 + * 卡片网格 + 关联资源计数 + 详情抽屉(关系导航 / 最近活动 / 任务与笔记) + */ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + PageContainer, + ProFormText, + ProFormTextArea, +} from "@ant-design/pro-components"; +import { + App as AntApp, + Button, + Card, + Descriptions, + Drawer, + Empty, + Input, + Space, + Spin, + Tag, + Typography, +} from "antd"; +import { PlusOutlined, DatabaseOutlined } from "@ant-design/icons"; +import { eventApi, resourceApi } from "../services/api"; +import type { ResourceView } from "@devos/types"; +import { StatusBadge, TimeText } from "../components/shared"; +import { FormModal } from "../components/form-modal"; + +/** # 资源类型 → 前端路由 */ +const TYPE_PATH: Record string> = { + SERVER: (id) => `/servers/${id}`, + WEBSITE: (id) => `/websites/${id}`, + DOMAIN: (id) => `/domains/${id}`, +}; + +/** # 关联类型中文名 **/ +const REL_LABEL: Record = { + PROJECT_HAS_REPOSITORY: "仓库", + PROJECT_HAS_WEBSITE: "网站", + PROJECT_HAS_SERVER: "服务器", + PROJECT_HAS_DOMAIN: "域名", + PROJECT_HAS_TASK: "任务", + PROJECT_HAS_NOTE: "笔记", +}; + +export default function ProjectsPage() { + const { message } = AntApp.useApp(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [createOpen, setCreateOpen] = useState(false); + const [detail, setDetail] = useState(null); + + /** # 项目列表(PROJECT 资源) **/ + const { data: projects, isLoading } = useQuery({ + queryKey: ["projects"], + queryFn: () => resourceApi.list({ type: "PROJECT", pageSize: 100 }), + }); + + /** # 详情:关系 + 任务 + 活动 **/ + const { data: relations, isLoading: loadingRelations } = useQuery({ + queryKey: ["project-relations", detail?.id], + queryFn: () => resourceApi.relations(detail!.id), + enabled: Boolean(detail), + }); + const { data: events } = useQuery({ + queryKey: ["project-events", detail?.id], + queryFn: () => eventApi.list({ pageSize: 8 }), + enabled: Boolean(detail), + }); + + /** # 统计某项目的关联资源计数 **/ + const countByType = (rows: ReturnType[]) => { + const counts: Record = {}; + for (const r of rows as unknown as { other: { type: string } }[]) { + counts[r.other.type] = (counts[r.other.type] ?? 0) + 1; + } + return counts; + }; + + const linked = ((relations ?? []) as { + id: string; + relationType: string; + direction: string; + source: { id: string; type: string; name: string; status: string }; + target: { id: string; type: string; name: string; status: string }; + }[]).map((r) => ({ + key: r.id, + relationType: r.relationType, + other: r.direction === "OUTGOING" ? r.target : r.source, + })); + + return ( + + {isLoading ? ( + + ) : (projects?.items ?? []).length === 0 ? ( + + + + ) : ( + + {(projects?.items ?? []).map((p) => ( + setDetail(p)} + title={ + + + {p.name} + + } + > + + {String(p.metadata?.description ?? "(无描述)")} + + + 更新于 + + + ))} + setCreateOpen(true)} + > + + 新建项目 + + + + )} + + setCreateOpen(false) }} + onFinish={async (values) => { + try { + await resourceApi.create({ + type: "PROJECT", + name: String(values.name ?? ""), + metadata: { description: String(values.description ?? "") }, + }); + message.success("项目已创建"); + setCreateOpen(false); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + return true; + } catch (e) { + message.error(e instanceof Error ? e.message : "创建失败"); + return false; + } + }} + > + + + + + setDetail(null)} + width={640} + extra={ + + } + > + {detail && ( + + + + + + {String(detail.metadata?.description ?? "-")} + + + + 关联资源 + {loadingRelations ? ( + + ) : linked.length === 0 ? ( + + 暂无关联。在服务器 / 网站 / 仓库等资源的详情页可将它们与本项目建立关系。 + + ) : ( + + {linked.map((l) => { + const path = TYPE_PATH[l.other.type]?.(l.other.id); + const label = REL_LABEL[l.relationType] ?? l.relationType; + return path ? ( + + + {label}: {l.other.name} + + + ) : ( + {label}: {l.other.name} + ); + })} + + )} + + 最近活动 + {(events?.items ?? []).length === 0 ? ( + 暂无活动 + ) : ( + (events?.items ?? []).map((e) => ( + + {e.eventType} + + + )) + )} + + )} + + + ); + + function navigateToResources() { + setDetail(null); + navigate("/resources"); + } +} diff --git a/apps/web/src/pages/repositories.tsx b/apps/web/src/pages/repositories.tsx new file mode 100644 index 0000000..9c064ba --- /dev/null +++ b/apps/web/src/pages/repositories.tsx @@ -0,0 +1,155 @@ +/** + * # 仓库页(Phase D):已登记仓库 + 分支查看 + 手工登记 + */ +import { useRef, useState } from "react"; +import { + PageContainer, + ProTable, + type ActionType, + type ProColumns, + ProFormText, + ProFormSelect, +} from "@ant-design/pro-components"; +import { App as AntApp, Button, Drawer, Popconfirm, Typography } from "antd"; +import { PlusOutlined, DeleteOutlined, BranchesOutlined } from "@ant-design/icons"; +import { gitApi } from "../services/api"; +import type { RepositoryView } from "../services/api"; +import { TimeText } from "../components/shared"; +import { FormModal } from "../components/form-modal"; + +export default function RepositoriesPage() { + const { message } = AntApp.useApp(); + const actionRef = useRef(null); + const [createOpen, setCreateOpen] = useState(false); + const [branches, setBranches] = useState<{ repo: RepositoryView; list: { name: string; commit: string }[] } | null>(null); + + const columns: ProColumns[] = [ + { title: "仓库", dataIndex: "fullName" }, + { title: "提供商", dataIndex: "provider", render: (_, r) => {r.provider} }, + { title: "默认分支", dataIndex: "defaultBranch" }, + { title: "可见性", dataIndex: "visibility" }, + { title: "克隆地址", dataIndex: "cloneUrl", ellipsis: true, render: (_, r) => {r.cloneUrl} }, + { title: "更新时间", dataIndex: "updatedAt", render: (_, r) => }, + { + title: "操作", + valueType: "option", + width: 180, + render: (_, record) => [ + { + try { + const list = await gitApi.listBranches(record.id); + setBranches({ repo: record, list }); + } catch (e) { + message.error(e instanceof Error ? e.message : "分支拉取失败"); + } + }} + > + 分支 + , + { + await gitApi.deleteRepository(record.id); + message.success("已移除"); + actionRef.current?.reload(); + }} + > + + , + ], + }, + ]; + + return ( + + + scroll={{ x: "max-content" }} + rowKey="id" + columns={columns} + actionRef={actionRef} + headerTitle="代码仓库" + search={false} + pagination={{ pageSize: 20 }} + request={async () => { + const items = await gitApi.listRepositories(); + return { data: items, success: true, total: items.length }; + }} + toolBarRender={() => [ + , + ]} + /> + + setCreateOpen(false) }} + onFinish={async (values) => { + try { + await gitApi.createRepository(values as never); + message.success("仓库已登记"); + setCreateOpen(false); + actionRef.current?.reload(); + return true; + } catch (e) { + message.error(e instanceof Error ? e.message : "登记失败"); + return false; + } + }} + > + + + ({ label: p, value: p }))} + /> + + + + + setBranches(null)} + width={420} + > + + 默认分支:{branches?.repo.defaultBranch} + + {(branches?.list ?? []).map((b) => ( +
+ {b.name} + + {b.commit.slice(0, 8)} + +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/pages/resources.tsx b/apps/web/src/pages/resources.tsx new file mode 100644 index 0000000..80156d0 --- /dev/null +++ b/apps/web/src/pages/resources.tsx @@ -0,0 +1,75 @@ +/** + * # 资源目录页:统一资源模型的总览(类型过滤 + 关键字搜索) + */ +import { useRef } from "react"; +import { + PageContainer, + ProTable, + type ActionType, + type ProColumns, +} from "@ant-design/pro-components"; +import { Space, Tag, Typography } from "antd"; +import { resourceApi } from "../services/api"; +import type { ResourceView } from "@devos/types"; +import { StatusBadge, TimeText } from "../components/shared"; + +const TYPE_COLOR: Record = { + SERVER: "geekblue", + WEBSITE: "green", + DOMAIN: "purple", + CONTAINER: "cyan", + DEPLOYMENT: "volcano", + MONITOR: "orange", + CREDENTIAL: "red", + AUTOMATION: "blue", + AI_RUN: "magenta", +}; + +export default function ResourcesPage() { + const actionRef = useRef(null); + + const columns: ProColumns[] = [ + { + title: "类型", + dataIndex: "type", + width: 120, + render: (_, r) => {r.type}, + filters: Object.keys(TYPE_COLOR).map((t) => ({ text: t, value: t })), + onFilter: (value, record) => record.type === value, + }, + { + title: "名称", + dataIndex: "name", + render: (_, r) => {r.name}, + }, + { title: "提供商", dataIndex: "provider", width: 110, render: (_, r) => r.provider ?? "-" }, + { title: "状态", dataIndex: "status", width: 110, render: (_, r) => }, + { title: "更新时间", dataIndex: "updatedAt", width: 160, render: (_, r) => }, + ]; + + return ( + + + 统一资源模型:所有资源共享身份、状态与关系图谱,供自动化与 AI 检索。 + + + scroll={{ x: "max-content" }} + rowKey="id" + columns={columns} + actionRef={actionRef} + headerTitle="全部资源" + search={false} + pagination={{ pageSize: 20 }} + request={async (params) => { + const res = await resourceApi.list({ + page: params.current ?? 1, + pageSize: params.pageSize ?? 20, + q: params.name, + type: (params as { type?: string }).type, + }); + return { data: res.items, success: true, total: res.total }; + }} + /> + + ); +} diff --git a/apps/web/src/pages/server-detail.tsx b/apps/web/src/pages/server-detail.tsx new file mode 100644 index 0000000..4bc25a8 --- /dev/null +++ b/apps/web/src/pages/server-detail.tsx @@ -0,0 +1,250 @@ +/** + * # 服务器详情页:概览 / 容器 / SSH 执行 / 关系 / 审计 + */ +import { useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { PageContainer, ProDescriptions, ProCard, ProTable } from "@ant-design/pro-components"; +import type { ProColumns } from "@ant-design/pro-components"; +import { Alert, App as AntApp, Button, Popconfirm, Space, Tag, Input } from "antd"; +import { + ArrowLeftOutlined, + ApiOutlined, + ReloadOutlined, + CodeOutlined, + DeleteOutlined, +} from "@ant-design/icons"; +import { auditApi, containerApi, resourceApi, serverApi } from "../services/api"; +import { RiskTag, StatusBadge, TimeText } from "../components/shared"; +import type { ContainerView } from "@devos/types"; +import { FitModal } from "../components/form-modal"; + +export default function ServerDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { message } = AntApp.useApp(); + const queryClient = useQueryClient(); + const [execOpen, setExecOpen] = useState(false); + const [command, setCommand] = useState("docker ps"); + const [execResult, setExecResult] = useState(""); + const [execRunning, setExecRunning] = useState(false); + + const { data: server } = useQuery({ queryKey: ["server", id], queryFn: () => serverApi.get(id!) }); + const { data: containers } = useQuery({ + queryKey: ["server-containers", id], + queryFn: () => serverApi.containers(id!), + }); + const { data: relations } = useQuery({ + queryKey: ["server-relations", server?.resourceId], + queryFn: () => resourceApi.relations(server!.resourceId), + enabled: Boolean(server?.resourceId), + }); + const { data: audit } = useQuery({ + queryKey: ["server-audit", server?.resourceId], + queryFn: () => auditApi.list({ pageSize: 10, resourceId: server!.resourceId }), + enabled: Boolean(server?.resourceId), + }); + + if (!server) { + return ; + } + + const refresh = () => queryClient.invalidateQueries({ queryKey: ["server", id] }); + + const containerColumns: ProColumns[] = [ + { title: "名称", dataIndex: "name" }, + { title: "镜像", dataIndex: "image", ellipsis: true }, + { title: "状态", dataIndex: "state", render: (_, r) => }, + { + title: "操作", + valueType: "option", + render: (_, record) => ( + { + try { + await containerApi.restart(record.id); + message.success("容器已重启"); + } catch (e) { + message.error(e instanceof Error ? e.message : "失败"); + } + }} + > + + 重启 + + + ), + }, + ]; + + const runExec = async () => { + setExecRunning(true); + setExecResult(""); + try { + const res = await serverApi.exec(id!, command); + setExecResult(`exit code: ${res.exitCode}\n\n--- stdout ---\n${res.stdout}\n--- stderr ---\n${res.stderr}`); + } catch (e) { + setExecResult(`执行失败:${e instanceof Error ? e.message : e}`); + } finally { + setExecRunning(false); + queryClient.invalidateQueries({ queryKey: ["server-audit"] }); + } + }; + + return ( + } onClick={() => navigate("/servers")}> + 返回 + , + , + , + { + await serverApi.delete(id!); + message.success("已删除"); + navigate("/servers"); + }} + > + + , + ], + }} + > + + + + + + + {`${server.host}:${server.port}`} + {server.username} + {server.authType} + {server.osInfo ?? "-"} + + + + + + + { + try { + const list = await serverApi.discoverContainers(id!); + message.success(`发现 ${list.length} 个容器`); + queryClient.invalidateQueries({ queryKey: ["server-containers", id] }); + } catch (e) { + message.error(e instanceof Error ? e.message : "发现失败"); + } + }} + > + 重新发现 + + } + > + + rowKey="id" + columns={containerColumns} + dataSource={containers} + search={false} + pagination={false} + options={false} + /> + + + + + + {(relations ?? []).map((r: { id: string; direction: string; relationType: string; target: { name: string; type: string }; source: { name: string; type: string } }) => ( + + {r.direction === "OUTGOING" + ? `${r.relationType} → ${r.target.name} (${r.target.type})` + : `← ${r.relationType} ${r.source.name} (${r.source.type})`} + + ))} + + + + + {(audit?.items ?? []).map((a: { id: string; createdAt: string; action: string; riskLevel: string; status: string }) => ( + + + {a.action} + + + + ))} + + + + + + setExecOpen(false)} + footer={[ + , + , + ]} + width={720} + > + + setCommand(e.target.value)} onPressEnter={runExec} /> +
+          {execResult || "(等待执行)"}
+        
+
+
+ ); +} diff --git a/apps/web/src/pages/servers.tsx b/apps/web/src/pages/servers.tsx new file mode 100644 index 0000000..6f3b773 --- /dev/null +++ b/apps/web/src/pages/servers.tsx @@ -0,0 +1,247 @@ +/** + * # 服务器列表页 + */ +import { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + PageContainer, + ProTable, + type ActionType, + type ProColumns, + ProFormText, + ProFormSelect, + ProFormTextArea, +} from "@ant-design/pro-components"; +import { App as AntApp, Button, Popconfirm, Space, Tabs, Tag, Typography } from "antd"; +import { FormModal } from "../components/form-modal"; +import { PlusOutlined, ApiOutlined, DeleteOutlined } from "@ant-design/icons"; +import { serverApi, credentialApi, type CredentialView } from "../services/api"; +import type { ServerView } from "@devos/types"; +import { StatusBadge, TimeText } from "../components/shared"; + +export default function ServersPage() { + const navigate = useNavigate(); + const { message } = AntApp.useApp(); + const actionRef = useRef(null); + const [createOpen, setCreateOpen] = useState(false); + + const columns: ProColumns[] = [ + { + title: "名称", + dataIndex: "name", + render: (_, record) => ( + navigate(`/servers/${record.id}`)}>{record.name} + ), + }, + { title: "地址", dataIndex: "host", render: (_, r) => `${r.host}:${r.port}` }, + { title: "用户", dataIndex: "username" }, + { + title: "状态", + dataIndex: "status", + render: (_, r) => , + }, + { + title: "系统", + dataIndex: "osInfo", + ellipsis: true, + render: (_, r) => r.osInfo ?? "-", + }, + { title: "最近在线", dataIndex: "lastSeenAt", render: (_, r) => }, + { + title: "操作", + valueType: "option", + width: 220, + render: (_, record) => [ + { + try { + const res = await serverApi.connect(record.id); + message.info(`连通性:${res.ok ? "在线" : "离线"}(${res.latencyMs}ms) ${res.info}`); + actionRef.current?.reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : "失败"); + } + }} + > + 连接测试 + , + { + try { + const list = await serverApi.discoverContainers(record.id); + message.success(`发现 ${list.length} 个容器`); + actionRef.current?.reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : "失败"); + } + }}> + 发现容器 + , + { + try { + await serverApi.delete(record.id); + message.success("已删除"); + actionRef.current?.reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : "失败"); + } + }} + > + 删除 + , + ], + }, + ]; + + return ( + + + scroll={{ x: "max-content" }} + rowKey="id" + columns={columns} + actionRef={actionRef} + request={async () => { + const items = await serverApi.list(); + return { data: items, success: true, total: items.length }; + }} + search={false} + pagination={false} + headerTitle="服务器列表" + toolBarRender={() => [ + , + ]} + /> + setCreateOpen(false)} + onDone={() => { + setCreateOpen(false); + actionRef.current?.reload(); + }} + /> + + ); +} + +/** # 创建服务器弹窗(local 提供者管理本机 docker;ssh 需凭据) */ +function CreateServerModal({ + open, + onCancel, + onDone, +}: { + open: boolean; + onCancel: () => void; + onDone: () => void; +}) { + const { message } = AntApp.useApp(); + const [provider, setProvider] = useState("ssh"); + const [credentials, setCredentials] = useState([]); + + const loadCredentials = async () => { + try { + setCredentials(await credentialApi.list()); + } catch { + /* ignore */ + } + }; + + return ( + { + try { + await serverApi.create(values); + message.success("服务器已创建"); + onDone(); + return true; + } catch (e) { + message.error(e instanceof Error ? e.message : "创建失败"); + return false; + } + }} + onOpenChange={(o) => { + if (o) void loadCredentials(); + if (!o) onCancel(); + }} + > + + + setProvider(v as string) }} + /> + + + + + ), + }, + { + key: "auth", + label: "访问凭据", + forceRender: true, + children: provider === "ssh" ? ( + <> + + + + ({ label: `${c.name}(${c.type})`, value: c.id }))} + /> + + 凭据将以 AES-256-GCM 信封加密入库,任何接口不会返回明文。 + + + ) : ( + + 本机 Docker Socket 模式无需凭据(通过宿主 /var/run/docker.sock 管理)。 + + ), + }, + ]} + /> + + ); +} diff --git a/apps/web/src/pages/tasks.tsx b/apps/web/src/pages/tasks.tsx new file mode 100644 index 0000000..a1e62ee --- /dev/null +++ b/apps/web/src/pages/tasks.tsx @@ -0,0 +1,195 @@ +/** + * # 任务页(Phase E/06):backlog/todo/doing/review/done + 优先级 + 标签 + 截止日 + */ +import { useRef, useState } from "react"; +import { + PageContainer, + ProTable, + type ActionType, + type ProColumns, + ProFormText, + ProFormSelect, + ProFormDatePicker, + ProFormTextArea, +} from "@ant-design/pro-components"; +import { App as AntApp, Button, Popconfirm, Select, Space, Tag, Typography } from "antd"; +import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; +import { taskApi } from "../services/api"; +import type { TaskView } from "../services/api"; +import { TimeText } from "../components/shared"; +import { FormModal } from "../components/form-modal"; + +const STATUS_COLOR: Record = { + BACKLOG: "default", + TODO: "blue", + DOING: "processing", + REVIEW: "gold", + DONE: "success", +}; +const PRIORITY_COLOR: Record = { LOW: "default", MEDIUM: "blue", HIGH: "volcano", URGENT: "red" }; + +export default function TasksPage() { + const { message } = AntApp.useApp(); + const actionRef = useRef(null); + const [statusFilter, setStatusFilter] = useState(); + const [editing, setEditing] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + + const reload = () => actionRef.current?.reload(); + + const columns: ProColumns[] = [ + { + title: "标题", + dataIndex: "title", + render: (_, r) => {r.title}, + }, + { + title: "状态", + dataIndex: "status", + width: 150, + render: (_, r) => ( +