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)
This commit is contained in:
@@ -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
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
apps/web/dist/
|
||||
.env
|
||||
*.local
|
||||
.DS_Store
|
||||
coverage/
|
||||
prisma/*.db
|
||||
devos.sqlite*
|
||||
logs/
|
||||
@@ -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
|
||||
|
||||
## 核心判断
|
||||
后续开发不得继续以“增加一个页面/接口”为单位。必须以“完整业务闭环”为单位。
|
||||
@@ -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
|
||||
- 部署
|
||||
- 监控
|
||||
- 任务
|
||||
- 文档
|
||||
- 自动化
|
||||
|
||||
并能从任何资源进入关联资源。
|
||||
@@ -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<string, unknown>
|
||||
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 替代领域模型;它负责索引/关系/统一查询,领域模块负责业务规则。
|
||||
@@ -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 体系。
|
||||
@@ -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,并展示证据。
|
||||
@@ -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,成为操作上下文的一部分。
|
||||
@@ -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 必须能创建、编辑、启停、测试、查看执行历史。
|
||||
@@ -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。
|
||||
@@ -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 必须校验。
|
||||
@@ -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。
|
||||
@@ -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。
|
||||
@@ -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 不是“页面全部存在”,而是上述闭环可真实运行。
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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<string, unknown>
|
||||
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 `<resource>.<verb>` or `<domain>.<verb>`:
|
||||
- deployment.created
|
||||
- deployment.started
|
||||
- deployment.success
|
||||
- deployment.failed
|
||||
- server.online
|
||||
- server.offline
|
||||
- website.down
|
||||
- website.recovered
|
||||
- email.received
|
||||
- github.push
|
||||
- task.created
|
||||
- task.completed
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 <JWT>" \
|
||||
-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)完成端到端验证 ——
|
||||
「总结 <url> 保存到 Note」→ web.fetch + note.create ✓
|
||||
「把 <github url> 安装到 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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* # 全局配置模块
|
||||
*/
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { ConfigService } from "./config.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ConfigService],
|
||||
exports: [ConfigService],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
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 } : {}) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* # JWT 认证守卫(全局)
|
||||
* 解析 Authorization: Bearer <jwt>,校验通过后挂载 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<boolean>(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;
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceRole, number> = { 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<boolean> {
|
||||
const noWorkspace = this.reflector.getAllAndOverride<boolean>(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<string, string> | 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<WorkspaceRole[] | undefined>(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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {
|
||||
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 {}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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<T> implements PipeTransform<unknown, T> {
|
||||
constructor(private readonly schema: ZodSchema<T>) {}
|
||||
|
||||
/** # 转换并校验输入值 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<RequestContext>();
|
||||
|
||||
/** # 读取当前上下文(可能为空,例如非 HTTP 场景) */
|
||||
export function getRequestContext(): RequestContext | undefined {
|
||||
return requestContextStore.getStore();
|
||||
}
|
||||
|
||||
/** # 在指定上下文中运行异步函数(用于 worker/调度器派生的执行单元) */
|
||||
export function runWithContext<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T> {
|
||||
return requestContextStore.run(ctx, fn);
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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<string, number[]>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* # Webhook HMAC 签名(Phase A/F 安全升级)
|
||||
* 签名格式(兼容 GitHub 风格):
|
||||
* X-Signature: t=<unix秒>,v1=<hex_hmac_sha256(secret, `${t}.${rawBody}`)>
|
||||
* 校验:时间戳容差 ±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 };
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* # 网页文本提取工具
|
||||
* 零依赖 HTML → 可读文本,供 AI 网页总结 / 内容抓取使用。
|
||||
*/
|
||||
|
||||
/** # 提取 <title> */
|
||||
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)));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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> {
|
||||
/* 手动模式下删除仅操作本地库 */
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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) ?? {},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user