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
|
@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.
|
- 只允许在 `devops-platform-api` 内写入。
|
||||||
- Do not write `devops-platform-web`.
|
- 不要写入 `devops-platform-web`。
|
||||||
- Do not write `devops-platform-docs/devops-platform-implementation-tracker.md`; the coordinating thread owns tracker updates.
|
- 不要写入 `devops-platform-docs/devops-platform-implementation-tracker.md`,协调线程负责跟踪文档更新。
|
||||||
- Do not revert unrelated user or worker changes.
|
- 不要回滚无关的用户或其他工作线程改动。
|
||||||
|
|
||||||
## Backend Standards
|
## 后端标准
|
||||||
|
|
||||||
- Use NestJS modules and dependency injection for all business boundaries.
|
- 所有业务边界使用 NestJS module 和依赖注入。
|
||||||
- Keep controllers thin. Validation, orchestration, and audit decisions belong in services.
|
- Controller 保持轻量;校验、编排和审计决策放在 service。
|
||||||
- Use Prisma as the default data access layer. Business code must not create mysql2 pools or scatter handwritten SQL.
|
- 默认使用 Prisma 作为数据访问层,业务代码不得创建 mysql2 连接池或散落手写 SQL。
|
||||||
- If native SQL is unavoidable, place it in a Repository, parameterize it, document why Prisma is insufficient, and keep the method narrow.
|
- 如无法避免原生 SQL,必须放在 Repository,使用参数化查询,说明 Prisma 不足之处,并保持方法范围收敛。
|
||||||
- Keep external clients behind integration modules: Jenkins, Gitea, notification providers, and LLM.
|
- 外部客户端必须放在集成模块后面,包括 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.
|
- 记录日志、写审计、返回错误或传给 LLM 前,必须脱敏 token、webhook URL、cookie、authorization header、私钥和长的不透明密钥字符串。
|
||||||
- Store future persisted secrets encrypted server-side only.
|
- 未来持久化密钥只能在服务端加密保存。
|
||||||
- Frontend must never receive Jenkins/Gitea/notification/LLM secret values.
|
- 前端永远不能收到 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.
|
- 发布、取消、重试、密钥变更和 Agent 调用需要记录操作者、资源、动作、变更前后摘要、请求来源和参数摘要。
|
||||||
- Deploy run status transitions must be idempotent and concurrency-safe when database writes are enabled.
|
- 启用数据库写入后,发布状态流转必须具备幂等和并发安全能力。
|
||||||
|
|
||||||
## Agent Standards
|
## Agent 标准
|
||||||
|
|
||||||
- Agent features must be tied to DevOps workflows, not general chat.
|
- Agent 能力必须绑定运维工作流,不能做通用聊天。
|
||||||
- Agent calls are read-only unless a future product decision explicitly changes this.
|
- 除非未来产品决策明确改变,否则 Agent 调用只读。
|
||||||
- Save prompt version, purpose, operator, input digest, output digest, token estimate, and sanitized failure summary for every real invocation.
|
- 每次真实调用都要保存 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.
|
- 只维护运维平台 API,不包含前端页面代码。
|
||||||
- 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.
|
- 数据访问默认使用 Prisma;如必须使用原生 SQL,只能放在 Repository 中并说明原因。
|
||||||
|
- 本地未配置 MySQL 时,项目、发布记录、账号和配置能力会使用内存降级数据,便于开发调试。
|
||||||
|
|
||||||
## Local Setup
|
## 本地启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
cp .env.example .env
|
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
pnpm start:dev
|
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
|
```bash
|
||||||
|
pnpm prisma:generate
|
||||||
pnpm lint
|
pnpm lint
|
||||||
pnpm test
|
pnpm test
|
||||||
pnpm build
|
pnpm build
|
||||||
pnpm check
|
pnpm check
|
||||||
pnpm prisma:migrate:dev --name init
|
pnpm prisma:migrate:dev --name init
|
||||||
|
pnpm prisma:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## 内置账号
|
||||||
|
|
||||||
- `GET /health`: dependency configuration and health summary.
|
- 超级管理员账号:`zhanxi`
|
||||||
- `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.
|
- 超级管理员初始密码:生产环境通过 `SUPER_ADMIN_INITIAL_PASSWORD` 注入;本地未配置时使用开发兜底口令。
|
||||||
- `GET /projects`: configured project list.
|
- 普通成员初始密码:`111111`
|
||||||
- `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.
|
|
||||||
|
|
||||||
## 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,
|
- Agent `key` 保存到服务端 `secrets` 表或本地内存配置,接口只返回脱敏值。
|
||||||
- Jenkins failure diagnosis,
|
- 生产环境保存 Agent 密钥必须配置 `SECRET_ENCRYPTION_KEY`。
|
||||||
- runbook Q&A against whitelisted sources,
|
- 审计、错误、日志和返回体不能包含完整 token、webhook、cookie、私钥或 Agent key。
|
||||||
- release notes drafts,
|
- Agent 调用只使用后端保存的系统级配置,不信任前端传入的模型配置。
|
||||||
- incident review drafts.
|
|
||||||
|
|
||||||
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`
|
Agent 不是通用聊天入口,只支持运维工作流:
|
||||||
- `NOTIFICATION_PROVIDER=feishu` with `FEISHU_WEBHOOK_URL`
|
|
||||||
- `NOTIFICATION_PROVIDER=generic` with `NOTIFICATION_WEBHOOK_URL`
|
|
||||||
|
|
||||||
Missing provider or webhook configuration returns a skipped notification result and does not block deploy status transitions.
|
- 发布风险摘要
|
||||||
|
- Jenkins 失败诊断
|
||||||
|
- 运维手册问答
|
||||||
|
- 发布说明草稿
|
||||||
|
- 事故复盘草稿
|
||||||
|
|
||||||
## Jenkins Auto Sync
|
普通成员使用 Agent 时必须绑定自己有权限访问的项目。Agent 调用只读,不会触发发布、修改 Jenkins、修改 Gitea 或暴露密钥。
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,42 +1,47 @@
|
|||||||
# Backend RTK
|
# 后端 RTK
|
||||||
|
|
||||||
## Architecture
|
## 架构
|
||||||
|
|
||||||
- Framework: NestJS + TypeScript.
|
- 框架:NestJS + TypeScript。
|
||||||
- Package manager: pnpm.
|
- 包管理器:pnpm。
|
||||||
- Data access: Prisma Client and Prisma migrations.
|
- 数据访问:Prisma Client 和 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.
|
- 本地默认行为:未配置 MySQL 时使用种子项目和内存数据保持 API 可运行;生产环境注入凭据后使用真实 Jenkins 和 Gitea 客户端。
|
||||||
|
|
||||||
## Module Map
|
## 模块地图
|
||||||
|
|
||||||
- `config`: environment schema and validation.
|
- `config`:环境变量 schema 和校验。
|
||||||
- `prisma`: PrismaService and DB health boundary.
|
- `prisma`:PrismaService 和数据库健康边界。
|
||||||
- `health`: runtime and dependency summary.
|
- `health`:运行时和依赖健康摘要。
|
||||||
- `projects`: project configuration API and repository.
|
- `auth`:登录鉴权、密码管理、登录 token 和当前用户上下文。
|
||||||
- `deploy-runs`: deploy run API, status model, BPMN step summary.
|
- `members`:成员管理和项目权限分配。
|
||||||
- `integrations/jenkins`: Jenkins API client boundary.
|
- `messages`:平台消息通知,当前用于普通成员忘记密码请求。
|
||||||
- `integrations/gitea`: Gitea API client boundary.
|
- `projects`:项目配置 API 和 Repository。
|
||||||
- `notifications/wecom`: notification provider boundary for WeCom, Feishu, and generic webhook adapters.
|
- `deploy-runs`:发布记录 API、状态模型和 BPMN 步骤摘要。
|
||||||
- `agent`: LLM proxy client boundary for DevOps-only workflows.
|
- `integrations/jenkins`:Jenkins API 客户端边界。
|
||||||
- `audit`: sanitized audit record boundary.
|
- `integrations/gitea`:Gitea API 客户端边界。
|
||||||
|
- `notifications/wecom`:企微、飞书和通用 webhook 通知边界。
|
||||||
|
- `agent-config`:Agent 系统级配置、密钥加密和连接测试。
|
||||||
|
- `agent`:只面向运维工作流的 LLM 代理边界。
|
||||||
|
- `audit`:脱敏审计记录边界。
|
||||||
|
|
||||||
## Data Access Rules
|
## 数据访问规则
|
||||||
|
|
||||||
1. Controllers call services.
|
1. Controller 调用 service。
|
||||||
2. Services call repositories and integration clients.
|
2. Service 调用 repository 和集成客户端。
|
||||||
3. Repositories call Prisma.
|
3. Repository 调用 Prisma。
|
||||||
4. Raw SQL is exceptional and repository-only.
|
4. 原生 SQL 只允许作为例外,并且只能放在 repository。
|
||||||
5. Transactions are required for deploy run writes once persistence is enabled.
|
5. 启用持久化写入后,发布记录写入必须使用事务。
|
||||||
|
|
||||||
## Environment Rules
|
## 环境规则
|
||||||
|
|
||||||
- `.env.example` may contain placeholders only.
|
- `.env.example` 只能包含占位值。
|
||||||
- `.env` is local-only and ignored by git.
|
- `.env` 只用于本地并且不能提交。
|
||||||
- Missing external integration configuration should degrade to `not_configured` in health checks, not crash local development.
|
- 缺少外部集成配置时,健康检查应降级为 `not_configured`,不能导致本地开发启动失败。
|
||||||
|
- 生产环境保存 Agent 密钥必须配置 `SECRET_ENCRYPTION_KEY`。
|
||||||
|
|
||||||
## Verification
|
## 验证
|
||||||
|
|
||||||
Run these before handing off backend changes when dependencies are available:
|
依赖可用时,交付前运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "devops-platform-api",
|
"name": "devops-platform-api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"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",
|
"license": "UNLICENSED",
|
||||||
"packageManager": "pnpm@9.15.4",
|
"packageManager": "pnpm@9.15.4",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"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\"",
|
"format": "prettier --write \"src/**/*.ts\" \"prisma/**/*.prisma\" \"*.md\"",
|
||||||
"lint": "eslint \"{src,prisma}/**/*.{ts,js}\" --ignore-pattern prisma/seed.js",
|
"lint": "eslint \"{src,prisma}/**/*.{ts,js}\" --ignore-pattern prisma/seed.js",
|
||||||
"prisma:generate": "prisma generate",
|
"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.
|
- 确认本地或测试环境 MySQL 连接后,使用 `pnpm prisma:migrate:dev --name <变更名称>` 生成迁移。
|
||||||
- Commit generated folders under `prisma/migrations/<timestamp>_<change-name>/migration.sql`.
|
- 需要提交 `prisma/migrations/<时间戳>_<变更名称>/migration.sql`。
|
||||||
- Run migration checks in deployment before starting the API.
|
- 部署前先执行迁移检查,再启动 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.
|
- 原生 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
|
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 {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
key String @unique
|
key String @unique
|
||||||
@@ -78,6 +102,7 @@ model Project {
|
|||||||
environments Environment[]
|
environments Environment[]
|
||||||
deployJobs DeployJob[]
|
deployJobs DeployJob[]
|
||||||
deployRuns DeployRun[]
|
deployRuns DeployRun[]
|
||||||
|
permissions ProjectMemberPermission[]
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
@@ -194,6 +219,68 @@ model AuditLog {
|
|||||||
@@map("audit_logs")
|
@@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 {
|
model Secret {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
provider SecretProvider
|
provider SecretProvider
|
||||||
|
|||||||
+56
-1
@@ -1,10 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* 将代码内的纳管项目清单同步到数据库,保持项目、环境和 Jenkins Job 可重复初始化。
|
* 将代码内的纳管项目清单同步到数据库,保持项目、环境和 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 { seedProjects } = require('../dist/projects/project.seed');
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
const passwordService = new PasswordService();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
for (const project of seedProjects) {
|
for (const project of seedProjects) {
|
||||||
@@ -44,6 +56,49 @@ async function main() {
|
|||||||
await upsertEnvironment(projectRecord.id, environment);
|
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) {
|
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 {
|
import {
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
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 { AgentInvocation } from './agent.types';
|
||||||
import { AgentService } from './agent.service';
|
import { AgentService } from './agent.service';
|
||||||
import {
|
import {
|
||||||
@@ -14,24 +18,26 @@ import {
|
|||||||
createAgentInvocationSchema,
|
createAgentInvocationSchema,
|
||||||
} from './create-agent-invocation.dto';
|
} from './create-agent-invocation.dto';
|
||||||
|
|
||||||
@ApiTags('agent')
|
@ApiTags('Agent 调用')
|
||||||
@Controller('agent/invocations')
|
@Controller('agent/invocations')
|
||||||
|
@UseGuards(AuthGuard, PasswordChangeGuard)
|
||||||
export class AgentController {
|
export class AgentController {
|
||||||
constructor(private readonly agentService: AgentService) {}
|
constructor(private readonly agentService: AgentService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ description: 'Agent invocation audit list.' })
|
@ApiOkResponse({ description: '查询 Agent 调用记录。' })
|
||||||
listInvocations(): AgentInvocation[] {
|
listInvocations(): AgentInvocation[] {
|
||||||
return this.agentService.listInvocations();
|
return this.agentService.listInvocations();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiBody({ type: CreateAgentInvocationDto })
|
@ApiBody({ type: CreateAgentInvocationDto })
|
||||||
@ApiCreatedResponse({ description: 'Create a DevOps-scoped agent invocation.' })
|
@ApiCreatedResponse({ description: '创建 DevOps 工作流范围内的 Agent 调用。' })
|
||||||
async createInvocation(
|
async createInvocation(
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
@Body(new ZodValidationPipe(createAgentInvocationSchema))
|
@Body(new ZodValidationPipe(createAgentInvocationSchema))
|
||||||
body: CreateAgentInvocationInput,
|
body: CreateAgentInvocationInput,
|
||||||
): Promise<AgentInvocation> {
|
): Promise<AgentInvocation> {
|
||||||
return this.agentService.createInvocation(body);
|
return this.agentService.createInvocation(body, user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AgentConfigModule } from '../agent-config/agent-config.module';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { AgentController } from './agent.controller';
|
import { AgentController } from './agent.controller';
|
||||||
import { AgentService } from './agent.service';
|
import { AgentService } from './agent.service';
|
||||||
import { LlmClient } from './llm.client';
|
import { LlmClient } from './llm.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule],
|
imports: [AgentConfigModule, AuditModule, AuthModule],
|
||||||
controllers: [AgentController],
|
controllers: [AgentController],
|
||||||
providers: [AgentService, LlmClient],
|
providers: [AgentService, LlmClient],
|
||||||
exports: [AgentService, LlmClient],
|
exports: [AgentService, LlmClient],
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { AuditService } from '../audit/audit.service';
|
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 { AppError } from '../common/errors/app-error';
|
||||||
import { AgentService } from './agent.service';
|
import { AgentService } from './agent.service';
|
||||||
import { CreateAgentInvocationInput } from './create-agent-invocation.dto';
|
import { CreateAgentInvocationInput } from './create-agent-invocation.dto';
|
||||||
@@ -8,6 +10,16 @@ import { LlmClient } from './llm.client';
|
|||||||
* 锁定 Agent 入口只服务 DevOps 场景,避免后续误扩成泛聊天接口。
|
* 锁定 Agent 入口只服务 DevOps 场景,避免后续误扩成泛聊天接口。
|
||||||
*/
|
*/
|
||||||
describe('AgentService', () => {
|
describe('AgentService', () => {
|
||||||
|
const operator: AuthenticatedUser = {
|
||||||
|
id: 'user_admin',
|
||||||
|
account: 'zhanxi',
|
||||||
|
displayName: '超级管理员',
|
||||||
|
role: 'super_admin',
|
||||||
|
status: 'active',
|
||||||
|
mustChangePassword: false,
|
||||||
|
passwordVersion: 1,
|
||||||
|
};
|
||||||
|
|
||||||
function createService(options?: {
|
function createService(options?: {
|
||||||
llmConfigured?: boolean;
|
llmConfigured?: boolean;
|
||||||
llmText?: string;
|
llmText?: string;
|
||||||
@@ -17,9 +29,14 @@ describe('AgentService', () => {
|
|||||||
} satisfies Pick<AuditService, 'record'>;
|
} satisfies Pick<AuditService, 'record'>;
|
||||||
const llm = {
|
const llm = {
|
||||||
healthSummary: jest.fn(() =>
|
healthSummary: jest.fn(() =>
|
||||||
options?.llmConfigured
|
Promise.resolve(
|
||||||
? { status: 'ok' as const }
|
options?.llmConfigured
|
||||||
: { status: 'not_configured' as const, message: 'Missing LLM_API_KEY' },
|
? { status: 'ok' as const }
|
||||||
|
: {
|
||||||
|
status: 'not_configured' as const,
|
||||||
|
message: 'Agent 配置不完整',
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
complete: jest.fn(() =>
|
complete: jest.fn(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
@@ -28,34 +45,45 @@ describe('AgentService', () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
} satisfies Pick<LlmClient, 'healthSummary' | 'complete'>;
|
} satisfies Pick<LlmClient, 'healthSummary' | 'complete'>;
|
||||||
|
const auth = {
|
||||||
|
requireProjectPermission: jest.fn(() => Promise.resolve()),
|
||||||
|
} satisfies Pick<AuthService, 'requireProjectPermission'>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audit,
|
audit,
|
||||||
|
auth,
|
||||||
llm,
|
llm,
|
||||||
service: new AgentService(
|
service: new AgentService(
|
||||||
audit as unknown as AuditService,
|
audit as unknown as AuditService,
|
||||||
llm as unknown as LlmClient,
|
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 { audit, llm, service } = createService();
|
||||||
|
|
||||||
const invocation = await service.createInvocation({
|
const invocation = await service.createInvocation(
|
||||||
type: 'failure-diagnosis',
|
{
|
||||||
runId: 'run_001',
|
type: 'failure-diagnosis',
|
||||||
projectKey: 'role-user',
|
runId: 'run_001',
|
||||||
promptSummary: 'Analyze latest failed Jenkins log excerpt.',
|
projectKey: 'role-user',
|
||||||
});
|
promptSummary: 'Analyze latest failed Jenkins log excerpt.',
|
||||||
|
},
|
||||||
|
operator,
|
||||||
|
);
|
||||||
|
|
||||||
expect(invocation.status).toBe('success');
|
expect(invocation.status).toBe('failed');
|
||||||
expect(invocation.resultMarkdown).toContain('失败诊断摘要');
|
expect(invocation.resultMarkdown).toContain('Agent 配置不完整');
|
||||||
expect(llm.complete).not.toHaveBeenCalled();
|
expect(llm.complete).not.toHaveBeenCalled();
|
||||||
expect(audit.record).toHaveBeenCalledWith(
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: 'AGENT_INVOCATION_CREATED',
|
action: 'AGENT_INVOCATION_FAILED',
|
||||||
resourceType: 'agent_invocation',
|
resourceType: 'agent_invocation',
|
||||||
|
after: expect.objectContaining({
|
||||||
|
mode: 'not_configured',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -66,11 +94,14 @@ describe('AgentService', () => {
|
|||||||
llmText: '真实 LLM 运维分析结果',
|
llmText: '真实 LLM 运维分析结果',
|
||||||
});
|
});
|
||||||
|
|
||||||
const invocation = await service.createInvocation({
|
const invocation = await service.createInvocation(
|
||||||
type: 'release-risk',
|
{
|
||||||
projectKey: 'access-manage',
|
type: 'release-risk',
|
||||||
promptSummary: 'Analyze production tag risk.',
|
projectKey: 'access-manage',
|
||||||
});
|
promptSummary: 'Analyze production tag risk.',
|
||||||
|
},
|
||||||
|
operator,
|
||||||
|
);
|
||||||
|
|
||||||
expect(invocation.status).toBe('success');
|
expect(invocation.status).toBe('success');
|
||||||
expect(invocation.resultMarkdown).toBe('真实 LLM 运维分析结果');
|
expect(invocation.resultMarkdown).toBe('真实 LLM 运维分析结果');
|
||||||
@@ -94,10 +125,13 @@ describe('AgentService', () => {
|
|||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.createInvocation({
|
service.createInvocation(
|
||||||
type: 'general-chat',
|
{
|
||||||
promptSummary: 'Tell me anything.',
|
type: 'general-chat',
|
||||||
} as unknown as CreateAgentInvocationInput),
|
promptSummary: 'Tell me anything.',
|
||||||
|
} as unknown as CreateAgentInvocationInput,
|
||||||
|
operator,
|
||||||
|
),
|
||||||
).rejects.toBeInstanceOf(AppError);
|
).rejects.toBeInstanceOf(AppError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+48
-23
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { AppError } from '../common/errors/app-error';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { redactSensitive } from '../common/security/redact-sensitive';
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
@@ -14,19 +16,6 @@ const allowedTypes: AgentPurpose[] = [
|
|||||||
'incident-review',
|
'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()
|
@Injectable()
|
||||||
export class AgentService {
|
export class AgentService {
|
||||||
private readonly invocations: AgentInvocation[] = [];
|
private readonly invocations: AgentInvocation[] = [];
|
||||||
@@ -34,6 +23,7 @@ export class AgentService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
private readonly llmClient: LlmClient,
|
private readonly llmClient: LlmClient,
|
||||||
|
private readonly authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
listInvocations(): AgentInvocation[] {
|
listInvocations(): AgentInvocation[] {
|
||||||
@@ -44,11 +34,12 @@ export class AgentService {
|
|||||||
|
|
||||||
async createInvocation(
|
async createInvocation(
|
||||||
input: CreateAgentInvocationInput,
|
input: CreateAgentInvocationInput,
|
||||||
|
operator: AuthenticatedUser,
|
||||||
): Promise<AgentInvocation> {
|
): Promise<AgentInvocation> {
|
||||||
if (!allowedTypes.includes(input.type)) {
|
if (!allowedTypes.includes(input.type)) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
'Unsupported agent invocation type',
|
'不支持的 Agent 调用类型',
|
||||||
400,
|
400,
|
||||||
{
|
{
|
||||||
type: input.type,
|
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 = {
|
const baseInvocation = {
|
||||||
id: `agent_${Date.now()}_${this.invocations.length + 1}`,
|
id: `agent_${Date.now()}_${this.invocations.length + 1}`,
|
||||||
runId: input.runId,
|
runId: input.runId,
|
||||||
@@ -79,9 +71,14 @@ export class AgentService {
|
|||||||
invocation = {
|
invocation = {
|
||||||
...baseInvocation,
|
...baseInvocation,
|
||||||
status: 'success',
|
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) {
|
} catch (error) {
|
||||||
invocation = {
|
invocation = {
|
||||||
...baseInvocation,
|
...baseInvocation,
|
||||||
@@ -89,19 +86,21 @@ export class AgentService {
|
|||||||
resultMarkdown:
|
resultMarkdown:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? redactSensitive({ message: error.message }).message
|
? redactSensitive({ message: error.message }).message
|
||||||
: 'LLM invocation failed',
|
: 'Agent 调用失败',
|
||||||
};
|
};
|
||||||
await this.recordInvocationAudit(invocation, 'llm_failed');
|
await this.recordInvocationAudit(invocation, operator, 'llm_failed');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
invocation = {
|
invocation = {
|
||||||
...baseInvocation,
|
...baseInvocation,
|
||||||
status: 'success',
|
status: 'failed',
|
||||||
resultMarkdown: mockResults[input.type],
|
resultMarkdown:
|
||||||
|
llmHealth.message ?? 'Agent 配置不完整,请先配置 key、baseURL 和 model。',
|
||||||
};
|
};
|
||||||
await this.recordInvocationAudit(
|
await this.recordInvocationAudit(
|
||||||
invocation,
|
invocation,
|
||||||
'mock',
|
operator,
|
||||||
|
'not_configured',
|
||||||
undefined,
|
undefined,
|
||||||
llmHealth.message,
|
llmHealth.message,
|
||||||
);
|
);
|
||||||
@@ -111,9 +110,33 @@ export class AgentService {
|
|||||||
return invocation;
|
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(
|
private async recordInvocationAudit(
|
||||||
invocation: AgentInvocation,
|
invocation: AgentInvocation,
|
||||||
mode: 'mock' | 'llm' | 'llm_failed',
|
operator: AuthenticatedUser,
|
||||||
|
mode: 'llm' | 'llm_failed' | 'not_configured',
|
||||||
model?: string,
|
model?: string,
|
||||||
reason?: string,
|
reason?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -126,6 +149,8 @@ export class AgentService {
|
|||||||
action,
|
action,
|
||||||
resourceType: 'agent_invocation',
|
resourceType: 'agent_invocation',
|
||||||
resourceId: invocation.id,
|
resourceId: invocation.id,
|
||||||
|
actorId: operator.id,
|
||||||
|
actorName: operator.account,
|
||||||
after: {
|
after: {
|
||||||
type: invocation.type,
|
type: invocation.type,
|
||||||
runId: invocation.runId,
|
runId: invocation.runId,
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export class CreateAgentInvocationDto {
|
|||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Analyze latest failed Jenkins log excerpt for access-manage test.',
|
description: '本次 Agent 调用的中文问题或任务摘要',
|
||||||
|
example: '分析 access-manage 测试环境最近一次 Jenkins 失败日志。',
|
||||||
})
|
})
|
||||||
promptSummary!: string;
|
promptSummary!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-31
@@ -1,34 +1,38 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { AgentConfigService } from '../agent-config/agent-config.service';
|
||||||
import { IntegrationRequestError, IntegrationUnavailableError } from '../common/errors/integration-error';
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { IntegrationRequestError } from '../common/errors/integration-error';
|
||||||
import { redactSensitive } from '../common/security/redact-sensitive';
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
import { EnvConfig } from '../config/env.schema';
|
|
||||||
import { LlmCompletionInput, LlmCompletionResult } from './agent.types';
|
import { LlmCompletionInput, LlmCompletionResult } from './agent.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LlmClient {
|
export class LlmClient {
|
||||||
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
|
constructor(private readonly agentConfigService: AgentConfigService) {}
|
||||||
|
|
||||||
healthSummary(): { status: 'ok' | 'not_configured'; message?: string } {
|
async healthSummary(): Promise<{
|
||||||
const missing = this.missingConfig();
|
status: 'ok' | 'not_configured';
|
||||||
return missing.length === 0
|
message?: string;
|
||||||
|
}> {
|
||||||
|
const config = await this.agentConfigService.resolveRuntimeConfig();
|
||||||
|
|
||||||
|
return config
|
||||||
? { status: 'ok' }
|
? { status: 'ok' }
|
||||||
: { status: 'not_configured', message: `Missing ${missing.join(', ')}` };
|
: {
|
||||||
|
status: 'not_configured',
|
||||||
|
message: 'Agent 配置不完整,请配置 key、baseURL 和 model',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(input: LlmCompletionInput): Promise<LlmCompletionResult> {
|
async complete(input: LlmCompletionInput): Promise<LlmCompletionResult> {
|
||||||
this.assertConfigured();
|
const config = await this.agentConfigService.requireRuntimeConfig();
|
||||||
const baseUrl = this.config.get('LLM_BASE_URL', { infer: true }) ?? '';
|
const response = await fetch(`${this.trimSlash(config.baseURL)}/chat/completions`, {
|
||||||
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`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${apiKey}`,
|
authorization: `Bearer ${config.key}`,
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model,
|
model: config.model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: input.systemPrompt },
|
{ role: 'system', content: input.systemPrompt },
|
||||||
{ role: 'user', content: input.userPrompt },
|
{ role: 'user', content: input.userPrompt },
|
||||||
@@ -37,7 +41,7 @@ export class LlmClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new IntegrationRequestError('llm', 'LLM request failed', {
|
throw new IntegrationRequestError('llm', 'Agent 请求失败', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
purpose: input.purpose,
|
purpose: input.purpose,
|
||||||
request: redactSensitive(input),
|
request: redactSensitive(input),
|
||||||
@@ -51,25 +55,19 @@ export class LlmClient {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
text: payload.choices?.[0]?.message?.content ?? '',
|
text: payload.choices?.[0]?.message?.content ?? '',
|
||||||
model: payload.model ?? model,
|
model: payload.model ?? config.model,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertConfigured(): void {
|
private trimSlash(value: string): string {
|
||||||
const missing = this.missingConfig();
|
if (!value) {
|
||||||
|
throw new AppError(
|
||||||
if (missing.length > 0) {
|
'CONFIG_MISSING',
|
||||||
throw new IntegrationUnavailableError('llm', missing);
|
'Agent baseURL 未配置,请联系超级管理员',
|
||||||
|
500,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private missingConfig(): string[] {
|
return value.replace(/\/+$/, '');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { AgentConfigModule } from './agent-config/agent-config.module';
|
||||||
import { AgentModule } from './agent/agent.module';
|
import { AgentModule } from './agent/agent.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { DeployRunsModule } from './deploy-runs/deploy-runs.module';
|
import { DeployRunsModule } from './deploy-runs/deploy-runs.module';
|
||||||
import { validateEnv } from './config/env.schema';
|
import { validateEnv } from './config/env.schema';
|
||||||
import { GiteaModule } from './integrations/gitea/gitea.module';
|
import { GiteaModule } from './integrations/gitea/gitea.module';
|
||||||
import { GiteaWebhookModule } from './webhooks/gitea/gitea-webhook.module';
|
import { GiteaWebhookModule } from './webhooks/gitea/gitea-webhook.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { JenkinsModule } from './integrations/jenkins/jenkins.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 { ProcessDefinitionsModule } from './process-definitions/process-definitions.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { ProjectsModule } from './projects/projects.module';
|
import { ProjectsModule } from './projects/projects.module';
|
||||||
@@ -24,6 +28,10 @@ import { WeComModule } from './notifications/wecom/wecom.module';
|
|||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
AuthModule,
|
||||||
|
MessagesModule,
|
||||||
|
MembersModule,
|
||||||
|
AgentConfigModule,
|
||||||
AgentModule,
|
AgentModule,
|
||||||
JenkinsModule,
|
JenkinsModule,
|
||||||
GiteaModule,
|
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 { 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';
|
import { AuditRecordSummary, AuditService } from './audit.service';
|
||||||
|
|
||||||
@ApiTags('audit')
|
@ApiTags('审计日志')
|
||||||
@Controller('audit-logs')
|
@Controller('audit-logs')
|
||||||
|
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
|
||||||
|
@AdminOnlyMessage('无权限访问审计日志')
|
||||||
export class AuditController {
|
export class AuditController {
|
||||||
constructor(private readonly auditService: AuditService) {}
|
constructor(private readonly auditService: AuditService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ description: 'Recent sanitized audit records.' })
|
@ApiOkResponse({ description: '查询最近的脱敏审计记录。' })
|
||||||
async listRecentRecords(): Promise<AuditRecordSummary[]> {
|
async listRecentRecords(): Promise<AuditRecordSummary[]> {
|
||||||
return this.auditService.getRecentRecords();
|
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 =
|
export type AppErrorCode =
|
||||||
|
| 'AUTH_REQUIRED'
|
||||||
| 'CONFIG_MISSING'
|
| 'CONFIG_MISSING'
|
||||||
|
| 'CONFLICT'
|
||||||
|
| 'FORBIDDEN'
|
||||||
| 'INTEGRATION_UNAVAILABLE'
|
| 'INTEGRATION_UNAVAILABLE'
|
||||||
| 'INTEGRATION_REQUEST_FAILED'
|
| 'INTEGRATION_REQUEST_FAILED'
|
||||||
|
| 'PASSWORD_CHANGE_REQUIRED'
|
||||||
| 'RESOURCE_NOT_FOUND'
|
| 'RESOURCE_NOT_FOUND'
|
||||||
| 'VALIDATION_FAILED'
|
| 'VALIDATION_FAILED'
|
||||||
| 'UNEXPECTED_ERROR';
|
| 'UNEXPECTED_ERROR';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export class IntegrationUnavailableError extends AppError {
|
|||||||
constructor(integration: string, missing: string[]) {
|
constructor(integration: string, missing: string[]) {
|
||||||
super(
|
super(
|
||||||
'INTEGRATION_UNAVAILABLE',
|
'INTEGRATION_UNAVAILABLE',
|
||||||
`${integration} integration is not configured`,
|
`${integration} 集成尚未配置`,
|
||||||
503,
|
503,
|
||||||
{ integration, missing },
|
{ integration, missing },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
return {
|
return {
|
||||||
statusCode: exception.getStatus(),
|
statusCode: exception.getStatus(),
|
||||||
code: 'VALIDATION_FAILED',
|
code: 'VALIDATION_FAILED',
|
||||||
message: exception.message,
|
message: '请求处理失败',
|
||||||
details: { response: exception.getResponse() },
|
details: { response: exception.getResponse() },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -69,14 +69,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
return {
|
return {
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
code: 'UNEXPECTED_ERROR',
|
code: 'UNEXPECTED_ERROR',
|
||||||
message: exception.message,
|
message: '系统异常,请联系管理员',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
code: 'UNEXPECTED_ERROR',
|
code: 'UNEXPECTED_ERROR',
|
||||||
message: 'Unexpected error',
|
message: '系统异常,请联系管理员',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
'Request body validation failed',
|
'请求参数校验失败',
|
||||||
400,
|
400,
|
||||||
{
|
{
|
||||||
issues: result.error.issues.map((issue: z.core.$ZodIssue) => ({
|
issues: result.error.issues.map((issue: z.core.$ZodIssue) => ({
|
||||||
path: issue.path.join('.'),
|
path: issue.path.join('.'),
|
||||||
code: issue.code,
|
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;
|
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,
|
USE_DATABASE_READS: booleanFromEnv,
|
||||||
PRISMA_CONNECT_ON_BOOT: booleanFromEnv,
|
PRISMA_CONNECT_ON_BOOT: booleanFromEnv,
|
||||||
REDIS_URL: optionalString,
|
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_BASE_URL: optionalUrl,
|
||||||
JENKINS_USERNAME: optionalString,
|
JENKINS_USERNAME: optionalString,
|
||||||
JENKINS_API_TOKEN: optionalString,
|
JENKINS_API_TOKEN: optionalString,
|
||||||
@@ -80,7 +85,7 @@ export function validateEnv(config: Record<string, unknown>): EnvConfig {
|
|||||||
const details = result.error.issues
|
const details = result.error.issues
|
||||||
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
||||||
.join('; ');
|
.join('; ');
|
||||||
throw new Error(`Invalid environment configuration: ${details}`);
|
throw new Error(`环境变量配置无效:${details}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export const createDeployRunSchema = z.object({
|
|||||||
projectKey: z.string().trim().min(1).max(80),
|
projectKey: z.string().trim().min(1).max(80),
|
||||||
environment: z.enum(['test', 'production']),
|
environment: z.enum(['test', 'production']),
|
||||||
ref: z.string().trim().min(1).max(200),
|
ref: z.string().trim().min(1).max(200),
|
||||||
operator: z.string().trim().min(1).max(120),
|
operator: optionalTrimmedString(120),
|
||||||
remark: optionalTrimmedString(500),
|
remark: optionalTrimmedString(500),
|
||||||
idempotencyKey: optionalTrimmedString(260),
|
idempotencyKey: optionalTrimmedString(260),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type CreateDeployRunRequestInput = z.infer<typeof createDeployRunSchema>;
|
||||||
|
|
||||||
export class CreateDeployRunDto {
|
export class CreateDeployRunDto {
|
||||||
@ApiProperty({ example: 'access-manage' })
|
@ApiProperty({ example: 'access-manage' })
|
||||||
projectKey!: string;
|
projectKey!: string;
|
||||||
@@ -32,8 +34,11 @@ export class CreateDeployRunDto {
|
|||||||
@ApiProperty({ example: 'develop' })
|
@ApiProperty({ example: 'develop' })
|
||||||
ref!: string;
|
ref!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'operator@example.com' })
|
@ApiPropertyOptional({
|
||||||
operator!: string;
|
example: 'operator@example.com',
|
||||||
|
description: '兼容旧前端字段;后端会优先使用当前登录账号',
|
||||||
|
})
|
||||||
|
operator?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Manual test deploy before release.' })
|
@ApiPropertyOptional({ example: 'Manual test deploy before release.' })
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
|||||||
@@ -121,10 +121,10 @@ describe('DeployExecutionService', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRun(
|
async function createRun(
|
||||||
repository: DeployRunRepository,
|
repository: DeployRunRepository,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
): DeployRunSummary {
|
): Promise<DeployRunSummary> {
|
||||||
return repository.create({
|
return repository.create({
|
||||||
...input,
|
...input,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
@@ -143,7 +143,7 @@ describe('DeployExecutionService', () => {
|
|||||||
},
|
},
|
||||||
notificationStatus: 'skipped',
|
notificationStatus: 'skipped',
|
||||||
});
|
});
|
||||||
const run = createRun(repository, 'run_dry_001');
|
const run = await createRun(repository, 'run_dry_001');
|
||||||
|
|
||||||
const result = await service.execute(run, project);
|
const result = await service.execute(run, project);
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ describe('DeployExecutionService', () => {
|
|||||||
notificationStatus: 'sent',
|
notificationStatus: 'sent',
|
||||||
queueId: 'jenkins_queue_42',
|
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);
|
const result = await service.execute(run, project);
|
||||||
|
|
||||||
@@ -207,7 +207,10 @@ describe('DeployExecutionService', () => {
|
|||||||
queueBuildNumber: 18,
|
queueBuildNumber: 18,
|
||||||
buildResult: 'SUCCESS',
|
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);
|
const result = await service.syncJenkinsRun(run, project);
|
||||||
|
|
||||||
@@ -234,15 +237,18 @@ describe('DeployExecutionService', () => {
|
|||||||
buildResult: 'FAILURE',
|
buildResult: 'FAILURE',
|
||||||
logText: ['install ok', 'build failed', 'token=secret-value'].join('\n'),
|
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);
|
const result = await service.syncJenkinsRun(run, project);
|
||||||
|
|
||||||
expect(result.status).toBe('failed');
|
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')?.status).toBe('failed');
|
||||||
expect(step(result, 'jenkins-build')?.errorSummary).toBe(
|
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, 'jenkins-build')?.logExcerpt).toContain('build failed');
|
||||||
expect(step(result, 'notify-wecom')?.status).toBe('skipped');
|
expect(step(result, 'notify-wecom')?.status).toBe('skipped');
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export class DeployExecutionService {
|
|||||||
currentRun.id,
|
currentRun.id,
|
||||||
'jenkins-build',
|
'jenkins-build',
|
||||||
'running',
|
'running',
|
||||||
`Jenkins build #${queueItem.buildNumber} is running.`,
|
`Jenkins 构建 #${queueItem.buildNumber} 正在运行。`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.auditService.record({
|
await this.auditService.record({
|
||||||
@@ -236,7 +236,7 @@ export class DeployExecutionService {
|
|||||||
currentRun.id,
|
currentRun.id,
|
||||||
'jenkins-build',
|
'jenkins-build',
|
||||||
'running',
|
'running',
|
||||||
`Jenkins build #${buildStatus.buildNumber} is running.`,
|
`Jenkins 构建 #${buildStatus.buildNumber} 正在运行。`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ export class DeployExecutionService {
|
|||||||
if (health.status === 'not_configured') {
|
if (health.status === 'not_configured') {
|
||||||
const reason =
|
const reason =
|
||||||
health.message ??
|
health.message ??
|
||||||
'Jenkins is not configured; simulated build completed.';
|
'Jenkins 尚未配置,已完成本地模拟构建。';
|
||||||
const currentRun = await this.completeStep(
|
const currentRun = await this.completeStep(
|
||||||
run.id,
|
run.id,
|
||||||
'jenkins-build',
|
'jenkins-build',
|
||||||
@@ -286,7 +286,7 @@ export class DeployExecutionService {
|
|||||||
if (!environment.jenkinsJobPath) {
|
if (!environment.jenkinsJobPath) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Project ${project.key} ${run.environment} Jenkins job path is empty`,
|
`项目 ${project.key} 的 ${run.environment} 环境 Jenkins Job 路径为空`,
|
||||||
400,
|
400,
|
||||||
{ projectKey: project.key, environment: run.environment },
|
{ projectKey: project.key, environment: run.environment },
|
||||||
);
|
);
|
||||||
@@ -312,8 +312,8 @@ export class DeployExecutionService {
|
|||||||
'jenkins-build',
|
'jenkins-build',
|
||||||
nextStatus,
|
nextStatus,
|
||||||
queueItem.buildNumber
|
queueItem.buildNumber
|
||||||
? `Jenkins build #${queueItem.buildNumber} is running.`
|
? `Jenkins 构建 #${queueItem.buildNumber} 正在运行。`
|
||||||
: `Jenkins queue ${queueItem.queueId} accepted.`,
|
: `Jenkins 队列 ${queueItem.queueId} 已受理。`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.auditService.record({
|
await this.auditService.record({
|
||||||
@@ -414,7 +414,7 @@ export class DeployExecutionService {
|
|||||||
jobPath: string,
|
jobPath: string,
|
||||||
): Promise<DeployRunSummary> {
|
): Promise<DeployRunSummary> {
|
||||||
const logExcerpt = await this.readFailureLogExcerpt(run, jobPath);
|
const logExcerpt = await this.readFailureLogExcerpt(run, jobPath);
|
||||||
const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`;
|
const failureSummary = `Jenkins 构建 #${run.jenkinsBuildNumber ?? '-'} ${result}`;
|
||||||
let currentRun =
|
let currentRun =
|
||||||
(await this.deployRunRepository.updateStep(run.id, 'jenkins-build', {
|
(await this.deployRunRepository.updateStep(run.id, 'jenkins-build', {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
@@ -481,7 +481,7 @@ export class DeployExecutionService {
|
|||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Project ${project.key} does not expose ${run.environment} environment`,
|
`项目 ${project.key} 未配置 ${run.environment} 环境`,
|
||||||
400,
|
400,
|
||||||
{ projectKey: project.key, environment: run.environment },
|
{ projectKey: project.key, environment: run.environment },
|
||||||
);
|
);
|
||||||
@@ -558,7 +558,7 @@ export class DeployExecutionService {
|
|||||||
if (!run) {
|
if (!run) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'RESOURCE_NOT_FOUND',
|
'RESOURCE_NOT_FOUND',
|
||||||
`Deploy run ${id} not found`,
|
`发布记录不存在:${id}`,
|
||||||
404,
|
404,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -574,6 +574,6 @@ export class DeployExecutionService {
|
|||||||
return redactSensitive({ message: error.message }).message;
|
return redactSensitive({ message: error.message }).message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Unknown deploy execution error';
|
return '未知发布执行错误';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ export class DeployRunRepository {
|
|||||||
|
|
||||||
if (!project || !environment) {
|
if (!project || !environment) {
|
||||||
throw new Error(
|
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 {
|
import {
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
@@ -8,62 +8,95 @@ import {
|
|||||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||||
import {
|
import {
|
||||||
CreateDeployRunDto,
|
CreateDeployRunDto,
|
||||||
|
CreateDeployRunRequestInput,
|
||||||
createDeployRunSchema,
|
createDeployRunSchema,
|
||||||
} from './create-deploy-run.dto';
|
} 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 {
|
import {
|
||||||
CreateDeployRunInput,
|
|
||||||
DeployRunJenkinsSyncSummary,
|
DeployRunJenkinsSyncSummary,
|
||||||
DeployRunSummary,
|
DeployRunSummary,
|
||||||
} from './deploy-run.types';
|
} from './deploy-run.types';
|
||||||
import { DeployRunsService } from './deploy-runs.service';
|
import { DeployRunsService } from './deploy-runs.service';
|
||||||
|
|
||||||
@ApiTags('deploy-runs')
|
@ApiTags('发布记录')
|
||||||
@Controller('deploy-runs')
|
@Controller('deploy-runs')
|
||||||
|
@UseGuards(AuthGuard, PasswordChangeGuard)
|
||||||
export class DeployRunsController {
|
export class DeployRunsController {
|
||||||
constructor(private readonly deployRunsService: DeployRunsService) {}
|
constructor(private readonly deployRunsService: DeployRunsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ description: 'Latest deploy runs.' })
|
@ApiOkResponse({ description: '查询当前账号可访问项目的发布记录。' })
|
||||||
async listRuns(): Promise<DeployRunSummary[]> {
|
async listRuns(
|
||||||
return this.deployRunsService.listRuns();
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
): Promise<DeployRunSummary[]> {
|
||||||
|
return this.deployRunsService.listRuns(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOkResponse({ description: 'Deploy run detail.' })
|
@ApiOkResponse({ description: '查询发布记录详情。' })
|
||||||
async getRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
async getRun(
|
||||||
return this.deployRunsService.getRun(id);
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
|
return this.deployRunsService.getRun(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiBody({ type: CreateDeployRunDto })
|
@ApiBody({ type: CreateDeployRunDto })
|
||||||
@ApiCreatedResponse({ description: 'Create a deploy run draft.' })
|
@ApiCreatedResponse({ description: '创建发布单并触发构建。' })
|
||||||
async createRun(
|
async createRun(
|
||||||
@Body(new ZodValidationPipe(createDeployRunSchema)) body: CreateDeployRunInput,
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
@Body(new ZodValidationPipe(createDeployRunSchema))
|
||||||
|
body: CreateDeployRunRequestInput,
|
||||||
): Promise<DeployRunSummary> {
|
): Promise<DeployRunSummary> {
|
||||||
return this.deployRunsService.createRun(body);
|
return this.deployRunsService.createRun(
|
||||||
|
{
|
||||||
|
...body,
|
||||||
|
operator: user.account,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(':id/cancel')
|
||||||
@ApiOkResponse({ description: 'Cancel a deploy run.' })
|
@ApiOkResponse({ description: '取消发布单。' })
|
||||||
async cancelRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
async cancelRun(
|
||||||
return this.deployRunsService.cancelRun(id);
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
|
return this.deployRunsService.cancelRun(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/retry')
|
@Post(':id/retry')
|
||||||
@ApiCreatedResponse({ description: 'Retry a deploy run.' })
|
@ApiCreatedResponse({ description: '重试发布单。' })
|
||||||
async retryRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
async retryRun(
|
||||||
return this.deployRunsService.retryRun(id);
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
|
return this.deployRunsService.retryRun(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync-jenkins')
|
@Post('sync-jenkins')
|
||||||
@ApiOkResponse({ description: 'Synchronize all queued/running Jenkins runs.' })
|
@UseGuards(SuperAdminGuard)
|
||||||
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
|
@AdminOnlyMessage('无权限同步全部 Jenkins 状态')
|
||||||
return this.deployRunsService.syncJenkinsRuns();
|
@ApiOkResponse({ description: '同步全部队列中或运行中的 Jenkins 发布状态。' })
|
||||||
|
async syncJenkinsRuns(
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
): Promise<DeployRunJenkinsSyncSummary> {
|
||||||
|
return this.deployRunsService.syncJenkinsRuns(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/sync-jenkins')
|
@Post(':id/sync-jenkins')
|
||||||
@ApiOkResponse({ description: 'Synchronize Jenkins queue/build state.' })
|
@ApiOkResponse({ description: '同步单个发布单的 Jenkins 队列或构建状态。' })
|
||||||
async syncJenkinsRun(@Param('id') id: string): Promise<DeployRunSummary> {
|
async syncJenkinsRun(
|
||||||
return this.deployRunsService.syncJenkinsRun(id);
|
@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 { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { JenkinsModule } from '../integrations/jenkins/jenkins.module';
|
import { JenkinsModule } from '../integrations/jenkins/jenkins.module';
|
||||||
import { WeComModule } from '../notifications/wecom/wecom.module';
|
import { WeComModule } from '../notifications/wecom/wecom.module';
|
||||||
import { ProjectsModule } from '../projects/projects.module';
|
import { ProjectsModule } from '../projects/projects.module';
|
||||||
@@ -10,7 +11,7 @@ import { DeployRunsService } from './deploy-runs.service';
|
|||||||
import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service';
|
import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule],
|
imports: [AuditModule, AuthModule, JenkinsModule, ProjectsModule, WeComModule],
|
||||||
controllers: [DeployRunsController],
|
controllers: [DeployRunsController],
|
||||||
providers: [
|
providers: [
|
||||||
DeployExecutionService,
|
DeployExecutionService,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { AppError } from '../common/errors/app-error';
|
import { AppError } from '../common/errors/app-error';
|
||||||
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
|
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
|
||||||
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
|
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
|
||||||
@@ -58,30 +59,36 @@ describe('DeployRunsService', () => {
|
|||||||
findById: jest.fn((id: string) =>
|
findById: jest.fn((id: string) =>
|
||||||
Promise.resolve(runs.find((run) => run.id === id) ?? null),
|
Promise.resolve(runs.find((run) => run.id === id) ?? null),
|
||||||
),
|
),
|
||||||
create: jest.fn((input: CreateDeployRunInput) => ({
|
create: jest.fn((input: CreateDeployRunInput) =>
|
||||||
...baseRun,
|
Promise.resolve({
|
||||||
id: input.idempotencyKey ?? 'generated_run',
|
...baseRun,
|
||||||
projectKey: input.projectKey,
|
id: input.idempotencyKey ?? 'generated_run',
|
||||||
environment: input.environment,
|
projectKey: input.projectKey,
|
||||||
ref: input.ref,
|
environment: input.environment,
|
||||||
operator: input.operator,
|
ref: input.ref,
|
||||||
trigger: input.trigger ?? 'manual',
|
operator: input.operator,
|
||||||
})),
|
trigger: input.trigger ?? 'manual',
|
||||||
|
}),
|
||||||
|
),
|
||||||
updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) =>
|
updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) =>
|
||||||
runs.some((run) => run.id === id)
|
Promise.resolve(
|
||||||
? {
|
runs.some((run) => run.id === id)
|
||||||
...(runs.find((run) => run.id === id) ?? baseRun),
|
? {
|
||||||
...patch,
|
...(runs.find((run) => run.id === id) ?? baseRun),
|
||||||
}
|
...patch,
|
||||||
: null,
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
updateStep: jest.fn((id: string) =>
|
updateStep: jest.fn((id: string) =>
|
||||||
runs.some((run) => run.id === id)
|
Promise.resolve(
|
||||||
? {
|
runs.some((run) => run.id === id)
|
||||||
...(runs.find((run) => run.id === id) ?? baseRun),
|
? {
|
||||||
status: 'canceled' as const,
|
...(runs.find((run) => run.id === id) ?? baseRun),
|
||||||
}
|
status: 'canceled' as const,
|
||||||
: null,
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
} satisfies Pick<
|
} satisfies Pick<
|
||||||
DeployRunRepository,
|
DeployRunRepository,
|
||||||
@@ -130,10 +137,22 @@ describe('DeployRunsService', () => {
|
|||||||
JenkinsClient,
|
JenkinsClient,
|
||||||
'healthSummary' | 'cancelQueueItem' | 'stopBuild'
|
'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 {
|
return {
|
||||||
audit,
|
audit,
|
||||||
|
auth,
|
||||||
execution,
|
execution,
|
||||||
|
jenkins,
|
||||||
notification,
|
notification,
|
||||||
projects,
|
projects,
|
||||||
repository,
|
repository,
|
||||||
@@ -144,6 +163,7 @@ describe('DeployRunsService', () => {
|
|||||||
notification as unknown as DeployNotificationService,
|
notification as unknown as DeployNotificationService,
|
||||||
execution as unknown as DeployExecutionService,
|
execution as unknown as DeployExecutionService,
|
||||||
jenkins as unknown as JenkinsClient,
|
jenkins as unknown as JenkinsClient,
|
||||||
|
auth as unknown as AuthService,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -222,7 +242,7 @@ describe('DeployRunsService', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: 'DEPLOY_REJECTED',
|
action: 'DEPLOY_REJECTED',
|
||||||
after: expect.objectContaining({
|
after: expect.objectContaining({
|
||||||
reason: 'Environment is not configured',
|
reason: '环境未配置',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -258,7 +278,7 @@ describe('DeployRunsService', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: 'DEPLOY_REJECTED',
|
action: 'DEPLOY_REJECTED',
|
||||||
after: expect.objectContaining({
|
after: expect.objectContaining({
|
||||||
reason: 'Ref does not match release policy',
|
reason: 'Ref 不符合发布策略',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { AppError } from '../common/errors/app-error';
|
||||||
import { redactSensitive } from '../common/security/redact-sensitive';
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
@@ -30,19 +32,33 @@ export class DeployRunsService {
|
|||||||
private readonly deployNotificationService: DeployNotificationService,
|
private readonly deployNotificationService: DeployNotificationService,
|
||||||
private readonly deployExecutionService: DeployExecutionService,
|
private readonly deployExecutionService: DeployExecutionService,
|
||||||
private readonly jenkinsClient: JenkinsClient,
|
private readonly jenkinsClient: JenkinsClient,
|
||||||
|
private readonly authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listRuns(): Promise<DeployRunSummary[]> {
|
async listRuns(user?: AuthenticatedUser): Promise<DeployRunSummary[]> {
|
||||||
return this.deployRunRepository.findMany();
|
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);
|
const run = await this.deployRunRepository.findById(id);
|
||||||
|
|
||||||
if (!run) {
|
if (!run) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'RESOURCE_NOT_FOUND',
|
'RESOURCE_NOT_FOUND',
|
||||||
`Deploy run ${id} not found`,
|
`发布记录不存在:${id}`,
|
||||||
404,
|
404,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -50,19 +66,38 @@ export class DeployRunsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await this.authService.requireProjectPermission(
|
||||||
|
user,
|
||||||
|
run.projectKey,
|
||||||
|
'read',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRun(input: CreateDeployRunInput): Promise<DeployRunSummary> {
|
async createRun(
|
||||||
|
input: CreateDeployRunInput,
|
||||||
|
user?: AuthenticatedUser,
|
||||||
|
): Promise<DeployRunSummary> {
|
||||||
if (!['test', 'production'].includes(input.environment)) {
|
if (!['test', 'production'].includes(input.environment)) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
'environment must be test or production',
|
'发布环境必须为 test 或 production',
|
||||||
400,
|
400,
|
||||||
{ environment: input.environment },
|
{ environment: input.environment },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await this.authService.requireProjectPermission(
|
||||||
|
user,
|
||||||
|
input.projectKey,
|
||||||
|
'build',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const project = await this.projectsService.getProject(input.projectKey);
|
const project = await this.projectsService.getProject(input.projectKey);
|
||||||
await this.validateReleaseRequest(input, project);
|
await this.validateReleaseRequest(input, project);
|
||||||
const run = await this.deployRunRepository.create(input);
|
const run = await this.deployRunRepository.create(input);
|
||||||
@@ -83,13 +118,24 @@ export class DeployRunsService {
|
|||||||
return this.deployExecutionService.execute(run, project);
|
return this.deployExecutionService.execute(run, project);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelRun(id: string): Promise<DeployRunSummary> {
|
async cancelRun(
|
||||||
const source = await this.getRun(id);
|
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)) {
|
if (['success', 'failed', 'canceled'].includes(source.status)) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Deploy run ${id} is already ${source.status} and cannot be canceled`,
|
`发布记录 ${id} 已是终态,不能取消`,
|
||||||
400,
|
400,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -130,27 +176,52 @@ export class DeployRunsService {
|
|||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
async retryRun(id: string): Promise<DeployRunSummary> {
|
async retryRun(
|
||||||
const source = await this.getRun(id);
|
id: string,
|
||||||
return this.createRun({
|
user?: AuthenticatedUser,
|
||||||
projectKey: source.projectKey,
|
): Promise<DeployRunSummary> {
|
||||||
environment: source.environment,
|
const source = await this.getRun(id, user);
|
||||||
ref: source.ref,
|
|
||||||
operator: source.operator,
|
return this.createRun(
|
||||||
remark: `Retry of ${source.id}`,
|
{
|
||||||
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
|
projectKey: source.projectKey,
|
||||||
trigger: 'retry',
|
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> {
|
async syncJenkinsRun(
|
||||||
const run = await this.getRun(id);
|
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);
|
const project = await this.projectsService.getProject(run.projectKey);
|
||||||
|
|
||||||
return this.deployExecutionService.syncJenkinsRun(run, project);
|
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 runs = await this.listRuns();
|
||||||
const candidates = runs.filter((run) => this.isJenkinsSyncCandidate(run));
|
const candidates = runs.filter((run) => this.isJenkinsSyncCandidate(run));
|
||||||
const syncedRuns: DeployRunSummary[] = [];
|
const syncedRuns: DeployRunSummary[] = [];
|
||||||
@@ -228,7 +299,7 @@ export class DeployRunsService {
|
|||||||
if (jenkinsHealth.status === 'not_configured') {
|
if (jenkinsHealth.status === 'not_configured') {
|
||||||
return {
|
return {
|
||||||
requested: false,
|
requested: false,
|
||||||
reason: jenkinsHealth.message ?? 'Jenkins is not configured',
|
reason: jenkinsHealth.message ?? 'Jenkins 尚未配置',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +316,7 @@ export class DeployRunsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
requested: false,
|
requested: false,
|
||||||
reason: 'Deploy run has no Jenkins queue id or build number',
|
reason: '发布记录缺少 Jenkins 队列编号或构建编号',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +331,7 @@ export class DeployRunsService {
|
|||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Project ${project.key} does not expose ${run.environment} environment`,
|
`项目 ${project.key} 未配置 ${run.environment} 环境`,
|
||||||
400,
|
400,
|
||||||
{ projectKey: project.key, environment: run.environment },
|
{ projectKey: project.key, environment: run.environment },
|
||||||
);
|
);
|
||||||
@@ -273,14 +344,14 @@ export class DeployRunsService {
|
|||||||
result: JenkinsCancelResult | { requested: false; reason: string },
|
result: JenkinsCancelResult | { requested: false; reason: string },
|
||||||
): string {
|
): string {
|
||||||
if (!result.requested) {
|
if (!result.requested) {
|
||||||
return `Cancellation recorded locally: ${result.reason}`;
|
return `已在本地记录取消结果:${result.reason}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.target === 'build') {
|
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(
|
private async validateReleaseRequest(
|
||||||
@@ -288,10 +359,10 @@ export class DeployRunsService {
|
|||||||
project: ProjectSummary,
|
project: ProjectSummary,
|
||||||
): Promise<ProjectEnvironmentSummary> {
|
): Promise<ProjectEnvironmentSummary> {
|
||||||
if (project.status !== 'active') {
|
if (project.status !== 'active') {
|
||||||
await this.recordDeployRejected(input, 'Project is archived');
|
await this.recordDeployRejected(input, '项目已归档');
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Project ${project.key} is archived and cannot be deployed`,
|
`项目 ${project.key} 已归档,不能发布`,
|
||||||
400,
|
400,
|
||||||
{ projectKey: project.key },
|
{ projectKey: project.key },
|
||||||
);
|
);
|
||||||
@@ -302,10 +373,10 @@ export class DeployRunsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
await this.recordDeployRejected(input, 'Environment is not configured');
|
await this.recordDeployRejected(input, '环境未配置');
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Project ${project.key} does not expose ${input.environment} environment`,
|
`项目 ${project.key} 未配置 ${input.environment} 环境`,
|
||||||
400,
|
400,
|
||||||
{ projectKey: project.key, environment: input.environment },
|
{ projectKey: project.key, environment: input.environment },
|
||||||
);
|
);
|
||||||
@@ -314,11 +385,11 @@ export class DeployRunsService {
|
|||||||
if (!new RegExp(environment.refPattern).test(input.ref)) {
|
if (!new RegExp(environment.refPattern).test(input.ref)) {
|
||||||
await this.recordDeployRejected(
|
await this.recordDeployRejected(
|
||||||
input,
|
input,
|
||||||
'Ref does not match release policy',
|
'Ref 不符合发布策略',
|
||||||
);
|
);
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Ref ${input.ref} does not match ${environment.releasePolicy} release policy`,
|
`Ref ${input.ref} 不符合 ${environment.releasePolicy} 发布策略`,
|
||||||
400,
|
400,
|
||||||
{
|
{
|
||||||
projectKey: project.key,
|
projectKey: project.key,
|
||||||
@@ -352,7 +423,7 @@ export class DeployRunsService {
|
|||||||
|
|
||||||
private toSafeSyncError(error: unknown): string {
|
private toSafeSyncError(error: unknown): string {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Jenkins sync failed';
|
error instanceof Error ? error.message : 'Jenkins 同步失败';
|
||||||
|
|
||||||
return redactSensitive({ message }).message;
|
return redactSensitive({ message }).message;
|
||||||
}
|
}
|
||||||
@@ -364,7 +435,7 @@ export class DeployRunsService {
|
|||||||
if (!run) {
|
if (!run) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'RESOURCE_NOT_FOUND',
|
'RESOURCE_NOT_FOUND',
|
||||||
`Deploy run ${id} not found`,
|
`发布记录不存在:${id}`,
|
||||||
404,
|
404,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class JenkinsSyncSchedulerService
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Jenkins auto sync failed';
|
error instanceof Error ? error.message : 'Jenkins 自动同步失败';
|
||||||
|
|
||||||
await this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'JENKINS_AUTO_SYNC_TICK_FAILED',
|
action: 'JENKINS_AUTO_SYNC_TICK_FAILED',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class HealthController {
|
|||||||
constructor(private readonly healthService: HealthService) {}
|
constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ description: 'Runtime dependency health summary.' })
|
@ApiOkResponse({ description: '查询运行时依赖健康状态。' })
|
||||||
async getHealth(): Promise<ReturnType<HealthService['getHealth']>> {
|
async getHealth(): Promise<ReturnType<HealthService['getHealth']>> {
|
||||||
return this.healthService.getHealth();
|
return this.healthService.getHealth();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ describe('HealthService', () => {
|
|||||||
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||||
} satisfies Pick<DeployNotificationService, 'healthSummary'>;
|
} satisfies Pick<DeployNotificationService, 'healthSummary'>;
|
||||||
const llm = {
|
const llm = {
|
||||||
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
healthSummary: jest.fn(() =>
|
||||||
|
Promise.resolve({ status: 'not_configured' as const }),
|
||||||
|
),
|
||||||
} satisfies Pick<LlmClient, 'healthSummary'>;
|
} satisfies Pick<LlmClient, 'healthSummary'>;
|
||||||
const service = new HealthService(
|
const service = new HealthService(
|
||||||
config as unknown as ConfigService<EnvConfig, true>,
|
config as unknown as ConfigService<EnvConfig, true>,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class HealthService {
|
|||||||
jenkins: this.jenkins.healthSummary(),
|
jenkins: this.jenkins.healthSummary(),
|
||||||
gitea: this.gitea.healthSummary(),
|
gitea: this.gitea.healthSummary(),
|
||||||
notification: this.notification.healthSummary(),
|
notification: this.notification.healthSummary(),
|
||||||
llm: this.llm.healthSummary(),
|
llm: await this.llm.healthSummary(),
|
||||||
};
|
};
|
||||||
const hasUnavailable = Object.values(dependencies).some(
|
const hasUnavailable = Object.values(dependencies).some(
|
||||||
(dependency) => dependency.status === 'unavailable',
|
(dependency) => dependency.status === 'unavailable',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export class GiteaClient {
|
|||||||
const missing = this.missingConfig();
|
const missing = this.missingConfig();
|
||||||
return missing.length === 0
|
return missing.length === 0
|
||||||
? { status: 'ok' }
|
? { status: 'ok' }
|
||||||
: { status: 'not_configured', message: `Missing ${missing.join(', ')}` };
|
: { status: 'not_configured', message: `缺少 ${missing.join(', ')}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
async listBranches(owner: string, repo: string): Promise<GiteaBranch[]> {
|
async listBranches(owner: string, repo: string): Promise<GiteaBranch[]> {
|
||||||
@@ -76,7 +76,7 @@ export class GiteaClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new IntegrationRequestError('gitea', 'Gitea request failed', {
|
throw new IntegrationRequestError('gitea', 'Gitea 请求失败', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
path,
|
path,
|
||||||
request: redactSensitive(init),
|
request: redactSensitive(init),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class JenkinsClient {
|
|||||||
const missing = this.missingConfig();
|
const missing = this.missingConfig();
|
||||||
return missing.length === 0
|
return missing.length === 0
|
||||||
? { status: 'ok' }
|
? { status: 'ok' }
|
||||||
: { status: 'not_configured', message: `Missing ${missing.join(', ')}` };
|
: { status: 'not_configured', message: `缺少 ${missing.join(', ')}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildWithParameters(
|
async buildWithParameters(
|
||||||
@@ -204,7 +204,7 @@ export class JenkinsClient {
|
|||||||
path: string,
|
path: string,
|
||||||
details?: Record<string, unknown>,
|
details?: Record<string, unknown>,
|
||||||
): IntegrationRequestError {
|
): IntegrationRequestError {
|
||||||
return new IntegrationRequestError('jenkins', 'Jenkins request failed', {
|
return new IntegrationRequestError('jenkins', 'Jenkins 请求失败', {
|
||||||
path,
|
path,
|
||||||
...details,
|
...details,
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -22,8 +22,8 @@ async function bootstrap(): Promise<void> {
|
|||||||
app.useGlobalInterceptors(new ApiEnvelopeInterceptor());
|
app.useGlobalInterceptors(new ApiEnvelopeInterceptor());
|
||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('DevOps Platform API')
|
.setTitle('运维平台 API')
|
||||||
.setDescription('Jenkins, Gitea, notification, BPMN deploy runs, and agent operation APIs.')
|
.setDescription('提供 Jenkins、Gitea、通知、发布流程、账号权限和 Agent 运维能力接口。')
|
||||||
.setVersion('0.1.0')
|
.setVersion('0.1.0')
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
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) {
|
if (!provider) {
|
||||||
return {
|
return {
|
||||||
status: 'not_configured',
|
status: 'not_configured',
|
||||||
message: 'Missing NOTIFICATION_PROVIDER',
|
message: '缺少 NOTIFICATION_PROVIDER',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export class DeployNotificationService {
|
|||||||
: {
|
: {
|
||||||
status: 'not_configured',
|
status: 'not_configured',
|
||||||
provider,
|
provider,
|
||||||
message: `Missing ${webhookKey}`,
|
message: `缺少 ${webhookKey}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export class DeployNotificationService {
|
|||||||
return {
|
return {
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
channel: health.provider ?? 'none',
|
channel: health.provider ?? 'none',
|
||||||
reason: health.message ?? 'Notification provider is not configured',
|
reason: health.message ?? '通知平台尚未配置',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export class DeployNotificationService {
|
|||||||
return {
|
return {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
channel: health.provider ?? 'none',
|
channel: health.provider ?? 'none',
|
||||||
reason: health.message ?? 'Notification provider is unavailable',
|
reason: health.message ?? '通知平台不可用',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export class DeployNotificationService {
|
|||||||
reason:
|
reason:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? redactSensitive({ message: error.message }).message
|
? redactSensitive({ message: error.message }).message
|
||||||
: 'Unknown notification error',
|
: '未知通知错误',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ export class DeployNotificationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 } {
|
healthSummary(): { status: 'ok' | 'not_configured'; message?: string } {
|
||||||
return this.config.get('WECOM_WEBHOOK_URL', { infer: true })
|
return this.config.get('WECOM_WEBHOOK_URL', { infer: true })
|
||||||
? { status: 'ok' }
|
? { status: 'ok' }
|
||||||
: { status: 'not_configured', message: 'Missing WECOM_WEBHOOK_URL' };
|
: { status: 'not_configured', message: '缺少 WECOM_WEBHOOK_URL' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMarkdown(message: WeComMarkdownMessage): Promise<WeComSendResult> {
|
async sendMarkdown(message: WeComMarkdownMessage): Promise<WeComSendResult> {
|
||||||
@@ -32,7 +32,7 @@ export class WeComClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new IntegrationRequestError('wecom', 'WeCom webhook request failed', {
|
throw new IntegrationRequestError('wecom', '企微 webhook 请求失败', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
message: redactSensitive(message),
|
message: redactSensitive(message),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class PrismaService
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
status: 'unavailable',
|
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 { 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 { ProcessDefinitionSummary } from './process-definition.types';
|
||||||
import { ProcessDefinitionsService } from './process-definitions.service';
|
import { ProcessDefinitionsService } from './process-definitions.service';
|
||||||
|
|
||||||
@ApiTags('process-definitions')
|
@ApiTags('流程定义')
|
||||||
@Controller('process-definitions')
|
@Controller('process-definitions')
|
||||||
|
@UseGuards(AuthGuard, PasswordChangeGuard)
|
||||||
export class ProcessDefinitionsController {
|
export class ProcessDefinitionsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly processDefinitionsService: ProcessDefinitionsService,
|
private readonly processDefinitionsService: ProcessDefinitionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('release')
|
@Get('release')
|
||||||
@ApiOkResponse({ description: 'BPMN process definition for deploy runs.' })
|
@ApiOkResponse({ description: '查询发布流程 BPMN 定义。' })
|
||||||
getReleaseProcess(): ProcessDefinitionSummary {
|
getReleaseProcess(): ProcessDefinitionSummary {
|
||||||
return this.processDefinitionsService.getReleaseProcess();
|
return this.processDefinitionsService.getReleaseProcess();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ProcessDefinitionsController } from './process-definitions.controller';
|
import { ProcessDefinitionsController } from './process-definitions.controller';
|
||||||
import { ProcessDefinitionsService } from './process-definitions.service';
|
import { ProcessDefinitionsService } from './process-definitions.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
controllers: [ProcessDefinitionsController],
|
controllers: [ProcessDefinitionsController],
|
||||||
providers: [ProcessDefinitionsService],
|
providers: [ProcessDefinitionsService],
|
||||||
exports: [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 { 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 { ProjectRefSummary, ProjectSummary } from './project.types';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
@ApiTags('projects')
|
@ApiTags('项目')
|
||||||
@Controller('projects')
|
@Controller('projects')
|
||||||
|
@UseGuards(AuthGuard, PasswordChangeGuard)
|
||||||
export class ProjectsController {
|
export class ProjectsController {
|
||||||
constructor(private readonly projectsService: ProjectsService) {}
|
constructor(private readonly projectsService: ProjectsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ description: 'Configured project list.' })
|
@ApiOkResponse({ description: '查询当前账号可访问的项目列表。' })
|
||||||
async listProjects(): Promise<ProjectSummary[]> {
|
async listProjects(
|
||||||
return this.projectsService.listProjects();
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
): Promise<ProjectSummary[]> {
|
||||||
|
return this.projectsService.listProjects(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':key')
|
@Get(':key')
|
||||||
@ApiOkResponse({ description: 'Project detail by key.' })
|
@ApiOkResponse({ description: '查询项目详情。' })
|
||||||
async getProject(@Param('key') key: string): Promise<ProjectSummary> {
|
async getProject(
|
||||||
return this.projectsService.getProject(key);
|
@Param('key') key: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
): Promise<ProjectSummary> {
|
||||||
|
return this.projectsService.getProject(key, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':key/refs')
|
@Get(':key/refs')
|
||||||
@ApiOkResponse({ description: 'Project branches and tags.' })
|
@ApiOkResponse({ description: '查询项目分支和标签。' })
|
||||||
async listProjectRefs(@Param('key') key: string): Promise<ProjectRefSummary> {
|
async listProjectRefs(
|
||||||
return this.projectsService.listProjectRefs(key);
|
@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 { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { GiteaModule } from '../integrations/gitea/gitea.module';
|
import { GiteaModule } from '../integrations/gitea/gitea.module';
|
||||||
import { ProjectRepository } from './project.repository';
|
import { ProjectRepository } from './project.repository';
|
||||||
import { ProjectsController } from './projects.controller';
|
import { ProjectsController } from './projects.controller';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [GiteaModule],
|
imports: [AuthModule, GiteaModule],
|
||||||
controllers: [ProjectsController],
|
controllers: [ProjectsController],
|
||||||
providers: [ProjectRepository, ProjectsService],
|
providers: [ProjectRepository, ProjectsService],
|
||||||
exports: [ProjectRepository, ProjectsService],
|
exports: [ProjectRepository, ProjectsService],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { AppError } from '../common/errors/app-error';
|
import { AppError } from '../common/errors/app-error';
|
||||||
import { GiteaClient } from '../integrations/gitea/gitea.client';
|
import { GiteaClient } from '../integrations/gitea/gitea.client';
|
||||||
import { ProjectRepository } from './project.repository';
|
import { ProjectRepository } from './project.repository';
|
||||||
@@ -37,13 +38,22 @@ describe('ProjectsService', () => {
|
|||||||
Promise.resolve(options?.tags ?? [{ name: 'v1.0.0', commitSha: 'def' }]),
|
Promise.resolve(options?.tags ?? [{ name: 'v1.0.0', commitSha: 'def' }]),
|
||||||
),
|
),
|
||||||
} satisfies Pick<GiteaClient, 'healthSummary' | 'listBranches' | 'listTags'>;
|
} 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 {
|
return {
|
||||||
|
auth,
|
||||||
gitea,
|
gitea,
|
||||||
repository,
|
repository,
|
||||||
service: new ProjectsService(
|
service: new ProjectsService(
|
||||||
repository as unknown as ProjectRepository,
|
repository as unknown as ProjectRepository,
|
||||||
gitea as unknown as GiteaClient,
|
gitea as unknown as GiteaClient,
|
||||||
|
auth as unknown as AuthService,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { AppError } from '../common/errors/app-error';
|
||||||
import { GiteaClient } from '../integrations/gitea/gitea.client';
|
import { GiteaClient } from '../integrations/gitea/gitea.client';
|
||||||
import { ProjectRepository } from './project.repository';
|
import { ProjectRepository } from './project.repository';
|
||||||
@@ -9,26 +11,47 @@ export class ProjectsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly projectRepository: ProjectRepository,
|
private readonly projectRepository: ProjectRepository,
|
||||||
private readonly giteaClient: GiteaClient,
|
private readonly giteaClient: GiteaClient,
|
||||||
|
private readonly authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listProjects(): Promise<ProjectSummary[]> {
|
async listProjects(user?: AuthenticatedUser): Promise<ProjectSummary[]> {
|
||||||
return this.projectRepository.findMany();
|
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);
|
const project = await this.projectRepository.findByKey(key);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new AppError('RESOURCE_NOT_FOUND', `Project ${key} not found`, 404, {
|
throw new AppError('RESOURCE_NOT_FOUND', `项目不存在:${key}`, 404, {
|
||||||
key,
|
key,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await this.authService.requireProjectPermission(user, key, 'read');
|
||||||
|
}
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listProjectRefs(key: string): Promise<ProjectRefSummary> {
|
async listProjectRefs(
|
||||||
const project = await this.getProject(key);
|
key: string,
|
||||||
|
user?: AuthenticatedUser,
|
||||||
|
): Promise<ProjectRefSummary> {
|
||||||
|
const project = await this.getProject(key, user);
|
||||||
const repo = this.parseRepository(project);
|
const repo = this.parseRepository(project);
|
||||||
|
|
||||||
if (repo && this.giteaClient.healthSummary().status === 'ok') {
|
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 { 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 { SettingsService } from './settings.service';
|
||||||
import { IntegrationConfigStatusResponse } from './settings.types';
|
import { IntegrationConfigStatusResponse } from './settings.types';
|
||||||
|
|
||||||
@ApiTags('settings')
|
@ApiTags('系统设置')
|
||||||
@Controller('settings')
|
@Controller('settings')
|
||||||
|
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
|
||||||
|
@AdminOnlyMessage('无权限访问系统设置')
|
||||||
export class SettingsController {
|
export class SettingsController {
|
||||||
constructor(private readonly settingsService: SettingsService) {}
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
|
|
||||||
@Get('integration-config')
|
@Get('integration-config')
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description:
|
description: '查询集成配置状态,所有密钥值均在服务端脱敏。',
|
||||||
'Integration configuration status with secret values redacted server-side.',
|
|
||||||
})
|
})
|
||||||
getIntegrationConfig(): IntegrationConfigStatusResponse {
|
getIntegrationConfig(): IntegrationConfigStatusResponse {
|
||||||
return this.settingsService.getIntegrationConfigStatus();
|
return this.settingsService.getIntegrationConfigStatus();
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { SettingsController } from './settings.controller';
|
import { SettingsController } from './settings.controller';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
controllers: [SettingsController],
|
controllers: [SettingsController],
|
||||||
providers: [SettingsService],
|
providers: [SettingsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class GiteaWebhookController {
|
|||||||
constructor(private readonly giteaWebhookService: GiteaWebhookService) {}
|
constructor(private readonly giteaWebhookService: GiteaWebhookService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiCreatedResponse({ description: 'Receive a Gitea webhook event.' })
|
@ApiCreatedResponse({ description: '接收 Gitea webhook 事件。' })
|
||||||
async receiveWebhook(
|
async receiveWebhook(
|
||||||
@Headers('x-gitea-event') event: string | undefined,
|
@Headers('x-gitea-event') event: string | undefined,
|
||||||
@Headers('x-gitea-delivery') delivery: string | undefined,
|
@Headers('x-gitea-delivery') delivery: string | undefined,
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe('GiteaWebhookService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.accepted).toBe(false);
|
expect(result.accepted).toBe(false);
|
||||||
expect(result.reason).toContain('Only branch push');
|
expect(result.reason).toContain('只有分支 push');
|
||||||
expect(deployRuns.createRun).not.toHaveBeenCalled();
|
expect(deployRuns.createRun).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class GiteaWebhookService {
|
|||||||
if (input.headers.event !== 'push') {
|
if (input.headers.event !== 'push') {
|
||||||
return this.ignore(
|
return this.ignore(
|
||||||
input.headers,
|
input.headers,
|
||||||
'Only push events can trigger test deploys',
|
'只有 push 事件可以触发测试环境发布',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export class GiteaWebhookService {
|
|||||||
if (!payload.ref.startsWith('refs/heads/')) {
|
if (!payload.ref.startsWith('refs/heads/')) {
|
||||||
return this.ignore(
|
return this.ignore(
|
||||||
input.headers,
|
input.headers,
|
||||||
'Only branch push events trigger test deploys',
|
'只有分支 push 事件可以触发测试环境发布',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export class GiteaWebhookService {
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
return this.ignore(
|
return this.ignore(
|
||||||
input.headers,
|
input.headers,
|
||||||
'Repository is not managed by this platform',
|
'仓库未纳入当前平台管理',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export class GiteaWebhookService {
|
|||||||
if (!allowedBranches.has(branch)) {
|
if (!allowedBranches.has(branch)) {
|
||||||
return this.ignore(
|
return this.ignore(
|
||||||
input.headers,
|
input.headers,
|
||||||
`Branch ${branch} is not configured for automatic test deploy`,
|
`分支 ${branch} 未配置自动测试环境发布`,
|
||||||
project.key,
|
project.key,
|
||||||
branch,
|
branch,
|
||||||
);
|
);
|
||||||
@@ -146,7 +146,7 @@ export class GiteaWebhookService {
|
|||||||
) {
|
) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
'Invalid Gitea webhook signature',
|
'Gitea webhook 签名无效',
|
||||||
401,
|
401,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ export class GiteaWebhookService {
|
|||||||
if (!isRecord(payload) || !isRecord(payload.repository)) {
|
if (!isRecord(payload) || !isRecord(payload.repository)) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
'Invalid Gitea push payload',
|
'Gitea push 载荷无效',
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ export class GiteaWebhookService {
|
|||||||
if (!ref || !after) {
|
if (!ref || !after) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
'Gitea push payload missing ref or after',
|
'Gitea push 载荷缺少 ref 或 after',
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user