diff --git a/AGENTS.md b/AGENTS.md index c216d2a..8e3829b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,38 +1,38 @@ @RTK.md -# Agent Instructions +# Agent 指令 -This backend owns the DevOps platform API only. Do not edit sibling projects from this directory. +当前后端只负责运维平台 API。除非用户明确扩大范围,否则不要编辑同级项目。 -## Hard Boundaries +## 硬性边界 -- Write only inside `devops-platform-api` unless the user explicitly expands the scope. -- Do not write `devops-platform-web`. -- Do not write `devops-platform-docs/devops-platform-implementation-tracker.md`; the coordinating thread owns tracker updates. -- Do not revert unrelated user or worker changes. +- 只允许在 `devops-platform-api` 内写入。 +- 不要写入 `devops-platform-web`。 +- 不要写入 `devops-platform-docs/devops-platform-implementation-tracker.md`,协调线程负责跟踪文档更新。 +- 不要回滚无关的用户或其他工作线程改动。 -## Backend Standards +## 后端标准 -- Use NestJS modules and dependency injection for all business boundaries. -- Keep controllers thin. Validation, orchestration, and audit decisions belong in services. -- Use Prisma as the default data access layer. Business code must not create mysql2 pools or scatter handwritten SQL. -- If native SQL is unavoidable, place it in a Repository, parameterize it, document why Prisma is insufficient, and keep the method narrow. -- Keep external clients behind integration modules: Jenkins, Gitea, notification providers, and LLM. +- 所有业务边界使用 NestJS module 和依赖注入。 +- Controller 保持轻量;校验、编排和审计决策放在 service。 +- 默认使用 Prisma 作为数据访问层,业务代码不得创建 mysql2 连接池或散落手写 SQL。 +- 如无法避免原生 SQL,必须放在 Repository,使用参数化查询,说明 Prisma 不足之处,并保持方法范围收敛。 +- 外部客户端必须放在集成模块后面,包括 Jenkins、Gitea、通知提供方和 LLM。 -## Security Standards +## 安全标准 -- Never commit real credentials. -- Redact tokens, webhook URLs, cookies, authorization headers, private keys, and long opaque secret-like strings before logging, auditing, returning errors, or passing content to LLMs. -- Store future persisted secrets encrypted server-side only. -- Frontend must never receive Jenkins/Gitea/notification/LLM secret values. +- 永远不要提交真实凭据。 +- 记录日志、写审计、返回错误或传给 LLM 前,必须脱敏 token、webhook URL、cookie、authorization header、私钥和长的不透明密钥字符串。 +- 未来持久化密钥只能在服务端加密保存。 +- 前端永远不能收到 Jenkins、Gitea、通知或 LLM 的完整密钥值。 -## Audit Standards +## 审计标准 -- Record actor, resource, action, before/after state summaries, request source, and parameter digests for deploy, cancel, retry, secret change, and agent invocation operations. -- Deploy run status transitions must be idempotent and concurrency-safe when database writes are enabled. +- 发布、取消、重试、密钥变更和 Agent 调用需要记录操作者、资源、动作、变更前后摘要、请求来源和参数摘要。 +- 启用数据库写入后,发布状态流转必须具备幂等和并发安全能力。 -## Agent Standards +## Agent 标准 -- Agent features must be tied to DevOps workflows, not general chat. -- Agent calls are read-only unless a future product decision explicitly changes this. -- Save prompt version, purpose, operator, input digest, output digest, token estimate, and sanitized failure summary for every real invocation. +- Agent 能力必须绑定运维工作流,不能做通用聊天。 +- 除非未来产品决策明确改变,否则 Agent 调用只读。 +- 每次真实调用都要保存 prompt 版本、目的、操作者、输入摘要、输出摘要、token 估算和脱敏后的失败摘要。 diff --git a/README.md b/README.md index 1944b18..4ea3f5b 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,113 @@ -# DevOps Platform API +# 运维平台 API -NestJS + TypeScript backend for the DevOps operations platform. The current local MVP provides the enterprise project skeleton, Prisma data model, configuration validation, integration client boundaries, Gitea webhook handling, Jenkins queue/build synchronization, notification provider boundaries, audit logs, BPMN process definitions, and runnable seed-backed APIs. +这是运维平台后端服务,基于 NestJS、TypeScript 和 Prisma 构建。后端负责账号鉴权、成员管理、项目权限、发布记录、Jenkins/Gitea 集成、通知、审计、流程定义和 Agent 运维能力。 -## Scope +## 范围 -- Manage projects, deploy runs, BPMN step state, Jenkins/Gitea integration state, notification delivery, audit records, and agent invocations. -- Keep all external credentials server-side. -- Use Prisma as the default data access layer. Native SQL is allowed only inside Repository methods with a documented reason. +- 只维护运维平台 API,不包含前端页面代码。 +- 外部系统密钥只保存在服务端,接口不会返回完整密钥。 +- 数据访问默认使用 Prisma;如必须使用原生 SQL,只能放在 Repository 中并说明原因。 +- 本地未配置 MySQL 时,项目、发布记录、账号和配置能力会使用内存降级数据,便于开发调试。 -## Local Setup +## 本地启动 ```bash pnpm install -cp .env.example .env pnpm prisma:generate pnpm start:dev ``` -The API listens on `http://localhost:4300` by default and runs without a local MySQL database when `USE_DATABASE_READS=false`. In that mode `/projects` and `/deploy-runs` use seed-backed repository data while keeping the same service and controller contracts intended for Prisma-backed reads. +默认监听地址为 `http://localhost:4300`。未配置 `DATABASE_URL` 或 `USE_DATABASE_READS=false` 时,不会强制连接 MySQL。 -## Useful Commands +## 常用命令 ```bash +pnpm prisma:generate pnpm lint pnpm test pnpm build pnpm check pnpm prisma:migrate:dev --name init +pnpm prisma:seed ``` -## API +## 内置账号 -- `GET /health`: dependency configuration and health summary. -- `GET /settings/integration-config`: Jenkins/Gitea/notification/LLM/Database/Redis/Secret Encryption Key configuration status and variable help text. The response only exposes environment variable names and safe descriptions, never secret values. -- `GET /projects`: configured project list. -- `GET /projects/:key`: one project by key. -- `GET /deploy-runs`: latest deploy runs. -- `POST /deploy-runs`: create a deploy run. Missing Jenkins config uses a local dry-run path; configured Jenkins calls `buildWithParameters` and returns `queued/running`. -- `POST /deploy-runs/:id/sync-jenkins`: synchronize Jenkins queue/build/log status and advance success or failure. -- `POST /deploy-runs/sync-jenkins`: synchronize all queued/running runs that have Jenkins queue/build identifiers. -- `POST /deploy-runs/:id/cancel`: cancel a deploy run. -- `POST /deploy-runs/:id/retry`: create a retry deploy run. -- `POST /webhooks/gitea`: accept Gitea push webhooks and trigger test deploy runs. -- `GET /process-definitions/release`: BPMN release process definition. -- `GET /audit-logs`: latest sanitized audit records. -- `GET /agent/invocations` and `POST /agent/invocations`: DevOps-scoped agent invocation records and mock results. -- `GET /docs`: Swagger UI. +- 超级管理员账号:`zhanxi` +- 超级管理员初始密码:生产环境通过 `SUPER_ADMIN_INITIAL_PASSWORD` 注入;本地未配置时使用开发兜底口令。 +- 普通成员初始密码:`111111` -## Data Access Rules +超级管理员初始化配置集中在 `src/auth/auth.constants.ts` 和服务器环境变量。生产环境缺少 `SUPER_ADMIN_INITIAL_PASSWORD` 时 API 会拒绝初始化超级管理员,避免默认口令上线;如果超级管理员已修改密码,后续初始化不会覆盖密码。 -- Business modules access data through Repository classes, not direct Prisma calls in controllers. -- Prisma owns connection pooling, transactions, migrations, and typed models. -- Deploy run creation, status transition, step transition, and audit writes must use explicit Prisma transactions once DB writes are enabled. -- Idempotency keys are mandatory for webhook callbacks, Jenkins queue/build tracking, and manual deploy retry paths. -- Raw SQL must be parameterized, isolated in a Repository method, covered by tests, and justified in the method comment. +## 关键接口 -## Secret Rules +- `POST /auth/login`:登录,返回 token 和当前账号信息。 +- `GET /auth/me`:查询当前登录账号。 +- `POST /auth/change-password`:修改当前登录账号密码。 +- `POST /auth/forgot-password`:普通成员忘记密码,通知超级管理员处理。 +- `GET /members`:超级管理员查询成员列表和项目权限。 +- `POST /members`:超级管理员创建普通成员,初始密码固定为 `111111`。 +- `PATCH /members/:id/permissions`:超级管理员更新普通成员项目权限。 +- `POST /members/:id/reset-password`:超级管理员将普通成员密码重置为 `111111`。 +- `PATCH /members/:id/status`:超级管理员启用或禁用普通成员。 +- `GET /messages`:超级管理员查询消息通知。 +- `POST /messages/:id/handled`:超级管理员将消息标记为已处理。 +- `GET /agent/config`:超级管理员查询 Agent 配置,密钥只返回脱敏值。 +- `PUT /agent/config`:超级管理员保存 Agent 的 `key`、`baseURL` 和 `model`。 +- `POST /agent/config/test`:超级管理员测试已保存的 Agent 配置。 +- `GET /projects`:查询当前账号有权限访问的项目。 +- `GET /projects/:key`:查询项目详情,需要只读或构建权限。 +- `GET /projects/:key/refs`:查询项目分支和标签,需要只读或构建权限。 +- `GET /deploy-runs`:查询当前账号有权限项目的发布记录。 +- `POST /deploy-runs`:创建发布单并触发构建,需要构建权限。 +- `POST /deploy-runs/:id/cancel`:取消发布单,需要构建权限。 +- `POST /deploy-runs/:id/retry`:重试发布单,需要构建权限。 +- `POST /deploy-runs/:id/sync-jenkins`:同步单个发布单 Jenkins 状态,需要构建权限。 +- `POST /deploy-runs/sync-jenkins`:同步全部 Jenkins 状态,仅超级管理员可用。 +- `GET /audit-logs`:超级管理员查询脱敏审计日志。 +- `GET /settings/integration-config`:超级管理员查询集成配置状态。 +- `GET /health`:运行健康检查。 +- `GET /docs`:Swagger 接口文档。 -- Never commit real `.env`, Jenkins tokens, Gitea tokens, notification webhook URLs, LLM keys, private keys, or cookies. -- Persisted secrets belong in the `secrets` table encrypted with a deployment-provided key or in the deployment secret manager. -- Logs, audit records, integration errors, and LLM prompts must pass through redaction before storage or return. +## 权限规则 -## Agent Boundary +- 未登录访问受保护接口会返回 `登录已失效,请重新登录`。 +- 普通成员首次登录或被重置密码后,必须先调用 `POST /auth/change-password` 修改密码。 +- 超级管理员拥有全部项目的全部权限,不需要单独授权。 +- 普通成员默认没有任何项目权限。 +- 只读权限可以查看项目和发布记录,不能触发构建。 +- 构建权限包含只读能力,可以对授权项目触发构建、取消、重试和同步单个 Jenkins 状态。 +- 成员管理、消息通知、系统设置、审计日志和 Agent 配置仅超级管理员可访问。 -The Agent module is not a generic chat entrypoint. It can only support DevOps workflows: +## 密钥规则 -- release risk summaries, -- Jenkins failure diagnosis, -- runbook Q&A against whitelisted sources, -- release notes drafts, -- incident review drafts. +- Agent `key` 保存到服务端 `secrets` 表或本地内存配置,接口只返回脱敏值。 +- 生产环境保存 Agent 密钥必须配置 `SECRET_ENCRYPTION_KEY`。 +- 审计、错误、日志和返回体不能包含完整 token、webhook、cookie、私钥或 Agent key。 +- Agent 调用只使用后端保存的系统级配置,不信任前端传入的模型配置。 -Agent calls are read-only. They must not trigger deploys, mutate Jenkins/Gitea, or expose raw secrets. When `LLM_BASE_URL`, `LLM_API_KEY`, and `LLM_MODEL` are configured, the backend proxies DevOps-scoped prompts to `/chat/completions`; otherwise it returns local mock results with the same API shape. +## 环境变量 -## Notification Providers +- `AUTH_TOKEN_SECRET`:登录 token 签名密钥。 +- `AUTH_TOKEN_TTL_SECONDS`:登录 token 有效期,默认 43200 秒。 +- `SUPER_ADMIN_INITIAL_PASSWORD`:生产环境初始化超级管理员时的初始密码。 +- `SECRET_ENCRYPTION_KEY`:服务端密钥加密密钥,生产环境保存 Agent 密钥时必须配置。 +- `DATABASE_URL`:MySQL 连接地址。 +- `USE_DATABASE_READS`:是否启用 Prisma 数据读写。 +- `PRISMA_CONNECT_ON_BOOT`:启动时是否主动连接数据库。 +- `JENKINS_BASE_URL`、`JENKINS_USERNAME`、`JENKINS_API_TOKEN`:Jenkins 集成配置。 +- `GITEA_BASE_URL`、`GITEA_TOKEN`、`GITEA_WEBHOOK_SECRET`:Gitea 集成配置。 +- `NOTIFICATION_PROVIDER`、`WECOM_WEBHOOK_URL`、`FEISHU_WEBHOOK_URL`、`NOTIFICATION_WEBHOOK_URL`:通知集成配置。 +- `LLM_BASE_URL`、`LLM_API_KEY`、`LLM_MODEL`:Agent 配置的环境变量后备值。 -`DeployNotificationService` supports the first provider boundary for: +## Agent 边界 -- `NOTIFICATION_PROVIDER=wecom` with `WECOM_WEBHOOK_URL` -- `NOTIFICATION_PROVIDER=feishu` with `FEISHU_WEBHOOK_URL` -- `NOTIFICATION_PROVIDER=generic` with `NOTIFICATION_WEBHOOK_URL` +Agent 不是通用聊天入口,只支持运维工作流: -Missing provider or webhook configuration returns a skipped notification result and does not block deploy status transitions. +- 发布风险摘要 +- Jenkins 失败诊断 +- 运维手册问答 +- 发布说明草稿 +- 事故复盘草稿 -## Jenkins Auto Sync - -Manual sync is always available through `POST /deploy-runs/sync-jenkins`. To enable a lightweight in-process poller before Redis/BullMQ is introduced: - -```env -JENKINS_AUTO_SYNC_ENABLED=true -JENKINS_AUTO_SYNC_INTERVAL_MS=30000 -``` - -The poller only scans queued/running runs that already have a Jenkins queue id or build number. +普通成员使用 Agent 时必须绑定自己有权限访问的项目。Agent 调用只读,不会触发发布、修改 Jenkins、修改 Gitea 或暴露密钥。 diff --git a/RTK.md b/RTK.md index 03397c7..5557fb7 100644 --- a/RTK.md +++ b/RTK.md @@ -1,42 +1,47 @@ -# Backend RTK +# 后端 RTK -## Architecture +## 架构 -- Framework: NestJS + TypeScript. -- Package manager: pnpm. -- Data access: Prisma Client and Prisma migrations. -- Default local behavior: seeded project configuration keeps APIs runnable while MySQL is not configured; production uses real Jenkins and Gitea clients when credentials are injected. +- 框架:NestJS + TypeScript。 +- 包管理器:pnpm。 +- 数据访问:Prisma Client 和 Prisma migrations。 +- 本地默认行为:未配置 MySQL 时使用种子项目和内存数据保持 API 可运行;生产环境注入凭据后使用真实 Jenkins 和 Gitea 客户端。 -## Module Map +## 模块地图 -- `config`: environment schema and validation. -- `prisma`: PrismaService and DB health boundary. -- `health`: runtime and dependency summary. -- `projects`: project configuration API and repository. -- `deploy-runs`: deploy run API, status model, BPMN step summary. -- `integrations/jenkins`: Jenkins API client boundary. -- `integrations/gitea`: Gitea API client boundary. -- `notifications/wecom`: notification provider boundary for WeCom, Feishu, and generic webhook adapters. -- `agent`: LLM proxy client boundary for DevOps-only workflows. -- `audit`: sanitized audit record boundary. +- `config`:环境变量 schema 和校验。 +- `prisma`:PrismaService 和数据库健康边界。 +- `health`:运行时和依赖健康摘要。 +- `auth`:登录鉴权、密码管理、登录 token 和当前用户上下文。 +- `members`:成员管理和项目权限分配。 +- `messages`:平台消息通知,当前用于普通成员忘记密码请求。 +- `projects`:项目配置 API 和 Repository。 +- `deploy-runs`:发布记录 API、状态模型和 BPMN 步骤摘要。 +- `integrations/jenkins`:Jenkins API 客户端边界。 +- `integrations/gitea`:Gitea API 客户端边界。 +- `notifications/wecom`:企微、飞书和通用 webhook 通知边界。 +- `agent-config`:Agent 系统级配置、密钥加密和连接测试。 +- `agent`:只面向运维工作流的 LLM 代理边界。 +- `audit`:脱敏审计记录边界。 -## Data Access Rules +## 数据访问规则 -1. Controllers call services. -2. Services call repositories and integration clients. -3. Repositories call Prisma. -4. Raw SQL is exceptional and repository-only. -5. Transactions are required for deploy run writes once persistence is enabled. +1. Controller 调用 service。 +2. Service 调用 repository 和集成客户端。 +3. Repository 调用 Prisma。 +4. 原生 SQL 只允许作为例外,并且只能放在 repository。 +5. 启用持久化写入后,发布记录写入必须使用事务。 -## Environment Rules +## 环境规则 -- `.env.example` may contain placeholders only. -- `.env` is local-only and ignored by git. -- Missing external integration configuration should degrade to `not_configured` in health checks, not crash local development. +- `.env.example` 只能包含占位值。 +- `.env` 只用于本地并且不能提交。 +- 缺少外部集成配置时,健康检查应降级为 `not_configured`,不能导致本地开发启动失败。 +- 生产环境保存 Agent 密钥必须配置 `SECRET_ENCRYPTION_KEY`。 -## Verification +## 验证 -Run these before handing off backend changes when dependencies are available: +依赖可用时,交付前运行: ```bash pnpm install diff --git a/package.json b/package.json index ff5b637..667eeb6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "devops-platform-api", "version": "0.1.0", "private": true, - "description": "DevOps operations platform API for Jenkins, Gitea, WeCom notifications, BPMN deploy state, and agent-assisted operations.", + "description": "运维平台后端 API,提供 Jenkins、Gitea、通知、发布流程、账号权限和 Agent 运维能力。", "license": "UNLICENSED", "packageManager": "pnpm@9.15.4", "engines": { @@ -11,7 +11,7 @@ }, "scripts": { "build": "nest build", - "check": "pnpm lint && pnpm test && pnpm build", + "check": "pnpm prisma:generate && pnpm lint && pnpm test && pnpm build", "format": "prettier --write \"src/**/*.ts\" \"prisma/**/*.prisma\" \"*.md\"", "lint": "eslint \"{src,prisma}/**/*.{ts,js}\" --ignore-pattern prisma/seed.js", "prisma:generate": "prisma generate", diff --git a/prisma/migrations/20260611120000_auth_members_messages/migration.sql b/prisma/migrations/20260611120000_auth_members_messages/migration.sql new file mode 100644 index 0000000..316e8a0 --- /dev/null +++ b/prisma/migrations/20260611120000_auth_members_messages/migration.sql @@ -0,0 +1,68 @@ +CREATE TABLE `users` ( + `id` VARCHAR(191) NOT NULL, + `account` VARCHAR(191) NOT NULL, + `displayName` VARCHAR(191) NOT NULL, + `role` ENUM('SUPER_ADMIN', 'MEMBER') NOT NULL DEFAULT 'MEMBER', + `status` ENUM('ACTIVE', 'DISABLED') NOT NULL DEFAULT 'ACTIVE', + `passwordHash` TEXT NOT NULL, + `mustChangePassword` BOOLEAN NOT NULL DEFAULT false, + `passwordVersion` INTEGER NOT NULL DEFAULT 1, + `lastLoginAt` DATETIME(3) NULL, + `createdById` VARCHAR(191) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `users_account_key`(`account`), + INDEX `users_role_status_idx`(`role`, `status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `project_member_permissions` ( + `id` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + `level` ENUM('READ', 'BUILD') NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `project_member_permissions_userId_projectId_key`(`userId`, `projectId`), + INDEX `project_member_permissions_projectId_idx`(`projectId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `platform_messages` ( + `id` VARCHAR(191) NOT NULL, + `type` ENUM('PASSWORD_RESET_REQUEST') NOT NULL, + `status` ENUM('PENDING', 'HANDLED') NOT NULL DEFAULT 'PENDING', + `title` VARCHAR(191) NOT NULL, + `content` TEXT NOT NULL, + `requesterId` VARCHAR(191) NULL, + `requesterAccount` VARCHAR(191) NOT NULL, + `targetResourceType` VARCHAR(191) NULL, + `targetResourceId` VARCHAR(191) NULL, + `metadata` JSON NULL, + `handledById` VARCHAR(191) NULL, + `handledAt` DATETIME(3) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + INDEX `platform_messages_type_status_createdAt_idx`(`type`, `status`, `createdAt`), + INDEX `platform_messages_requesterId_status_idx`(`requesterId`, `status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `project_member_permissions` + ADD CONSTRAINT `project_member_permissions_userId_fkey` + FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `project_member_permissions` + ADD CONSTRAINT `project_member_permissions_projectId_fkey` + FOREIGN KEY (`projectId`) REFERENCES `projects`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `platform_messages` + ADD CONSTRAINT `platform_messages_requesterId_fkey` + FOREIGN KEY (`requesterId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE `platform_messages` + ADD CONSTRAINT `platform_messages_handledById_fkey` + FOREIGN KEY (`handledById`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/README.md b/prisma/migrations/README.md index 41e9a74..2d96c5c 100644 --- a/prisma/migrations/README.md +++ b/prisma/migrations/README.md @@ -1,12 +1,12 @@ -# Prisma Migration Structure +# Prisma 迁移目录 -This project uses Prisma as the default data access layer. +本项目默认使用 Prisma 作为数据访问层。 -Migration policy: +迁移规则: -- Create schema changes with `pnpm prisma:migrate:dev --name ` after the final MySQL connection details are confirmed. -- Commit generated folders under `prisma/migrations/_/migration.sql`. -- Run migration checks in deployment before starting the API. -- Keep raw SQL exceptional. If native SQL is required for performance or vendor-specific behavior, place it behind a Repository method and document why Prisma Client is not sufficient. +- 确认本地或测试环境 MySQL 连接后,使用 `pnpm prisma:migrate:dev --name <变更名称>` 生成迁移。 +- 需要提交 `prisma/migrations/<时间戳>_<变更名称>/migration.sql`。 +- 部署前先执行迁移检查,再启动 API 服务。 +- 原生 SQL 只允许作为例外使用,必须放在 Repository 方法中并说明原因。 -The first-stage schema is defined in `prisma/schema.prisma`; no live migration is executed until a local or staging MySQL database is explicitly provided. +首阶段 schema 定义在 `prisma/schema.prisma`。没有明确数据库连接前,不要求本地自动执行迁移。 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 12f102d..549406e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,6 +64,30 @@ enum OutboxStatus { DEAD } +enum UserRole { + SUPER_ADMIN + MEMBER +} + +enum UserStatus { + ACTIVE + DISABLED +} + +enum ProjectPermissionLevel { + READ + BUILD +} + +enum PlatformMessageType { + PASSWORD_RESET_REQUEST +} + +enum PlatformMessageStatus { + PENDING + HANDLED +} + model Project { id String @id @default(cuid()) key String @unique @@ -78,6 +102,7 @@ model Project { environments Environment[] deployJobs DeployJob[] deployRuns DeployRun[] + permissions ProjectMemberPermission[] @@map("projects") } @@ -194,6 +219,68 @@ model AuditLog { @@map("audit_logs") } +model User { + id String @id @default(cuid()) + account String @unique + displayName String + role UserRole @default(MEMBER) + status UserStatus @default(ACTIVE) + passwordHash String @db.Text + mustChangePassword Boolean @default(false) + passwordVersion Int @default(1) + lastLoginAt DateTime? + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + projectPermissions ProjectMemberPermission[] + requestedMessages PlatformMessage[] @relation("message_requester") + handledMessages PlatformMessage[] @relation("message_handler") + + @@index([role, status]) + @@map("users") +} + +model ProjectMemberPermission { + id String @id @default(cuid()) + userId String + projectId String + level ProjectPermissionLevel + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([userId, projectId]) + @@index([projectId]) + @@map("project_member_permissions") +} + +model PlatformMessage { + id String @id @default(cuid()) + type PlatformMessageType + status PlatformMessageStatus @default(PENDING) + title String + content String @db.Text + requesterId String? + requesterAccount String + targetResourceType String? + targetResourceId String? + metadata Json? + handledById String? + handledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + requester User? @relation("message_requester", fields: [requesterId], references: [id], onDelete: SetNull) + handler User? @relation("message_handler", fields: [handledById], references: [id], onDelete: SetNull) + + @@index([type, status, createdAt]) + @@index([requesterId, status]) + @@map("platform_messages") +} + model Secret { id String @id @default(cuid()) provider SecretProvider diff --git a/prisma/seed.js b/prisma/seed.js index a12be84..63eca0d 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -1,10 +1,22 @@ /** * 将代码内的纳管项目清单同步到数据库,保持项目、环境和 Jenkins Job 可重复初始化。 */ -const { EnvironmentKind, PrismaClient, ProjectStatus } = require('@prisma/client'); +const { + EnvironmentKind, + PrismaClient, + ProjectStatus, + UserRole, + UserStatus, +} = require('@prisma/client'); +const { + LOCAL_SUPER_ADMIN_INITIAL_PASSWORD, + SUPER_ADMIN_SEED, +} = require('../dist/auth/auth.constants'); +const { PasswordService } = require('../dist/auth/password.service'); const { seedProjects } = require('../dist/projects/project.seed'); const prisma = new PrismaClient(); +const passwordService = new PasswordService(); async function main() { for (const project of seedProjects) { @@ -44,6 +56,49 @@ async function main() { await upsertEnvironment(projectRecord.id, environment); } } + + await upsertSuperAdmin(); +} + +async function upsertSuperAdmin() { + const passwordHash = await passwordService.hashPassword( + resolveSuperAdminInitialPassword(), + ); + + await prisma.user.upsert({ + where: { + account: SUPER_ADMIN_SEED.account, + }, + create: { + id: SUPER_ADMIN_SEED.id, + account: SUPER_ADMIN_SEED.account, + displayName: SUPER_ADMIN_SEED.displayName, + role: UserRole.SUPER_ADMIN, + status: UserStatus.ACTIVE, + passwordHash, + mustChangePassword: false, + }, + update: { + displayName: SUPER_ADMIN_SEED.displayName, + role: UserRole.SUPER_ADMIN, + status: UserStatus.ACTIVE, + mustChangePassword: false, + }, + }); +} + +function resolveSuperAdminInitialPassword() { + if (process.env.SUPER_ADMIN_INITIAL_PASSWORD) { + return process.env.SUPER_ADMIN_INITIAL_PASSWORD; + } + + if (process.env.NODE_ENV === 'production') { + throw new Error( + 'Missing SUPER_ADMIN_INITIAL_PASSWORD in production seed run', + ); + } + + return LOCAL_SUPER_ADMIN_INITIAL_PASSWORD; } async function upsertEnvironment(projectId, environment) { diff --git a/src/agent-config/agent-config.controller.ts b/src/agent-config/agent-config.controller.ts new file mode 100644 index 0000000..59f9364 --- /dev/null +++ b/src/agent-config/agent-config.controller.ts @@ -0,0 +1,54 @@ +import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common'; +import { ApiBody, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { AdminOnlyMessage } from '../auth/admin-only-message.decorator'; +import { AuthGuard } from '../auth/auth.guard'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { PasswordChangeGuard } from '../auth/password-change.guard'; +import { SuperAdminGuard } from '../auth/super-admin.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; +import { + SaveAgentConfigDto, + SaveAgentConfigInput, + saveAgentConfigSchema, +} from './agent-config.dto'; +import { AgentConfigService } from './agent-config.service'; +import { + AgentConfigSummary, + AgentConnectionTestResult, +} from './agent-config.types'; + +@ApiTags('Agent 配置') +@Controller('agent/config') +@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard) +@AdminOnlyMessage('无权限访问 Agent 配置') +export class AgentConfigController { + constructor(private readonly agentConfigService: AgentConfigService) {} + + @Get() + @ApiOkResponse({ description: '获取 Agent 配置,密钥只返回脱敏内容。' }) + async getConfig( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.agentConfigService.getConfig(user); + } + + @Put() + @ApiBody({ type: SaveAgentConfigDto }) + @ApiOkResponse({ description: '保存 Agent 配置。' }) + async saveConfig( + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(saveAgentConfigSchema)) + body: SaveAgentConfigInput, + ): Promise { + return this.agentConfigService.saveConfig(user, body); + } + + @Post('test') + @ApiOkResponse({ description: '测试已保存的 Agent 配置连接。' }) + async testConnection( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.agentConfigService.testConnection(user); + } +} diff --git a/src/agent-config/agent-config.dto.ts b/src/agent-config/agent-config.dto.ts new file mode 100644 index 0000000..a48a9bf --- /dev/null +++ b/src/agent-config/agent-config.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { z } from 'zod'; + +export const saveAgentConfigSchema = z.object({ + key: z + .string() + .trim() + .max(500) + .optional() + .or(z.literal('').transform(() => undefined)), + baseURL: z.string().trim().url().max(300), + model: z.string().trim().min(1).max(120), +}); + +export type SaveAgentConfigInput = z.infer; + +export class SaveAgentConfigDto { + @ApiPropertyOptional({ + description: 'Agent 密钥;留空表示保留已保存密钥', + example: 'sk-xxxx', + }) + key?: string; + + @ApiProperty({ + description: 'Agent 服务基础地址,例如 OpenAI 兼容接口的 /v1 地址', + example: 'https://api.example.com/v1', + }) + baseURL!: string; + + @ApiProperty({ description: 'Agent 使用的模型名称', example: 'gpt-4.1' }) + model!: string; +} diff --git a/src/agent-config/agent-config.module.ts b/src/agent-config/agent-config.module.ts new file mode 100644 index 0000000..320941b --- /dev/null +++ b/src/agent-config/agent-config.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { AuthModule } from '../auth/auth.module'; +import { AgentConfigController } from './agent-config.controller'; +import { AgentConfigRepository } from './agent-config.repository'; +import { AgentConfigService } from './agent-config.service'; +import { SecretCryptoService } from './secret-crypto.service'; + +@Module({ + imports: [AuditModule, AuthModule], + controllers: [AgentConfigController], + providers: [AgentConfigRepository, AgentConfigService, SecretCryptoService], + exports: [AgentConfigRepository, AgentConfigService, SecretCryptoService], +}) +export class AgentConfigModule {} diff --git a/src/agent-config/agent-config.repository.ts b/src/agent-config/agent-config.repository.ts new file mode 100644 index 0000000..bb2dcb6 --- /dev/null +++ b/src/agent-config/agent-config.repository.ts @@ -0,0 +1,148 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Prisma, SecretProvider } from '@prisma/client'; +import { AppError } from '../common/errors/app-error'; +import { EnvConfig } from '../config/env.schema'; +import { PrismaService } from '../prisma/prisma.service'; +import { SaveAgentConfigInput } from './agent-config.dto'; +import { AgentRuntimeConfig } from './agent-config.types'; +import { SecretCryptoService } from './secret-crypto.service'; + +type StoredAgentConfig = Partial & { + updatedAt?: string; +}; + +const AGENT_SECRET_SCOPE_TYPE = 'system'; +const AGENT_SECRET_SCOPE_ID = 'agent-config'; +const AGENT_SECRET_NAMES = { + key: 'api_key', + baseURL: 'base_url', + model: 'model', +} as const; + +@Injectable() +export class AgentConfigRepository { + private memoryConfig?: StoredAgentConfig; + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + private readonly secretCrypto: SecretCryptoService, + ) {} + + async getRuntimeConfig(): Promise { + const storedConfig = this.useDatabase() + ? await this.readDatabaseConfig() + : this.memoryConfig; + + return { + key: storedConfig?.key ?? this.config.get('LLM_API_KEY', { infer: true }), + baseURL: + storedConfig?.baseURL ?? this.config.get('LLM_BASE_URL', { infer: true }), + model: storedConfig?.model ?? this.config.get('LLM_MODEL', { infer: true }), + updatedAt: storedConfig?.updatedAt, + }; + } + + async saveConfig(input: SaveAgentConfigInput): Promise { + const currentConfig = await this.getRuntimeConfig(); + const nextConfig: AgentRuntimeConfig = { + key: input.key ?? currentConfig.key ?? '', + baseURL: input.baseURL, + model: input.model, + }; + + if (!nextConfig.key) { + throw new AppError('VALIDATION_FAILED', '请填写 Agent 密钥', 400); + } + + if (!this.useDatabase()) { + this.memoryConfig = { + ...nextConfig, + updatedAt: new Date().toISOString(), + }; + return this.memoryConfig; + } + + await this.prisma.$transaction(async (tx) => { + await this.upsertSecret(tx, AGENT_SECRET_NAMES.key, nextConfig.key); + await this.upsertSecret( + tx, + AGENT_SECRET_NAMES.baseURL, + nextConfig.baseURL, + ); + await this.upsertSecret(tx, AGENT_SECRET_NAMES.model, nextConfig.model); + }); + + return this.getRuntimeConfig(); + } + + private async readDatabaseConfig(): Promise { + const secrets = await this.prisma.secret.findMany({ + where: { + provider: SecretProvider.LLM, + scopeType: AGENT_SECRET_SCOPE_TYPE, + scopeId: AGENT_SECRET_SCOPE_ID, + }, + }); + const secretByName = new Map( + secrets.map((secret) => [secret.name, secret] as const), + ); + const updatedAt = secrets + .map((secret) => secret.updatedAt) + .sort((left, right) => right.getTime() - left.getTime())[0] + ?.toISOString(); + + return { + key: this.decryptSecret(secretByName.get(AGENT_SECRET_NAMES.key)), + baseURL: this.decryptSecret(secretByName.get(AGENT_SECRET_NAMES.baseURL)), + model: this.decryptSecret(secretByName.get(AGENT_SECRET_NAMES.model)), + updatedAt, + }; + } + + private decryptSecret( + secret: { encryptedValue: string } | undefined, + ): string | undefined { + return secret ? this.secretCrypto.decrypt(secret.encryptedValue) : undefined; + } + + private async upsertSecret( + tx: Prisma.TransactionClient, + name: string, + value: string, + ): Promise { + await tx.secret.upsert({ + where: { + provider_scopeType_scopeId_name: { + provider: SecretProvider.LLM, + scopeType: AGENT_SECRET_SCOPE_TYPE, + scopeId: AGENT_SECRET_SCOPE_ID, + name, + }, + }, + create: { + provider: SecretProvider.LLM, + scopeType: AGENT_SECRET_SCOPE_TYPE, + scopeId: AGENT_SECRET_SCOPE_ID, + name, + encryptedValue: this.secretCrypto.encrypt(value), + rotatedAt: new Date(), + }, + update: { + encryptedValue: this.secretCrypto.encrypt(value), + version: { + increment: 1, + }, + rotatedAt: new Date(), + }, + }); + } + + private useDatabase(): boolean { + return ( + this.config.get('USE_DATABASE_READS', { infer: true }) && + Boolean(this.config.get('DATABASE_URL', { infer: true })) + ); + } +} diff --git a/src/agent-config/agent-config.service.spec.ts b/src/agent-config/agent-config.service.spec.ts new file mode 100644 index 0000000..33c13f4 --- /dev/null +++ b/src/agent-config/agent-config.service.spec.ts @@ -0,0 +1,96 @@ +import { AuditService } from '../audit/audit.service'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { AppError } from '../common/errors/app-error'; +import { AgentConfigRepository } from './agent-config.repository'; +import { AgentConfigService } from './agent-config.service'; + +describe('AgentConfigService', () => { + const admin: AuthenticatedUser = { + id: 'user_admin', + account: 'zhanxi', + displayName: '超级管理员', + role: 'super_admin', + status: 'active', + mustChangePassword: false, + passwordVersion: 1, + }; + const member: AuthenticatedUser = { + id: 'user_member', + account: 'member01', + displayName: '成员一', + role: 'member', + status: 'active', + mustChangePassword: false, + passwordVersion: 1, + }; + + function createService() { + let config = { + key: 'sk-test-1234567890abcd', + baseURL: 'https://api.example.com/v1', + model: 'gpt-test', + updatedAt: '2026-06-11T00:00:00.000Z', + }; + const repository = { + getRuntimeConfig: jest.fn(() => Promise.resolve(config)), + saveConfig: jest.fn((input) => { + config = { + ...config, + key: input.key ?? config.key, + baseURL: input.baseURL, + model: input.model, + }; + return Promise.resolve(config); + }), + } satisfies Pick; + const audit = { + record: jest.fn(() => Promise.resolve()), + } satisfies Pick; + + return { + audit, + repository, + service: new AgentConfigService( + repository as unknown as AgentConfigRepository, + audit as unknown as AuditService, + ), + }; + } + + it('拒绝普通成员访问 Agent 配置', async () => { + const { service } = createService(); + + await expect(service.getConfig(member)).rejects.toBeInstanceOf(AppError); + }); + + it('返回配置时只展示脱敏密钥', async () => { + const { service } = createService(); + + const result = await service.getConfig(admin); + + expect(result.hasKey).toBe(true); + expect(result.maskedKey).toBe('sk-****abcd'); + expect(JSON.stringify(result)).not.toContain('1234567890'); + }); + + it('保存配置时审计变更字段但不记录完整密钥', async () => { + const { audit, service } = createService(); + + await service.saveConfig(admin, { + key: 'sk-new-abcdef123456', + baseURL: 'https://api.example.com/v1', + model: 'gpt-new', + }); + + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + after: expect.objectContaining({ + changedFields: expect.arrayContaining(['key', 'model']), + }), + }), + ); + expect(JSON.stringify(audit.record.mock.calls)).not.toContain( + 'sk-new-abcdef123456', + ); + }); +}); diff --git a/src/agent-config/agent-config.service.ts b/src/agent-config/agent-config.service.ts new file mode 100644 index 0000000..4536c06 --- /dev/null +++ b/src/agent-config/agent-config.service.ts @@ -0,0 +1,174 @@ +import { Injectable } from '@nestjs/common'; +import { AuditService } from '../audit/audit.service'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { AppError } from '../common/errors/app-error'; +import { redactSensitive } from '../common/security/redact-sensitive'; +import { SaveAgentConfigInput } from './agent-config.dto'; +import { AgentConfigRepository } from './agent-config.repository'; +import { + AgentConfigSummary, + AgentConnectionTestResult, + AgentRuntimeConfig, +} from './agent-config.types'; + +const AGENT_CONFIG_WARNING = + '该配置影响系统 Agent 能力,请谨慎修改。密钥仅在服务端加密保存,前端不会返回完整明文。'; + +@Injectable() +export class AgentConfigService { + constructor( + private readonly agentConfigRepository: AgentConfigRepository, + private readonly auditService: AuditService, + ) {} + + async getConfig(user: AuthenticatedUser): Promise { + this.assertSuperAdmin(user); + return this.toSummary(await this.agentConfigRepository.getRuntimeConfig()); + } + + async saveConfig( + user: AuthenticatedUser, + input: SaveAgentConfigInput, + ): Promise { + this.assertSuperAdmin(user); + const before = await this.agentConfigRepository.getRuntimeConfig(); + const savedConfig = await this.agentConfigRepository.saveConfig(input); + + await this.auditService.record({ + action: 'AGENT_CONFIG_UPDATED', + resourceType: 'agent_config', + actorId: user.id, + actorName: user.account, + before: { + baseURL: before.baseURL, + model: before.model, + hasKey: Boolean(before.key), + }, + after: { + baseURL: savedConfig.baseURL, + model: savedConfig.model, + hasKey: Boolean(savedConfig.key), + changedFields: this.changedFields(before, savedConfig, Boolean(input.key)), + }, + }); + + return this.toSummary(savedConfig); + } + + async testConnection( + user: AuthenticatedUser, + ): Promise { + this.assertSuperAdmin(user); + const config = await this.requireRuntimeConfig(); + + try { + const response = await fetch(`${this.trimSlash(config.baseURL)}/models`, { + method: 'GET', + headers: { + authorization: `Bearer ${config.key}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return { + success: true, + message: 'Agent 连接测试成功', + checkedAt: new Date().toISOString(), + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Agent 连接测试失败'; + const safeMessage = redactSensitive({ message }).message; + + throw new AppError( + 'INTEGRATION_REQUEST_FAILED', + `Agent 连接测试失败:${safeMessage}`, + 400, + ); + } + } + + async resolveRuntimeConfig(): Promise { + const config = await this.agentConfigRepository.getRuntimeConfig(); + + if (!config.key || !config.baseURL || !config.model) { + return null; + } + + return { + key: config.key, + baseURL: config.baseURL, + model: config.model, + }; + } + + async requireRuntimeConfig(): Promise { + const config = await this.resolveRuntimeConfig(); + + if (!config) { + throw new AppError( + 'CONFIG_MISSING', + 'Agent 配置不完整,请联系超级管理员配置 key、baseURL 和 model', + 500, + ); + } + + return config; + } + + private assertSuperAdmin(user: AuthenticatedUser): void { + if (user.role !== 'super_admin') { + throw new AppError('FORBIDDEN', '无权限访问 Agent 配置', 403); + } + } + + private toSummary( + config: Partial & { updatedAt?: string }, + ): AgentConfigSummary { + return { + baseURL: config.baseURL ?? '', + model: config.model ?? '', + hasKey: Boolean(config.key), + maskedKey: config.key ? this.maskKey(config.key) : undefined, + warning: AGENT_CONFIG_WARNING, + updatedAt: config.updatedAt, + }; + } + + private maskKey(key: string): string { + if (key.length <= 8) { + return `${key.slice(0, 2)}****${key.slice(-2)}`; + } + + return `${key.slice(0, 3)}****${key.slice(-4)}`; + } + + private changedFields( + before: Partial, + after: Partial, + keyProvided: boolean, + ): string[] { + const fields: string[] = []; + + if (keyProvided && before.key !== after.key) { + fields.push('key'); + } + + if (before.baseURL !== after.baseURL) { + fields.push('baseURL'); + } + + if (before.model !== after.model) { + fields.push('model'); + } + + return fields; + } + + private trimSlash(value: string): string { + return value.replace(/\/+$/, ''); + } +} diff --git a/src/agent-config/agent-config.types.ts b/src/agent-config/agent-config.types.ts new file mode 100644 index 0000000..3fe0da9 --- /dev/null +++ b/src/agent-config/agent-config.types.ts @@ -0,0 +1,20 @@ +export type AgentConfigSummary = { + baseURL: string; + model: string; + hasKey: boolean; + maskedKey?: string; + warning: string; + updatedAt?: string; +}; + +export type AgentRuntimeConfig = { + key: string; + baseURL: string; + model: string; +}; + +export type AgentConnectionTestResult = { + success: boolean; + message: string; + checkedAt: string; +}; diff --git a/src/agent-config/secret-crypto.service.ts b/src/agent-config/secret-crypto.service.ts new file mode 100644 index 0000000..73fbe7d --- /dev/null +++ b/src/agent-config/secret-crypto.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'; +import { AppError } from '../common/errors/app-error'; +import { EnvConfig } from '../config/env.schema'; + +const ENCRYPTION_PREFIX = 'enc:v1'; + +@Injectable() +export class SecretCryptoService { + constructor(private readonly config: ConfigService) {} + + encrypt(value: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.resolveKey(), iv); + const encrypted = Buffer.concat([ + cipher.update(value, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return [ + ENCRYPTION_PREFIX, + iv.toString('base64url'), + authTag.toString('base64url'), + encrypted.toString('base64url'), + ].join(':'); + } + + decrypt(value: string): string { + if (!value.startsWith(`${ENCRYPTION_PREFIX}:`)) { + return value; + } + + const [, , encodedIv, encodedAuthTag, encodedEncrypted] = value.split(':'); + + if (!encodedIv || !encodedAuthTag || !encodedEncrypted) { + throw new AppError('CONFIG_MISSING', '密钥密文格式不正确', 500); + } + + const decipher = createDecipheriv( + 'aes-256-gcm', + this.resolveKey(), + Buffer.from(encodedIv, 'base64url'), + ); + decipher.setAuthTag(Buffer.from(encodedAuthTag, 'base64url')); + + return Buffer.concat([ + decipher.update(Buffer.from(encodedEncrypted, 'base64url')), + decipher.final(), + ]).toString('utf8'); + } + + private resolveKey(): Buffer { + const secret = + this.config.get('SECRET_ENCRYPTION_KEY', { infer: true }) ?? + this.config.get('AUTH_TOKEN_SECRET', { infer: true }) ?? + (this.config.get('NODE_ENV', { infer: true }) === 'production' + ? undefined + : 'devops-platform-local-secret-key'); + + if (!secret) { + throw new AppError( + 'CONFIG_MISSING', + '缺少 SECRET_ENCRYPTION_KEY,不能保存 Agent 密钥', + 500, + ); + } + + return createHash('sha256').update(secret).digest(); + } +} diff --git a/src/agent/agent.controller.ts b/src/agent/agent.controller.ts index c7eead7..f55dccc 100644 --- a/src/agent/agent.controller.ts +++ b/src/agent/agent.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { ApiBody, ApiCreatedResponse, @@ -6,6 +6,10 @@ import { ApiTags, } from '@nestjs/swagger'; import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; +import { AuthGuard } from '../auth/auth.guard'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { PasswordChangeGuard } from '../auth/password-change.guard'; import { AgentInvocation } from './agent.types'; import { AgentService } from './agent.service'; import { @@ -14,24 +18,26 @@ import { createAgentInvocationSchema, } from './create-agent-invocation.dto'; -@ApiTags('agent') +@ApiTags('Agent 调用') @Controller('agent/invocations') +@UseGuards(AuthGuard, PasswordChangeGuard) export class AgentController { constructor(private readonly agentService: AgentService) {} @Get() - @ApiOkResponse({ description: 'Agent invocation audit list.' }) + @ApiOkResponse({ description: '查询 Agent 调用记录。' }) listInvocations(): AgentInvocation[] { return this.agentService.listInvocations(); } @Post() @ApiBody({ type: CreateAgentInvocationDto }) - @ApiCreatedResponse({ description: 'Create a DevOps-scoped agent invocation.' }) + @ApiCreatedResponse({ description: '创建 DevOps 工作流范围内的 Agent 调用。' }) async createInvocation( + @CurrentUser() user: AuthenticatedUser, @Body(new ZodValidationPipe(createAgentInvocationSchema)) body: CreateAgentInvocationInput, ): Promise { - return this.agentService.createInvocation(body); + return this.agentService.createInvocation(body, user); } } diff --git a/src/agent/agent.module.ts b/src/agent/agent.module.ts index d837130..5ea63f0 100644 --- a/src/agent/agent.module.ts +++ b/src/agent/agent.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; +import { AgentConfigModule } from '../agent-config/agent-config.module'; import { AuditModule } from '../audit/audit.module'; +import { AuthModule } from '../auth/auth.module'; import { AgentController } from './agent.controller'; import { AgentService } from './agent.service'; import { LlmClient } from './llm.client'; @Module({ - imports: [AuditModule], + imports: [AgentConfigModule, AuditModule, AuthModule], controllers: [AgentController], providers: [AgentService, LlmClient], exports: [AgentService, LlmClient], diff --git a/src/agent/agent.service.spec.ts b/src/agent/agent.service.spec.ts index 2f1cd70..ece435b 100644 --- a/src/agent/agent.service.spec.ts +++ b/src/agent/agent.service.spec.ts @@ -1,4 +1,6 @@ import { AuditService } from '../audit/audit.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthenticatedUser } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; import { AgentService } from './agent.service'; import { CreateAgentInvocationInput } from './create-agent-invocation.dto'; @@ -8,6 +10,16 @@ import { LlmClient } from './llm.client'; * 锁定 Agent 入口只服务 DevOps 场景,避免后续误扩成泛聊天接口。 */ describe('AgentService', () => { + const operator: AuthenticatedUser = { + id: 'user_admin', + account: 'zhanxi', + displayName: '超级管理员', + role: 'super_admin', + status: 'active', + mustChangePassword: false, + passwordVersion: 1, + }; + function createService(options?: { llmConfigured?: boolean; llmText?: string; @@ -17,9 +29,14 @@ describe('AgentService', () => { } satisfies Pick; const llm = { healthSummary: jest.fn(() => - options?.llmConfigured - ? { status: 'ok' as const } - : { status: 'not_configured' as const, message: 'Missing LLM_API_KEY' }, + Promise.resolve( + options?.llmConfigured + ? { status: 'ok' as const } + : { + status: 'not_configured' as const, + message: 'Agent 配置不完整', + }, + ), ), complete: jest.fn(() => Promise.resolve({ @@ -28,34 +45,45 @@ describe('AgentService', () => { }), ), } satisfies Pick; + const auth = { + requireProjectPermission: jest.fn(() => Promise.resolve()), + } satisfies Pick; return { audit, + auth, llm, service: new AgentService( audit as unknown as AuditService, llm as unknown as LlmClient, + auth as unknown as AuthService, ), }; } - it('creates a DevOps-scoped mock invocation when LLM is not configured', async () => { + it('returns a failed invocation when LLM is not configured', async () => { const { audit, llm, service } = createService(); - const invocation = await service.createInvocation({ - type: 'failure-diagnosis', - runId: 'run_001', - projectKey: 'role-user', - promptSummary: 'Analyze latest failed Jenkins log excerpt.', - }); + const invocation = await service.createInvocation( + { + type: 'failure-diagnosis', + runId: 'run_001', + projectKey: 'role-user', + promptSummary: 'Analyze latest failed Jenkins log excerpt.', + }, + operator, + ); - expect(invocation.status).toBe('success'); - expect(invocation.resultMarkdown).toContain('失败诊断摘要'); + expect(invocation.status).toBe('failed'); + expect(invocation.resultMarkdown).toContain('Agent 配置不完整'); expect(llm.complete).not.toHaveBeenCalled(); expect(audit.record).toHaveBeenCalledWith( expect.objectContaining({ - action: 'AGENT_INVOCATION_CREATED', + action: 'AGENT_INVOCATION_FAILED', resourceType: 'agent_invocation', + after: expect.objectContaining({ + mode: 'not_configured', + }), }), ); }); @@ -66,11 +94,14 @@ describe('AgentService', () => { llmText: '真实 LLM 运维分析结果', }); - const invocation = await service.createInvocation({ - type: 'release-risk', - projectKey: 'access-manage', - promptSummary: 'Analyze production tag risk.', - }); + const invocation = await service.createInvocation( + { + type: 'release-risk', + projectKey: 'access-manage', + promptSummary: 'Analyze production tag risk.', + }, + operator, + ); expect(invocation.status).toBe('success'); expect(invocation.resultMarkdown).toBe('真实 LLM 运维分析结果'); @@ -94,10 +125,13 @@ describe('AgentService', () => { const { service } = createService(); await expect( - service.createInvocation({ - type: 'general-chat', - promptSummary: 'Tell me anything.', - } as unknown as CreateAgentInvocationInput), + service.createInvocation( + { + type: 'general-chat', + promptSummary: 'Tell me anything.', + } as unknown as CreateAgentInvocationInput, + operator, + ), ).rejects.toBeInstanceOf(AppError); }); }); diff --git a/src/agent/agent.service.ts b/src/agent/agent.service.ts index a6b9cd2..e0ec7cc 100644 --- a/src/agent/agent.service.ts +++ b/src/agent/agent.service.ts @@ -1,4 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { AuthService } from '../auth/auth.service'; +import { AuthenticatedUser } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; import { AuditService } from '../audit/audit.service'; import { redactSensitive } from '../common/security/redact-sensitive'; @@ -14,19 +16,6 @@ const allowedTypes: AgentPurpose[] = [ 'incident-review', ]; -const mockResults: Record = { - 'release-risk': - '发布风险摘要:当前为第一阶段 mock 分析。真实接入后会读取 Gitea ref diff、历史失败记录和项目部署策略。', - 'failure-diagnosis': - '失败诊断摘要:当前为第一阶段 mock 分析。真实接入后会基于 Jenkins progressiveText 日志和 run steps 生成建议。', - 'runbook-qa': - 'Runbook 回答:当前仅允许围绕 DevOps 平台文档、项目 README 和白名单运维资料回答。', - 'release-note': - '发布说明草稿:当前为 mock 草稿。真实接入后会根据 commit、PR 和 tag 信息生成。', - 'incident-review': - '事故复盘草稿:当前为 mock 草稿。真实接入后会汇总失败 run、耗时、日志摘要和人工处理记录。', -}; - @Injectable() export class AgentService { private readonly invocations: AgentInvocation[] = []; @@ -34,6 +23,7 @@ export class AgentService { constructor( private readonly auditService: AuditService, private readonly llmClient: LlmClient, + private readonly authService: AuthService, ) {} listInvocations(): AgentInvocation[] { @@ -44,11 +34,12 @@ export class AgentService { async createInvocation( input: CreateAgentInvocationInput, + operator: AuthenticatedUser, ): Promise { if (!allowedTypes.includes(input.type)) { throw new AppError( 'VALIDATION_FAILED', - 'Unsupported agent invocation type', + '不支持的 Agent 调用类型', 400, { type: input.type, @@ -56,7 +47,8 @@ export class AgentService { ); } - const llmHealth = this.llmClient.healthSummary(); + await this.validateAgentScope(input, operator); + const llmHealth = await this.llmClient.healthSummary(); const baseInvocation = { id: `agent_${Date.now()}_${this.invocations.length + 1}`, runId: input.runId, @@ -79,9 +71,14 @@ export class AgentService { invocation = { ...baseInvocation, status: 'success', - resultMarkdown: completion.text || mockResults[input.type], + resultMarkdown: completion.text || 'Agent 返回了空结果,请检查模型配置。', }; - await this.recordInvocationAudit(invocation, 'llm', completion.model); + await this.recordInvocationAudit( + invocation, + operator, + 'llm', + completion.model, + ); } catch (error) { invocation = { ...baseInvocation, @@ -89,19 +86,21 @@ export class AgentService { resultMarkdown: error instanceof Error ? redactSensitive({ message: error.message }).message - : 'LLM invocation failed', + : 'Agent 调用失败', }; - await this.recordInvocationAudit(invocation, 'llm_failed'); + await this.recordInvocationAudit(invocation, operator, 'llm_failed'); } } else { invocation = { ...baseInvocation, - status: 'success', - resultMarkdown: mockResults[input.type], + status: 'failed', + resultMarkdown: + llmHealth.message ?? 'Agent 配置不完整,请先配置 key、baseURL 和 model。', }; await this.recordInvocationAudit( invocation, - 'mock', + operator, + 'not_configured', undefined, llmHealth.message, ); @@ -111,9 +110,33 @@ export class AgentService { return invocation; } + private async validateAgentScope( + input: CreateAgentInvocationInput, + operator: AuthenticatedUser, + ): Promise { + if (operator.role === 'super_admin') { + return; + } + + if (!input.projectKey) { + throw new AppError( + 'FORBIDDEN', + '普通成员只能在已授权项目内使用 Agent', + 403, + ); + } + + await this.authService.requireProjectPermission( + operator, + input.projectKey, + 'read', + ); + } + private async recordInvocationAudit( invocation: AgentInvocation, - mode: 'mock' | 'llm' | 'llm_failed', + operator: AuthenticatedUser, + mode: 'llm' | 'llm_failed' | 'not_configured', model?: string, reason?: string, ): Promise { @@ -126,6 +149,8 @@ export class AgentService { action, resourceType: 'agent_invocation', resourceId: invocation.id, + actorId: operator.id, + actorName: operator.account, after: { type: invocation.type, runId: invocation.runId, diff --git a/src/agent/create-agent-invocation.dto.ts b/src/agent/create-agent-invocation.dto.ts index d84b81a..1071e5a 100644 --- a/src/agent/create-agent-invocation.dto.ts +++ b/src/agent/create-agent-invocation.dto.ts @@ -52,7 +52,8 @@ export class CreateAgentInvocationDto { projectKey?: string; @ApiProperty({ - example: 'Analyze latest failed Jenkins log excerpt for access-manage test.', + description: '本次 Agent 调用的中文问题或任务摘要', + example: '分析 access-manage 测试环境最近一次 Jenkins 失败日志。', }) promptSummary!: string; } diff --git a/src/agent/llm.client.ts b/src/agent/llm.client.ts index 06f5f46..db1dee3 100644 --- a/src/agent/llm.client.ts +++ b/src/agent/llm.client.ts @@ -1,34 +1,38 @@ import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { IntegrationRequestError, IntegrationUnavailableError } from '../common/errors/integration-error'; +import { AgentConfigService } from '../agent-config/agent-config.service'; +import { AppError } from '../common/errors/app-error'; +import { IntegrationRequestError } from '../common/errors/integration-error'; import { redactSensitive } from '../common/security/redact-sensitive'; -import { EnvConfig } from '../config/env.schema'; import { LlmCompletionInput, LlmCompletionResult } from './agent.types'; @Injectable() export class LlmClient { - constructor(private readonly config: ConfigService) {} + constructor(private readonly agentConfigService: AgentConfigService) {} - healthSummary(): { status: 'ok' | 'not_configured'; message?: string } { - const missing = this.missingConfig(); - return missing.length === 0 + async healthSummary(): Promise<{ + status: 'ok' | 'not_configured'; + message?: string; + }> { + const config = await this.agentConfigService.resolveRuntimeConfig(); + + return config ? { status: 'ok' } - : { status: 'not_configured', message: `Missing ${missing.join(', ')}` }; + : { + status: 'not_configured', + message: 'Agent 配置不完整,请配置 key、baseURL 和 model', + }; } async complete(input: LlmCompletionInput): Promise { - this.assertConfigured(); - const baseUrl = this.config.get('LLM_BASE_URL', { infer: true }) ?? ''; - const apiKey = this.config.get('LLM_API_KEY', { infer: true }) ?? ''; - const model = this.config.get('LLM_MODEL', { infer: true }) ?? ''; - const response = await fetch(`${baseUrl}/chat/completions`, { + const config = await this.agentConfigService.requireRuntimeConfig(); + const response = await fetch(`${this.trimSlash(config.baseURL)}/chat/completions`, { method: 'POST', headers: { - authorization: `Bearer ${apiKey}`, + authorization: `Bearer ${config.key}`, 'content-type': 'application/json', }, body: JSON.stringify({ - model, + model: config.model, messages: [ { role: 'system', content: input.systemPrompt }, { role: 'user', content: input.userPrompt }, @@ -37,7 +41,7 @@ export class LlmClient { }); if (!response.ok) { - throw new IntegrationRequestError('llm', 'LLM request failed', { + throw new IntegrationRequestError('llm', 'Agent 请求失败', { status: response.status, purpose: input.purpose, request: redactSensitive(input), @@ -51,25 +55,19 @@ export class LlmClient { return { text: payload.choices?.[0]?.message?.content ?? '', - model: payload.model ?? model, + model: payload.model ?? config.model, }; } - private assertConfigured(): void { - const missing = this.missingConfig(); - - if (missing.length > 0) { - throw new IntegrationUnavailableError('llm', missing); + private trimSlash(value: string): string { + if (!value) { + throw new AppError( + 'CONFIG_MISSING', + 'Agent baseURL 未配置,请联系超级管理员', + 500, + ); } - } - private missingConfig(): string[] { - const entries: Array<[string, string | undefined]> = [ - ['LLM_BASE_URL', this.config.get('LLM_BASE_URL', { infer: true })], - ['LLM_API_KEY', this.config.get('LLM_API_KEY', { infer: true })], - ['LLM_MODEL', this.config.get('LLM_MODEL', { infer: true })], - ]; - - return entries.filter(([, value]) => !value).map(([key]) => key); + return value.replace(/\/+$/, ''); } } diff --git a/src/app.module.ts b/src/app.module.ts index faebc50..626b12d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,13 +1,17 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { AgentConfigModule } from './agent-config/agent-config.module'; import { AgentModule } from './agent/agent.module'; import { AuditModule } from './audit/audit.module'; +import { AuthModule } from './auth/auth.module'; import { DeployRunsModule } from './deploy-runs/deploy-runs.module'; import { validateEnv } from './config/env.schema'; import { GiteaModule } from './integrations/gitea/gitea.module'; import { GiteaWebhookModule } from './webhooks/gitea/gitea-webhook.module'; import { HealthModule } from './health/health.module'; import { JenkinsModule } from './integrations/jenkins/jenkins.module'; +import { MembersModule } from './members/members.module'; +import { MessagesModule } from './messages/messages.module'; import { ProcessDefinitionsModule } from './process-definitions/process-definitions.module'; import { PrismaModule } from './prisma/prisma.module'; import { ProjectsModule } from './projects/projects.module'; @@ -24,6 +28,10 @@ import { WeComModule } from './notifications/wecom/wecom.module'; }), PrismaModule, AuditModule, + AuthModule, + MessagesModule, + MembersModule, + AgentConfigModule, AgentModule, JenkinsModule, GiteaModule, diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts index 8731a97..8418249 100644 --- a/src/audit/audit.controller.ts +++ b/src/audit/audit.controller.ts @@ -1,17 +1,23 @@ /** * 审计日志查询入口只返回脱敏后的最近记录。 */ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { AdminOnlyMessage } from '../auth/admin-only-message.decorator'; +import { AuthGuard } from '../auth/auth.guard'; +import { PasswordChangeGuard } from '../auth/password-change.guard'; +import { SuperAdminGuard } from '../auth/super-admin.guard'; import { AuditRecordSummary, AuditService } from './audit.service'; -@ApiTags('audit') +@ApiTags('审计日志') @Controller('audit-logs') +@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard) +@AdminOnlyMessage('无权限访问审计日志') export class AuditController { constructor(private readonly auditService: AuditService) {} @Get() - @ApiOkResponse({ description: 'Recent sanitized audit records.' }) + @ApiOkResponse({ description: '查询最近的脱敏审计记录。' }) async listRecentRecords(): Promise { return this.auditService.getRecentRecords(); } diff --git a/src/auth/admin-only-message.decorator.ts b/src/auth/admin-only-message.decorator.ts new file mode 100644 index 0000000..5c83aaa --- /dev/null +++ b/src/auth/admin-only-message.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ADMIN_ONLY_MESSAGE_KEY = 'adminOnlyMessage'; + +export const AdminOnlyMessage = (message: string) => + SetMetadata(ADMIN_ONLY_MESSAGE_KEY, message); diff --git a/src/auth/auth-token.service.ts b/src/auth/auth-token.service.ts new file mode 100644 index 0000000..f57e653 --- /dev/null +++ b/src/auth/auth-token.service.ts @@ -0,0 +1,143 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createHmac, timingSafeEqual } from 'crypto'; +import { AppError } from '../common/errors/app-error'; +import { EnvConfig } from '../config/env.schema'; +import { AUTH_TOKEN_VERSION } from './auth.constants'; +import { AuthenticatedUser } from './auth.types'; +import { UserRepository } from './user.repository'; + +type TokenPayload = { + ver: typeof AUTH_TOKEN_VERSION; + sub: string; + account: string; + role: AuthenticatedUser['role']; + passwordVersion: number; + iat: number; + exp: number; +}; + +@Injectable() +export class AuthTokenService { + constructor( + private readonly config: ConfigService, + private readonly userRepository: UserRepository, + ) {} + + sign(user: AuthenticatedUser): { token: string; expiresAt: string } { + const now = Math.floor(Date.now() / 1000); + const ttlSeconds = this.config.get('AUTH_TOKEN_TTL_SECONDS', { + infer: true, + }); + const payload: TokenPayload = { + ver: AUTH_TOKEN_VERSION, + sub: user.id, + account: user.account, + role: user.role, + passwordVersion: user.passwordVersion, + iat: now, + exp: now + ttlSeconds, + }; + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString( + 'base64url', + ); + const signature = this.signPayload(encodedPayload); + + return { + token: `${AUTH_TOKEN_VERSION}.${encodedPayload}.${signature}`, + expiresAt: new Date(payload.exp * 1000).toISOString(), + }; + } + + async verify(token: string): Promise { + const payload = this.parseToken(token); + const user = await this.userRepository.findById(payload.sub); + + if (!user) { + throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401); + } + + if (user.status !== 'active') { + throw new AppError('FORBIDDEN', '账号已被禁用,请联系超级管理员', 403); + } + + if (user.passwordVersion !== payload.passwordVersion) { + throw new AppError('AUTH_REQUIRED', '密码已变更,请重新登录', 401); + } + + return this.toAuthenticatedUser(user); + } + + private parseToken(token: string): TokenPayload { + const [version, encodedPayload, signature] = token.split('.'); + + if (version !== AUTH_TOKEN_VERSION || !encodedPayload || !signature) { + throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401); + } + + const expectedSignature = this.signPayload(encodedPayload); + const actual = Buffer.from(signature); + const expected = Buffer.from(expectedSignature); + + if ( + actual.length !== expected.length || + !timingSafeEqual(actual, expected) + ) { + throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401); + } + + let payload: TokenPayload; + + try { + payload = JSON.parse( + Buffer.from(encodedPayload, 'base64url').toString('utf8'), + ) as TokenPayload; + } catch { + throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401); + } + + if (payload.exp < Math.floor(Date.now() / 1000)) { + throw new AppError('AUTH_REQUIRED', '登录已过期,请重新登录', 401); + } + + return payload; + } + + private signPayload(encodedPayload: string): string { + return createHmac('sha256', this.resolveSecret()) + .update(encodedPayload) + .digest('base64url'); + } + + private resolveSecret(): string { + const secret = + this.config.get('AUTH_TOKEN_SECRET', { infer: true }) ?? + this.config.get('SECRET_ENCRYPTION_KEY', { infer: true }); + + if (secret) { + return secret; + } + + if (this.config.get('NODE_ENV', { infer: true }) === 'production') { + throw new AppError( + 'CONFIG_MISSING', + '生产环境缺少 AUTH_TOKEN_SECRET,不能签发登录令牌', + 500, + ); + } + + return 'devops-platform-local-auth-secret'; + } + + private toAuthenticatedUser(user: AuthenticatedUser): AuthenticatedUser { + return { + id: user.id, + account: user.account, + displayName: user.displayName, + role: user.role, + status: user.status, + mustChangePassword: user.mustChangePassword, + passwordVersion: user.passwordVersion, + }; + } +} diff --git a/src/auth/auth.constants.ts b/src/auth/auth.constants.ts new file mode 100644 index 0000000..8652fd4 --- /dev/null +++ b/src/auth/auth.constants.ts @@ -0,0 +1,13 @@ +export const SUPER_ADMIN_SEED = { + id: 'user_super_admin_zhanxi', + account: 'zhanxi', + displayName: '超级管理员', +} as const; + +export const LOCAL_SUPER_ADMIN_INITIAL_PASSWORD = 'devops-local-admin'; + +export const MEMBER_INITIAL_PASSWORD = '111111'; + +export const AUTH_COOKIE_NAME = 'devops_auth_token'; + +export const AUTH_TOKEN_VERSION = 'v1'; diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..c5b8d41 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,78 @@ +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { + ApiBody, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; +import { AuthGuard } from './auth.guard'; +import { + ChangePasswordDto, + ChangePasswordInput, + ForgotPasswordDto, + ForgotPasswordInput, + LoginDto, + LoginInput, + changePasswordSchema, + forgotPasswordSchema, + loginSchema, +} from './auth.dto'; +import { AuthService } from './auth.service'; +import { CurrentUser } from './current-user.decorator'; +import { AuthenticatedUser, LoginResult } from './auth.types'; + +@ApiTags('登录鉴权') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('login') + @ApiBody({ type: LoginDto }) + @ApiCreatedResponse({ description: '登录成功,返回登录令牌和当前账号信息。' }) + async login( + @Body(new ZodValidationPipe(loginSchema)) body: LoginInput, + ): Promise { + return this.authService.login(body); + } + + @Get('me') + @UseGuards(AuthGuard) + @ApiOkResponse({ description: '获取当前登录账号信息。' }) + getCurrentUser(@CurrentUser() user: AuthenticatedUser): AuthenticatedUser { + return user; + } + + @Post('change-password') + @UseGuards(AuthGuard) + @ApiBody({ type: ChangePasswordDto }) + @ApiOkResponse({ description: '修改当前登录账号密码。' }) + async changePassword( + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(changePasswordSchema)) + body: ChangePasswordInput, + ): Promise { + return this.authService.changePassword(user, body); + } + + @Post('forgot-password') + @ApiBody({ type: ForgotPasswordDto }) + @ApiCreatedResponse({ + description: '普通成员忘记密码时通知超级管理员处理。', + }) + async forgotPassword( + @Body(new ZodValidationPipe(forgotPasswordSchema)) + body: ForgotPasswordInput, + ): Promise<{ message: string }> { + return this.authService.forgotPassword(body); + } + + @Post('logout') + @UseGuards(AuthGuard) + @ApiOkResponse({ description: '退出登录。' }) + logout(): { message: string } { + return { + message: '已退出登录', + }; + } +} diff --git a/src/auth/auth.dto.ts b/src/auth/auth.dto.ts new file mode 100644 index 0000000..6c80146 --- /dev/null +++ b/src/auth/auth.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { z } from 'zod'; + +export const loginSchema = z.object({ + account: z.string().trim().min(1).max(80), + password: z.string().min(1).max(200), +}); + +export type LoginInput = z.infer; + +export class LoginDto { + @ApiProperty({ example: 'zhanxi', description: '登录账号' }) + account!: string; + + @ApiProperty({ example: 'change-me-from-server-env', description: '登录密码' }) + password!: string; +} + +export const changePasswordSchema = z.object({ + currentPassword: z.string().min(1).max(200), + newPassword: z.string().min(6).max(200), +}); + +export type ChangePasswordInput = z.infer; + +export class ChangePasswordDto { + @ApiProperty({ description: '当前密码' }) + currentPassword!: string; + + @ApiProperty({ description: '新密码,至少 6 位' }) + newPassword!: string; +} + +export const forgotPasswordSchema = z.object({ + account: z.string().trim().min(1).max(80), +}); + +export type ForgotPasswordInput = z.infer; + +export class ForgotPasswordDto { + @ApiProperty({ example: 'member01', description: '普通成员账号' }) + account!: string; +} diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts new file mode 100644 index 0000000..6cffdfd --- /dev/null +++ b/src/auth/auth.guard.ts @@ -0,0 +1,47 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { AUTH_COOKIE_NAME } from './auth.constants'; +import { AuthTokenService } from './auth-token.service'; +import { RequestWithUser } from './auth.types'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor(private readonly authTokenService: AuthTokenService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.resolveToken(request); + + if (!token) { + throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401); + } + + request.user = await this.authTokenService.verify(token); + return true; + } + + private resolveToken(request: RequestWithUser): string | undefined { + const authorization = this.firstHeader(request.headers.authorization); + + if (authorization?.startsWith('Bearer ')) { + return authorization.slice('Bearer '.length).trim(); + } + + const cookie = this.firstHeader(request.headers.cookie); + + if (!cookie) { + return undefined; + } + + const entries = cookie.split(';').map((item) => item.trim().split('=')); + const tokenEntry = entries.find(([name]) => name === AUTH_COOKIE_NAME); + + return tokenEntry?.[1] ? decodeURIComponent(tokenEntry[1]) : undefined; + } + + private firstHeader( + value: string | string[] | undefined, + ): string | undefined { + return Array.isArray(value) ? value[0] : value; + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..e184385 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,36 @@ +import { Global, Module, forwardRef } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { MessagesModule } from '../messages/messages.module'; +import { AuthController } from './auth.controller'; +import { AuthGuard } from './auth.guard'; +import { AuthService } from './auth.service'; +import { AuthTokenService } from './auth-token.service'; +import { PasswordChangeGuard } from './password-change.guard'; +import { PasswordService } from './password.service'; +import { SuperAdminGuard } from './super-admin.guard'; +import { UserRepository } from './user.repository'; + +@Global() +@Module({ + imports: [AuditModule, forwardRef(() => MessagesModule)], + controllers: [AuthController], + providers: [ + AuthGuard, + AuthService, + AuthTokenService, + PasswordChangeGuard, + PasswordService, + SuperAdminGuard, + UserRepository, + ], + exports: [ + AuthGuard, + AuthService, + AuthTokenService, + PasswordChangeGuard, + PasswordService, + SuperAdminGuard, + UserRepository, + ], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..5e945b1 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,179 @@ +import { AuditService } from '../audit/audit.service'; +import { MessagesService } from '../messages/messages.service'; +import { AppError } from '../common/errors/app-error'; +import { EnvConfig } from '../config/env.schema'; +import { AuthTokenService } from './auth-token.service'; +import { AuthService } from './auth.service'; +import { MEMBER_INITIAL_PASSWORD } from './auth.constants'; +import { PasswordService } from './password.service'; +import { UserRepository } from './user.repository'; +import { UserRecord } from './auth.types'; +import { ConfigService } from '@nestjs/config'; + +const TEST_ADMIN_PASSWORD = 'test-admin-password'; + +describe('AuthService', () => { + const passwordService = new PasswordService(); + + async function createService() { + const adminHash = await passwordService.hashPassword(TEST_ADMIN_PASSWORD); + const memberHash = await passwordService.hashPassword(MEMBER_INITIAL_PASSWORD); + const users = new Map(); + const admin: UserRecord = { + id: 'user_admin', + account: 'zhanxi', + displayName: '超级管理员', + role: 'super_admin', + status: 'active', + passwordHash: adminHash, + mustChangePassword: false, + passwordVersion: 1, + createdAt: '2026-06-11T00:00:00.000Z', + updatedAt: '2026-06-11T00:00:00.000Z', + projectPermissions: [], + }; + const member: UserRecord = { + id: 'user_member', + account: 'member01', + displayName: '成员一', + role: 'member', + status: 'active', + passwordHash: memberHash, + mustChangePassword: true, + passwordVersion: 1, + createdAt: '2026-06-11T00:00:00.000Z', + updatedAt: '2026-06-11T00:00:00.000Z', + projectPermissions: [], + }; + + users.set(admin.id, admin); + users.set(member.id, member); + + const repository = { + ensureSuperAdmin: jest.fn(), + findByAccount: jest.fn((account: string) => + Promise.resolve( + [...users.values()].find((user) => user.account === account) ?? null, + ), + ), + findById: jest.fn((id: string) => Promise.resolve(users.get(id) ?? null)), + markLogin: jest.fn(() => Promise.resolve()), + updatePassword: jest.fn( + (id: string, passwordHash: string, mustChangePassword: boolean) => { + const user = users.get(id); + + if (!user) { + return Promise.reject(new Error('成员不存在')); + } + + const updatedUser: UserRecord = { + ...user, + passwordHash, + mustChangePassword, + passwordVersion: user.passwordVersion + 1, + }; + users.set(id, updatedUser); + return Promise.resolve(updatedUser); + }, + ), + getProjectPermissionLevel: jest.fn(), + listAllowedProjectKeys: jest.fn(), + } satisfies Partial; + const token = { + sign: jest.fn(() => ({ + token: 'token_001', + expiresAt: '2026-06-11T12:00:00.000Z', + })), + } satisfies Pick; + const messages = { + createPasswordResetRequest: jest.fn(() => + Promise.resolve({ + id: 'message_001', + type: 'password_reset_request' as const, + status: 'pending' as const, + title: '成员密码重置请求', + content: '账号 member01 的成员请求重置密码,请及时处理。', + requesterId: 'user_member', + requesterAccount: 'member01', + createdAt: '2026-06-11T00:00:00.000Z', + updatedAt: '2026-06-11T00:00:00.000Z', + }), + ), + } satisfies Pick; + const audit = { + record: jest.fn(() => Promise.resolve()), + } satisfies Pick; + const config = { + get: jest.fn((key: keyof EnvConfig) => { + if (key === 'SUPER_ADMIN_INITIAL_PASSWORD') { + return TEST_ADMIN_PASSWORD; + } + + if (key === 'NODE_ENV') { + return 'test'; + } + + return undefined; + }), + }; + + return { + admin, + audit, + member, + messages, + repository, + service: new AuthService( + config as unknown as ConfigService, + repository as unknown as UserRepository, + passwordService, + token as unknown as AuthTokenService, + messages as unknown as MessagesService, + audit as unknown as AuditService, + ), + }; + } + + it('使用内置超级管理员账号登录成功', async () => { + const { repository, service } = await createService(); + + const result = await service.login({ + account: 'zhanxi', + password: TEST_ADMIN_PASSWORD, + }); + + expect(result.token).toBe('token_001'); + expect(result.user.role).toBe('super_admin'); + expect(repository.markLogin).toHaveBeenCalledWith('user_admin'); + }); + + it('拒绝超级管理员使用忘记密码流程', async () => { + const { service } = await createService(); + + await expect( + service.forgotPassword({ account: 'zhanxi' }), + ).rejects.toBeInstanceOf(AppError); + }); + + it('普通成员忘记密码会生成通知消息', async () => { + const { messages, service } = await createService(); + + const result = await service.forgotPassword({ account: 'member01' }); + + expect(result.message).toBe('已通知超级管理员协助重置密码'); + expect(messages.createPasswordResetRequest).toHaveBeenCalledWith( + expect.objectContaining({ account: 'member01' }), + ); + }); + + it('普通成员改密不能继续使用初始密码', async () => { + const { member, service } = await createService(); + + await expect( + service.changePassword(member, { + currentPassword: MEMBER_INITIAL_PASSWORD, + newPassword: MEMBER_INITIAL_PASSWORD, + }), + ).rejects.toBeInstanceOf(AppError); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..2b396ac --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,252 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuditService } from '../audit/audit.service'; +import { AppError } from '../common/errors/app-error'; +import { EnvConfig } from '../config/env.schema'; +import { MessagesService } from '../messages/messages.service'; +import { + LOCAL_SUPER_ADMIN_INITIAL_PASSWORD, + MEMBER_INITIAL_PASSWORD, +} from './auth.constants'; +import { AuthTokenService } from './auth-token.service'; +import { + ChangePasswordInput, + ForgotPasswordInput, + LoginInput, +} from './auth.dto'; +import { + AuthenticatedUser, + LoginResult, + ProjectPermissionLevel, + UserRecord, +} from './auth.types'; +import { PasswordService } from './password.service'; +import { UserRepository } from './user.repository'; + +@Injectable() +export class AuthService implements OnModuleInit { + constructor( + private readonly config: ConfigService, + private readonly userRepository: UserRepository, + private readonly passwordService: PasswordService, + private readonly authTokenService: AuthTokenService, + private readonly messagesService: MessagesService, + private readonly auditService: AuditService, + ) {} + + async onModuleInit(): Promise { + const passwordHash = await this.passwordService.hashPassword( + this.resolveSuperAdminInitialPassword(), + ); + await this.userRepository.ensureSuperAdmin(passwordHash); + } + + async login(input: LoginInput): Promise { + const user = await this.userRepository.findByAccount(input.account); + + if (!user) { + throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401); + } + + if (user.status !== 'active') { + throw new AppError('FORBIDDEN', '账号已被禁用,请联系超级管理员', 403); + } + + const passwordMatched = await this.passwordService.verifyPassword( + input.password, + user.passwordHash, + ); + + if (!passwordMatched) { + throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401); + } + + await this.userRepository.markLogin(user.id); + await this.auditService.record({ + action: 'USER_LOGIN', + resourceType: 'user', + resourceId: user.id, + actorId: user.id, + actorName: user.account, + after: { + role: user.role, + mustChangePassword: user.mustChangePassword, + }, + }); + + const authenticatedUser = this.toAuthenticatedUser(user); + const tokenResult = this.authTokenService.sign(authenticatedUser); + + return { + token: tokenResult.token, + expiresAt: tokenResult.expiresAt, + user: authenticatedUser, + }; + } + + async changePassword( + currentUser: AuthenticatedUser, + input: ChangePasswordInput, + ): Promise { + const user = await this.requireUser(currentUser.id); + const passwordMatched = await this.passwordService.verifyPassword( + input.currentPassword, + user.passwordHash, + ); + + if (!passwordMatched) { + throw new AppError('VALIDATION_FAILED', '当前密码不正确', 400); + } + + if (input.currentPassword === input.newPassword) { + throw new AppError('VALIDATION_FAILED', '新密码不能与当前密码相同', 400); + } + + if (user.role === 'member' && input.newPassword === MEMBER_INITIAL_PASSWORD) { + throw new AppError( + 'VALIDATION_FAILED', + '新密码不能继续使用初始密码', + 400, + ); + } + + const passwordHash = await this.passwordService.hashPassword( + input.newPassword, + ); + const updatedUser = await this.userRepository.updatePassword( + user.id, + passwordHash, + false, + ); + + await this.auditService.record({ + action: 'USER_PASSWORD_CHANGED', + resourceType: 'user', + resourceId: user.id, + actorId: user.id, + actorName: user.account, + after: { + mustChangePassword: false, + }, + }); + + return this.toAuthenticatedUser(updatedUser); + } + + async forgotPassword( + input: ForgotPasswordInput, + ): Promise<{ message: string }> { + const user = await this.userRepository.findByAccount(input.account); + + if (!user) { + throw new AppError('RESOURCE_NOT_FOUND', '账号不存在,请确认后重试', 404); + } + + if (user.role === 'super_admin') { + throw new AppError( + 'FORBIDDEN', + '超级管理员不能使用忘记密码流程,请通过服务器运维方式安全重置', + 403, + ); + } + + if (user.status !== 'active') { + throw new AppError('FORBIDDEN', '账号已被禁用,请联系超级管理员', 403); + } + + await this.messagesService.createPasswordResetRequest(user); + await this.auditService.record({ + action: 'PASSWORD_RESET_REQUESTED', + resourceType: 'user', + resourceId: user.id, + actorId: user.id, + actorName: user.account, + after: { + requestMerged: true, + }, + }); + + return { + message: '已通知超级管理员协助重置密码', + }; + } + + async requireProjectPermission( + user: AuthenticatedUser, + projectKey: string, + requiredLevel: Exclude, + ): Promise { + if (user.role === 'super_admin') { + return; + } + + const level = await this.userRepository.getProjectPermissionLevel( + user, + projectKey, + ); + + if (level === 'none') { + throw new AppError('FORBIDDEN', '无权限访问该项目', 403, { + projectKey, + }); + } + + if (requiredLevel === 'build' && level !== 'build') { + throw new AppError('FORBIDDEN', '无权限执行该项目构建', 403, { + projectKey, + }); + } + } + + async listAllowedProjectKeys(user: AuthenticatedUser): Promise { + return this.userRepository.listAllowedProjectKeys(user); + } + + assertSuperAdmin(user: AuthenticatedUser, message = '无权限访问该功能'): void { + if (user.role !== 'super_admin') { + throw new AppError('FORBIDDEN', message, 403); + } + } + + toAuthenticatedUser(user: UserRecord): AuthenticatedUser { + return { + id: user.id, + account: user.account, + displayName: user.displayName, + role: user.role, + status: user.status, + mustChangePassword: user.mustChangePassword, + passwordVersion: user.passwordVersion, + }; + } + + private async requireUser(userId: string): Promise { + const user = await this.userRepository.findById(userId); + + if (!user) { + throw new AppError('AUTH_REQUIRED', '登录已失效,请重新登录', 401); + } + + return user; + } + + private resolveSuperAdminInitialPassword(): string { + const password = this.config.get('SUPER_ADMIN_INITIAL_PASSWORD', { + infer: true, + }); + + if (password) { + return password; + } + + if (this.config.get('NODE_ENV', { infer: true }) === 'production') { + throw new AppError( + 'CONFIG_MISSING', + '生产环境缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能初始化超级管理员', + 500, + ); + } + + return LOCAL_SUPER_ADMIN_INITIAL_PASSWORD; + } +} diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts new file mode 100644 index 0000000..f62459a --- /dev/null +++ b/src/auth/auth.types.ts @@ -0,0 +1,57 @@ +export type AuthRole = 'super_admin' | 'member'; + +export type AuthStatus = 'active' | 'disabled'; + +export type ProjectPermissionLevel = 'none' | 'read' | 'build'; + +export type AuthenticatedUser = { + id: string; + account: string; + displayName: string; + role: AuthRole; + status: AuthStatus; + mustChangePassword: boolean; + passwordVersion: number; +}; + +export type UserRecord = AuthenticatedUser & { + passwordHash: string; + createdAt: string; + updatedAt: string; + lastLoginAt?: string; + createdById?: string; + projectPermissions: UserProjectPermission[]; +}; + +export type UserProjectPermission = { + projectId: string; + projectKey: string; + projectName: string; + level: Exclude; +}; + +export type UserSummary = Omit & { + hasPendingPasswordResetRequest: boolean; +}; + +export type CreateMemberInput = { + account: string; + displayName: string; + permissions?: ProjectPermissionInput[]; +}; + +export type ProjectPermissionInput = { + projectKey: string; + level: ProjectPermissionLevel; +}; + +export type LoginResult = { + token: string; + user: AuthenticatedUser; + expiresAt: string; +}; + +export type RequestWithUser = { + headers: Record; + user?: AuthenticatedUser; +}; diff --git a/src/auth/current-user.decorator.ts b/src/auth/current-user.decorator.ts new file mode 100644 index 0000000..2a82ae0 --- /dev/null +++ b/src/auth/current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthenticatedUser, RequestWithUser } from './auth.types'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, context: ExecutionContext): AuthenticatedUser => { + const request = context.switchToHttp().getRequest(); + return request.user as AuthenticatedUser; + }, +); diff --git a/src/auth/password-change.guard.ts b/src/auth/password-change.guard.ts new file mode 100644 index 0000000..f80a59b --- /dev/null +++ b/src/auth/password-change.guard.ts @@ -0,0 +1,21 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { AppError } from '../common/errors/app-error'; +import { RequestWithUser } from './auth.types'; + +@Injectable() +export class PasswordChangeGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (user?.role === 'member' && user.mustChangePassword) { + throw new AppError( + 'PASSWORD_CHANGE_REQUIRED', + '首次登录必须修改密码后才能访问平台功能', + 403, + ); + } + + return true; + } +} diff --git a/src/auth/password.service.ts b/src/auth/password.service.ts new file mode 100644 index 0000000..fc89737 --- /dev/null +++ b/src/auth/password.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'crypto'; +import { promisify } from 'util'; + +const scrypt = promisify(scryptCallback); +const PASSWORD_HASH_PREFIX = 'scrypt-sha256-v1'; +const PASSWORD_KEY_LENGTH = 64; + +@Injectable() +export class PasswordService { + async hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('base64url'); + const derivedKey = (await scrypt( + password, + salt, + PASSWORD_KEY_LENGTH, + )) as Buffer; + + return [ + PASSWORD_HASH_PREFIX, + salt, + derivedKey.toString('base64url'), + ].join(':'); + } + + async verifyPassword(password: string, passwordHash: string): Promise { + const [prefix, salt, encodedHash] = passwordHash.split(':'); + + if (prefix !== PASSWORD_HASH_PREFIX || !salt || !encodedHash) { + return false; + } + + const expected = Buffer.from(encodedHash, 'base64url'); + const actual = (await scrypt(password, salt, expected.length)) as Buffer; + + return ( + expected.length === actual.length && timingSafeEqual(expected, actual) + ); + } +} diff --git a/src/auth/super-admin.guard.ts b/src/auth/super-admin.guard.ts new file mode 100644 index 0000000..2da3c0e --- /dev/null +++ b/src/auth/super-admin.guard.ts @@ -0,0 +1,29 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AppError } from '../common/errors/app-error'; +import { + ADMIN_ONLY_MESSAGE_KEY, +} from './admin-only-message.decorator'; +import { RequestWithUser } from './auth.types'; + +@Injectable() +export class SuperAdminGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (user?.role !== 'super_admin') { + const message = + this.reflector.getAllAndOverride(ADMIN_ONLY_MESSAGE_KEY, [ + context.getHandler(), + context.getClass(), + ]) ?? '无权限访问该功能'; + + throw new AppError('FORBIDDEN', message, 403); + } + + return true; + } +} diff --git a/src/auth/user.repository.ts b/src/auth/user.repository.ts new file mode 100644 index 0000000..2f9e04f --- /dev/null +++ b/src/auth/user.repository.ts @@ -0,0 +1,514 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + Prisma, + ProjectPermissionLevel as PrismaProjectPermissionLevel, + UserRole as PrismaUserRole, + UserStatus as PrismaUserStatus, +} from '@prisma/client'; +import { AppError } from '../common/errors/app-error'; +import { EnvConfig } from '../config/env.schema'; +import { PrismaService } from '../prisma/prisma.service'; +import { seedProjects } from '../projects/project.seed'; +import { SUPER_ADMIN_SEED } from './auth.constants'; +import { + AuthenticatedUser, + CreateMemberInput, + ProjectPermissionInput, + ProjectPermissionLevel, + UserRecord, +} from './auth.types'; + +type UserRecordWithPermissions = Prisma.UserGetPayload<{ + include: { + projectPermissions: { + include: { + project: true; + }; + }; + }; +}>; + +type CreateUserRecordInput = CreateMemberInput & { + id?: string; + passwordHash: string; + role: AuthenticatedUser['role']; + mustChangePassword: boolean; + createdById?: string; +}; + +@Injectable() +export class UserRepository { + private readonly memoryUsers: UserRecord[] = []; + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + ) {} + + async ensureSuperAdmin(passwordHash: string): Promise { + if (!this.useDatabase()) { + const existing = await this.findByAccount(SUPER_ADMIN_SEED.account); + + if (existing) { + existing.role = 'super_admin'; + existing.status = 'active'; + existing.displayName = SUPER_ADMIN_SEED.displayName; + existing.mustChangePassword = false; + existing.updatedAt = new Date().toISOString(); + return existing; + } + + return this.createUser({ + id: SUPER_ADMIN_SEED.id, + account: SUPER_ADMIN_SEED.account, + displayName: SUPER_ADMIN_SEED.displayName, + passwordHash, + role: 'super_admin', + mustChangePassword: false, + }); + } + + const user = await this.prisma.user.upsert({ + where: { + account: SUPER_ADMIN_SEED.account, + }, + create: { + id: SUPER_ADMIN_SEED.id, + account: SUPER_ADMIN_SEED.account, + displayName: SUPER_ADMIN_SEED.displayName, + passwordHash, + role: PrismaUserRole.SUPER_ADMIN, + status: PrismaUserStatus.ACTIVE, + mustChangePassword: false, + }, + update: { + displayName: SUPER_ADMIN_SEED.displayName, + role: PrismaUserRole.SUPER_ADMIN, + status: PrismaUserStatus.ACTIVE, + mustChangePassword: false, + }, + include: this.includePermissions(), + }); + + return this.toRecord(user); + } + + async findByAccount(account: string): Promise { + if (!this.useDatabase()) { + return ( + this.memoryUsers.find((user) => user.account === account.trim()) ?? null + ); + } + + const user = await this.prisma.user.findUnique({ + where: { + account: account.trim(), + }, + include: this.includePermissions(), + }); + + return user ? this.toRecord(user) : null; + } + + async findById(id: string): Promise { + if (!this.useDatabase()) { + return this.memoryUsers.find((user) => user.id === id) ?? null; + } + + const user = await this.prisma.user.findUnique({ + where: { id }, + include: this.includePermissions(), + }); + + return user ? this.toRecord(user) : null; + } + + async listUsers(): Promise { + if (!this.useDatabase()) { + return [...this.memoryUsers].sort((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); + } + + const users = await this.prisma.user.findMany({ + include: this.includePermissions(), + orderBy: [ + { + role: 'desc', + }, + { + createdAt: 'asc', + }, + ], + }); + + return users.map((user) => this.toRecord(user)); + } + + async createUser(input: CreateUserRecordInput): Promise { + if (!this.useDatabase()) { + const now = new Date().toISOString(); + const user: UserRecord = { + id: input.id ?? `user_${Date.now()}_${this.memoryUsers.length + 1}`, + account: input.account.trim(), + displayName: input.displayName.trim(), + role: input.role, + status: 'active', + passwordHash: input.passwordHash, + mustChangePassword: input.mustChangePassword, + passwordVersion: 1, + createdById: input.createdById, + createdAt: now, + updatedAt: now, + projectPermissions: [], + }; + + this.memoryUsers.push(user); + return user; + } + + const user = await this.prisma.user.create({ + data: { + id: input.id, + account: input.account.trim(), + displayName: input.displayName.trim(), + passwordHash: input.passwordHash, + role: this.toPrismaRole(input.role), + status: PrismaUserStatus.ACTIVE, + mustChangePassword: input.mustChangePassword, + createdById: input.createdById, + }, + include: this.includePermissions(), + }); + + return this.toRecord(user); + } + + async updatePassword( + userId: string, + passwordHash: string, + mustChangePassword: boolean, + ): Promise { + if (!this.useDatabase()) { + const user = this.requireMemoryUser(userId); + user.passwordHash = passwordHash; + user.mustChangePassword = mustChangePassword; + user.passwordVersion += 1; + user.updatedAt = new Date().toISOString(); + return user; + } + + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { + passwordHash, + mustChangePassword, + passwordVersion: { + increment: 1, + }, + }, + include: this.includePermissions(), + }); + + return this.toRecord(user); + } + + async markLogin(userId: string): Promise { + if (!this.useDatabase()) { + const user = this.memoryUsers.find((item) => item.id === userId); + + if (user) { + user.lastLoginAt = new Date().toISOString(); + } + + return; + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { + lastLoginAt: new Date(), + }, + }); + } + + async setStatus( + userId: string, + status: Exclude | 'disabled', + ): Promise { + if (!this.useDatabase()) { + const user = this.requireMemoryUser(userId); + user.status = status; + user.updatedAt = new Date().toISOString(); + return user; + } + + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { + status: + status === 'active' + ? PrismaUserStatus.ACTIVE + : PrismaUserStatus.DISABLED, + }, + include: this.includePermissions(), + }); + + return this.toRecord(user); + } + + async deleteUser(userId: string): Promise { + if (!this.useDatabase()) { + const index = this.memoryUsers.findIndex((user) => user.id === userId); + + if (index >= 0) { + this.memoryUsers.splice(index, 1); + } + + return; + } + + await this.prisma.user.delete({ + where: { id: userId }, + }); + } + + async replaceProjectPermissions( + userId: string, + permissions: ProjectPermissionInput[], + ): Promise { + const normalizedPermissions = permissions.filter( + (permission) => permission.level !== 'none', + ); + + if (!this.useDatabase()) { + const user = this.requireMemoryUser(userId); + user.projectPermissions = normalizedPermissions.map((permission) => { + const project = this.findSeedProject(permission.projectKey); + + return { + projectId: project.id, + projectKey: project.key, + projectName: project.name, + level: permission.level as Exclude, + }; + }); + user.updatedAt = new Date().toISOString(); + return user; + } + + const projects = await this.prisma.project.findMany({ + where: { + key: { + in: normalizedPermissions.map((permission) => permission.projectKey), + }, + }, + }); + const missingProjectKeys = normalizedPermissions + .map((permission) => permission.projectKey) + .filter((projectKey) => + projects.every((project) => project.key !== projectKey), + ); + + if (missingProjectKeys.length > 0) { + throw new AppError( + 'RESOURCE_NOT_FOUND', + `项目不存在:${missingProjectKeys.join('、')}`, + 404, + { projectKeys: missingProjectKeys }, + ); + } + + await this.prisma.$transaction(async (tx) => { + await tx.projectMemberPermission.deleteMany({ + where: { userId }, + }); + + if (normalizedPermissions.length > 0) { + await tx.projectMemberPermission.createMany({ + data: normalizedPermissions.map((permission) => { + const project = projects.find( + (item) => item.key === permission.projectKey, + ); + + if (!project) { + throw new Error('项目授权校验异常'); + } + + return { + userId, + projectId: project.id, + level: this.toPrismaPermissionLevel(permission.level), + }; + }), + }); + } + }); + + const user = await this.findById(userId); + + if (!user) { + throw new AppError('RESOURCE_NOT_FOUND', '成员不存在', 404, { userId }); + } + + return user; + } + + async getProjectPermissionLevel( + user: AuthenticatedUser, + projectKey: string, + ): Promise { + if (user.role === 'super_admin') { + return 'build'; + } + + if (!this.useDatabase()) { + const currentUser = this.memoryUsers.find((item) => item.id === user.id); + return ( + currentUser?.projectPermissions.find( + (permission) => permission.projectKey === projectKey, + )?.level ?? 'none' + ); + } + + const permission = await this.prisma.projectMemberPermission.findFirst({ + where: { + userId: user.id, + project: { + key: projectKey, + }, + }, + }); + + return permission ? this.toPermissionLevel(permission.level) : 'none'; + } + + async listAllowedProjectKeys(user: AuthenticatedUser): Promise { + if (user.role === 'super_admin') { + if (!this.useDatabase()) { + return seedProjects.map((project) => project.key); + } + + const projects = await this.prisma.project.findMany({ + select: { + key: true, + }, + }); + + return projects.map((project) => project.key); + } + + if (!this.useDatabase()) { + const currentUser = this.memoryUsers.find((item) => item.id === user.id); + return ( + currentUser?.projectPermissions.map( + (permission) => permission.projectKey, + ) ?? [] + ); + } + + const permissions = await this.prisma.projectMemberPermission.findMany({ + where: { + userId: user.id, + }, + include: { + project: true, + }, + }); + + return permissions.map((permission) => permission.project.key); + } + + private requireMemoryUser(userId: string): UserRecord { + const user = this.memoryUsers.find((item) => item.id === userId); + + if (!user) { + throw new AppError('RESOURCE_NOT_FOUND', '成员不存在', 404, { userId }); + } + + return user; + } + + private findSeedProject(projectKey: string) { + const project = seedProjects.find((item) => item.key === projectKey); + + if (!project) { + throw new AppError( + 'RESOURCE_NOT_FOUND', + `项目不存在:${projectKey}`, + 404, + { projectKey }, + ); + } + + return project; + } + + private includePermissions(): { + projectPermissions: { + include: { + project: true; + }; + }; + } { + return { + projectPermissions: { + include: { + project: true, + }, + }, + }; + } + + private toRecord(user: UserRecordWithPermissions): UserRecord { + return { + id: user.id, + account: user.account, + displayName: user.displayName, + role: + user.role === PrismaUserRole.SUPER_ADMIN ? 'super_admin' : 'member', + status: user.status === PrismaUserStatus.ACTIVE ? 'active' : 'disabled', + passwordHash: user.passwordHash, + mustChangePassword: user.mustChangePassword, + passwordVersion: user.passwordVersion, + lastLoginAt: user.lastLoginAt?.toISOString(), + createdById: user.createdById ?? undefined, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + projectPermissions: user.projectPermissions.map((permission) => ({ + projectId: permission.projectId, + projectKey: permission.project.key, + projectName: permission.project.name, + level: this.toPermissionLevel(permission.level), + })), + }; + } + + private toPrismaRole(role: AuthenticatedUser['role']): PrismaUserRole { + return role === 'super_admin' + ? PrismaUserRole.SUPER_ADMIN + : PrismaUserRole.MEMBER; + } + + private toPermissionLevel( + level: PrismaProjectPermissionLevel, + ): Exclude { + return level === PrismaProjectPermissionLevel.BUILD ? 'build' : 'read'; + } + + private toPrismaPermissionLevel( + level: ProjectPermissionLevel, + ): PrismaProjectPermissionLevel { + return level === 'build' + ? PrismaProjectPermissionLevel.BUILD + : PrismaProjectPermissionLevel.READ; + } + + private useDatabase(): boolean { + return ( + this.config.get('USE_DATABASE_READS', { infer: true }) && + Boolean(this.config.get('DATABASE_URL', { infer: true })) + ); + } +} diff --git a/src/common/errors/app-error.ts b/src/common/errors/app-error.ts index b015fd4..fb20880 100644 --- a/src/common/errors/app-error.ts +++ b/src/common/errors/app-error.ts @@ -1,7 +1,11 @@ export type AppErrorCode = + | 'AUTH_REQUIRED' | 'CONFIG_MISSING' + | 'CONFLICT' + | 'FORBIDDEN' | 'INTEGRATION_UNAVAILABLE' | 'INTEGRATION_REQUEST_FAILED' + | 'PASSWORD_CHANGE_REQUIRED' | 'RESOURCE_NOT_FOUND' | 'VALIDATION_FAILED' | 'UNEXPECTED_ERROR'; diff --git a/src/common/errors/integration-error.ts b/src/common/errors/integration-error.ts index 29fb541..1078d90 100644 --- a/src/common/errors/integration-error.ts +++ b/src/common/errors/integration-error.ts @@ -4,7 +4,7 @@ export class IntegrationUnavailableError extends AppError { constructor(integration: string, missing: string[]) { super( 'INTEGRATION_UNAVAILABLE', - `${integration} integration is not configured`, + `${integration} 集成尚未配置`, 503, { integration, missing }, ); diff --git a/src/common/http/all-exceptions.filter.ts b/src/common/http/all-exceptions.filter.ts index e1e4a92..bad7689 100644 --- a/src/common/http/all-exceptions.filter.ts +++ b/src/common/http/all-exceptions.filter.ts @@ -60,7 +60,7 @@ export class AllExceptionsFilter implements ExceptionFilter { return { statusCode: exception.getStatus(), code: 'VALIDATION_FAILED', - message: exception.message, + message: '请求处理失败', details: { response: exception.getResponse() }, }; } @@ -69,14 +69,14 @@ export class AllExceptionsFilter implements ExceptionFilter { return { statusCode: 500, code: 'UNEXPECTED_ERROR', - message: exception.message, + message: '系统异常,请联系管理员', }; } return { statusCode: 500, code: 'UNEXPECTED_ERROR', - message: 'Unexpected error', + message: '系统异常,请联系管理员', }; } } diff --git a/src/common/pipes/zod-validation.pipe.ts b/src/common/pipes/zod-validation.pipe.ts index 5bd4a16..f5246b1 100644 --- a/src/common/pipes/zod-validation.pipe.ts +++ b/src/common/pipes/zod-validation.pipe.ts @@ -15,13 +15,13 @@ export class ZodValidationPipe implements PipeTransform { if (!result.success) { throw new AppError( 'VALIDATION_FAILED', - 'Request body validation failed', + '请求参数校验失败', 400, { issues: result.error.issues.map((issue: z.core.$ZodIssue) => ({ path: issue.path.join('.'), code: issue.code, - message: issue.message, + message: this.toChineseIssueMessage(issue), })), }, ); @@ -29,4 +29,28 @@ export class ZodValidationPipe implements PipeTransform { return result.data; } + + private toChineseIssueMessage(issue: z.core.$ZodIssue): string { + if (issue.code === 'invalid_type') { + return '字段类型不正确或缺少必填字段'; + } + + if (issue.code === 'too_small') { + return '字段长度或数值小于允许范围'; + } + + if (issue.code === 'too_big') { + return '字段长度或数值超过允许范围'; + } + + if (issue.code === 'invalid_format') { + return '字段格式不正确'; + } + + if (issue.code === 'invalid_value') { + return '字段取值不在允许范围内'; + } + + return '字段校验未通过'; + } } diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index 060b20a..91d1107 100644 --- a/src/config/env.schema.ts +++ b/src/config/env.schema.ts @@ -52,6 +52,11 @@ export const envSchema = z.object({ USE_DATABASE_READS: booleanFromEnv, PRISMA_CONNECT_ON_BOOT: booleanFromEnv, REDIS_URL: optionalString, + AUTH_TOKEN_SECRET: optionalString, + AUTH_TOKEN_TTL_SECONDS: positiveIntegerFromEnv(43200), + SUPER_ADMIN_ACCOUNT: optionalString, + SUPER_ADMIN_DISPLAY_NAME: optionalString, + SUPER_ADMIN_INITIAL_PASSWORD: optionalString, JENKINS_BASE_URL: optionalUrl, JENKINS_USERNAME: optionalString, JENKINS_API_TOKEN: optionalString, @@ -80,7 +85,7 @@ export function validateEnv(config: Record): EnvConfig { const details = result.error.issues .map((issue) => `${issue.path.join('.')}: ${issue.message}`) .join('; '); - throw new Error(`Invalid environment configuration: ${details}`); + throw new Error(`环境变量配置无效:${details}`); } return result.data; diff --git a/src/deploy-runs/create-deploy-run.dto.ts b/src/deploy-runs/create-deploy-run.dto.ts index 04ecd33..8323e05 100644 --- a/src/deploy-runs/create-deploy-run.dto.ts +++ b/src/deploy-runs/create-deploy-run.dto.ts @@ -17,11 +17,13 @@ export const createDeployRunSchema = z.object({ projectKey: z.string().trim().min(1).max(80), environment: z.enum(['test', 'production']), ref: z.string().trim().min(1).max(200), - operator: z.string().trim().min(1).max(120), + operator: optionalTrimmedString(120), remark: optionalTrimmedString(500), idempotencyKey: optionalTrimmedString(260), }); +export type CreateDeployRunRequestInput = z.infer; + export class CreateDeployRunDto { @ApiProperty({ example: 'access-manage' }) projectKey!: string; @@ -32,8 +34,11 @@ export class CreateDeployRunDto { @ApiProperty({ example: 'develop' }) ref!: string; - @ApiProperty({ example: 'operator@example.com' }) - operator!: string; + @ApiPropertyOptional({ + example: 'operator@example.com', + description: '兼容旧前端字段;后端会优先使用当前登录账号', + }) + operator?: string; @ApiPropertyOptional({ example: 'Manual test deploy before release.' }) remark?: string; diff --git a/src/deploy-runs/deploy-execution.service.spec.ts b/src/deploy-runs/deploy-execution.service.spec.ts index 90196ca..1a43960 100644 --- a/src/deploy-runs/deploy-execution.service.spec.ts +++ b/src/deploy-runs/deploy-execution.service.spec.ts @@ -121,10 +121,10 @@ describe('DeployExecutionService', () => { }; } - function createRun( + async function createRun( repository: DeployRunRepository, idempotencyKey: string, - ): DeployRunSummary { + ): Promise { return repository.create({ ...input, idempotencyKey, @@ -143,7 +143,7 @@ describe('DeployExecutionService', () => { }, notificationStatus: 'skipped', }); - const run = createRun(repository, 'run_dry_001'); + const run = await createRun(repository, 'run_dry_001'); const result = await service.execute(run, project); @@ -169,7 +169,7 @@ describe('DeployExecutionService', () => { notificationStatus: 'sent', queueId: 'jenkins_queue_42', }); - const run = createRun(repository, 'run_queue_001'); + const run = await createRun(repository, 'run_queue_001'); const result = await service.execute(run, project); @@ -207,7 +207,10 @@ describe('DeployExecutionService', () => { queueBuildNumber: 18, buildResult: 'SUCCESS', }); - const run = await service.execute(createRun(repository, 'run_success_001'), project); + const run = await service.execute( + await createRun(repository, 'run_success_001'), + project, + ); const result = await service.syncJenkinsRun(run, project); @@ -234,15 +237,18 @@ describe('DeployExecutionService', () => { buildResult: 'FAILURE', logText: ['install ok', 'build failed', 'token=secret-value'].join('\n'), }); - const run = await service.execute(createRun(repository, 'run_failure_001'), project); + const run = await service.execute( + await createRun(repository, 'run_failure_001'), + project, + ); const result = await service.syncJenkinsRun(run, project); expect(result.status).toBe('failed'); - expect(result.failureSummary).toBe('Jenkins build #19 FAILURE'); + expect(result.failureSummary).toBe('Jenkins 构建 #19 FAILURE'); expect(step(result, 'jenkins-build')?.status).toBe('failed'); expect(step(result, 'jenkins-build')?.errorSummary).toBe( - 'Jenkins build #19 FAILURE', + 'Jenkins 构建 #19 FAILURE', ); expect(step(result, 'jenkins-build')?.logExcerpt).toContain('build failed'); expect(step(result, 'notify-wecom')?.status).toBe('skipped'); diff --git a/src/deploy-runs/deploy-execution.service.ts b/src/deploy-runs/deploy-execution.service.ts index 4e2ab2d..b6120b9 100644 --- a/src/deploy-runs/deploy-execution.service.ts +++ b/src/deploy-runs/deploy-execution.service.ts @@ -194,7 +194,7 @@ export class DeployExecutionService { currentRun.id, 'jenkins-build', 'running', - `Jenkins build #${queueItem.buildNumber} is running.`, + `Jenkins 构建 #${queueItem.buildNumber} 正在运行。`, ); await this.auditService.record({ @@ -236,7 +236,7 @@ export class DeployExecutionService { currentRun.id, 'jenkins-build', 'running', - `Jenkins build #${buildStatus.buildNumber} is running.`, + `Jenkins 构建 #${buildStatus.buildNumber} 正在运行。`, ); } @@ -261,7 +261,7 @@ export class DeployExecutionService { if (health.status === 'not_configured') { const reason = health.message ?? - 'Jenkins is not configured; simulated build completed.'; + 'Jenkins 尚未配置,已完成本地模拟构建。'; const currentRun = await this.completeStep( run.id, 'jenkins-build', @@ -286,7 +286,7 @@ export class DeployExecutionService { if (!environment.jenkinsJobPath) { throw new AppError( 'VALIDATION_FAILED', - `Project ${project.key} ${run.environment} Jenkins job path is empty`, + `项目 ${project.key} 的 ${run.environment} 环境 Jenkins Job 路径为空`, 400, { projectKey: project.key, environment: run.environment }, ); @@ -312,8 +312,8 @@ export class DeployExecutionService { 'jenkins-build', nextStatus, queueItem.buildNumber - ? `Jenkins build #${queueItem.buildNumber} is running.` - : `Jenkins queue ${queueItem.queueId} accepted.`, + ? `Jenkins 构建 #${queueItem.buildNumber} 正在运行。` + : `Jenkins 队列 ${queueItem.queueId} 已受理。`, ); await this.auditService.record({ @@ -414,7 +414,7 @@ export class DeployExecutionService { jobPath: string, ): Promise { const logExcerpt = await this.readFailureLogExcerpt(run, jobPath); - const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`; + const failureSummary = `Jenkins 构建 #${run.jenkinsBuildNumber ?? '-'} ${result}`; let currentRun = (await this.deployRunRepository.updateStep(run.id, 'jenkins-build', { status: 'failed', @@ -481,7 +481,7 @@ export class DeployExecutionService { if (!environment) { throw new AppError( 'VALIDATION_FAILED', - `Project ${project.key} does not expose ${run.environment} environment`, + `项目 ${project.key} 未配置 ${run.environment} 环境`, 400, { projectKey: project.key, environment: run.environment }, ); @@ -558,7 +558,7 @@ export class DeployExecutionService { if (!run) { throw new AppError( 'RESOURCE_NOT_FOUND', - `Deploy run ${id} not found`, + `发布记录不存在:${id}`, 404, { id, @@ -574,6 +574,6 @@ export class DeployExecutionService { return redactSensitive({ message: error.message }).message; } - return 'Unknown deploy execution error'; + return '未知发布执行错误'; } } diff --git a/src/deploy-runs/deploy-run.repository.ts b/src/deploy-runs/deploy-run.repository.ts index 862c828..34a6c02 100644 --- a/src/deploy-runs/deploy-run.repository.ts +++ b/src/deploy-runs/deploy-run.repository.ts @@ -405,7 +405,7 @@ export class DeployRunRepository { if (!project || !environment) { throw new Error( - `Project ${input.projectKey} ${input.environment} is not seeded in database`, + `数据库未初始化项目 ${input.projectKey} 的 ${input.environment} 环境`, ); } diff --git a/src/deploy-runs/deploy-runs.controller.ts b/src/deploy-runs/deploy-runs.controller.ts index 42ab0e9..5bcd04f 100644 --- a/src/deploy-runs/deploy-runs.controller.ts +++ b/src/deploy-runs/deploy-runs.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiBody, ApiCreatedResponse, @@ -8,62 +8,95 @@ import { import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; import { CreateDeployRunDto, + CreateDeployRunRequestInput, createDeployRunSchema, } from './create-deploy-run.dto'; +import { AdminOnlyMessage } from '../auth/admin-only-message.decorator'; +import { AuthGuard } from '../auth/auth.guard'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { PasswordChangeGuard } from '../auth/password-change.guard'; +import { SuperAdminGuard } from '../auth/super-admin.guard'; import { - CreateDeployRunInput, DeployRunJenkinsSyncSummary, DeployRunSummary, } from './deploy-run.types'; import { DeployRunsService } from './deploy-runs.service'; -@ApiTags('deploy-runs') +@ApiTags('发布记录') @Controller('deploy-runs') +@UseGuards(AuthGuard, PasswordChangeGuard) export class DeployRunsController { constructor(private readonly deployRunsService: DeployRunsService) {} @Get() - @ApiOkResponse({ description: 'Latest deploy runs.' }) - async listRuns(): Promise { - return this.deployRunsService.listRuns(); + @ApiOkResponse({ description: '查询当前账号可访问项目的发布记录。' }) + async listRuns( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.deployRunsService.listRuns(user); } @Get(':id') - @ApiOkResponse({ description: 'Deploy run detail.' }) - async getRun(@Param('id') id: string): Promise { - return this.deployRunsService.getRun(id); + @ApiOkResponse({ description: '查询发布记录详情。' }) + async getRun( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.deployRunsService.getRun(id, user); } @Post() @ApiBody({ type: CreateDeployRunDto }) - @ApiCreatedResponse({ description: 'Create a deploy run draft.' }) + @ApiCreatedResponse({ description: '创建发布单并触发构建。' }) async createRun( - @Body(new ZodValidationPipe(createDeployRunSchema)) body: CreateDeployRunInput, + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(createDeployRunSchema)) + body: CreateDeployRunRequestInput, ): Promise { - return this.deployRunsService.createRun(body); + return this.deployRunsService.createRun( + { + ...body, + operator: user.account, + }, + user, + ); } @Post(':id/cancel') - @ApiOkResponse({ description: 'Cancel a deploy run.' }) - async cancelRun(@Param('id') id: string): Promise { - return this.deployRunsService.cancelRun(id); + @ApiOkResponse({ description: '取消发布单。' }) + async cancelRun( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.deployRunsService.cancelRun(id, user); } @Post(':id/retry') - @ApiCreatedResponse({ description: 'Retry a deploy run.' }) - async retryRun(@Param('id') id: string): Promise { - return this.deployRunsService.retryRun(id); + @ApiCreatedResponse({ description: '重试发布单。' }) + async retryRun( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.deployRunsService.retryRun(id, user); } @Post('sync-jenkins') - @ApiOkResponse({ description: 'Synchronize all queued/running Jenkins runs.' }) - async syncJenkinsRuns(): Promise { - return this.deployRunsService.syncJenkinsRuns(); + @UseGuards(SuperAdminGuard) + @AdminOnlyMessage('无权限同步全部 Jenkins 状态') + @ApiOkResponse({ description: '同步全部队列中或运行中的 Jenkins 发布状态。' }) + async syncJenkinsRuns( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.deployRunsService.syncJenkinsRuns(user); } @Post(':id/sync-jenkins') - @ApiOkResponse({ description: 'Synchronize Jenkins queue/build state.' }) - async syncJenkinsRun(@Param('id') id: string): Promise { - return this.deployRunsService.syncJenkinsRun(id); + @ApiOkResponse({ description: '同步单个发布单的 Jenkins 队列或构建状态。' }) + async syncJenkinsRun( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.deployRunsService.syncJenkinsRun(id, user); } } diff --git a/src/deploy-runs/deploy-runs.module.ts b/src/deploy-runs/deploy-runs.module.ts index d443c56..5163b3a 100644 --- a/src/deploy-runs/deploy-runs.module.ts +++ b/src/deploy-runs/deploy-runs.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module'; +import { AuthModule } from '../auth/auth.module'; import { JenkinsModule } from '../integrations/jenkins/jenkins.module'; import { WeComModule } from '../notifications/wecom/wecom.module'; import { ProjectsModule } from '../projects/projects.module'; @@ -10,7 +11,7 @@ import { DeployRunsService } from './deploy-runs.service'; import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service'; @Module({ - imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule], + imports: [AuditModule, AuthModule, JenkinsModule, ProjectsModule, WeComModule], controllers: [DeployRunsController], providers: [ DeployExecutionService, diff --git a/src/deploy-runs/deploy-runs.service.spec.ts b/src/deploy-runs/deploy-runs.service.spec.ts index f720aa3..829a173 100644 --- a/src/deploy-runs/deploy-runs.service.spec.ts +++ b/src/deploy-runs/deploy-runs.service.spec.ts @@ -1,4 +1,5 @@ import { AuditService } from '../audit/audit.service'; +import { AuthService } from '../auth/auth.service'; import { AppError } from '../common/errors/app-error'; import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; @@ -58,30 +59,36 @@ describe('DeployRunsService', () => { findById: jest.fn((id: string) => Promise.resolve(runs.find((run) => run.id === id) ?? null), ), - create: jest.fn((input: CreateDeployRunInput) => ({ - ...baseRun, - id: input.idempotencyKey ?? 'generated_run', - projectKey: input.projectKey, - environment: input.environment, - ref: input.ref, - operator: input.operator, - trigger: input.trigger ?? 'manual', - })), + create: jest.fn((input: CreateDeployRunInput) => + Promise.resolve({ + ...baseRun, + id: input.idempotencyKey ?? 'generated_run', + projectKey: input.projectKey, + environment: input.environment, + ref: input.ref, + operator: input.operator, + trigger: input.trigger ?? 'manual', + }), + ), updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) => - runs.some((run) => run.id === id) - ? { - ...(runs.find((run) => run.id === id) ?? baseRun), - ...patch, - } - : null, + Promise.resolve( + runs.some((run) => run.id === id) + ? { + ...(runs.find((run) => run.id === id) ?? baseRun), + ...patch, + } + : null, + ), ), updateStep: jest.fn((id: string) => - runs.some((run) => run.id === id) - ? { - ...(runs.find((run) => run.id === id) ?? baseRun), - status: 'canceled' as const, - } - : null, + Promise.resolve( + runs.some((run) => run.id === id) + ? { + ...(runs.find((run) => run.id === id) ?? baseRun), + status: 'canceled' as const, + } + : null, + ), ), } satisfies Pick< DeployRunRepository, @@ -130,10 +137,22 @@ describe('DeployRunsService', () => { JenkinsClient, 'healthSummary' | 'cancelQueueItem' | 'stopBuild' >; + const auth = { + listAllowedProjectKeys: jest.fn(() => + Promise.resolve(runs.map((run) => run.projectKey)), + ), + requireProjectPermission: jest.fn(() => Promise.resolve()), + assertSuperAdmin: jest.fn(), + } satisfies Pick< + AuthService, + 'listAllowedProjectKeys' | 'requireProjectPermission' | 'assertSuperAdmin' + >; return { audit, + auth, execution, + jenkins, notification, projects, repository, @@ -144,6 +163,7 @@ describe('DeployRunsService', () => { notification as unknown as DeployNotificationService, execution as unknown as DeployExecutionService, jenkins as unknown as JenkinsClient, + auth as unknown as AuthService, ), }; } @@ -222,7 +242,7 @@ describe('DeployRunsService', () => { expect.objectContaining({ action: 'DEPLOY_REJECTED', after: expect.objectContaining({ - reason: 'Environment is not configured', + reason: '环境未配置', }), }), ); @@ -258,7 +278,7 @@ describe('DeployRunsService', () => { expect.objectContaining({ action: 'DEPLOY_REJECTED', after: expect.objectContaining({ - reason: 'Ref does not match release policy', + reason: 'Ref 不符合发布策略', }), }), ); diff --git a/src/deploy-runs/deploy-runs.service.ts b/src/deploy-runs/deploy-runs.service.ts index 7325cc3..461c9fc 100644 --- a/src/deploy-runs/deploy-runs.service.ts +++ b/src/deploy-runs/deploy-runs.service.ts @@ -1,4 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { AuthService } from '../auth/auth.service'; +import { AuthenticatedUser } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; import { redactSensitive } from '../common/security/redact-sensitive'; import { AuditService } from '../audit/audit.service'; @@ -30,19 +32,33 @@ export class DeployRunsService { private readonly deployNotificationService: DeployNotificationService, private readonly deployExecutionService: DeployExecutionService, private readonly jenkinsClient: JenkinsClient, + private readonly authService: AuthService, ) {} - async listRuns(): Promise { - return this.deployRunRepository.findMany(); + async listRuns(user?: AuthenticatedUser): Promise { + const runs = await this.deployRunRepository.findMany(); + + if (!user || user.role === 'super_admin') { + return runs; + } + + const allowedProjectKeys = new Set( + await this.authService.listAllowedProjectKeys(user), + ); + + return runs.filter((run) => allowedProjectKeys.has(run.projectKey)); } - async getRun(id: string): Promise { + async getRun( + id: string, + user?: AuthenticatedUser, + ): Promise { const run = await this.deployRunRepository.findById(id); if (!run) { throw new AppError( 'RESOURCE_NOT_FOUND', - `Deploy run ${id} not found`, + `发布记录不存在:${id}`, 404, { id, @@ -50,19 +66,38 @@ export class DeployRunsService { ); } + if (user) { + await this.authService.requireProjectPermission( + user, + run.projectKey, + 'read', + ); + } + return run; } - async createRun(input: CreateDeployRunInput): Promise { + async createRun( + input: CreateDeployRunInput, + user?: AuthenticatedUser, + ): Promise { if (!['test', 'production'].includes(input.environment)) { throw new AppError( 'VALIDATION_FAILED', - 'environment must be test or production', + '发布环境必须为 test 或 production', 400, { environment: input.environment }, ); } + if (user) { + await this.authService.requireProjectPermission( + user, + input.projectKey, + 'build', + ); + } + const project = await this.projectsService.getProject(input.projectKey); await this.validateReleaseRequest(input, project); const run = await this.deployRunRepository.create(input); @@ -83,13 +118,24 @@ export class DeployRunsService { return this.deployExecutionService.execute(run, project); } - async cancelRun(id: string): Promise { - const source = await this.getRun(id); + async cancelRun( + id: string, + user?: AuthenticatedUser, + ): Promise { + const source = await this.getRun(id, user); + + if (user) { + await this.authService.requireProjectPermission( + user, + source.projectKey, + 'build', + ); + } if (['success', 'failed', 'canceled'].includes(source.status)) { throw new AppError( 'VALIDATION_FAILED', - `Deploy run ${id} is already ${source.status} and cannot be canceled`, + `发布记录 ${id} 已是终态,不能取消`, 400, { id, @@ -130,27 +176,52 @@ export class DeployRunsService { return run; } - async retryRun(id: string): Promise { - const source = await this.getRun(id); - return this.createRun({ - projectKey: source.projectKey, - environment: source.environment, - ref: source.ref, - operator: source.operator, - remark: `Retry of ${source.id}`, - idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`, - trigger: 'retry', - }); + async retryRun( + id: string, + user?: AuthenticatedUser, + ): Promise { + const source = await this.getRun(id, user); + + return this.createRun( + { + projectKey: source.projectKey, + environment: source.environment, + ref: source.ref, + operator: user?.account ?? source.operator, + remark: `重试发布记录 ${source.id}`, + idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`, + trigger: 'retry', + }, + user, + ); } - async syncJenkinsRun(id: string): Promise { - const run = await this.getRun(id); + async syncJenkinsRun( + id: string, + user?: AuthenticatedUser, + ): Promise { + const run = await this.getRun(id, user); + + if (user) { + await this.authService.requireProjectPermission( + user, + run.projectKey, + 'build', + ); + } + const project = await this.projectsService.getProject(run.projectKey); return this.deployExecutionService.syncJenkinsRun(run, project); } - async syncJenkinsRuns(): Promise { + async syncJenkinsRuns( + user?: AuthenticatedUser, + ): Promise { + if (user) { + this.authService.assertSuperAdmin(user, '无权限同步全部 Jenkins 状态'); + } + const runs = await this.listRuns(); const candidates = runs.filter((run) => this.isJenkinsSyncCandidate(run)); const syncedRuns: DeployRunSummary[] = []; @@ -228,7 +299,7 @@ export class DeployRunsService { if (jenkinsHealth.status === 'not_configured') { return { requested: false, - reason: jenkinsHealth.message ?? 'Jenkins is not configured', + reason: jenkinsHealth.message ?? 'Jenkins 尚未配置', }; } @@ -245,7 +316,7 @@ export class DeployRunsService { return { requested: false, - reason: 'Deploy run has no Jenkins queue id or build number', + reason: '发布记录缺少 Jenkins 队列编号或构建编号', }; } @@ -260,7 +331,7 @@ export class DeployRunsService { if (!environment) { throw new AppError( 'VALIDATION_FAILED', - `Project ${project.key} does not expose ${run.environment} environment`, + `项目 ${project.key} 未配置 ${run.environment} 环境`, 400, { projectKey: project.key, environment: run.environment }, ); @@ -273,14 +344,14 @@ export class DeployRunsService { result: JenkinsCancelResult | { requested: false; reason: string }, ): string { if (!result.requested) { - return `Cancellation recorded locally: ${result.reason}`; + return `已在本地记录取消结果:${result.reason}`; } if (result.target === 'build') { - return `Jenkins build #${result.buildNumber ?? '-'} stop requested.`; + return `已请求停止 Jenkins 构建 #${result.buildNumber ?? '-'}。`; } - return `Jenkins queue ${result.queueId ?? '-'} cancel requested.`; + return `已请求取消 Jenkins 队列 ${result.queueId ?? '-'}。`; } private async validateReleaseRequest( @@ -288,10 +359,10 @@ export class DeployRunsService { project: ProjectSummary, ): Promise { if (project.status !== 'active') { - await this.recordDeployRejected(input, 'Project is archived'); + await this.recordDeployRejected(input, '项目已归档'); throw new AppError( 'VALIDATION_FAILED', - `Project ${project.key} is archived and cannot be deployed`, + `项目 ${project.key} 已归档,不能发布`, 400, { projectKey: project.key }, ); @@ -302,10 +373,10 @@ export class DeployRunsService { ); if (!environment) { - await this.recordDeployRejected(input, 'Environment is not configured'); + await this.recordDeployRejected(input, '环境未配置'); throw new AppError( 'VALIDATION_FAILED', - `Project ${project.key} does not expose ${input.environment} environment`, + `项目 ${project.key} 未配置 ${input.environment} 环境`, 400, { projectKey: project.key, environment: input.environment }, ); @@ -314,11 +385,11 @@ export class DeployRunsService { if (!new RegExp(environment.refPattern).test(input.ref)) { await this.recordDeployRejected( input, - 'Ref does not match release policy', + 'Ref 不符合发布策略', ); throw new AppError( 'VALIDATION_FAILED', - `Ref ${input.ref} does not match ${environment.releasePolicy} release policy`, + `Ref ${input.ref} 不符合 ${environment.releasePolicy} 发布策略`, 400, { projectKey: project.key, @@ -352,7 +423,7 @@ export class DeployRunsService { private toSafeSyncError(error: unknown): string { const message = - error instanceof Error ? error.message : 'Jenkins sync failed'; + error instanceof Error ? error.message : 'Jenkins 同步失败'; return redactSensitive({ message }).message; } @@ -364,7 +435,7 @@ export class DeployRunsService { if (!run) { throw new AppError( 'RESOURCE_NOT_FOUND', - `Deploy run ${id} not found`, + `发布记录不存在:${id}`, 404, { id, diff --git a/src/deploy-runs/jenkins-sync-scheduler.service.ts b/src/deploy-runs/jenkins-sync-scheduler.service.ts index 1f30bb6..88f1dde 100644 --- a/src/deploy-runs/jenkins-sync-scheduler.service.ts +++ b/src/deploy-runs/jenkins-sync-scheduler.service.ts @@ -65,7 +65,7 @@ export class JenkinsSyncSchedulerService }); } catch (error) { const message = - error instanceof Error ? error.message : 'Jenkins auto sync failed'; + error instanceof Error ? error.message : 'Jenkins 自动同步失败'; await this.auditService.record({ action: 'JENKINS_AUTO_SYNC_TICK_FAILED', diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 2f041cc..66b4fe7 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -8,7 +8,7 @@ export class HealthController { constructor(private readonly healthService: HealthService) {} @Get() - @ApiOkResponse({ description: 'Runtime dependency health summary.' }) + @ApiOkResponse({ description: '查询运行时依赖健康状态。' }) async getHealth(): Promise> { return this.healthService.getHealth(); } diff --git a/src/health/health.service.spec.ts b/src/health/health.service.spec.ts index a981e2e..09706af 100644 --- a/src/health/health.service.spec.ts +++ b/src/health/health.service.spec.ts @@ -30,7 +30,9 @@ describe('HealthService', () => { healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), } satisfies Pick; const llm = { - healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), + healthSummary: jest.fn(() => + Promise.resolve({ status: 'not_configured' as const }), + ), } satisfies Pick; const service = new HealthService( config as unknown as ConfigService, diff --git a/src/health/health.service.ts b/src/health/health.service.ts index 9980dc3..95988c4 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -33,7 +33,7 @@ export class HealthService { jenkins: this.jenkins.healthSummary(), gitea: this.gitea.healthSummary(), notification: this.notification.healthSummary(), - llm: this.llm.healthSummary(), + llm: await this.llm.healthSummary(), }; const hasUnavailable = Object.values(dependencies).some( (dependency) => dependency.status === 'unavailable', diff --git a/src/integrations/gitea/gitea.client.ts b/src/integrations/gitea/gitea.client.ts index b40308f..33f1f75 100644 --- a/src/integrations/gitea/gitea.client.ts +++ b/src/integrations/gitea/gitea.client.ts @@ -13,7 +13,7 @@ export class GiteaClient { const missing = this.missingConfig(); return missing.length === 0 ? { status: 'ok' } - : { status: 'not_configured', message: `Missing ${missing.join(', ')}` }; + : { status: 'not_configured', message: `缺少 ${missing.join(', ')}` }; } async listBranches(owner: string, repo: string): Promise { @@ -76,7 +76,7 @@ export class GiteaClient { }); if (!response.ok) { - throw new IntegrationRequestError('gitea', 'Gitea request failed', { + throw new IntegrationRequestError('gitea', 'Gitea 请求失败', { status: response.status, path, request: redactSensitive(init), diff --git a/src/integrations/jenkins/jenkins.client.ts b/src/integrations/jenkins/jenkins.client.ts index 146ebd5..5cb48c3 100644 --- a/src/integrations/jenkins/jenkins.client.ts +++ b/src/integrations/jenkins/jenkins.client.ts @@ -24,7 +24,7 @@ export class JenkinsClient { const missing = this.missingConfig(); return missing.length === 0 ? { status: 'ok' } - : { status: 'not_configured', message: `Missing ${missing.join(', ')}` }; + : { status: 'not_configured', message: `缺少 ${missing.join(', ')}` }; } async buildWithParameters( @@ -204,7 +204,7 @@ export class JenkinsClient { path: string, details?: Record, ): IntegrationRequestError { - return new IntegrationRequestError('jenkins', 'Jenkins request failed', { + return new IntegrationRequestError('jenkins', 'Jenkins 请求失败', { path, ...details, }); diff --git a/src/main.ts b/src/main.ts index e178aa7..51c1ce7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,8 +22,8 @@ async function bootstrap(): Promise { app.useGlobalInterceptors(new ApiEnvelopeInterceptor()); const swaggerConfig = new DocumentBuilder() - .setTitle('DevOps Platform API') - .setDescription('Jenkins, Gitea, notification, BPMN deploy runs, and agent operation APIs.') + .setTitle('运维平台 API') + .setDescription('提供 Jenkins、Gitea、通知、发布流程、账号权限和 Agent 运维能力接口。') .setVersion('0.1.0') .build(); const document = SwaggerModule.createDocument(app, swaggerConfig); diff --git a/src/members/member.dto.ts b/src/members/member.dto.ts new file mode 100644 index 0000000..b312251 --- /dev/null +++ b/src/members/member.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { z } from 'zod'; + +const permissionLevelSchema = z.enum(['none', 'read', 'build']); + +export const projectPermissionSchema = z.object({ + projectKey: z.string().trim().min(1).max(80), + level: permissionLevelSchema, +}); + +export const createMemberSchema = z.object({ + account: z.string().trim().min(1).max(80), + displayName: z.string().trim().min(1).max(120), + permissions: z.array(projectPermissionSchema).optional().default([]), +}); + +export type CreateMemberDtoInput = z.infer; + +export class ProjectPermissionDto { + @ApiProperty({ example: 'devops-platform-api', description: '项目标识' }) + projectKey!: string; + + @ApiProperty({ + enum: ['none', 'read', 'build'], + description: '项目权限:none 无权限,read 只读,build 构建', + }) + level!: 'none' | 'read' | 'build'; +} + +export class CreateMemberDto { + @ApiProperty({ example: 'member01', description: '成员账号' }) + account!: string; + + @ApiProperty({ example: '成员一', description: '成员名称' }) + displayName!: string; + + @ApiProperty({ + type: [ProjectPermissionDto], + required: false, + description: '成员项目权限,未传项目默认为无权限', + }) + permissions?: ProjectPermissionDto[]; +} + +export const updateMemberPermissionsSchema = z.object({ + permissions: z.array(projectPermissionSchema).default([]), +}); + +export type UpdateMemberPermissionsInput = z.infer< + typeof updateMemberPermissionsSchema +>; + +export class UpdateMemberPermissionsDto { + @ApiProperty({ type: [ProjectPermissionDto], description: '完整项目权限列表' }) + permissions!: ProjectPermissionDto[]; +} + +export const updateMemberStatusSchema = z.object({ + status: z.enum(['active', 'disabled']), +}); + +export type UpdateMemberStatusInput = z.infer; + +export class UpdateMemberStatusDto { + @ApiProperty({ + enum: ['active', 'disabled'], + description: '成员状态:active 启用,disabled 禁用', + }) + status!: 'active' | 'disabled'; +} diff --git a/src/members/member.types.ts b/src/members/member.types.ts new file mode 100644 index 0000000..ba98dc3 --- /dev/null +++ b/src/members/member.types.ts @@ -0,0 +1,25 @@ +import { AuthRole, AuthStatus, ProjectPermissionLevel } from '../auth/auth.types'; + +export type MemberProjectPermissionSummary = { + projectId: string; + projectKey: string; + projectName: string; + level: ProjectPermissionLevel; + levelName: string; +}; + +export type MemberSummary = { + id: string; + account: string; + displayName: string; + role: AuthRole; + roleName: string; + status: AuthStatus; + statusName: string; + mustChangePassword: boolean; + hasPendingPasswordResetRequest: boolean; + lastLoginAt?: string; + createdAt: string; + updatedAt: string; + projectPermissions: MemberProjectPermissionSummary[]; +}; diff --git a/src/members/members.controller.ts b/src/members/members.controller.ts new file mode 100644 index 0000000..5867886 --- /dev/null +++ b/src/members/members.controller.ts @@ -0,0 +1,105 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiBody, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; +import { AdminOnlyMessage } from '../auth/admin-only-message.decorator'; +import { AuthGuard } from '../auth/auth.guard'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { PasswordChangeGuard } from '../auth/password-change.guard'; +import { SuperAdminGuard } from '../auth/super-admin.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; +import { + CreateMemberDto, + CreateMemberDtoInput, + UpdateMemberPermissionsDto, + UpdateMemberPermissionsInput, + UpdateMemberStatusDto, + UpdateMemberStatusInput, + createMemberSchema, + updateMemberPermissionsSchema, + updateMemberStatusSchema, +} from './member.dto'; +import { MemberSummary } from './member.types'; +import { MembersService } from './members.service'; + +@ApiTags('成员管理') +@Controller('members') +@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard) +@AdminOnlyMessage('无权限访问成员管理') +export class MembersController { + constructor(private readonly membersService: MembersService) {} + + @Get() + @ApiOkResponse({ description: '查询成员列表和项目权限。' }) + async listMembers(): Promise { + return this.membersService.listMembers(); + } + + @Post() + @ApiBody({ type: CreateMemberDto }) + @ApiCreatedResponse({ description: '创建普通成员,初始密码固定为 111111。' }) + async createMember( + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(createMemberSchema)) + body: CreateMemberDtoInput, + ): Promise { + return this.membersService.createMember(body, user); + } + + @Patch(':id/permissions') + @ApiBody({ type: UpdateMemberPermissionsDto }) + @ApiOkResponse({ description: '更新普通成员的项目权限。' }) + async updatePermissions( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(updateMemberPermissionsSchema)) + body: UpdateMemberPermissionsInput, + ): Promise { + return this.membersService.updatePermissions(id, body, user); + } + + @Post(':id/reset-password') + @ApiOkResponse({ + description: '将普通成员密码重置为 111111,并要求下次登录改密。', + }) + async resetPassword( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.membersService.resetPassword(id, user); + } + + @Patch(':id/status') + @ApiBody({ type: UpdateMemberStatusDto }) + @ApiOkResponse({ description: '启用或禁用普通成员账号。' }) + async updateStatus( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + @Body(new ZodValidationPipe(updateMemberStatusSchema)) + body: UpdateMemberStatusInput, + ): Promise { + return this.membersService.updateStatus(id, body, user); + } + + @Delete(':id') + @ApiOkResponse({ description: '删除普通成员账号。' }) + async deleteMember( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise<{ message: string }> { + return this.membersService.deleteMember(id, user); + } +} diff --git a/src/members/members.module.ts b/src/members/members.module.ts new file mode 100644 index 0000000..5772cb9 --- /dev/null +++ b/src/members/members.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { AuthModule } from '../auth/auth.module'; +import { MessagesModule } from '../messages/messages.module'; +import { ProjectsModule } from '../projects/projects.module'; +import { MembersController } from './members.controller'; +import { MembersService } from './members.service'; + +@Module({ + imports: [AuditModule, AuthModule, MessagesModule, ProjectsModule], + controllers: [MembersController], + providers: [MembersService], + exports: [MembersService], +}) +export class MembersModule {} diff --git a/src/members/members.service.ts b/src/members/members.service.ts new file mode 100644 index 0000000..f7ee3f5 --- /dev/null +++ b/src/members/members.service.ts @@ -0,0 +1,288 @@ +import { Injectable } from '@nestjs/common'; +import { AuditService } from '../audit/audit.service'; +import { MEMBER_INITIAL_PASSWORD, SUPER_ADMIN_SEED } from '../auth/auth.constants'; +import { + AuthenticatedUser, + ProjectPermissionInput, + ProjectPermissionLevel, + UserRecord, +} from '../auth/auth.types'; +import { PasswordService } from '../auth/password.service'; +import { UserRepository } from '../auth/user.repository'; +import { AppError } from '../common/errors/app-error'; +import { MessagesService } from '../messages/messages.service'; +import { ProjectRepository } from '../projects/project.repository'; +import { + CreateMemberDtoInput, + UpdateMemberPermissionsInput, + UpdateMemberStatusInput, +} from './member.dto'; +import { + MemberProjectPermissionSummary, + MemberSummary, +} from './member.types'; + +@Injectable() +export class MembersService { + constructor( + private readonly userRepository: UserRepository, + private readonly passwordService: PasswordService, + private readonly messagesService: MessagesService, + private readonly projectRepository: ProjectRepository, + private readonly auditService: AuditService, + ) {} + + async listMembers(): Promise { + const users = await this.userRepository.listUsers(); + + return Promise.all(users.map((user) => this.toMemberSummary(user))); + } + + async createMember( + input: CreateMemberDtoInput, + operator: AuthenticatedUser, + ): Promise { + const account = input.account.trim(); + + if (account === SUPER_ADMIN_SEED.account) { + throw new AppError( + 'CONFLICT', + '该账号为内置超级管理员账号,不能创建为普通成员', + 409, + ); + } + + const existing = await this.userRepository.findByAccount(account); + + if (existing) { + throw new AppError('CONFLICT', '成员账号已存在', 409, { account }); + } + + const passwordHash = await this.passwordService.hashPassword( + MEMBER_INITIAL_PASSWORD, + ); + const user = await this.userRepository.createUser({ + account, + displayName: input.displayName, + passwordHash, + role: 'member', + mustChangePassword: true, + createdById: operator.id, + }); + const updatedUser = await this.userRepository.replaceProjectPermissions( + user.id, + input.permissions ?? [], + ); + + await this.auditService.record({ + action: 'MEMBER_CREATED', + resourceType: 'user', + resourceId: updatedUser.id, + actorId: operator.id, + actorName: operator.account, + after: { + account: updatedUser.account, + permissions: this.safePermissionDigest(input.permissions ?? []), + mustChangePassword: true, + }, + }); + + return this.toMemberSummary(updatedUser); + } + + async updatePermissions( + memberId: string, + input: UpdateMemberPermissionsInput, + operator: AuthenticatedUser, + ): Promise { + const user = await this.requireMutableMember(memberId); + const updatedUser = await this.userRepository.replaceProjectPermissions( + user.id, + input.permissions, + ); + + await this.auditService.record({ + action: 'MEMBER_PERMISSIONS_UPDATED', + resourceType: 'user', + resourceId: updatedUser.id, + actorId: operator.id, + actorName: operator.account, + before: { + permissions: this.safePermissionDigest(user.projectPermissions), + }, + after: { + permissions: this.safePermissionDigest(input.permissions), + }, + }); + + return this.toMemberSummary(updatedUser); + } + + async resetPassword( + memberId: string, + operator: AuthenticatedUser, + ): Promise { + const user = await this.requireMutableMember(memberId); + const passwordHash = await this.passwordService.hashPassword( + MEMBER_INITIAL_PASSWORD, + ); + const updatedUser = await this.userRepository.updatePassword( + user.id, + passwordHash, + true, + ); + + await this.messagesService.markPasswordResetHandledForRequester( + user.id, + operator, + ); + await this.auditService.record({ + action: 'MEMBER_PASSWORD_RESET', + resourceType: 'user', + resourceId: user.id, + actorId: operator.id, + actorName: operator.account, + after: { + resetToInitialPassword: true, + mustChangePassword: true, + }, + }); + + return this.toMemberSummary(updatedUser); + } + + async updateStatus( + memberId: string, + input: UpdateMemberStatusInput, + operator: AuthenticatedUser, + ): Promise { + const user = await this.requireMutableMember(memberId); + const updatedUser = await this.userRepository.setStatus( + user.id, + input.status, + ); + + await this.auditService.record({ + action: 'MEMBER_STATUS_UPDATED', + resourceType: 'user', + resourceId: user.id, + actorId: operator.id, + actorName: operator.account, + before: { + status: user.status, + }, + after: { + status: updatedUser.status, + }, + }); + + return this.toMemberSummary(updatedUser); + } + + async deleteMember( + memberId: string, + operator: AuthenticatedUser, + ): Promise<{ message: string }> { + const user = await this.requireMutableMember(memberId); + await this.userRepository.deleteUser(user.id); + await this.auditService.record({ + action: 'MEMBER_DELETED', + resourceType: 'user', + resourceId: user.id, + actorId: operator.id, + actorName: operator.account, + before: { + account: user.account, + }, + }); + + return { + message: '成员已删除', + }; + } + + private async requireMutableMember(memberId: string): Promise { + const user = await this.userRepository.findById(memberId); + + if (!user) { + throw new AppError('RESOURCE_NOT_FOUND', '成员不存在', 404, { memberId }); + } + + if (user.role === 'super_admin') { + throw new AppError( + 'FORBIDDEN', + '超级管理员不可删除、不可禁用、不可降级、不可重置为普通成员初始密码', + 403, + ); + } + + return user; + } + + private async toMemberSummary(user: UserRecord): Promise { + return { + id: user.id, + account: user.account, + displayName: user.displayName, + role: user.role, + roleName: user.role === 'super_admin' ? '超级管理员' : '普通成员', + status: user.status, + statusName: user.status === 'active' ? '启用' : '禁用', + mustChangePassword: user.mustChangePassword, + hasPendingPasswordResetRequest: + await this.messagesService.hasPendingPasswordResetRequest(user.id), + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + projectPermissions: await this.resolveProjectPermissions(user), + }; + } + + private async resolveProjectPermissions( + user: UserRecord, + ): Promise { + const projects = await this.projectRepository.findMany(); + const permissionByProject = new Map( + user.projectPermissions.map((permission) => [ + permission.projectKey, + permission.level, + ]), + ); + + return projects.map((project) => { + const level = + user.role === 'super_admin' + ? 'build' + : (permissionByProject.get(project.key) ?? 'none'); + + return { + projectId: project.id, + projectKey: project.key, + projectName: project.name, + level, + levelName: this.permissionLevelName(level), + }; + }); + } + + private permissionLevelName(level: ProjectPermissionLevel): string { + if (level === 'build') { + return '构建权限'; + } + + if (level === 'read') { + return '只读权限'; + } + + return '无权限'; + } + + private safePermissionDigest( + permissions: Array, + ): Array<{ projectKey: string; level: string }> { + return permissions.map((permission) => ({ + projectKey: permission.projectKey, + level: permission.level, + })); + } +} diff --git a/src/messages/message.repository.ts b/src/messages/message.repository.ts new file mode 100644 index 0000000..ab46e2f --- /dev/null +++ b/src/messages/message.repository.ts @@ -0,0 +1,230 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + PlatformMessage, + PlatformMessageStatus as PrismaMessageStatus, + PlatformMessageType as PrismaMessageType, + Prisma, +} from '@prisma/client'; +import { EnvConfig } from '../config/env.schema'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthenticatedUser, UserRecord } from '../auth/auth.types'; +import { PlatformMessageSummary } from './message.types'; + +@Injectable() +export class MessageRepository { + private readonly memoryMessages: PlatformMessageSummary[] = []; + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + ) {} + + async listMessages(): Promise { + if (!this.useDatabase()) { + return [...this.memoryMessages].sort((left, right) => + right.createdAt.localeCompare(left.createdAt), + ); + } + + const messages = await this.prisma.platformMessage.findMany({ + orderBy: { + createdAt: 'desc', + }, + take: 100, + }); + + return messages.map((message) => this.toSummary(message)); + } + + async countPendingPasswordResetRequests(userId: string): Promise { + if (!this.useDatabase()) { + return this.memoryMessages.filter( + (message) => + message.requesterId === userId && + message.type === 'password_reset_request' && + message.status === 'pending', + ).length; + } + + return this.prisma.platformMessage.count({ + where: { + requesterId: userId, + type: PrismaMessageType.PASSWORD_RESET_REQUEST, + status: PrismaMessageStatus.PENDING, + }, + }); + } + + async createOrReusePasswordResetRequest( + requester: UserRecord, + ): Promise { + const content = `账号 ${requester.account} 的成员请求重置密码,请及时处理。`; + + if (!this.useDatabase()) { + const existing = this.memoryMessages.find( + (message) => + message.requesterId === requester.id && + message.type === 'password_reset_request' && + message.status === 'pending', + ); + + if (existing) { + return existing; + } + + const now = new Date().toISOString(); + const message: PlatformMessageSummary = { + id: `message_${Date.now()}_${this.memoryMessages.length + 1}`, + type: 'password_reset_request', + status: 'pending', + title: '成员密码重置请求', + content, + requesterId: requester.id, + requesterAccount: requester.account, + targetResourceType: 'member', + targetResourceId: requester.id, + targetUrl: `/members?account=${encodeURIComponent(requester.account)}`, + createdAt: now, + updatedAt: now, + }; + + this.memoryMessages.unshift(message); + return message; + } + + const existing = await this.prisma.platformMessage.findFirst({ + where: { + requesterId: requester.id, + type: PrismaMessageType.PASSWORD_RESET_REQUEST, + status: PrismaMessageStatus.PENDING, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + if (existing) { + return this.toSummary(existing); + } + + const message = await this.prisma.platformMessage.create({ + data: { + type: PrismaMessageType.PASSWORD_RESET_REQUEST, + status: PrismaMessageStatus.PENDING, + title: '成员密码重置请求', + content, + requesterId: requester.id, + requesterAccount: requester.account, + targetResourceType: 'member', + targetResourceId: requester.id, + metadata: { + targetUrl: `/members?account=${encodeURIComponent(requester.account)}`, + }, + }, + }); + + return this.toSummary(message); + } + + async markPasswordResetHandledForRequester( + requesterId: string, + handler: AuthenticatedUser, + ): Promise { + if (!this.useDatabase()) { + const now = new Date().toISOString(); + + for (const message of this.memoryMessages) { + if ( + message.requesterId === requesterId && + message.type === 'password_reset_request' && + message.status === 'pending' + ) { + message.status = 'handled'; + message.handledById = handler.id; + message.handledAt = now; + message.updatedAt = now; + } + } + + return; + } + + await this.prisma.platformMessage.updateMany({ + where: { + requesterId, + type: PrismaMessageType.PASSWORD_RESET_REQUEST, + status: PrismaMessageStatus.PENDING, + }, + data: { + status: PrismaMessageStatus.HANDLED, + handledById: handler.id, + handledAt: new Date(), + }, + }); + } + + async markHandled(messageId: string, handler: AuthenticatedUser): Promise { + if (!this.useDatabase()) { + const message = this.memoryMessages.find((item) => item.id === messageId); + + if (message) { + const now = new Date().toISOString(); + message.status = 'handled'; + message.handledById = handler.id; + message.handledAt = now; + message.updatedAt = now; + } + + return; + } + + await this.prisma.platformMessage.update({ + where: { id: messageId }, + data: { + status: PrismaMessageStatus.HANDLED, + handledById: handler.id, + handledAt: new Date(), + }, + }); + } + + private toSummary(message: PlatformMessage): PlatformMessageSummary { + const metadata = this.objectMetadata(message.metadata); + const targetUrl = + typeof metadata.targetUrl === 'string' ? metadata.targetUrl : undefined; + + return { + id: message.id, + type: 'password_reset_request', + status: + message.status === PrismaMessageStatus.PENDING ? 'pending' : 'handled', + title: message.title, + content: message.content, + requesterId: message.requesterId ?? undefined, + requesterAccount: message.requesterAccount, + targetResourceType: message.targetResourceType ?? undefined, + targetResourceId: message.targetResourceId ?? undefined, + targetUrl, + handledById: message.handledById ?? undefined, + handledAt: message.handledAt?.toISOString(), + createdAt: message.createdAt.toISOString(), + updatedAt: message.updatedAt.toISOString(), + }; + } + + private objectMetadata(value: Prisma.JsonValue): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return value; + } + + private useDatabase(): boolean { + return ( + this.config.get('USE_DATABASE_READS', { infer: true }) && + Boolean(this.config.get('DATABASE_URL', { infer: true })) + ); + } +} diff --git a/src/messages/message.types.ts b/src/messages/message.types.ts new file mode 100644 index 0000000..7f30c41 --- /dev/null +++ b/src/messages/message.types.ts @@ -0,0 +1,20 @@ +export type PlatformMessageKind = 'password_reset_request'; + +export type PlatformMessageState = 'pending' | 'handled'; + +export type PlatformMessageSummary = { + id: string; + type: PlatformMessageKind; + status: PlatformMessageState; + title: string; + content: string; + requesterId?: string; + requesterAccount: string; + targetResourceType?: string; + targetResourceId?: string; + targetUrl?: string; + handledById?: string; + handledAt?: string; + createdAt: string; + updatedAt: string; +}; diff --git a/src/messages/messages.controller.ts b/src/messages/messages.controller.ts new file mode 100644 index 0000000..69ef38b --- /dev/null +++ b/src/messages/messages.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { AdminOnlyMessage } from '../auth/admin-only-message.decorator'; +import { AuthGuard } from '../auth/auth.guard'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { PasswordChangeGuard } from '../auth/password-change.guard'; +import { SuperAdminGuard } from '../auth/super-admin.guard'; +import { PlatformMessageSummary } from './message.types'; +import { MessagesService } from './messages.service'; + +@ApiTags('消息通知') +@Controller('messages') +@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard) +@AdminOnlyMessage('无权限访问消息通知') +export class MessagesController { + constructor(private readonly messagesService: MessagesService) {} + + @Get() + @ApiOkResponse({ description: '查询超级管理员消息列表。' }) + async listMessages(): Promise { + return this.messagesService.listMessages(); + } + + @Post(':id/handled') + @ApiOkResponse({ description: '将消息标记为已处理。' }) + async markHandled( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise<{ message: string }> { + await this.messagesService.markHandled(id, user); + + return { + message: '消息已标记为已处理', + }; + } +} diff --git a/src/messages/messages.module.ts b/src/messages/messages.module.ts new file mode 100644 index 0000000..0611392 --- /dev/null +++ b/src/messages/messages.module.ts @@ -0,0 +1,13 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { MessageRepository } from './message.repository'; +import { MessagesController } from './messages.controller'; +import { MessagesService } from './messages.service'; + +@Module({ + imports: [forwardRef(() => AuthModule)], + controllers: [MessagesController], + providers: [MessageRepository, MessagesService], + exports: [MessageRepository, MessagesService], +}) +export class MessagesModule {} diff --git a/src/messages/messages.service.ts b/src/messages/messages.service.ts new file mode 100644 index 0000000..4cb5e49 --- /dev/null +++ b/src/messages/messages.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { AuthenticatedUser, UserRecord } from '../auth/auth.types'; +import { MessageRepository } from './message.repository'; +import { PlatformMessageSummary } from './message.types'; + +@Injectable() +export class MessagesService { + constructor(private readonly messageRepository: MessageRepository) {} + + async listMessages(): Promise { + return this.messageRepository.listMessages(); + } + + async hasPendingPasswordResetRequest(userId: string): Promise { + const count = + await this.messageRepository.countPendingPasswordResetRequests(userId); + return count > 0; + } + + async createPasswordResetRequest( + requester: UserRecord, + ): Promise { + return this.messageRepository.createOrReusePasswordResetRequest(requester); + } + + async markPasswordResetHandledForRequester( + requesterId: string, + handler: AuthenticatedUser, + ): Promise { + await this.messageRepository.markPasswordResetHandledForRequester( + requesterId, + handler, + ); + } + + async markHandled( + messageId: string, + handler: AuthenticatedUser, + ): Promise { + await this.messageRepository.markHandled(messageId, handler); + } +} diff --git a/src/notifications/wecom/deploy-notification.service.ts b/src/notifications/wecom/deploy-notification.service.ts index 94ca314..44d46d8 100644 --- a/src/notifications/wecom/deploy-notification.service.ts +++ b/src/notifications/wecom/deploy-notification.service.ts @@ -43,7 +43,7 @@ export class DeployNotificationService { if (!provider) { return { status: 'not_configured', - message: 'Missing NOTIFICATION_PROVIDER', + message: '缺少 NOTIFICATION_PROVIDER', }; } @@ -63,7 +63,7 @@ export class DeployNotificationService { : { status: 'not_configured', provider, - message: `Missing ${webhookKey}`, + message: `缺少 ${webhookKey}`, }; } @@ -77,7 +77,7 @@ export class DeployNotificationService { return { status: 'skipped', channel: health.provider ?? 'none', - reason: health.message ?? 'Notification provider is not configured', + reason: health.message ?? '通知平台尚未配置', }; } @@ -85,7 +85,7 @@ export class DeployNotificationService { return { status: 'failed', channel: health.provider ?? 'none', - reason: health.message ?? 'Notification provider is unavailable', + reason: health.message ?? '通知平台不可用', }; } @@ -100,7 +100,7 @@ export class DeployNotificationService { reason: error instanceof Error ? redactSensitive({ message: error.message }).message - : 'Unknown notification error', + : '未知通知错误', }; } } @@ -196,7 +196,7 @@ export class DeployNotificationService { }); if (!response.ok) { - throw new Error(`${provider} webhook request failed: ${response.status}`); + throw new Error(`${provider} webhook 请求失败:${response.status}`); } } diff --git a/src/notifications/wecom/wecom.client.ts b/src/notifications/wecom/wecom.client.ts index 844deaa..a4567e2 100644 --- a/src/notifications/wecom/wecom.client.ts +++ b/src/notifications/wecom/wecom.client.ts @@ -12,7 +12,7 @@ export class WeComClient { healthSummary(): { status: 'ok' | 'not_configured'; message?: string } { return this.config.get('WECOM_WEBHOOK_URL', { infer: true }) ? { status: 'ok' } - : { status: 'not_configured', message: 'Missing WECOM_WEBHOOK_URL' }; + : { status: 'not_configured', message: '缺少 WECOM_WEBHOOK_URL' }; } async sendMarkdown(message: WeComMarkdownMessage): Promise { @@ -32,7 +32,7 @@ export class WeComClient { }); if (!response.ok) { - throw new IntegrationRequestError('wecom', 'WeCom webhook request failed', { + throw new IntegrationRequestError('wecom', '企微 webhook 请求失败', { status: response.status, message: redactSensitive(message), }); diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index e9d4188..1470ea8 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -44,7 +44,7 @@ export class PrismaService } catch (error) { return { status: 'unavailable', - message: error instanceof Error ? error.message : 'Unknown database error', + message: error instanceof Error ? error.message : '未知数据库错误', }; } } diff --git a/src/process-definitions/process-definitions.controller.ts b/src/process-definitions/process-definitions.controller.ts index 11c9a67..4664837 100644 --- a/src/process-definitions/process-definitions.controller.ts +++ b/src/process-definitions/process-definitions.controller.ts @@ -1,17 +1,20 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { AuthGuard } from '../auth/auth.guard'; +import { PasswordChangeGuard } from '../auth/password-change.guard'; import { ProcessDefinitionSummary } from './process-definition.types'; import { ProcessDefinitionsService } from './process-definitions.service'; -@ApiTags('process-definitions') +@ApiTags('流程定义') @Controller('process-definitions') +@UseGuards(AuthGuard, PasswordChangeGuard) export class ProcessDefinitionsController { constructor( private readonly processDefinitionsService: ProcessDefinitionsService, ) {} @Get('release') - @ApiOkResponse({ description: 'BPMN process definition for deploy runs.' }) + @ApiOkResponse({ description: '查询发布流程 BPMN 定义。' }) getReleaseProcess(): ProcessDefinitionSummary { return this.processDefinitionsService.getReleaseProcess(); } diff --git a/src/process-definitions/process-definitions.module.ts b/src/process-definitions/process-definitions.module.ts index 034f62e..3122ab6 100644 --- a/src/process-definitions/process-definitions.module.ts +++ b/src/process-definitions/process-definitions.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { ProcessDefinitionsController } from './process-definitions.controller'; import { ProcessDefinitionsService } from './process-definitions.service'; @Module({ + imports: [AuthModule], controllers: [ProcessDefinitionsController], providers: [ProcessDefinitionsService], exports: [ProcessDefinitionsService], diff --git a/src/projects/projects.controller.ts b/src/projects/projects.controller.ts index 2c0a97b..d2a9516 100644 --- a/src/projects/projects.controller.ts +++ b/src/projects/projects.controller.ts @@ -1,28 +1,41 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { AuthGuard } from '../auth/auth.guard'; +import { AuthenticatedUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { PasswordChangeGuard } from '../auth/password-change.guard'; import { ProjectRefSummary, ProjectSummary } from './project.types'; import { ProjectsService } from './projects.service'; -@ApiTags('projects') +@ApiTags('项目') @Controller('projects') +@UseGuards(AuthGuard, PasswordChangeGuard) export class ProjectsController { constructor(private readonly projectsService: ProjectsService) {} @Get() - @ApiOkResponse({ description: 'Configured project list.' }) - async listProjects(): Promise { - return this.projectsService.listProjects(); + @ApiOkResponse({ description: '查询当前账号可访问的项目列表。' }) + async listProjects( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.projectsService.listProjects(user); } @Get(':key') - @ApiOkResponse({ description: 'Project detail by key.' }) - async getProject(@Param('key') key: string): Promise { - return this.projectsService.getProject(key); + @ApiOkResponse({ description: '查询项目详情。' }) + async getProject( + @Param('key') key: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.projectsService.getProject(key, user); } @Get(':key/refs') - @ApiOkResponse({ description: 'Project branches and tags.' }) - async listProjectRefs(@Param('key') key: string): Promise { - return this.projectsService.listProjectRefs(key); + @ApiOkResponse({ description: '查询项目分支和标签。' }) + async listProjectRefs( + @Param('key') key: string, + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.projectsService.listProjectRefs(key, user); } } diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index 67f18d0..845a270 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { GiteaModule } from '../integrations/gitea/gitea.module'; import { ProjectRepository } from './project.repository'; import { ProjectsController } from './projects.controller'; import { ProjectsService } from './projects.service'; @Module({ - imports: [GiteaModule], + imports: [AuthModule, GiteaModule], controllers: [ProjectsController], providers: [ProjectRepository, ProjectsService], exports: [ProjectRepository, ProjectsService], diff --git a/src/projects/projects.service.spec.ts b/src/projects/projects.service.spec.ts index 4ac940b..3123edf 100644 --- a/src/projects/projects.service.spec.ts +++ b/src/projects/projects.service.spec.ts @@ -1,3 +1,4 @@ +import { AuthService } from '../auth/auth.service'; import { AppError } from '../common/errors/app-error'; import { GiteaClient } from '../integrations/gitea/gitea.client'; import { ProjectRepository } from './project.repository'; @@ -37,13 +38,22 @@ describe('ProjectsService', () => { Promise.resolve(options?.tags ?? [{ name: 'v1.0.0', commitSha: 'def' }]), ), } satisfies Pick; + const auth = { + listAllowedProjectKeys: jest.fn(() => Promise.resolve([project.key])), + requireProjectPermission: jest.fn(() => Promise.resolve()), + } satisfies Pick< + AuthService, + 'listAllowedProjectKeys' | 'requireProjectPermission' + >; return { + auth, gitea, repository, service: new ProjectsService( repository as unknown as ProjectRepository, gitea as unknown as GiteaClient, + auth as unknown as AuthService, ), }; } diff --git a/src/projects/projects.service.ts b/src/projects/projects.service.ts index 38a1cef..bd14f44 100644 --- a/src/projects/projects.service.ts +++ b/src/projects/projects.service.ts @@ -1,4 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { AuthService } from '../auth/auth.service'; +import { AuthenticatedUser } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; import { GiteaClient } from '../integrations/gitea/gitea.client'; import { ProjectRepository } from './project.repository'; @@ -9,26 +11,47 @@ export class ProjectsService { constructor( private readonly projectRepository: ProjectRepository, private readonly giteaClient: GiteaClient, + private readonly authService: AuthService, ) {} - async listProjects(): Promise { - return this.projectRepository.findMany(); + async listProjects(user?: AuthenticatedUser): Promise { + const projects = await this.projectRepository.findMany(); + + if (!user || user.role === 'super_admin') { + return projects; + } + + const allowedProjectKeys = new Set( + await this.authService.listAllowedProjectKeys(user), + ); + + return projects.filter((project) => allowedProjectKeys.has(project.key)); } - async getProject(key: string): Promise { + async getProject( + key: string, + user?: AuthenticatedUser, + ): Promise { const project = await this.projectRepository.findByKey(key); if (!project) { - throw new AppError('RESOURCE_NOT_FOUND', `Project ${key} not found`, 404, { + throw new AppError('RESOURCE_NOT_FOUND', `项目不存在:${key}`, 404, { key, }); } + if (user) { + await this.authService.requireProjectPermission(user, key, 'read'); + } + return project; } - async listProjectRefs(key: string): Promise { - const project = await this.getProject(key); + async listProjectRefs( + key: string, + user?: AuthenticatedUser, + ): Promise { + const project = await this.getProject(key, user); const repo = this.parseRepository(project); if (repo && this.giteaClient.healthSummary().status === 'ok') { diff --git a/src/settings/settings.controller.ts b/src/settings/settings.controller.ts index 65ebdcc..c9bd153 100644 --- a/src/settings/settings.controller.ts +++ b/src/settings/settings.controller.ts @@ -1,17 +1,22 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { AdminOnlyMessage } from '../auth/admin-only-message.decorator'; +import { AuthGuard } from '../auth/auth.guard'; +import { PasswordChangeGuard } from '../auth/password-change.guard'; +import { SuperAdminGuard } from '../auth/super-admin.guard'; import { SettingsService } from './settings.service'; import { IntegrationConfigStatusResponse } from './settings.types'; -@ApiTags('settings') +@ApiTags('系统设置') @Controller('settings') +@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard) +@AdminOnlyMessage('无权限访问系统设置') export class SettingsController { constructor(private readonly settingsService: SettingsService) {} @Get('integration-config') @ApiOkResponse({ - description: - 'Integration configuration status with secret values redacted server-side.', + description: '查询集成配置状态,所有密钥值均在服务端脱敏。', }) getIntegrationConfig(): IntegrationConfigStatusResponse { return this.settingsService.getIntegrationConfigStatus(); diff --git a/src/settings/settings.module.ts b/src/settings/settings.module.ts index 63e54df..bcf96b2 100644 --- a/src/settings/settings.module.ts +++ b/src/settings/settings.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { SettingsController } from './settings.controller'; import { SettingsService } from './settings.service'; @Module({ + imports: [AuthModule], controllers: [SettingsController], providers: [SettingsService], }) diff --git a/src/webhooks/gitea/gitea-webhook.controller.ts b/src/webhooks/gitea/gitea-webhook.controller.ts index f9ff49a..452717a 100644 --- a/src/webhooks/gitea/gitea-webhook.controller.ts +++ b/src/webhooks/gitea/gitea-webhook.controller.ts @@ -15,7 +15,7 @@ export class GiteaWebhookController { constructor(private readonly giteaWebhookService: GiteaWebhookService) {} @Post() - @ApiCreatedResponse({ description: 'Receive a Gitea webhook event.' }) + @ApiCreatedResponse({ description: '接收 Gitea webhook 事件。' }) async receiveWebhook( @Headers('x-gitea-event') event: string | undefined, @Headers('x-gitea-delivery') delivery: string | undefined, diff --git a/src/webhooks/gitea/gitea-webhook.service.spec.ts b/src/webhooks/gitea/gitea-webhook.service.spec.ts index 4a2d3b8..de645d8 100644 --- a/src/webhooks/gitea/gitea-webhook.service.spec.ts +++ b/src/webhooks/gitea/gitea-webhook.service.spec.ts @@ -116,7 +116,7 @@ describe('GiteaWebhookService', () => { }); expect(result.accepted).toBe(false); - expect(result.reason).toContain('Only branch push'); + expect(result.reason).toContain('只有分支 push'); expect(deployRuns.createRun).not.toHaveBeenCalled(); }); diff --git a/src/webhooks/gitea/gitea-webhook.service.ts b/src/webhooks/gitea/gitea-webhook.service.ts index 84539f7..9e4fc18 100644 --- a/src/webhooks/gitea/gitea-webhook.service.ts +++ b/src/webhooks/gitea/gitea-webhook.service.ts @@ -34,7 +34,7 @@ export class GiteaWebhookService { if (input.headers.event !== 'push') { return this.ignore( input.headers, - 'Only push events can trigger test deploys', + '只有 push 事件可以触发测试环境发布', ); } @@ -43,7 +43,7 @@ export class GiteaWebhookService { if (!payload.ref.startsWith('refs/heads/')) { return this.ignore( input.headers, - 'Only branch push events trigger test deploys', + '只有分支 push 事件可以触发测试环境发布', ); } @@ -70,7 +70,7 @@ export class GiteaWebhookService { if (!project) { return this.ignore( input.headers, - 'Repository is not managed by this platform', + '仓库未纳入当前平台管理', ); } @@ -83,7 +83,7 @@ export class GiteaWebhookService { if (!allowedBranches.has(branch)) { return this.ignore( input.headers, - `Branch ${branch} is not configured for automatic test deploy`, + `分支 ${branch} 未配置自动测试环境发布`, project.key, branch, ); @@ -146,7 +146,7 @@ export class GiteaWebhookService { ) { throw new AppError( 'VALIDATION_FAILED', - 'Invalid Gitea webhook signature', + 'Gitea webhook 签名无效', 401, ); } @@ -156,7 +156,7 @@ export class GiteaWebhookService { if (!isRecord(payload) || !isRecord(payload.repository)) { throw new AppError( 'VALIDATION_FAILED', - 'Invalid Gitea push payload', + 'Gitea push 载荷无效', 400, ); } @@ -167,7 +167,7 @@ export class GiteaWebhookService { if (!ref || !after) { throw new AppError( 'VALIDATION_FAILED', - 'Gitea push payload missing ref or after', + 'Gitea push 载荷缺少 ref 或 after', 400, ); }