feat: 接入真实登录与成员权限
- auth/members/messages: 新增超级管理员登录、成员权限和密码消息流程 - agent-config/agent: 支持服务端保存 Agent 配置并取消未配置 mock 成功结果 - projects/deploy-runs/settings: 按当前用户权限保护真实接口 - prisma: 新增用户、项目权限和平台消息表结构
This commit is contained in:
@@ -1,38 +1,38 @@
|
||||
@RTK.md
|
||||
|
||||
# Agent Instructions
|
||||
# Agent 指令
|
||||
|
||||
This backend owns the DevOps platform API only. Do not edit sibling projects from this directory.
|
||||
当前后端只负责运维平台 API。除非用户明确扩大范围,否则不要编辑同级项目。
|
||||
|
||||
## Hard Boundaries
|
||||
## 硬性边界
|
||||
|
||||
- Write only inside `devops-platform-api` unless the user explicitly expands the scope.
|
||||
- Do not write `devops-platform-web`.
|
||||
- Do not write `devops-platform-docs/devops-platform-implementation-tracker.md`; the coordinating thread owns tracker updates.
|
||||
- Do not revert unrelated user or worker changes.
|
||||
- 只允许在 `devops-platform-api` 内写入。
|
||||
- 不要写入 `devops-platform-web`。
|
||||
- 不要写入 `devops-platform-docs/devops-platform-implementation-tracker.md`,协调线程负责跟踪文档更新。
|
||||
- 不要回滚无关的用户或其他工作线程改动。
|
||||
|
||||
## Backend Standards
|
||||
## 后端标准
|
||||
|
||||
- Use NestJS modules and dependency injection for all business boundaries.
|
||||
- Keep controllers thin. Validation, orchestration, and audit decisions belong in services.
|
||||
- Use Prisma as the default data access layer. Business code must not create mysql2 pools or scatter handwritten SQL.
|
||||
- If native SQL is unavoidable, place it in a Repository, parameterize it, document why Prisma is insufficient, and keep the method narrow.
|
||||
- Keep external clients behind integration modules: Jenkins, Gitea, notification providers, and LLM.
|
||||
- 所有业务边界使用 NestJS module 和依赖注入。
|
||||
- Controller 保持轻量;校验、编排和审计决策放在 service。
|
||||
- 默认使用 Prisma 作为数据访问层,业务代码不得创建 mysql2 连接池或散落手写 SQL。
|
||||
- 如无法避免原生 SQL,必须放在 Repository,使用参数化查询,说明 Prisma 不足之处,并保持方法范围收敛。
|
||||
- 外部客户端必须放在集成模块后面,包括 Jenkins、Gitea、通知提供方和 LLM。
|
||||
|
||||
## Security Standards
|
||||
## 安全标准
|
||||
|
||||
- Never commit real credentials.
|
||||
- Redact tokens, webhook URLs, cookies, authorization headers, private keys, and long opaque secret-like strings before logging, auditing, returning errors, or passing content to LLMs.
|
||||
- Store future persisted secrets encrypted server-side only.
|
||||
- Frontend must never receive Jenkins/Gitea/notification/LLM secret values.
|
||||
- 永远不要提交真实凭据。
|
||||
- 记录日志、写审计、返回错误或传给 LLM 前,必须脱敏 token、webhook URL、cookie、authorization header、私钥和长的不透明密钥字符串。
|
||||
- 未来持久化密钥只能在服务端加密保存。
|
||||
- 前端永远不能收到 Jenkins、Gitea、通知或 LLM 的完整密钥值。
|
||||
|
||||
## Audit Standards
|
||||
## 审计标准
|
||||
|
||||
- Record actor, resource, action, before/after state summaries, request source, and parameter digests for deploy, cancel, retry, secret change, and agent invocation operations.
|
||||
- Deploy run status transitions must be idempotent and concurrency-safe when database writes are enabled.
|
||||
- 发布、取消、重试、密钥变更和 Agent 调用需要记录操作者、资源、动作、变更前后摘要、请求来源和参数摘要。
|
||||
- 启用数据库写入后,发布状态流转必须具备幂等和并发安全能力。
|
||||
|
||||
## Agent Standards
|
||||
## Agent 标准
|
||||
|
||||
- Agent features must be tied to DevOps workflows, not general chat.
|
||||
- Agent calls are read-only unless a future product decision explicitly changes this.
|
||||
- Save prompt version, purpose, operator, input digest, output digest, token estimate, and sanitized failure summary for every real invocation.
|
||||
- Agent 能力必须绑定运维工作流,不能做通用聊天。
|
||||
- 除非未来产品决策明确改变,否则 Agent 调用只读。
|
||||
- 每次真实调用都要保存 prompt 版本、目的、操作者、输入摘要、输出摘要、token 估算和脱敏后的失败摘要。
|
||||
|
||||
@@ -1,95 +1,113 @@
|
||||
# DevOps Platform API
|
||||
# 运维平台 API
|
||||
|
||||
NestJS + TypeScript backend for the DevOps operations platform. The current local MVP provides the enterprise project skeleton, Prisma data model, configuration validation, integration client boundaries, Gitea webhook handling, Jenkins queue/build synchronization, notification provider boundaries, audit logs, BPMN process definitions, and runnable seed-backed APIs.
|
||||
这是运维平台后端服务,基于 NestJS、TypeScript 和 Prisma 构建。后端负责账号鉴权、成员管理、项目权限、发布记录、Jenkins/Gitea 集成、通知、审计、流程定义和 Agent 运维能力。
|
||||
|
||||
## Scope
|
||||
## 范围
|
||||
|
||||
- Manage projects, deploy runs, BPMN step state, Jenkins/Gitea integration state, notification delivery, audit records, and agent invocations.
|
||||
- Keep all external credentials server-side.
|
||||
- Use Prisma as the default data access layer. Native SQL is allowed only inside Repository methods with a documented reason.
|
||||
- 只维护运维平台 API,不包含前端页面代码。
|
||||
- 外部系统密钥只保存在服务端,接口不会返回完整密钥。
|
||||
- 数据访问默认使用 Prisma;如必须使用原生 SQL,只能放在 Repository 中并说明原因。
|
||||
- 本地未配置 MySQL 时,项目、发布记录、账号和配置能力会使用内存降级数据,便于开发调试。
|
||||
|
||||
## Local Setup
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
pnpm prisma:generate
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
The API listens on `http://localhost:4300` by default and runs without a local MySQL database when `USE_DATABASE_READS=false`. In that mode `/projects` and `/deploy-runs` use seed-backed repository data while keeping the same service and controller contracts intended for Prisma-backed reads.
|
||||
默认监听地址为 `http://localhost:4300`。未配置 `DATABASE_URL` 或 `USE_DATABASE_READS=false` 时,不会强制连接 MySQL。
|
||||
|
||||
## Useful Commands
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm prisma:generate
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm build
|
||||
pnpm check
|
||||
pnpm prisma:migrate:dev --name init
|
||||
pnpm prisma:seed
|
||||
```
|
||||
|
||||
## API
|
||||
## 内置账号
|
||||
|
||||
- `GET /health`: dependency configuration and health summary.
|
||||
- `GET /settings/integration-config`: Jenkins/Gitea/notification/LLM/Database/Redis/Secret Encryption Key configuration status and variable help text. The response only exposes environment variable names and safe descriptions, never secret values.
|
||||
- `GET /projects`: configured project list.
|
||||
- `GET /projects/:key`: one project by key.
|
||||
- `GET /deploy-runs`: latest deploy runs.
|
||||
- `POST /deploy-runs`: create a deploy run. Missing Jenkins config uses a local dry-run path; configured Jenkins calls `buildWithParameters` and returns `queued/running`.
|
||||
- `POST /deploy-runs/:id/sync-jenkins`: synchronize Jenkins queue/build/log status and advance success or failure.
|
||||
- `POST /deploy-runs/sync-jenkins`: synchronize all queued/running runs that have Jenkins queue/build identifiers.
|
||||
- `POST /deploy-runs/:id/cancel`: cancel a deploy run.
|
||||
- `POST /deploy-runs/:id/retry`: create a retry deploy run.
|
||||
- `POST /webhooks/gitea`: accept Gitea push webhooks and trigger test deploy runs.
|
||||
- `GET /process-definitions/release`: BPMN release process definition.
|
||||
- `GET /audit-logs`: latest sanitized audit records.
|
||||
- `GET /agent/invocations` and `POST /agent/invocations`: DevOps-scoped agent invocation records and mock results.
|
||||
- `GET /docs`: Swagger UI.
|
||||
- 超级管理员账号:`zhanxi`
|
||||
- 超级管理员初始密码:生产环境通过 `SUPER_ADMIN_INITIAL_PASSWORD` 注入;本地未配置时使用开发兜底口令。
|
||||
- 普通成员初始密码:`111111`
|
||||
|
||||
## Data Access Rules
|
||||
超级管理员初始化配置集中在 `src/auth/auth.constants.ts` 和服务器环境变量。生产环境缺少 `SUPER_ADMIN_INITIAL_PASSWORD` 时 API 会拒绝初始化超级管理员,避免默认口令上线;如果超级管理员已修改密码,后续初始化不会覆盖密码。
|
||||
|
||||
- Business modules access data through Repository classes, not direct Prisma calls in controllers.
|
||||
- Prisma owns connection pooling, transactions, migrations, and typed models.
|
||||
- Deploy run creation, status transition, step transition, and audit writes must use explicit Prisma transactions once DB writes are enabled.
|
||||
- Idempotency keys are mandatory for webhook callbacks, Jenkins queue/build tracking, and manual deploy retry paths.
|
||||
- Raw SQL must be parameterized, isolated in a Repository method, covered by tests, and justified in the method comment.
|
||||
## 关键接口
|
||||
|
||||
## Secret Rules
|
||||
- `POST /auth/login`:登录,返回 token 和当前账号信息。
|
||||
- `GET /auth/me`:查询当前登录账号。
|
||||
- `POST /auth/change-password`:修改当前登录账号密码。
|
||||
- `POST /auth/forgot-password`:普通成员忘记密码,通知超级管理员处理。
|
||||
- `GET /members`:超级管理员查询成员列表和项目权限。
|
||||
- `POST /members`:超级管理员创建普通成员,初始密码固定为 `111111`。
|
||||
- `PATCH /members/:id/permissions`:超级管理员更新普通成员项目权限。
|
||||
- `POST /members/:id/reset-password`:超级管理员将普通成员密码重置为 `111111`。
|
||||
- `PATCH /members/:id/status`:超级管理员启用或禁用普通成员。
|
||||
- `GET /messages`:超级管理员查询消息通知。
|
||||
- `POST /messages/:id/handled`:超级管理员将消息标记为已处理。
|
||||
- `GET /agent/config`:超级管理员查询 Agent 配置,密钥只返回脱敏值。
|
||||
- `PUT /agent/config`:超级管理员保存 Agent 的 `key`、`baseURL` 和 `model`。
|
||||
- `POST /agent/config/test`:超级管理员测试已保存的 Agent 配置。
|
||||
- `GET /projects`:查询当前账号有权限访问的项目。
|
||||
- `GET /projects/:key`:查询项目详情,需要只读或构建权限。
|
||||
- `GET /projects/:key/refs`:查询项目分支和标签,需要只读或构建权限。
|
||||
- `GET /deploy-runs`:查询当前账号有权限项目的发布记录。
|
||||
- `POST /deploy-runs`:创建发布单并触发构建,需要构建权限。
|
||||
- `POST /deploy-runs/:id/cancel`:取消发布单,需要构建权限。
|
||||
- `POST /deploy-runs/:id/retry`:重试发布单,需要构建权限。
|
||||
- `POST /deploy-runs/:id/sync-jenkins`:同步单个发布单 Jenkins 状态,需要构建权限。
|
||||
- `POST /deploy-runs/sync-jenkins`:同步全部 Jenkins 状态,仅超级管理员可用。
|
||||
- `GET /audit-logs`:超级管理员查询脱敏审计日志。
|
||||
- `GET /settings/integration-config`:超级管理员查询集成配置状态。
|
||||
- `GET /health`:运行健康检查。
|
||||
- `GET /docs`:Swagger 接口文档。
|
||||
|
||||
- Never commit real `.env`, Jenkins tokens, Gitea tokens, notification webhook URLs, LLM keys, private keys, or cookies.
|
||||
- Persisted secrets belong in the `secrets` table encrypted with a deployment-provided key or in the deployment secret manager.
|
||||
- Logs, audit records, integration errors, and LLM prompts must pass through redaction before storage or return.
|
||||
## 权限规则
|
||||
|
||||
## Agent Boundary
|
||||
- 未登录访问受保护接口会返回 `登录已失效,请重新登录`。
|
||||
- 普通成员首次登录或被重置密码后,必须先调用 `POST /auth/change-password` 修改密码。
|
||||
- 超级管理员拥有全部项目的全部权限,不需要单独授权。
|
||||
- 普通成员默认没有任何项目权限。
|
||||
- 只读权限可以查看项目和发布记录,不能触发构建。
|
||||
- 构建权限包含只读能力,可以对授权项目触发构建、取消、重试和同步单个 Jenkins 状态。
|
||||
- 成员管理、消息通知、系统设置、审计日志和 Agent 配置仅超级管理员可访问。
|
||||
|
||||
The Agent module is not a generic chat entrypoint. It can only support DevOps workflows:
|
||||
## 密钥规则
|
||||
|
||||
- release risk summaries,
|
||||
- Jenkins failure diagnosis,
|
||||
- runbook Q&A against whitelisted sources,
|
||||
- release notes drafts,
|
||||
- incident review drafts.
|
||||
- Agent `key` 保存到服务端 `secrets` 表或本地内存配置,接口只返回脱敏值。
|
||||
- 生产环境保存 Agent 密钥必须配置 `SECRET_ENCRYPTION_KEY`。
|
||||
- 审计、错误、日志和返回体不能包含完整 token、webhook、cookie、私钥或 Agent key。
|
||||
- Agent 调用只使用后端保存的系统级配置,不信任前端传入的模型配置。
|
||||
|
||||
Agent calls are read-only. They must not trigger deploys, mutate Jenkins/Gitea, or expose raw secrets. When `LLM_BASE_URL`, `LLM_API_KEY`, and `LLM_MODEL` are configured, the backend proxies DevOps-scoped prompts to `/chat/completions`; otherwise it returns local mock results with the same API shape.
|
||||
## 环境变量
|
||||
|
||||
## Notification Providers
|
||||
- `AUTH_TOKEN_SECRET`:登录 token 签名密钥。
|
||||
- `AUTH_TOKEN_TTL_SECONDS`:登录 token 有效期,默认 43200 秒。
|
||||
- `SUPER_ADMIN_INITIAL_PASSWORD`:生产环境初始化超级管理员时的初始密码。
|
||||
- `SECRET_ENCRYPTION_KEY`:服务端密钥加密密钥,生产环境保存 Agent 密钥时必须配置。
|
||||
- `DATABASE_URL`:MySQL 连接地址。
|
||||
- `USE_DATABASE_READS`:是否启用 Prisma 数据读写。
|
||||
- `PRISMA_CONNECT_ON_BOOT`:启动时是否主动连接数据库。
|
||||
- `JENKINS_BASE_URL`、`JENKINS_USERNAME`、`JENKINS_API_TOKEN`:Jenkins 集成配置。
|
||||
- `GITEA_BASE_URL`、`GITEA_TOKEN`、`GITEA_WEBHOOK_SECRET`:Gitea 集成配置。
|
||||
- `NOTIFICATION_PROVIDER`、`WECOM_WEBHOOK_URL`、`FEISHU_WEBHOOK_URL`、`NOTIFICATION_WEBHOOK_URL`:通知集成配置。
|
||||
- `LLM_BASE_URL`、`LLM_API_KEY`、`LLM_MODEL`:Agent 配置的环境变量后备值。
|
||||
|
||||
`DeployNotificationService` supports the first provider boundary for:
|
||||
## Agent 边界
|
||||
|
||||
- `NOTIFICATION_PROVIDER=wecom` with `WECOM_WEBHOOK_URL`
|
||||
- `NOTIFICATION_PROVIDER=feishu` with `FEISHU_WEBHOOK_URL`
|
||||
- `NOTIFICATION_PROVIDER=generic` with `NOTIFICATION_WEBHOOK_URL`
|
||||
Agent 不是通用聊天入口,只支持运维工作流:
|
||||
|
||||
Missing provider or webhook configuration returns a skipped notification result and does not block deploy status transitions.
|
||||
- 发布风险摘要
|
||||
- Jenkins 失败诊断
|
||||
- 运维手册问答
|
||||
- 发布说明草稿
|
||||
- 事故复盘草稿
|
||||
|
||||
## Jenkins Auto Sync
|
||||
|
||||
Manual sync is always available through `POST /deploy-runs/sync-jenkins`. To enable a lightweight in-process poller before Redis/BullMQ is introduced:
|
||||
|
||||
```env
|
||||
JENKINS_AUTO_SYNC_ENABLED=true
|
||||
JENKINS_AUTO_SYNC_INTERVAL_MS=30000
|
||||
```
|
||||
|
||||
The poller only scans queued/running runs that already have a Jenkins queue id or build number.
|
||||
普通成员使用 Agent 时必须绑定自己有权限访问的项目。Agent 调用只读,不会触发发布、修改 Jenkins、修改 Gitea 或暴露密钥。
|
||||
|
||||
@@ -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
@@ -2,7 +2,7 @@
|
||||
"name": "devops-platform-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "DevOps operations platform API for Jenkins, Gitea, WeCom notifications, BPMN deploy state, and agent-assisted operations.",
|
||||
"description": "运维平台后端 API,提供 Jenkins、Gitea、通知、发布流程、账号权限和 Agent 运维能力。",
|
||||
"license": "UNLICENSED",
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"engines": {
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"check": "pnpm lint && pnpm test && pnpm build",
|
||||
"check": "pnpm prisma:generate && pnpm lint && pnpm test && pnpm build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"prisma/**/*.prisma\" \"*.md\"",
|
||||
"lint": "eslint \"{src,prisma}/**/*.{ts,js}\" --ignore-pattern prisma/seed.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
|
||||
@@ -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;
|
||||
@@ -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`。没有明确数据库连接前,不要求本地自动执行迁移。
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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: '已退出登录',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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: '系统异常,请联系管理员',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '字段校验未通过';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 '未知发布执行错误';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} 环境`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 不符合发布策略',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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('提供 Jenkins、Gitea、通知、发布流程、账号权限和 Agent 运维能力接口。')
|
||||
.setVersion('0.1.0')
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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: '消息已标记为已处理',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user