feat: 接入真实登录与成员权限

- auth/members/messages: 新增超级管理员登录、成员权限和密码消息流程
- agent-config/agent: 支持服务端保存 Agent 配置并取消未配置 mock 成功结果
- projects/deploy-runs/settings: 按当前用户权限保护真实接口
- prisma: 新增用户、项目权限和平台消息表结构
This commit is contained in:
湛兮
2026-06-12 00:38:23 +08:00
parent cf1bebf625
commit bc3aa31289
83 changed files with 3844 additions and 378 deletions
+25 -25
View File
@@ -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 不足之处,并保持方法范围收敛。
- 外部客户端必须放在集成模块后面,包括 JenkinsGitea、通知提供方和 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 前,必须脱敏 tokenwebhook URLcookieauthorization header、私钥和长的不透明密钥字符串。
- 未来持久化密钥只能在服务端加密保存。
- 前端永远不能收到 JenkinsGitea、通知或 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 估算和脱敏后的失败摘要。
+78 -60
View File
@@ -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.
这是运维平台后端服务,基于 NestJSTypeScript 和 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 或暴露密钥。
+34 -29
View File
@@ -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
+2 -2
View File
@@ -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,提供 JenkinsGitea、通知、发布流程、账号权限和 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",
@@ -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;
+8 -8
View File
@@ -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 <change-name>` after the final MySQL connection details are confirmed.
- Commit generated folders under `prisma/migrations/<timestamp>_<change-name>/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`。没有明确数据库连接前,不要求本地自动执行迁移。
+87
View File
@@ -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
+56 -1
View File
@@ -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) {
@@ -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<AgentConfigSummary> {
return this.agentConfigService.getConfig(user);
}
@Put()
@ApiBody({ type: SaveAgentConfigDto })
@ApiOkResponse({ description: '保存 Agent 配置。' })
async saveConfig(
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(saveAgentConfigSchema))
body: SaveAgentConfigInput,
): Promise<AgentConfigSummary> {
return this.agentConfigService.saveConfig(user, body);
}
@Post('test')
@ApiOkResponse({ description: '测试已保存的 Agent 配置连接。' })
async testConnection(
@CurrentUser() user: AuthenticatedUser,
): Promise<AgentConnectionTestResult> {
return this.agentConfigService.testConnection(user);
}
}
+32
View File
@@ -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<typeof saveAgentConfigSchema>;
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;
}
+15
View File
@@ -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 {}
+148
View File
@@ -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<AgentRuntimeConfig> & {
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<EnvConfig, true>,
private readonly prisma: PrismaService,
private readonly secretCrypto: SecretCryptoService,
) {}
async getRuntimeConfig(): Promise<StoredAgentConfig> {
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<StoredAgentConfig> {
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<StoredAgentConfig> {
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<void> {
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 }))
);
}
}
@@ -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<AgentConfigRepository, 'getRuntimeConfig' | 'saveConfig'>;
const audit = {
record: jest.fn(() => Promise.resolve()),
} satisfies Pick<AuditService, 'record'>;
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',
);
});
});
+174
View File
@@ -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<AgentConfigSummary> {
this.assertSuperAdmin(user);
return this.toSummary(await this.agentConfigRepository.getRuntimeConfig());
}
async saveConfig(
user: AuthenticatedUser,
input: SaveAgentConfigInput,
): Promise<AgentConfigSummary> {
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<AgentConnectionTestResult> {
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<AgentRuntimeConfig | null> {
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<AgentRuntimeConfig> {
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<AgentRuntimeConfig> & { 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<AgentRuntimeConfig>,
after: Partial<AgentRuntimeConfig>,
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(/\/+$/, '');
}
}
+20
View File
@@ -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;
};
+72
View File
@@ -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<EnvConfig, true>) {}
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();
}
}
+11 -5
View File
@@ -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<AgentInvocation> {
return this.agentService.createInvocation(body);
return this.agentService.createInvocation(body, user);
}
}
+3 -1
View File
@@ -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],
+56 -22
View File
@@ -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<AuditService, 'record'>;
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<LlmClient, 'healthSummary' | 'complete'>;
const auth = {
requireProjectPermission: jest.fn(() => Promise.resolve()),
} satisfies Pick<AuthService, 'requireProjectPermission'>;
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);
});
});
+48 -23
View File
@@ -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<AgentPurpose, string> = {
'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<AgentInvocation> {
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<void> {
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<void> {
@@ -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,
+2 -1
View File
@@ -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;
}
+29 -31
View File
@@ -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<EnvConfig, true>) {}
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<LlmCompletionResult> {
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(/\/+$/, '');
}
}
+8
View File
@@ -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,
+9 -3
View File
@@ -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<AuditRecordSummary[]> {
return this.auditService.getRecentRecords();
}
+6
View File
@@ -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);
+143
View File
@@ -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<EnvConfig, true>,
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<AuthenticatedUser> {
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,
};
}
}
+13
View File
@@ -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';
+78
View File
@@ -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<LoginResult> {
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<AuthenticatedUser> {
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: '已退出登录',
};
}
}
+43
View File
@@ -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<typeof loginSchema>;
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<typeof changePasswordSchema>;
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<typeof forgotPasswordSchema>;
export class ForgotPasswordDto {
@ApiProperty({ example: 'member01', description: '普通成员账号' })
account!: string;
}
+47
View File
@@ -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<boolean> {
const request = context.switchToHttp().getRequest<RequestWithUser>();
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;
}
}
+36
View File
@@ -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 {}
+179
View File
@@ -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<string, UserRecord>();
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<UserRepository>;
const token = {
sign: jest.fn(() => ({
token: 'token_001',
expiresAt: '2026-06-11T12:00:00.000Z',
})),
} satisfies Pick<AuthTokenService, 'sign'>;
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<MessagesService, 'createPasswordResetRequest'>;
const audit = {
record: jest.fn(() => Promise.resolve()),
} satisfies Pick<AuditService, 'record'>;
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<EnvConfig, true>,
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);
});
});
+252
View File
@@ -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<EnvConfig, true>,
private readonly userRepository: UserRepository,
private readonly passwordService: PasswordService,
private readonly authTokenService: AuthTokenService,
private readonly messagesService: MessagesService,
private readonly auditService: AuditService,
) {}
async onModuleInit(): Promise<void> {
const passwordHash = await this.passwordService.hashPassword(
this.resolveSuperAdminInitialPassword(),
);
await this.userRepository.ensureSuperAdmin(passwordHash);
}
async login(input: LoginInput): Promise<LoginResult> {
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<AuthenticatedUser> {
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<ProjectPermissionLevel, 'none'>,
): Promise<void> {
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<string[]> {
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<UserRecord> {
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;
}
}
+57
View File
@@ -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<ProjectPermissionLevel, 'none'>;
};
export type UserSummary = Omit<UserRecord, 'passwordHash'> & {
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<string, string | string[] | undefined>;
user?: AuthenticatedUser;
};
+9
View File
@@ -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<RequestWithUser>();
return request.user as AuthenticatedUser;
},
);
+21
View File
@@ -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<RequestWithUser>();
const user = request.user;
if (user?.role === 'member' && user.mustChangePassword) {
throw new AppError(
'PASSWORD_CHANGE_REQUIRED',
'首次登录必须修改密码后才能访问平台功能',
403,
);
}
return true;
}
}
+40
View File
@@ -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<string> {
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<boolean> {
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)
);
}
}
+29
View File
@@ -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<RequestWithUser>();
const user = request.user;
if (user?.role !== 'super_admin') {
const message =
this.reflector.getAllAndOverride<string>(ADMIN_ONLY_MESSAGE_KEY, [
context.getHandler(),
context.getClass(),
]) ?? '无权限访问该功能';
throw new AppError('FORBIDDEN', message, 403);
}
return true;
}
}
+514
View File
@@ -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<EnvConfig, true>,
private readonly prisma: PrismaService,
) {}
async ensureSuperAdmin(passwordHash: string): Promise<UserRecord> {
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<UserRecord | null> {
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<UserRecord | null> {
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<UserRecord[]> {
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<UserRecord> {
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<UserRecord> {
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<void> {
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<AuthenticatedUser['status'], 'disabled'> | 'disabled',
): Promise<UserRecord> {
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<void> {
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<UserRecord> {
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<ProjectPermissionLevel, 'none'>,
};
});
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<ProjectPermissionLevel> {
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<string[]> {
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<ProjectPermissionLevel, 'none'> {
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 }))
);
}
}
+4
View File
@@ -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';
+1 -1
View File
@@ -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 },
);
+3 -3
View File
@@ -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: '系统异常,请联系管理员',
};
}
}
+26 -2
View File
@@ -15,13 +15,13 @@ export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
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<T> implements PipeTransform<unknown, T> {
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 '字段校验未通过';
}
}
+6 -1
View File
@@ -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<string, unknown>): 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;
+8 -3
View File
@@ -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<typeof createDeployRunSchema>;
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;
@@ -121,10 +121,10 @@ describe('DeployExecutionService', () => {
};
}
function createRun(
async function createRun(
repository: DeployRunRepository,
idempotencyKey: string,
): DeployRunSummary {
): Promise<DeployRunSummary> {
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');
+10 -10
View File
@@ -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<DeployRunSummary> {
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 '未知发布执行错误';
}
}
+1 -1
View File
@@ -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} 环境`,
);
}
+57 -24
View File
@@ -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<DeployRunSummary[]> {
return this.deployRunsService.listRuns();
@ApiOkResponse({ description: '查询当前账号可访问项目的发布记录。' })
async listRuns(
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary[]> {
return this.deployRunsService.listRuns(user);
}
@Get(':id')
@ApiOkResponse({ description: 'Deploy run detail.' })
async getRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.getRun(id);
@ApiOkResponse({ description: '查询发布记录详情。' })
async getRun(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary> {
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<DeployRunSummary> {
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<DeployRunSummary> {
return this.deployRunsService.cancelRun(id);
@ApiOkResponse({ description: '取消发布单。' })
async cancelRun(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary> {
return this.deployRunsService.cancelRun(id, user);
}
@Post(':id/retry')
@ApiCreatedResponse({ description: 'Retry a deploy run.' })
async retryRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.retryRun(id);
@ApiCreatedResponse({ description: '重试发布单。' })
async retryRun(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary> {
return this.deployRunsService.retryRun(id, user);
}
@Post('sync-jenkins')
@ApiOkResponse({ description: 'Synchronize all queued/running Jenkins runs.' })
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
return this.deployRunsService.syncJenkinsRuns();
@UseGuards(SuperAdminGuard)
@AdminOnlyMessage('无权限同步全部 Jenkins 状态')
@ApiOkResponse({ description: '同步全部队列中或运行中的 Jenkins 发布状态。' })
async syncJenkinsRuns(
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunJenkinsSyncSummary> {
return this.deployRunsService.syncJenkinsRuns(user);
}
@Post(':id/sync-jenkins')
@ApiOkResponse({ description: 'Synchronize Jenkins queue/build state.' })
async syncJenkinsRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.syncJenkinsRun(id);
@ApiOkResponse({ description: '同步单个发布单的 Jenkins 队列或构建状态。' })
async syncJenkinsRun(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunSummary> {
return this.deployRunsService.syncJenkinsRun(id, user);
}
}
+2 -1
View File
@@ -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,
+43 -23
View File
@@ -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 不符合发布策略',
}),
}),
);
+108 -37
View File
@@ -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<DeployRunSummary[]> {
return this.deployRunRepository.findMany();
async listRuns(user?: AuthenticatedUser): Promise<DeployRunSummary[]> {
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<DeployRunSummary> {
async getRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
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<DeployRunSummary> {
async createRun(
input: CreateDeployRunInput,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
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<DeployRunSummary> {
const source = await this.getRun(id);
async cancelRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
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<DeployRunSummary> {
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<DeployRunSummary> {
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<DeployRunSummary> {
const run = await this.getRun(id);
async syncJenkinsRun(
id: string,
user?: AuthenticatedUser,
): Promise<DeployRunSummary> {
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<DeployRunJenkinsSyncSummary> {
async syncJenkinsRuns(
user?: AuthenticatedUser,
): Promise<DeployRunJenkinsSyncSummary> {
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<ProjectEnvironmentSummary> {
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,
@@ -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',
+1 -1
View File
@@ -8,7 +8,7 @@ export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
@ApiOkResponse({ description: 'Runtime dependency health summary.' })
@ApiOkResponse({ description: '查询运行时依赖健康状态。' })
async getHealth(): Promise<ReturnType<HealthService['getHealth']>> {
return this.healthService.getHealth();
}
+3 -1
View File
@@ -30,7 +30,9 @@ describe('HealthService', () => {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
} satisfies Pick<DeployNotificationService, 'healthSummary'>;
const llm = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
healthSummary: jest.fn(() =>
Promise.resolve({ status: 'not_configured' as const }),
),
} satisfies Pick<LlmClient, 'healthSummary'>;
const service = new HealthService(
config as unknown as ConfigService<EnvConfig, true>,
+1 -1
View File
@@ -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',
+2 -2
View File
@@ -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<GiteaBranch[]> {
@@ -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),
+2 -2
View File
@@ -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<string, unknown>,
): IntegrationRequestError {
return new IntegrationRequestError('jenkins', 'Jenkins request failed', {
return new IntegrationRequestError('jenkins', 'Jenkins 请求失败', {
path,
...details,
});
+2 -2
View File
@@ -22,8 +22,8 @@ async function bootstrap(): Promise<void> {
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('提供 JenkinsGitea、通知、发布流程、账号权限和 Agent 运维能力接口。')
.setVersion('0.1.0')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
+70
View File
@@ -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<typeof createMemberSchema>;
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<typeof updateMemberStatusSchema>;
export class UpdateMemberStatusDto {
@ApiProperty({
enum: ['active', 'disabled'],
description: '成员状态:active 启用,disabled 禁用',
})
status!: 'active' | 'disabled';
}
+25
View File
@@ -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[];
};
+105
View File
@@ -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<MemberSummary[]> {
return this.membersService.listMembers();
}
@Post()
@ApiBody({ type: CreateMemberDto })
@ApiCreatedResponse({ description: '创建普通成员,初始密码固定为 111111。' })
async createMember(
@CurrentUser() user: AuthenticatedUser,
@Body(new ZodValidationPipe(createMemberSchema))
body: CreateMemberDtoInput,
): Promise<MemberSummary> {
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<MemberSummary> {
return this.membersService.updatePermissions(id, body, user);
}
@Post(':id/reset-password')
@ApiOkResponse({
description: '将普通成员密码重置为 111111,并要求下次登录改密。',
})
async resetPassword(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<MemberSummary> {
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<MemberSummary> {
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);
}
}
+15
View File
@@ -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 {}
+288
View File
@@ -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<MemberSummary[]> {
const users = await this.userRepository.listUsers();
return Promise.all(users.map((user) => this.toMemberSummary(user)));
}
async createMember(
input: CreateMemberDtoInput,
operator: AuthenticatedUser,
): Promise<MemberSummary> {
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<MemberSummary> {
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<MemberSummary> {
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<MemberSummary> {
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<UserRecord> {
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<MemberSummary> {
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<MemberProjectPermissionSummary[]> {
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<ProjectPermissionInput | { projectKey: string; level: string }>,
): Array<{ projectKey: string; level: string }> {
return permissions.map((permission) => ({
projectKey: permission.projectKey,
level: permission.level,
}));
}
}
+230
View File
@@ -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<EnvConfig, true>,
private readonly prisma: PrismaService,
) {}
async listMessages(): Promise<PlatformMessageSummary[]> {
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<number> {
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<PlatformMessageSummary> {
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<void> {
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<void> {
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<string, unknown> {
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 }))
);
}
}
+20
View File
@@ -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;
};
+37
View File
@@ -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<PlatformMessageSummary[]> {
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: '消息已标记为已处理',
};
}
}
+13
View File
@@ -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 {}
+42
View File
@@ -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<PlatformMessageSummary[]> {
return this.messageRepository.listMessages();
}
async hasPendingPasswordResetRequest(userId: string): Promise<boolean> {
const count =
await this.messageRepository.countPendingPasswordResetRequests(userId);
return count > 0;
}
async createPasswordResetRequest(
requester: UserRecord,
): Promise<PlatformMessageSummary> {
return this.messageRepository.createOrReusePasswordResetRequest(requester);
}
async markPasswordResetHandledForRequester(
requesterId: string,
handler: AuthenticatedUser,
): Promise<void> {
await this.messageRepository.markPasswordResetHandledForRequester(
requesterId,
handler,
);
}
async markHandled(
messageId: string,
handler: AuthenticatedUser,
): Promise<void> {
await this.messageRepository.markHandled(messageId, handler);
}
}
@@ -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}`);
}
}
+2 -2
View File
@@ -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<WeComSendResult> {
@@ -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),
});
+1 -1
View File
@@ -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 : '未知数据库错误',
};
}
}
@@ -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();
}
@@ -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],
+24 -11
View File
@@ -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<ProjectSummary[]> {
return this.projectsService.listProjects();
@ApiOkResponse({ description: '查询当前账号可访问的项目列表。' })
async listProjects(
@CurrentUser() user: AuthenticatedUser,
): Promise<ProjectSummary[]> {
return this.projectsService.listProjects(user);
}
@Get(':key')
@ApiOkResponse({ description: 'Project detail by key.' })
async getProject(@Param('key') key: string): Promise<ProjectSummary> {
return this.projectsService.getProject(key);
@ApiOkResponse({ description: '查询项目详情。' })
async getProject(
@Param('key') key: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<ProjectSummary> {
return this.projectsService.getProject(key, user);
}
@Get(':key/refs')
@ApiOkResponse({ description: 'Project branches and tags.' })
async listProjectRefs(@Param('key') key: string): Promise<ProjectRefSummary> {
return this.projectsService.listProjectRefs(key);
@ApiOkResponse({ description: '查询项目分支和标签。' })
async listProjectRefs(
@Param('key') key: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<ProjectRefSummary> {
return this.projectsService.listProjectRefs(key, user);
}
}
+2 -1
View File
@@ -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],
+10
View File
@@ -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<GiteaClient, 'healthSummary' | 'listBranches' | 'listTags'>;
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,
),
};
}
+29 -6
View File
@@ -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<ProjectSummary[]> {
return this.projectRepository.findMany();
async listProjects(user?: AuthenticatedUser): Promise<ProjectSummary[]> {
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<ProjectSummary> {
async getProject(
key: string,
user?: AuthenticatedUser,
): Promise<ProjectSummary> {
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<ProjectRefSummary> {
const project = await this.getProject(key);
async listProjectRefs(
key: string,
user?: AuthenticatedUser,
): Promise<ProjectRefSummary> {
const project = await this.getProject(key, user);
const repo = this.parseRepository(project);
if (repo && this.giteaClient.healthSummary().status === 'ok') {
+9 -4
View File
@@ -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();
+2
View File
@@ -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],
})
@@ -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,
@@ -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();
});
+7 -7
View File
@@ -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,
);
}