DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops
- Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages - PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs) - Auth/RBAC/workspace isolation, credential vault (AES-256-GCM) - Servers/SSH, Docker discovery & lifecycle, Job Center - Websites & deployments (git provider credential clone, env, health verification) - Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring - Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools) - AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals) - Notes: Vditor WYSIWYG markdown editor, non-blocking autosave - Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "devos-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "bun dist/main.js",
|
||||
"test": "bun test ../../tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@devos/auth": "workspace:*",
|
||||
"@devos/types": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-fastify": "^11.0.0",
|
||||
"@prisma/client": "6.19.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"ssh2": "^1.16.0",
|
||||
"zod": "^3.24.0",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ssh2": "^1.15.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* # DevOS API 根模块(模块化单体)
|
||||
* 核心域:identity / workspace / resource / permission(守卫) / audit
|
||||
* 基础设施域:server / container / website / domain / deployment / monitoring
|
||||
* 自动化与智能:event / job / worker / automation / ai / mcp / credential
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { APP_GUARD, APP_FILTER } from "@nestjs/core";
|
||||
import { ConfigModule } from "./common/config/config.module";
|
||||
import { LoggerModule } from "./common/logger/logger.module";
|
||||
import { SecurityModule } from "./common/security/security.module";
|
||||
import { AiProviderModule } from "./common/ai/ai-provider.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
|
||||
import { WorkspaceGuard } from "./common/guards/workspace.guard";
|
||||
import { AllExceptionsFilter } from "./common/errors/all-exceptions.filter";
|
||||
import { SystemController } from "./common/system.controller";
|
||||
|
||||
// 模块
|
||||
import { IdentityModule } from "./modules/identity/identity.module";
|
||||
import { WorkspaceModule } from "./modules/workspace/workspace.module";
|
||||
import { ResourceModule } from "./modules/resource/resource.module";
|
||||
import { AuditModule } from "./modules/audit/audit.module";
|
||||
import { CredentialModule } from "./modules/credential/credential.module";
|
||||
import { EventModule } from "./modules/event/event.module";
|
||||
import { JobModule } from "./modules/job/job.module";
|
||||
import { ServerModule } from "./modules/server/server.module";
|
||||
import { ContainerModule } from "./modules/container/container.module";
|
||||
import { WebsiteModule } from "./modules/website/website.module";
|
||||
import { DeploymentModule } from "./modules/deployment/deployment.module";
|
||||
import { DomainModule } from "./modules/domain/domain.module";
|
||||
import { MonitoringModule } from "./modules/monitoring/monitoring.module";
|
||||
import { AutomationModule } from "./modules/automation/automation.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { McpModule } from "./modules/mcp/mcp.module";
|
||||
import { WorkerModule } from "./modules/worker/worker.module";
|
||||
import { NotificationModule } from "./modules/notification/notification.module";
|
||||
import { TaskModule } from "./modules/task/task.module";
|
||||
import { NoteModule } from "./modules/note/note.module";
|
||||
import { CertificateModule } from "./modules/certificate/certificate.module";
|
||||
import { GitModule } from "./modules/git/git.module";
|
||||
import { SearchModule } from "./modules/search/search.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
LoggerModule,
|
||||
SecurityModule,
|
||||
AiProviderModule,
|
||||
PrismaModule,
|
||||
IdentityModule,
|
||||
WorkspaceModule,
|
||||
ResourceModule,
|
||||
AuditModule,
|
||||
CredentialModule,
|
||||
EventModule,
|
||||
JobModule,
|
||||
ServerModule,
|
||||
ContainerModule,
|
||||
WebsiteModule,
|
||||
DeploymentModule,
|
||||
DomainModule,
|
||||
MonitoringModule,
|
||||
AutomationModule,
|
||||
AiModule,
|
||||
McpModule,
|
||||
WorkerModule,
|
||||
NotificationModule,
|
||||
TaskModule,
|
||||
NoteModule,
|
||||
CertificateModule,
|
||||
GitModule,
|
||||
SearchModule,
|
||||
],
|
||||
controllers: [SystemController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: WorkspaceGuard },
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* # AI Provider 模块(全局共享 LLM 补全)
|
||||
*/
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { AiProviderService } from "./ai-provider.service";
|
||||
import { ConfigModule } from "../config/config.module";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [AiProviderService],
|
||||
exports: [AiProviderService],
|
||||
})
|
||||
export class AiProviderModule {}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* # AI Provider 服务(共享 LLM 补全)
|
||||
* OpenAI 兼容 /chat/completions;未配置 provider 时可优雅降级。
|
||||
* 供 AI Gateway 规划器与 Notes AI 助手等场景共用。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { ApiError } from "../errors/api-error";
|
||||
|
||||
@Injectable()
|
||||
export class AiProviderService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
/** # 是否已配置 LLM 提供商 */
|
||||
get enabled(): boolean {
|
||||
return this.config.aiEnabled;
|
||||
}
|
||||
|
||||
/** # 文本补全(单轮 system+user → text)
|
||||
* @param system 系统提示词
|
||||
* @param user 用户内容
|
||||
* @param opts 温度/最大输出
|
||||
*/
|
||||
async complete(
|
||||
system: string,
|
||||
user: string,
|
||||
opts: { temperature?: number; maxTokens?: number } = {},
|
||||
): Promise<string> {
|
||||
if (!this.config.aiEnabled) {
|
||||
throw ApiError.validation(
|
||||
"AI provider is not configured (set AI_PROVIDER_BASE_URL / AI_PROVIDER_API_KEY)",
|
||||
);
|
||||
}
|
||||
const res = await fetch(
|
||||
`${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.aiApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.aiModel,
|
||||
messages: [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
max_tokens: opts.maxTokens ?? 2000,
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw ApiError.upstream(`AI provider HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
const body = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (!content) throw ApiError.upstream("AI provider returned no content");
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* # 全局配置模块
|
||||
*/
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { ConfigService } from "./config.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ConfigService],
|
||||
exports: [ConfigService],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* # 配置服务
|
||||
* 集中读取环境变量(Bun 自动加载 .env),类型化访问,缺失必填项时快速失败。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { assertProductionSecrets } from "../security/production-check";
|
||||
|
||||
/** # 必填环境变量列表 **/
|
||||
const REQUIRED_KEYS = ["APP_SECRET", "ENCRYPTION_KEY", "DATABASE_URL"] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
/** # API 监听端口 **/
|
||||
readonly port = Number(process.env.PORT ?? 4100);
|
||||
/** # 监听地址 **/
|
||||
readonly host = process.env.HOST ?? "0.0.0.0";
|
||||
/** # JWT/应用密钥 **/
|
||||
readonly appSecret = process.env.APP_SECRET ?? "";
|
||||
/** # 凭据信封加密主密钥(64 位 hex = 32 字节) **/
|
||||
readonly encryptionKey = process.env.ENCRYPTION_KEY ?? "";
|
||||
/** # PostgreSQL 连接串 **/
|
||||
readonly databaseUrl = process.env.DATABASE_URL ?? "";
|
||||
/** # Redis 连接串 **/
|
||||
readonly redisUrl = process.env.REDIS_URL ?? "";
|
||||
/** # 服务版本标识 **/
|
||||
readonly version = process.env.APP_VERSION ?? "1.0.0";
|
||||
/** # AI Provider(OpenAI 兼容)配置,可选 **/
|
||||
readonly aiBaseUrl = process.env.AI_PROVIDER_BASE_URL ?? "";
|
||||
readonly aiApiKey = process.env.AI_PROVIDER_API_KEY ?? "";
|
||||
readonly aiModel = process.env.AI_PROVIDER_MODEL ?? "gpt-4o-mini";
|
||||
|
||||
constructor() {
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
if (!process.env[key]) {
|
||||
throw new Error(`Missing required env: ${key}`);
|
||||
}
|
||||
}
|
||||
if (this.encryptionKey.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(this.encryptionKey)) {
|
||||
throw new Error("ENCRYPTION_KEY must be 64 hex chars (32 bytes)");
|
||||
}
|
||||
assertProductionSecrets({
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
appSecret: this.appSecret,
|
||||
encryptionKey: this.encryptionKey,
|
||||
});
|
||||
}
|
||||
|
||||
/** # 是否配置了 AI Provider */
|
||||
get aiEnabled(): boolean {
|
||||
return Boolean(this.aiBaseUrl && this.aiApiKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* # 认证与授权相关自定义装饰器
|
||||
*/
|
||||
import { SetMetadata, createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
import type { WorkspaceRole } from "@devos/types";
|
||||
|
||||
/** # 路由公开标记(跳过 JWT 守卫)的 metadata key **/
|
||||
export const IS_PUBLIC_KEY = "isPublic";
|
||||
|
||||
/** # @Public():标记路由为公开(健康检查、登录、注册) */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
/** # 角色要求 metadata key **/
|
||||
export const ROLES_KEY = "minRoles";
|
||||
|
||||
/** # @Roles(...roles):要求当前工作区角色在列表内(VIEWER 只读) */
|
||||
export const Roles = (...roles: WorkspaceRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
/** # @NoWorkspace():跳过工作区解析(如 /auth/me) */
|
||||
export const NO_WORKSPACE_KEY = "noWorkspace";
|
||||
export const NoWorkspace = () => SetMetadata(NO_WORKSPACE_KEY, true);
|
||||
|
||||
/** # 请求上的认证用户 **/
|
||||
export interface RequestUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** # 请求上的工作区信息 **/
|
||||
export interface RequestWorkspace {
|
||||
id: string;
|
||||
role: WorkspaceRole;
|
||||
}
|
||||
|
||||
/** # @CurrentUser():注入当前认证用户 */
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user as RequestUser | undefined;
|
||||
});
|
||||
|
||||
/** # @CurrentWorkspace():注入当前工作区(id + role) */
|
||||
export const CurrentWorkspace = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.workspace as RequestWorkspace | undefined;
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* # 全局异常过滤器
|
||||
* 输出 05-API-SPEC 定义的错误契约:
|
||||
* { "error": { "code", "message", "requestId" } }
|
||||
* 绝不向客户端暴露堆栈、凭据或内部细节。
|
||||
*/
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { ZodError } from "zod";
|
||||
import { ApiError } from "./api-error";
|
||||
|
||||
/** # 从请求对象读取 requestId(中间件在请求上挂载) */
|
||||
function getRequestId(request: FastifyRequest | undefined): string {
|
||||
const rid = (request as { requestId?: string } | undefined)?.requestId;
|
||||
return typeof rid === "string" && rid ? rid : "unknown";
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
/** # 捕获所有异常并归一化输出 */
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const reply = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
const requestId = getRequestId(request);
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = "INTERNAL_ERROR";
|
||||
let message = "Internal server error";
|
||||
let details: unknown;
|
||||
|
||||
if (exception instanceof ApiError) {
|
||||
status = exception.getStatus();
|
||||
code = exception.code;
|
||||
message = exception.message;
|
||||
details = exception.details;
|
||||
} else if (exception instanceof ZodError) {
|
||||
status = HttpStatus.BAD_REQUEST;
|
||||
code = "VALIDATION_ERROR";
|
||||
message = "Request validation failed";
|
||||
details = exception.issues.map((i) => ({ path: i.path.join("."), message: i.message }));
|
||||
} else if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
code = status === HttpStatus.NOT_FOUND ? "RESOURCE_NOT_FOUND" : "HTTP_ERROR";
|
||||
message = exception.message;
|
||||
} else {
|
||||
// 服务端内部错误:只在日志中记录细节
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
time: new Date().toISOString(),
|
||||
level: "error",
|
||||
ctx: "ExceptionsFilter",
|
||||
requestId,
|
||||
msg: exception instanceof Error ? `${exception.message}` : String(exception),
|
||||
stack: exception instanceof Error ? exception.stack : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!reply.sent) {
|
||||
reply.code(status).send({
|
||||
error: { code, message, requestId, ...(details !== undefined ? { details } : {}) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* # HTTP API 异常
|
||||
* 业务代码抛出的统一异常:携带机器可读错误码(对齐 05-API-SPEC 错误契约)。
|
||||
*/
|
||||
import { HttpException } from "@nestjs/common";
|
||||
import type { ErrorCode } from "@devos/types";
|
||||
|
||||
/** # API 业务异常 **/
|
||||
export class ApiError extends HttpException {
|
||||
/** # 机器可读错误码 **/
|
||||
readonly code: ErrorCode | string;
|
||||
/** # 附加详情(绝不包含堆栈/凭据) **/
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(status: number, code: ErrorCode | string, message: string, details?: unknown) {
|
||||
super(message, status);
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
/** # 快捷构造:400 校验失败 */
|
||||
static validation(message: string, details?: unknown) {
|
||||
return new ApiError(400, "VALIDATION_ERROR", message, details);
|
||||
}
|
||||
/** # 快捷构造:401 未认证 */
|
||||
static unauthorized(message = "Authentication required") {
|
||||
return new ApiError(401, "UNAUTHORIZED", message);
|
||||
}
|
||||
/** # 快捷构造:403 禁止 */
|
||||
static forbidden(message = "Permission denied") {
|
||||
return new ApiError(403, "FORBIDDEN", message);
|
||||
}
|
||||
/** # 快捷构造:404 资源不存在 */
|
||||
static notFound(message = "Resource was not found") {
|
||||
return new ApiError(404, "RESOURCE_NOT_FOUND", message);
|
||||
}
|
||||
/** # 快捷构造:409 冲突 */
|
||||
static conflict(message: string) {
|
||||
return new ApiError(409, "CONFLICT", message);
|
||||
}
|
||||
/** # 快捷构造:502 上游失败 */
|
||||
static upstream(message: string, details?: unknown) {
|
||||
return new ApiError(502, "UPSTREAM_FAILURE", message, details);
|
||||
}
|
||||
/** # 快捷构造:504 超时 */
|
||||
static timeout(message: string) {
|
||||
return new ApiError(504, "TIMEOUT", message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* # JWT 认证守卫(全局)
|
||||
* 解析 Authorization: Bearer <jwt>,校验通过后挂载 req.user 并更新请求上下文。
|
||||
* @Public() 标记的路由跳过。
|
||||
*/
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { verifyJwt } from "@devos/auth";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
import { ApiError } from "../errors/api-error";
|
||||
import { IS_PUBLIC_KEY } from "../decorators";
|
||||
import { getRequestContext } from "../request-context";
|
||||
|
||||
/** # Fastify 请求扩展(认证部分) **/
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
user?: { id: string; email: string };
|
||||
jwtPayload?: { wid: string | null };
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
/** # 校验 JWT 并挂载用户 */
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const header = request.headers["authorization"];
|
||||
if (!header || !header.startsWith("Bearer ")) {
|
||||
throw ApiError.unauthorized();
|
||||
}
|
||||
const result = verifyJwt(header.slice(7), this.config.appSecret);
|
||||
if (!result.ok) {
|
||||
throw ApiError.unauthorized(result.reason === "EXPIRED" ? "Token expired" : "Invalid token");
|
||||
}
|
||||
request.user = { id: result.payload.sub, email: result.payload.email };
|
||||
request.jwtPayload = result.payload;
|
||||
const ctx = getRequestContext();
|
||||
if (ctx) {
|
||||
ctx.actorId = result.payload.sub;
|
||||
ctx.actorType = "USER";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* # 工作区守卫(全局)
|
||||
* 解析当前工作区:X-Workspace-Id 请求头 > JWT wid 声明 > 第一个成员关系。
|
||||
* 校验成员资格后挂载 req.workspace(含角色)并写入请求上下文。
|
||||
* @NoWorkspace() 标记的路由跳过(但已认证用户仍可访问)。
|
||||
*/
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { ApiError } from "../errors/api-error";
|
||||
import { NO_WORKSPACE_KEY, ROLES_KEY } from "../decorators";
|
||||
import { getRequestContext } from "../request-context";
|
||||
import type { WorkspaceRole } from "@devos/types";
|
||||
|
||||
/** # 角色权限序(数值越大权限越高) **/
|
||||
const ROLE_RANK: Record<WorkspaceRole, number> = { VIEWER: 0, MEMBER: 1, ADMIN: 2, OWNER: 3 };
|
||||
|
||||
/** # Fastify 请求扩展(工作区部分) **/
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
workspace?: { id: string; role: WorkspaceRole };
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
/** # 解析并校验当前工作区 */
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const noWorkspace = this.reflector.getAllAndOverride<boolean>(NO_WORKSPACE_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
const request = context.switchToHttp().getRequest();
|
||||
if (noWorkspace || !request.user) {
|
||||
this.checkRoles(request, undefined, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
const headerWid = request.headers["x-workspace-id"] as string | undefined;
|
||||
const tokenWid = request.jwtPayload?.wid ?? null;
|
||||
const membership = await this.resolveMembership(request.user.id, headerWid, tokenWid);
|
||||
if (!membership) {
|
||||
throw ApiError.forbidden("No workspace membership");
|
||||
}
|
||||
request.workspace = { id: membership.workspaceId, role: membership.role };
|
||||
const ctx = getRequestContext();
|
||||
if (ctx) ctx.workspaceId = membership.workspaceId;
|
||||
|
||||
// 路径中出现 :workspaceId 时,必须对其具备成员资格(防止越权访问其它工作区)
|
||||
const paramWid = (request.params as Record<string, string> | undefined)?.workspaceId;
|
||||
if (paramWid && paramWid !== membership.workspaceId) {
|
||||
const paramMembership = await this.prisma.workspaceMember.findUnique({
|
||||
where: { workspaceId_userId: { workspaceId: paramWid, userId: request.user.id } },
|
||||
select: { workspaceId: true, role: true },
|
||||
});
|
||||
if (!paramMembership) {
|
||||
throw ApiError.forbidden("Not a member of the target workspace");
|
||||
}
|
||||
request.workspace = paramMembership;
|
||||
if (ctx) ctx.workspaceId = paramMembership.workspaceId;
|
||||
}
|
||||
|
||||
this.checkRoles(request, request.workspace.role, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** # 校验角色要求 */
|
||||
private checkRoles(
|
||||
request: { workspace?: { role: WorkspaceRole } },
|
||||
role: WorkspaceRole | undefined,
|
||||
context: ExecutionContext,
|
||||
): void {
|
||||
const required = this.reflector.getAllAndOverride<WorkspaceRole[] | undefined>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required || required.length === 0) return;
|
||||
const current = role ?? request.workspace?.role;
|
||||
if (!current || !required.some((r) => ROLE_RANK[current] >= ROLE_RANK[r])) {
|
||||
throw ApiError.forbidden(`Requires role: ${required.join(" or ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 解析用户在某工作区的成员关系 */
|
||||
private async resolveMembership(
|
||||
userId: string,
|
||||
headerWid?: string,
|
||||
tokenWid?: string | null,
|
||||
): Promise<{ workspaceId: string; role: WorkspaceRole } | null> {
|
||||
const wid = headerWid || tokenWid;
|
||||
if (wid) {
|
||||
const m = await this.prisma.workspaceMember.findUnique({
|
||||
where: { workspaceId_userId: { workspaceId: wid, userId } },
|
||||
select: { workspaceId: true, role: true },
|
||||
});
|
||||
if (m) return m;
|
||||
if (headerWid) {
|
||||
// 显式指定了不属于自己的工作区:明确拒绝
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const first = await this.prisma.workspaceMember.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { workspaceId: true, role: true },
|
||||
});
|
||||
return first;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* # 日志工厂(全局模块)
|
||||
* 为每个消费者提供独立 context 的结构化日志实例。
|
||||
*/
|
||||
import { Global, Injectable, Module } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class LogService {
|
||||
/** # 创建带类别的日志器
|
||||
* @param context 模块名(写入日志 ctx 字段)
|
||||
*/
|
||||
child(context: string): StructuredLoggerInstance {
|
||||
const logger = new StructuredLoggerInstance();
|
||||
logger.setContext(context);
|
||||
return logger;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 结构化日志器实例(非 DI 管理,由 LogService 工厂创建) */
|
||||
export class StructuredLoggerInstance {
|
||||
/** # 日志类别 **/
|
||||
private context = "App";
|
||||
|
||||
/** # 设置类别 **/
|
||||
setContext(context: string): void {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private write(level: "debug" | "log" | "warn" | "error", message: unknown, meta?: unknown): void {
|
||||
const ctx = requestContext();
|
||||
const entry: Record<string, unknown> = {
|
||||
time: new Date().toISOString(),
|
||||
level,
|
||||
ctx: this.context,
|
||||
msg: typeof message === "string" ? message : JSON.stringify(message),
|
||||
};
|
||||
if (ctx?.requestId) entry.requestId = ctx.requestId;
|
||||
if (ctx?.correlationId) entry.correlationId = ctx.correlationId;
|
||||
if (ctx?.workspaceId) entry.workspaceId = ctx.workspaceId;
|
||||
if (ctx?.actorId) entry.actorId = ctx.actorId;
|
||||
if (meta !== undefined) {
|
||||
entry.meta = meta instanceof Error ? { name: meta.name, message: meta.message } : meta;
|
||||
}
|
||||
const line = JSON.stringify(entry);
|
||||
if (level === "error") console.error(line);
|
||||
else if (level === "warn") console.warn(line);
|
||||
else console.log(line);
|
||||
}
|
||||
|
||||
/** # 调试日志 */
|
||||
debug(message: unknown, meta?: unknown): void {
|
||||
this.write("debug", message, meta);
|
||||
}
|
||||
/** # 信息日志 */
|
||||
log(message: unknown, meta?: unknown): void {
|
||||
this.write("log", message, meta);
|
||||
}
|
||||
/** # 警告日志 */
|
||||
warn(message: unknown, meta?: unknown): void {
|
||||
this.write("warn", message, meta);
|
||||
}
|
||||
/** # 错误日志 */
|
||||
error(message: unknown, meta?: unknown): void {
|
||||
this.write("error", message, meta);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 延迟引用避免循环 import(request-context 无依赖,直接引入即可) */
|
||||
import { requestContextStore } from "../request-context";
|
||||
|
||||
/** # 快捷读取当前请求上下文 */
|
||||
function requestContext(): { requestId?: string; correlationId?: string; workspaceId?: string; actorId?: string } | undefined {
|
||||
return requestContextStore.getStore();
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [LogService],
|
||||
exports: [LogService],
|
||||
})
|
||||
export class LoggerModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* # 请求上下文 Fastify 钩子
|
||||
* 在 onRequest 阶段(拿到真正的 FastifyRequest/FastifyReply):
|
||||
* - 透传或生成 X-Request-Id,写响应头;
|
||||
* - 用 AsyncLocalStorage 建立请求级上下文(后续 Guard 补充 actor/workspace)。
|
||||
*/
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { requestContextStore } from "../request-context";
|
||||
|
||||
/** # 请求对象扩展字段 **/
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
requestId: string;
|
||||
/** # 原始请求体(Nest rawBody: true 注入,webhook HMAC 验签用) **/
|
||||
rawBody?: Buffer;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 注册钩子(传入 Fastify 实例;必须在路由初始化前调用) */
|
||||
export function registerRequestContextHook(fastify: FastifyInstance): void {
|
||||
fastify.addHook("onRequest", (req: FastifyRequest, reply: FastifyReply, done: () => void) => {
|
||||
const requestId = (req.headers["x-request-id"] as string) || randomUUID();
|
||||
req.requestId = requestId;
|
||||
reply.header("X-Request-Id", requestId);
|
||||
requestContextStore.run({ requestId, ip: req.ip }, () => done());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* # Zod 校验管道
|
||||
* 将路由参数 DTO 与 zod schema 绑定:校验失败抛出 400 VALIDATION_ERROR,
|
||||
* 校验通过返回 parse 后的强类型值(转换/默认值均已应用)。
|
||||
*/
|
||||
import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";
|
||||
import { ZodSchema } from "zod";
|
||||
import { ApiError } from "../errors/api-error";
|
||||
|
||||
@Injectable()
|
||||
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
|
||||
constructor(private readonly schema: ZodSchema<T>) {}
|
||||
|
||||
/** # 转换并校验输入值 */
|
||||
transform(value: unknown, _metadata: ArgumentMetadata): T {
|
||||
const result = this.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw ApiError.validation(
|
||||
"Request validation failed",
|
||||
result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* # 请求上下文
|
||||
* 基于 AsyncLocalStorage 贯穿一次请求/一次异步任务的执行单元:
|
||||
* requestId(传输层标识)、correlationId(跨资源因果链)、actor(审计主体)、workspaceId。
|
||||
*/
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { ActorType } from "@devos/types";
|
||||
|
||||
/** # 请求上下文内容 **/
|
||||
export interface RequestContext {
|
||||
requestId: string;
|
||||
correlationId?: string;
|
||||
actorType?: ActorType;
|
||||
actorId?: string;
|
||||
workspaceId?: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
/** # 全局 ALS 实例 **/
|
||||
export const requestContextStore = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
/** # 读取当前上下文(可能为空,例如非 HTTP 场景) */
|
||||
export function getRequestContext(): RequestContext | undefined {
|
||||
return requestContextStore.getStore();
|
||||
}
|
||||
|
||||
/** # 在指定上下文中运行异步函数(用于 worker/调度器派生的执行单元) */
|
||||
export function runWithContext<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T> {
|
||||
return requestContextStore.run(ctx, fn);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* # 生产环境 secret 校验(独立模块,避免与 ConfigService 循环依赖)
|
||||
*/
|
||||
|
||||
/** # 已知开发默认 secret(生产启动时拒绝) **/
|
||||
export const KNOWN_DEV_SECRETS = new Set([
|
||||
"dev-devos-app-secret-change-me-please-0123456789",
|
||||
"change-me-to-a-long-random-secret",
|
||||
"changeme",
|
||||
"secret",
|
||||
]);
|
||||
|
||||
/** # 校验生产 secret 配置(启动时调用;失败抛错阻止启动) */
|
||||
export function assertProductionSecrets(values: {
|
||||
nodeEnv: string | undefined;
|
||||
appSecret: string;
|
||||
encryptionKey: string;
|
||||
}): void {
|
||||
if (values.nodeEnv !== "production") return;
|
||||
if (values.appSecret.length < 32 || KNOWN_DEV_SECRETS.has(values.appSecret)) {
|
||||
throw new Error("Production refuses weak/default APP_SECRET (>=32 random chars required)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* # 安全加固(Phase A)
|
||||
* - 生产环境 secret 校验:拒绝明显开发默认值/弱密钥;
|
||||
* - 注册开关:ENABLE_REGISTRATION(生产默认关闭);
|
||||
* - 登录/注册限流:进程内滑动窗口(多实例部署时建议换 Redis 实现)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "../config/config.service";
|
||||
|
||||
export { assertProductionSecrets } from "./production-check";
|
||||
|
||||
@Injectable()
|
||||
export class SecurityPolicyService {
|
||||
/** # 是否允许公开注册(生产默认关闭,需显式 ENABLE_REGISTRATION=1) **/
|
||||
readonly registrationEnabled: boolean;
|
||||
/** # 限流窗口内的最大尝试次数 **/
|
||||
private readonly limit = 10;
|
||||
/** # 滑动窗口长度(毫秒) **/
|
||||
private readonly windowMs = 60_000;
|
||||
/** # ip → 时间戳列表 **/
|
||||
private attempts = new Map<string, number[]>();
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const flag = process.env.ENABLE_REGISTRATION;
|
||||
this.registrationEnabled =
|
||||
flag !== undefined ? flag === "1" || flag === "true" : process.env.NODE_ENV !== "production";
|
||||
}
|
||||
|
||||
/** # 注册是否被允许;不允许时返回原因 */
|
||||
checkRegistration(): { allowed: boolean; reason?: string } {
|
||||
if (this.registrationEnabled) return { allowed: true };
|
||||
return { allowed: false, reason: "Registration is disabled on this instance (invite-only)" };
|
||||
}
|
||||
|
||||
/** # 限流检查(滑动窗口);超限返回 false
|
||||
* @param key 维度键(如 `login:1.2.3.4`)
|
||||
*/
|
||||
checkRate(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const list = (this.attempts.get(key) ?? []).filter((t) => now - t < this.windowMs);
|
||||
if (list.length >= this.limit) {
|
||||
this.attempts.set(key, list);
|
||||
return false;
|
||||
}
|
||||
list.push(now);
|
||||
this.attempts.set(key, list);
|
||||
// 防内存膨胀:键过多时清理过期项
|
||||
if (this.attempts.size > 10_000) {
|
||||
for (const [k, v] of this.attempts) {
|
||||
if (v.every((t) => now - t >= this.windowMs)) this.attempts.delete(k);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** # webhook secret 生成提示(HMAC 用) */
|
||||
get webhookLegacyFallback(): boolean {
|
||||
return this.config.appSecret.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* # 安全策略模块(全局)
|
||||
*/
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { SecurityPolicyService } from "./security-policy.service";
|
||||
import { ConfigModule } from "../config/config.module";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [SecurityPolicyService],
|
||||
exports: [SecurityPolicyService],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* # Webhook HMAC 签名(Phase A/F 安全升级)
|
||||
* 签名格式(兼容 GitHub 风格):
|
||||
* X-Signature: t=<unix秒>,v1=<hex_hmac_sha256(secret, `${t}.${rawBody}`)>
|
||||
* 校验:时间戳容差 ±5 分钟(防重放),签名常量时间比较。
|
||||
* 未携带签名头的旧客户端回退 X-Webhook-Secret 明文比对(legacy,计划移除)。
|
||||
*/
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/** # 签名时间戳容差(毫秒) **/
|
||||
export const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** # 计算 webhook 签名
|
||||
* @param secret webhook secret
|
||||
* @param timestamp unix 秒
|
||||
* @param body 原始请求体字符串
|
||||
*/
|
||||
export function signWebhookPayload(secret: string, timestamp: number, body: string): string {
|
||||
return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
|
||||
}
|
||||
|
||||
/** # 构造完整签名头 */
|
||||
export function buildSignatureHeader(secret: string, body: string, nowSec?: number): string {
|
||||
const t = nowSec ?? Math.floor(Date.now() / 1000);
|
||||
return `t=${t},v1=${signWebhookPayload(secret, t, body)}`;
|
||||
}
|
||||
|
||||
/** # 常量时间字符串比较 */
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a, "utf8");
|
||||
const bb = Buffer.from(b, "utf8");
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/** # 校验签名头
|
||||
* @returns ok + 失败原因(供审计)
|
||||
*/
|
||||
export function verifyWebhookSignature(
|
||||
secret: string,
|
||||
signatureHeader: string | undefined,
|
||||
body: string,
|
||||
nowMs?: number,
|
||||
): { ok: boolean; reason?: string } {
|
||||
if (!signatureHeader) return { ok: false, reason: "missing signature" };
|
||||
const m = signatureHeader.match(/^t=(\d+),v1=([0-9a-f]{64})$/);
|
||||
if (!m) return { ok: false, reason: "malformed signature" };
|
||||
const t = Number(m[1]);
|
||||
const now = nowMs ?? Date.now();
|
||||
if (Math.abs(now - t * 1000) > SIGNATURE_TOLERANCE_MS) {
|
||||
return { ok: false, reason: "timestamp outside tolerance (replay?)" };
|
||||
}
|
||||
const expected = signWebhookPayload(secret, t, body);
|
||||
if (!safeEqual(expected, m[2])) return { ok: false, reason: "signature mismatch" };
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* # 系统模块:健康检查 + 工作台统计
|
||||
*/
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Public, CurrentWorkspace, NoWorkspace } from "./decorators";
|
||||
import { ConfigService } from "./config/config.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { RequestWorkspace } from "./decorators";
|
||||
|
||||
@Controller()
|
||||
export class SystemController {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
/** # 健康检查(公开;负载均衡/监控探针用) */
|
||||
@Public()
|
||||
@NoWorkspace()
|
||||
@Get("health")
|
||||
health() {
|
||||
return { status: "ok", service: "devos-api", version: this.config.version, time: new Date().toISOString() };
|
||||
}
|
||||
|
||||
/** # 工作台统计:资源计数 + 最近活动(Dashboard 首页) */
|
||||
@Get("dashboard")
|
||||
async dashboard(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
if (!ws) {
|
||||
return { serverCount: 0, websiteCount: 0, domainCount: 0, containerCount: 0, deploymentCount: 0, monitorsUp: 0, monitorsDown: 0, recentEvents: [], recentDeployments: [] };
|
||||
}
|
||||
const wid = ws.id;
|
||||
const [serverCount, websiteCount, domainCount, containerCount, deploymentCount, monitorsUp, monitorsDown, recentEvents, recentDeployments] =
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.server.count({ where: { workspaceId: wid } }),
|
||||
this.prisma.website.count({ where: { workspaceId: wid } }),
|
||||
this.prisma.domain.count({ where: { workspaceId: wid } }),
|
||||
this.prisma.container.count({ where: { workspaceId: wid } }),
|
||||
this.prisma.deployment.count({ where: { workspaceId: wid } }),
|
||||
this.prisma.monitor.count({ where: { workspaceId: wid, lastStatus: "UP" } }),
|
||||
this.prisma.monitor.count({ where: { workspaceId: wid, lastStatus: "DOWN" } }),
|
||||
this.prisma.eventRecord.findMany({
|
||||
where: { workspaceId: wid },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.deployment.findMany({
|
||||
where: { workspaceId: wid },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
include: { website: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
serverCount,
|
||||
websiteCount,
|
||||
domainCount,
|
||||
containerCount,
|
||||
deploymentCount,
|
||||
monitorsUp,
|
||||
monitorsDown,
|
||||
recentEvents,
|
||||
recentDeployments: recentDeployments.map((d) => ({
|
||||
id: d.id,
|
||||
status: d.status,
|
||||
websiteName: d.website?.name,
|
||||
createdAt: d.createdAt,
|
||||
trigger: d.trigger,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* # 网页文本提取工具
|
||||
* 零依赖 HTML → 可读文本,供 AI 网页总结 / 内容抓取使用。
|
||||
*/
|
||||
|
||||
/** # 提取 <title> */
|
||||
export function extractHtmlTitle(html: string): string | null {
|
||||
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (!m) return null;
|
||||
return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 200) || null;
|
||||
}
|
||||
|
||||
/** # 提取 meta description */
|
||||
export function extractMetaDescription(html: string): string | null {
|
||||
const m = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) ??
|
||||
html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i);
|
||||
if (!m) return null;
|
||||
return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 400) || null;
|
||||
}
|
||||
|
||||
/** # 提取正文段落(按长度过滤导航/噪音) */
|
||||
export function extractMainParagraphs(html: string, max = 8): string[] {
|
||||
const cleaned = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, " ")
|
||||
.replace(/<header[\s\S]*?<\/header>/gi, " ")
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, " ")
|
||||
.replace(/<!--[\s\S]*?-->/g, " ");
|
||||
const paragraphs: string[] = [];
|
||||
const re = /<(?:p|h1|h2|h3|li)[^>]*>([\s\S]*?)<\/(?:p|h1|h2|h3|li)>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(cleaned)) && paragraphs.length < max * 3) {
|
||||
const text = decodeEntities(m[1].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
||||
// 过滤太短的噪音(菜单项等)
|
||||
if (text.length >= 40) paragraphs.push(text);
|
||||
if (paragraphs.length >= max) break;
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
/** # HTML → 纯文本(含标题/正文,截断) */
|
||||
export function htmlToText(html: string, maxChars = 12000): string {
|
||||
const title = extractHtmlTitle(html);
|
||||
const paragraphs = extractMainParagraphs(html, 40);
|
||||
const body = [title ? `# ${title}` : "", ...paragraphs].filter(Boolean).join("\n\n");
|
||||
return body.slice(0, maxChars);
|
||||
}
|
||||
|
||||
/** # 抽取式摘要(无 LLM 时的兜底:标题 + meta 描述 + 前几段) */
|
||||
export function extractiveSummary(html: string, url: string): { title: string; summaryText: string } {
|
||||
const title = extractHtmlTitle(html) ?? url;
|
||||
const meta = extractMetaDescription(html);
|
||||
const paragraphs = extractMainParagraphs(html, 4);
|
||||
const lines = [
|
||||
meta ? `📌 ${meta}` : "",
|
||||
...paragraphs.map((p) => `• ${p.slice(0, 220)}`),
|
||||
].filter(Boolean);
|
||||
return {
|
||||
title,
|
||||
summaryText: lines.join("\n") || "(页面无可提取正文)",
|
||||
};
|
||||
}
|
||||
|
||||
/** # HTML 实体解码(常用集合) */
|
||||
export function decodeEntities(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&#(\d+);/g, (_, d: string) => String.fromCodePoint(Number(d)));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* # 脱敏工具
|
||||
* 对日志、审计元数据、工具结果中可能出现的敏感键值进行遮蔽。
|
||||
* 键名匹配敏感词(password/token/secret/key/credential/authorization 等)时替换为 "***";
|
||||
* 字符串值中内嵌的密钥样式(Bearer xxx、sk-xxx)也会被遮蔽。
|
||||
*/
|
||||
|
||||
/** # 敏感键名匹配 **/
|
||||
const SENSITIVE_KEY = /pass(word)?|secret|token|api[-_]?key|access[-_]?key|credential|authorization|private[-_]?key/i;
|
||||
/** # 内嵌密钥样式 **/
|
||||
const SENSITIVE_PATTERN = /(Bearer\s+)[\w.\-~+/=]+|(sk-|ghp_|github_pat_)[\w]+/g;
|
||||
|
||||
/** # 遮蔽字符串值 */
|
||||
export function maskString(value: string): string {
|
||||
return value.replace(SENSITIVE_PATTERN, (_m, p1) => (p1 ? `${p1}***` : "***"));
|
||||
}
|
||||
|
||||
/** # 递归遮蔽对象/数组中的敏感数据;原对象不被修改
|
||||
* @param input 任意值
|
||||
* @returns 遮蔽后的值
|
||||
*/
|
||||
export function maskSecrets<T>(input: T, depth = 0): T {
|
||||
if (depth > 6) return input;
|
||||
if (typeof input === "string") return maskString(input) as unknown as T;
|
||||
if (Array.isArray(input)) return input.map((v) => maskSecrets(v, depth + 1)) as unknown as T;
|
||||
if (input && typeof input === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
|
||||
out[k] = SENSITIVE_KEY.test(k) ? "***" : maskSecrets(v, depth + 1);
|
||||
}
|
||||
return out as unknown as T;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/** # 危险命令检测(用于 server.exec 与 AI 工具风险升级)
|
||||
* @param command 待执行的 shell 命令
|
||||
* @returns 是否包含破坏性模式
|
||||
*/
|
||||
export function isDestructiveCommand(command: string): boolean {
|
||||
const patterns = [
|
||||
/rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+/,
|
||||
/mkfs(\.\w+)?\s/,
|
||||
/dd\s+if=/,
|
||||
/>\s*\/dev\/sd[a-z]/,
|
||||
/:\(\)\s*\{\s*:\|\:/,
|
||||
/shutdown|reboot|init\s+0|init\s+6/,
|
||||
/chmod\s+-R\s+777\s+\//,
|
||||
/DROP\s+(TABLE|DATABASE)\s/i,
|
||||
];
|
||||
return patterns.some((p) => p.test(command));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* # DNS 集成模块(Cloudflare / Manual 适配器)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CloudflareDnsProvider, ManualDnsProvider } from "./dns.providers";
|
||||
|
||||
@Module({
|
||||
providers: [CloudflareDnsProvider, ManualDnsProvider],
|
||||
exports: [CloudflareDnsProvider, ManualDnsProvider],
|
||||
})
|
||||
export class DnsModule {}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* # DNS Provider 适配器(Task 013)
|
||||
* 接口 + 两种实现:
|
||||
* - CloudflareDnsProvider:Cloudflare v4 API(token 凭据);
|
||||
* - ManualDnsProvider:无外部 API,记录仅存本地(本地事实源)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
/** # DNS 记录 **/
|
||||
export interface DnsRecord {
|
||||
id: string | null;
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
ttl: number;
|
||||
proxied: boolean;
|
||||
}
|
||||
|
||||
/** # DNS Provider 接口 **/
|
||||
export interface DnsProvider {
|
||||
/** # 提供商标识 **/
|
||||
readonly provider: string;
|
||||
listZones(params: { token: string; name?: string }): Promise<{ id: string; name: string }[]>;
|
||||
listRecords(params: { token: string; zoneName: string }): Promise<DnsRecord[]>;
|
||||
upsertRecord(params: {
|
||||
token: string;
|
||||
zoneName: string;
|
||||
record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean };
|
||||
}): Promise<DnsRecord>;
|
||||
deleteRecord(params: { token: string; zoneName: string; recordId: string }): Promise<void>;
|
||||
}
|
||||
|
||||
/** # Cloudflare API 响应包络 **/
|
||||
interface CfEnvelope<T> {
|
||||
success: boolean;
|
||||
errors: { code: number; message: string }[];
|
||||
result: T;
|
||||
}
|
||||
|
||||
/** # Cloudflare 记录 **/
|
||||
interface CfRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CloudflareDnsProvider implements DnsProvider {
|
||||
/** # 提供商标识 **/
|
||||
readonly provider = "CLOUDFLARE";
|
||||
/** # API 基址 **/
|
||||
private base = process.env.CLOUDFLARE_API_BASE ?? "https://api.cloudflare.com/client/v4";
|
||||
|
||||
/** # 请求 Cloudflare API */
|
||||
private async call<T>(token: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as CfEnvelope<T> | null;
|
||||
if (!body || !body.success) {
|
||||
const message = body?.errors?.[0]?.message ?? `Cloudflare API HTTP ${res.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return body.result;
|
||||
}
|
||||
|
||||
/** # 列出区域 */
|
||||
async listZones(params: { token: string; name?: string }) {
|
||||
const query = params.name ? `?name=${encodeURIComponent(params.name)}` : "?per_page=50";
|
||||
const zones = await this.call<{ id: string; name: string }[]>(params.token, `/zones${query}`);
|
||||
return zones.map((z) => ({ id: z.id, name: z.name }));
|
||||
}
|
||||
|
||||
/** # 列出记录 */
|
||||
async listRecords(params: { token: string; zoneName: string }) {
|
||||
const zones = await this.listZones({ token: params.token, name: params.zoneName });
|
||||
if (zones.length === 0) return [];
|
||||
const records = await this.call<CfRecord[]>(
|
||||
params.token,
|
||||
`/zones/${zones[0].id}/dns_records?per_page=100`,
|
||||
);
|
||||
return records.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
value: r.content,
|
||||
ttl: r.ttl,
|
||||
proxied: Boolean(r.proxied),
|
||||
}));
|
||||
}
|
||||
|
||||
/** # 创建或更新记录(按 type+name 查找,存在则 PATCH) */
|
||||
async upsertRecord(params: {
|
||||
token: string;
|
||||
zoneName: string;
|
||||
record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean };
|
||||
}) {
|
||||
const zones = await this.listZones({ token: params.token, name: params.zoneName });
|
||||
if (zones.length === 0) throw new Error(`Zone not found: ${params.zoneName}`);
|
||||
const zoneId = zones[0].id;
|
||||
const existing = await this.call<CfRecord[]>(
|
||||
params.token,
|
||||
`/zones/${zoneId}/dns_records?type=${params.record.type}&name=${encodeURIComponent(params.record.name)}`,
|
||||
);
|
||||
const payload = {
|
||||
type: params.record.type,
|
||||
name: params.record.name,
|
||||
content: params.record.value,
|
||||
ttl: params.record.ttl ?? 300,
|
||||
proxied: params.record.proxied ?? false,
|
||||
};
|
||||
if (existing.length > 0) {
|
||||
const updated = await this.call<CfRecord>(
|
||||
params.token,
|
||||
`/zones/${zoneId}/dns_records/${existing[0].id}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
);
|
||||
return { id: updated.id, type: updated.type, name: updated.name, value: updated.content, ttl: updated.ttl, proxied: Boolean(updated.proxied) };
|
||||
}
|
||||
const created = await this.call<CfRecord>(params.token, `/zones/${zoneId}/dns_records`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return { id: created.id, type: created.type, name: created.name, value: created.content, ttl: created.ttl, proxied: Boolean(created.proxied) };
|
||||
}
|
||||
|
||||
/** # 删除记录 */
|
||||
async deleteRecord(params: { token: string; zoneName: string; recordId: string }): Promise<void> {
|
||||
const zones = await this.listZones({ token: params.token, name: params.zoneName });
|
||||
if (zones.length === 0) throw new Error(`Zone not found: ${params.zoneName}`);
|
||||
await this.call(params.token, `/zones/${zones[0].id}/dns_records/${params.recordId}`, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ManualDnsProvider implements DnsProvider {
|
||||
/** # 提供商标识 **/
|
||||
readonly provider = "MANUAL";
|
||||
|
||||
/** # 手动模式:区域/记录由本地数据库管理,适配器返回空集合(由服务层处理) */
|
||||
async listZones(): Promise<{ id: string; name: string }[]> {
|
||||
return [];
|
||||
}
|
||||
async listRecords(): Promise<DnsRecord[]> {
|
||||
return [];
|
||||
}
|
||||
async upsertRecord(params: {
|
||||
record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean };
|
||||
}): Promise<DnsRecord> {
|
||||
return {
|
||||
id: null,
|
||||
type: params.record.type,
|
||||
name: params.record.name,
|
||||
value: params.record.value,
|
||||
ttl: params.record.ttl ?? 300,
|
||||
proxied: params.record.proxied ?? false,
|
||||
};
|
||||
}
|
||||
async deleteRecord(): Promise<void> {
|
||||
/* 手动模式下删除仅操作本地库 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* # Docker 集成模块
|
||||
*/
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { DockerService } from "./docker.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DockerService],
|
||||
exports: [DockerService],
|
||||
})
|
||||
export class DockerModule {}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* # Docker 适配器(Task 010)
|
||||
* 统一接口 + 两种实现:
|
||||
* - SshDockerAdapter:通过 SSH 调用目标机 docker CLI(零额外依赖、权限模型清晰);
|
||||
* - LocalSocketAdapter:通过本机 /var/run/docker.sock 调用 Docker Engine API。
|
||||
* DevOS 不重新实现 Docker,仅提供统一适配层。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { SshClientService, type SshTarget } from "../ssh/ssh-client.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
|
||||
/** # 容器摘要视图 **/
|
||||
export interface DockerContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: string;
|
||||
status: string;
|
||||
ports: Record<string, unknown>[];
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
/** # Docker 适配器接口 **/
|
||||
export interface DockerAdapter {
|
||||
/** # 是否支持该目标(例如本地 socket 仅限本机) */
|
||||
supports(server: { host: string; provider?: string }): boolean;
|
||||
listContainers(target: SshTarget): Promise<DockerContainerInfo[]>;
|
||||
inspectContainer(target: SshTarget, containerId: string): Promise<Record<string, unknown>>;
|
||||
restartContainer(target: SshTarget, containerId: string): Promise<void>;
|
||||
checkDocker(target: SshTarget): Promise<{ available: boolean; version: string }>;
|
||||
}
|
||||
|
||||
/** # docker ps --format 输出行结构(部分字段) **/
|
||||
interface DockerPsRow {
|
||||
ID: string;
|
||||
Names: string;
|
||||
Image: string;
|
||||
State: string;
|
||||
Status: string;
|
||||
Ports?: string;
|
||||
Labels?: string;
|
||||
Command?: string;
|
||||
CreatedAt?: string;
|
||||
Networks?: string;
|
||||
LocalVolumes?: string;
|
||||
Mounts?: string;
|
||||
RunningFor?: string;
|
||||
Size?: string;
|
||||
}
|
||||
|
||||
/** # 解析 docker ps --format '{{json .}}' 的 NDJSON 输出(纯函数,可单测) */
|
||||
export function parseDockerPsOutput(output: string): DockerContainerInfo[] {
|
||||
const lines = output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
const result: DockerContainerInfo[] = [];
|
||||
for (const line of lines) {
|
||||
let row: DockerPsRow;
|
||||
try {
|
||||
row = JSON.parse(line) as DockerPsRow;
|
||||
} catch {
|
||||
continue; // 跳过非 JSON 噪音行
|
||||
}
|
||||
result.push({
|
||||
id: row.ID ?? "",
|
||||
name: (row.Names ?? "").split(",").filter(Boolean)[0] ?? "",
|
||||
image: row.Image ?? "",
|
||||
state: row.State ?? "",
|
||||
status: row.Status ?? "",
|
||||
ports: parseDockerPorts(row.Ports ?? ""),
|
||||
labels: parseDockerLabels(row.Labels ?? ""),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** # 解析 docker ps Ports 列(如 0.0.0.0:8080->80/tcp, :::8080->80/tcp) */
|
||||
export function parseDockerPorts(ports: string): Record<string, unknown>[] {
|
||||
if (!ports || ports === "") return [];
|
||||
return ports.split(", ").filter(Boolean).map((p) => ({ raw: p }));
|
||||
}
|
||||
|
||||
/** # 解析 docker ps Labels 列(逗号分隔 key=value) */
|
||||
export function parseDockerLabels(labels: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!labels) return out;
|
||||
for (const pair of labels.split(",")) {
|
||||
const idx = pair.indexOf("=");
|
||||
if (idx > 0) out[pair.slice(0, idx)] = pair.slice(idx + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DockerService implements DockerAdapter {
|
||||
/** # 本机 docker socket 路径 **/
|
||||
private static readonly LOCAL_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock";
|
||||
|
||||
constructor(private readonly ssh: SshClientService) {}
|
||||
|
||||
/** # 是否支持该目标 */
|
||||
supports(server: { host: string; provider?: string }): boolean {
|
||||
return true; // 接口层总是可用;选择 SSH 或 socket 由 targetProvider 决定
|
||||
}
|
||||
|
||||
/** # 目标是否走本地 socket(server.provider === 'local') */
|
||||
isLocal(server: { provider?: string | null }): boolean {
|
||||
return server.provider === "local";
|
||||
}
|
||||
|
||||
/** # 列出容器(含停止的) */
|
||||
async listContainers(target: SshTarget, isLocal = false): Promise<DockerContainerInfo[]> {
|
||||
if (isLocal) {
|
||||
const rows = await this.localApi<Record<string, unknown>[]>("/containers/json?all=1");
|
||||
return rows.map(mapEngineContainer);
|
||||
}
|
||||
const res = await this.ssh.exec(target, "docker ps -a --format '{{json .}}'", 30_000);
|
||||
if (res.code !== 0 && !res.stdout.trim()) {
|
||||
throw ApiError.upstream(`docker ps failed: ${res.stderr.slice(0, 500)}`);
|
||||
}
|
||||
return parseDockerPsOutput(res.stdout);
|
||||
}
|
||||
|
||||
/** # 检查容器详情 */
|
||||
async inspectContainer(
|
||||
target: SshTarget,
|
||||
containerId: string,
|
||||
isLocal = false,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
return this.localApi<Record<string, unknown>>(`/containers/${safeId}/json`);
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker inspect ${safeId}`, 20_000);
|
||||
if (res.code !== 0) throw ApiError.upstream(`docker inspect failed: ${res.stderr.slice(0, 300)}`);
|
||||
const parsed = JSON.parse(res.stdout) as Record<string, unknown>[];
|
||||
return parsed[0] ?? {};
|
||||
}
|
||||
|
||||
/** # 重启容器 */
|
||||
async restartContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
await this.localApiVoid(`/containers/${safeId}/restart?t=10`, "POST");
|
||||
return;
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker restart -t 10 ${safeId}`, 60_000);
|
||||
if (res.code !== 0) throw ApiError.upstream(`docker restart failed: ${res.stderr.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
/** # 启动容器 */
|
||||
async startContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
await this.localApiVoid(`/containers/${safeId}/start`, "POST");
|
||||
return;
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker start ${safeId}`, 60_000);
|
||||
if (res.code !== 0) throw ApiError.upstream(`docker start failed: ${res.stderr.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
/** # 停止容器 */
|
||||
async stopContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
await this.localApiVoid(`/containers/${safeId}/stop?t=10`, "POST");
|
||||
return;
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker stop -t 10 ${safeId}`, 60_000);
|
||||
if (res.code !== 0) throw ApiError.upstream(`docker stop failed: ${res.stderr.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
/** # 容器日志(tail N 行) */
|
||||
async containerLogs(target: SshTarget, containerId: string, tail = 200, isLocal = false): Promise<string[]> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
const res = await this.localApi<string>(`/containers/${safeId}/logs?stdout=1&stderr=1&tail=${tail}`);
|
||||
// Docker API 多路复用流:剥离 8 字节帧头
|
||||
return res
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/^[^\r\n]*\r?/, ""))
|
||||
.slice(-tail);
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker logs --tail ${tail} ${safeId} 2>&1`, 30_000);
|
||||
return res.stdout.split("\n").slice(-tail);
|
||||
}
|
||||
|
||||
/** # 镜像列表 */
|
||||
async listImages(target: SshTarget, isLocal = false): Promise<{ repository: string; tag: string; size: string; created: string }[]> {
|
||||
if (isLocal) {
|
||||
const rows = await this.localApi<Record<string, unknown>[]>("/images/json");
|
||||
return rows.map((r) => {
|
||||
const tags = (r.RepoTags as string[] | undefined) ?? ["<none>:<none>"];
|
||||
return {
|
||||
repository: (tags[0].split(":")[0]) ?? "<none>",
|
||||
tag: tags[0].split(":").slice(1).join(":") || "<none>",
|
||||
size: `${Math.round((Number(r.Size) || 0) / 1024 / 1024)}MB`,
|
||||
created: new String(r.Created ?? "").toString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
const res = await this.ssh.exec(target, "docker images --format '{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}'", 20_000);
|
||||
return res.stdout
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.map((l) => {
|
||||
const [repository, tag, size, created] = l.split("|");
|
||||
return { repository, tag, size, created };
|
||||
});
|
||||
}
|
||||
|
||||
/** # 容器资源用量(stats no-stream) */
|
||||
async containerStats(
|
||||
target: SshTarget,
|
||||
containerId: string,
|
||||
isLocal = false,
|
||||
): Promise<{ cpuPct: string; memUsage: string; memPct: string }> {
|
||||
const safeId = assertSafeContainerId(containerId);
|
||||
if (isLocal) {
|
||||
const s = await this.localApi<{ cpu_stats: Record<string, unknown>; memory_stats: Record<string, unknown> }>(
|
||||
`/containers/${safeId}/stats?stream=false`,
|
||||
);
|
||||
return { cpuPct: "n/a", memUsage: `${Math.round((Number(s.memory_stats.usage) || 0) / 1024 / 1024)}MB`, memPct: "n/a" };
|
||||
}
|
||||
const res = await this.ssh.exec(target, `docker stats --no-stream --format '{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}' ${safeId}`, 20_000);
|
||||
const [cpuPct, memUsage, memPct] = res.stdout.trim().split("|");
|
||||
return { cpuPct: cpuPct ?? "n/a", memUsage: memUsage ?? "n/a", memPct: memPct ?? "n/a" };
|
||||
}
|
||||
|
||||
/** # Docker 可用性检查 */
|
||||
async checkDocker(target: SshTarget, isLocal = false): Promise<{ available: boolean; version: string }> {
|
||||
try {
|
||||
if (isLocal) {
|
||||
const version = await this.localApi<{ Version: string }>("/version");
|
||||
return { available: true, version: version.Version };
|
||||
}
|
||||
const res = await this.ssh.exec(target, "docker version --format '{{.Server.Version}}'", 20_000);
|
||||
if (res.code === 0 && res.stdout.trim()) {
|
||||
return { available: true, version: res.stdout.trim() };
|
||||
}
|
||||
return { available: false, version: res.stderr.trim().slice(0, 200) };
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
version: error instanceof Error ? error.message.slice(0, 200) : "unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** # 通过本机 socket 调用 Docker Engine API */
|
||||
private localApi<T>(path: string, method = "GET"): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
socketPath: DockerService.LOCAL_SOCKET,
|
||||
path: `http://localhost/v1.41${path}`,
|
||||
method,
|
||||
timeout: 15_000,
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (c) => (body += c.toString("utf8")));
|
||||
res.on("end", () => {
|
||||
if ((res.statusCode ?? 500) >= 400) {
|
||||
reject(ApiError.upstream(`Docker API ${path} -> ${res.statusCode}: ${body.slice(0, 200)}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(body ? (JSON.parse(body) as T) : (undefined as T));
|
||||
} catch {
|
||||
resolve(undefined as T);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(ApiError.timeout("Docker API timeout"));
|
||||
});
|
||||
req.on("error", (err) => reject(ApiError.upstream(`Docker API error: ${err.message}`)));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** # 本机 socket 无返回体调用 */
|
||||
private async localApiVoid(path: string, method: string): Promise<void> {
|
||||
await this.localApi<unknown>(path, method);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 容器 ID 白名单校验(防注入;docker id 为 [0-9a-f]{12,64} 或 name) */
|
||||
export function assertSafeContainerId(id: string): string {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(id)) {
|
||||
throw ApiError.validation("Invalid container id");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** # Docker Engine API 容器对象 → 摘要视图 */
|
||||
function mapEngineContainer(row: Record<string, unknown>): DockerContainerInfo {
|
||||
const names = (row.Names as string[] | undefined) ?? [];
|
||||
const ports = (row.Ports as Record<string, unknown>[] | undefined) ?? [];
|
||||
return {
|
||||
id: (row.Id as string | undefined)?.slice(0, 12) ?? "",
|
||||
name: (names[0] ?? "").replace(/^\//, ""),
|
||||
image: (row.Image as string | undefined) ?? "",
|
||||
state: (row.State as string | undefined) ?? "",
|
||||
status: (row.Status as string | undefined) ?? "",
|
||||
ports,
|
||||
labels: (row.Labels as Record<string, string> | undefined) ?? {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* # SSH 集成客户端
|
||||
* 基于 ssh2 的统一封装:连接池化(每目标复用)、命令执行(强制超时)、连通性探测。
|
||||
* 安全约束:所有命令必须带超时;凭据只在内存中短暂存在。
|
||||
*/
|
||||
import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import { Client, type ClientCallback, type ConnectConfig } from "ssh2";
|
||||
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
|
||||
/** # 连接参数 **/
|
||||
export interface SshTarget {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
/** # 密码或私钥二选一 **/
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
/** # 私钥口令(可选) **/
|
||||
passphrase?: string;
|
||||
}
|
||||
|
||||
/** # 命令执行结果 **/
|
||||
export interface SshExecResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
/** # 是否因超时被终止 **/
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
/** # 默认连接超时(毫秒) **/
|
||||
const CONNECT_TIMEOUT_MS = 10_000;
|
||||
/** # 默认命令超时(毫秒) **/
|
||||
const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
|
||||
|
||||
@Injectable()
|
||||
export class SshClientService implements OnModuleDestroy {
|
||||
/** # 连接缓存 key → Client **/
|
||||
private connections = new Map<string, { client: Client; busy: boolean }>();
|
||||
|
||||
/** # 日志器 **/
|
||||
private readonly logger: StructuredLoggerInstance;
|
||||
|
||||
constructor(loggers: LogService) {
|
||||
this.logger = loggers.child("SshClient");
|
||||
}
|
||||
|
||||
/** # 连接缓存键(不含凭据) */
|
||||
private cacheKey(target: SshTarget): string {
|
||||
return `${target.username}@${target.host}:${target.port}`;
|
||||
}
|
||||
|
||||
/** # 建立或复用连接
|
||||
* @returns 就绪的 ssh2 Client
|
||||
*/
|
||||
private async connect(target: SshTarget): Promise<Client> {
|
||||
const key = this.cacheKey(target);
|
||||
const cached = this.connections.get(key);
|
||||
if (cached) {
|
||||
// 已缓存的连接复用(ssh2 Client 支持串行 exec)
|
||||
return cached.client;
|
||||
}
|
||||
const config: ConnectConfig = {
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
username: target.username,
|
||||
readyTimeout: CONNECT_TIMEOUT_MS,
|
||||
keepaliveInterval: 10_000,
|
||||
tryKeyboard: false,
|
||||
};
|
||||
if (target.privateKey) {
|
||||
config.privateKey = target.privateKey;
|
||||
if (target.passphrase) config.passphrase = target.passphrase;
|
||||
} else if (target.password) {
|
||||
config.password = target.password;
|
||||
} else {
|
||||
throw ApiError.validation("SSH target requires password or privateKey");
|
||||
}
|
||||
return new Promise<Client>((resolve, reject) => {
|
||||
const client = new Client();
|
||||
const timer = setTimeout(() => {
|
||||
client.end();
|
||||
reject(ApiError.timeout(`SSH connect timeout after ${CONNECT_TIMEOUT_MS}ms`));
|
||||
}, CONNECT_TIMEOUT_MS + 2000);
|
||||
client
|
||||
.on("ready", () => {
|
||||
clearTimeout(timer);
|
||||
this.connections.set(key, { client, busy: false });
|
||||
resolve(client);
|
||||
})
|
||||
.on("error", (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
this.connections.delete(key);
|
||||
reject(
|
||||
new ApiError(
|
||||
502,
|
||||
"UPSTREAM_FAILURE",
|
||||
`SSH connection failed: ${err.message}`,
|
||||
),
|
||||
);
|
||||
})
|
||||
.connect(config);
|
||||
});
|
||||
}
|
||||
|
||||
/** # 在目标机上执行命令(强制超时)
|
||||
* @param target SSH 目标
|
||||
* @param command 待执行命令
|
||||
* @param timeoutMs 命令超时(默认 30s)
|
||||
*/
|
||||
async exec(target: SshTarget, command: string, timeoutMs = DEFAULT_EXEC_TIMEOUT_MS): Promise<SshExecResult> {
|
||||
const client = await this.connect(target);
|
||||
return new Promise<SshExecResult>((resolve, reject) => {
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
// 超时后断开整条连接,避免流挂起
|
||||
this.closeKey(this.cacheKey(target));
|
||||
reject(ApiError.timeout(`SSH command timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
client.exec(command, ((err: Error | undefined, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timer);
|
||||
this.closeKey(this.cacheKey(target));
|
||||
reject(new ApiError(502, "UPSTREAM_FAILURE", `SSH exec failed: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
stream.on("close", (code: number | null) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) return;
|
||||
resolve({ code: code ?? -1, stdout, stderr, timedOut: false });
|
||||
});
|
||||
}) as ClientCallback);
|
||||
});
|
||||
}
|
||||
|
||||
/** # 连通性探测:执行 echo 并测量耗时
|
||||
* @returns 延迟毫秒与远端 uname 信息
|
||||
*/
|
||||
async check(target: SshTarget): Promise<{ latencyMs: number; uname: string }> {
|
||||
const start = Date.now();
|
||||
const result = await this.exec(target, "uname -srm && cat /etc/os-release 2>/dev/null | head -2", 15_000);
|
||||
return { latencyMs: Date.now() - start, uname: result.stdout.trim() };
|
||||
}
|
||||
|
||||
/** # 关闭指定目标的连接 */
|
||||
private closeKey(key: string): void {
|
||||
const cached = this.connections.get(key);
|
||||
if (cached) {
|
||||
try {
|
||||
cached.client.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.connections.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 关闭所有连接 */
|
||||
closeAll(): void {
|
||||
for (const [key] of this.connections) this.closeKey(key);
|
||||
}
|
||||
|
||||
/** # 停止时清理连接 */
|
||||
onModuleDestroy(): void {
|
||||
this.closeAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* # SSH 集成模块
|
||||
*/
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { SshClientService } from "./ssh-client.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SshClientService],
|
||||
exports: [SshClientService],
|
||||
})
|
||||
export class SshModule {}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* # DevOS API 入口
|
||||
* NestJS + Fastify 适配器,全局前缀 /api/v1(05-API-SPEC)。
|
||||
* 启动:结构化日志、全局错误契约、CORS(开发跨源)、优雅停机。
|
||||
* 生产模式(WEB_DIST 存在):同进程托管 Web 静态资源 + SPA fallback(nginx 纯反代即可)。
|
||||
*/
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify";
|
||||
import { Logger as NestConsoleLogger } from "@nestjs/common";
|
||||
import { HttpAdapterHost } from "@nestjs/core";
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { join, extname, resolve, sep } from "node:path";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { AppModule } from "./app.module";
|
||||
import { LogService } from "./common/logger/logger.module";
|
||||
import { registerRequestContextHook } from "./common/middleware/request-context.hook";
|
||||
|
||||
/** # 静态资源 MIME 映射(覆盖 SPA 所需集合) **/
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".map": "application/json",
|
||||
};
|
||||
|
||||
/** # 注册 Web 静态托管(生产):静态文件命中即服务;非 /api 的 GET 回退 index.html(SPA fallback) */
|
||||
function registerWebStatic(app: NestFastifyApplication): void {
|
||||
const webDist = resolve(process.env.WEB_DIST ?? join(process.cwd(), "apps/web/dist"));
|
||||
if (!existsSync(webDist)) return;
|
||||
const fastify = app.get(HttpAdapterHost).httpAdapter.getInstance();
|
||||
|
||||
fastify.get("/*", (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = req.raw.url ?? "/";
|
||||
if (url.startsWith("/api/") || url.startsWith("/mcp")) {
|
||||
return reply.code(404).send({ error: { code: "RESOURCE_NOT_FOUND", message: "Not found", requestId: (req as unknown as { requestId?: string }).requestId ?? "" } });
|
||||
}
|
||||
const rel = (req.params as Record<string, string>)["*"] ?? "";
|
||||
const file = resolve(webDist, rel);
|
||||
if (file.startsWith(webDist + sep) && existsSync(file) && statSync(file).isFile()) {
|
||||
const mime = MIME[extname(file)] ?? "application/octet-stream";
|
||||
const cache = rel.startsWith("assets/") || /-[\w]{8}\.\w+$/.test(rel) ? "public, max-age=31536000, immutable" : "no-cache";
|
||||
return reply.header("Content-Type", mime).header("Cache-Control", cache).send(createReadStream(file));
|
||||
}
|
||||
// 带扩展名的未命中 = 真实静态资源缺失,返回 404(避免 fallback 返回 HTML 破坏模块加载)
|
||||
if (/\.[a-zA-Z0-9]{1,6}$/.test(rel)) {
|
||||
return reply.code(404).send({ error: { code: "RESOURCE_NOT_FOUND", message: "Asset not found", requestId: (req as unknown as { requestId?: string }).requestId ?? "" } });
|
||||
}
|
||||
// SPA fallback(仅无扩展名的页面路径)
|
||||
return reply
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.send(createReadStream(join(webDist, "index.html")));
|
||||
});
|
||||
console.log(JSON.stringify({ time: new Date().toISOString(), level: "log", ctx: "Bootstrap", msg: `web static served from ${webDist}` }));
|
||||
}
|
||||
|
||||
/** # 引导 API 应用 */
|
||||
async function bootstrap(): Promise<void> {
|
||||
const adapter = new FastifyAdapter({ trustProxy: true, bodyLimit: 2 * 1024 * 1024 });
|
||||
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, {
|
||||
bufferLogs: false,
|
||||
rawBody: true, // webhook HMAC 验签需要原始字节
|
||||
});
|
||||
|
||||
// 结构化日志接管 Nest 内部日志
|
||||
app.useLogger(new LogService().child("Nest"));
|
||||
NestConsoleLogger.overrideLogger(false);
|
||||
|
||||
const fastify = app.get(HttpAdapterHost).httpAdapter.getInstance();
|
||||
registerRequestContextHook(fastify);
|
||||
|
||||
app.setGlobalPrefix("api/v1");
|
||||
app.enableCors({ origin: true, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] });
|
||||
app.enableShutdownHooks();
|
||||
|
||||
registerWebStatic(app);
|
||||
|
||||
const port = Number(process.env.PORT ?? 4100);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
await app.listen(port, host);
|
||||
console.log(
|
||||
JSON.stringify({ time: new Date().toISOString(), level: "log", ctx: "Bootstrap", msg: `DevOS API listening on http://${host}:${port}/api/v1` }),
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap().catch((error) => {
|
||||
console.error("bootstrap failed", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* # AI 聊天控制器(/api/v1/ai/chat)
|
||||
* 对话式操作入口;审批复用 /ai/runs/:id/approve。
|
||||
* 提供两种形态:
|
||||
* - POST /ai/chat 普通 JSON 响应
|
||||
* - POST /ai/chat/stream SSE 流式(status/tool/reply_token/done 事件,现代聊天体验)
|
||||
*/
|
||||
import { Body, Controller, Post, Res } from "@nestjs/common";
|
||||
import type { FastifyReply } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { AiChatService, type ChatTurn, type ChatResponse } from "./ai-chat.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentUser, CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestUser, RequestWorkspace } from "../../common/decorators";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
|
||||
const ChatSchema = z.object({
|
||||
message: z.string().min(1).max(4000),
|
||||
history: z
|
||||
.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().max(4000) }))
|
||||
.max(20)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
@Controller("ai/chat")
|
||||
export class AiChatController {
|
||||
constructor(
|
||||
private readonly chat: AiChatService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
/** # 发送一条聊天消息(普通 JSON 响应) */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
async send(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body(new ZodValidationPipe(ChatSchema)) dto: { message: string; history: ChatTurn[] },
|
||||
) {
|
||||
const response = await this.chat.chat(ws.id, user.id, dto.message, dto.history);
|
||||
await this.auditRecord(ws.id, user.id, dto.message, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** # 流式聊天(SSE):
|
||||
* event: status 进度文本("正在执行 devos.server.list…")
|
||||
* event: tool 单个工具执行结果 chip
|
||||
* event: reply_token 回复文本分片(模拟逐字流式)
|
||||
* event: done 完整 ChatResponse(含 approval/refs)
|
||||
*/
|
||||
@Roles("MEMBER")
|
||||
@Post("stream")
|
||||
async stream(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body(new ZodValidationPipe(ChatSchema)) dto: { message: string; history: ChatTurn[] },
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
const send = (event: string, data: unknown) => {
|
||||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
try {
|
||||
const response: ChatResponse = await this.chat.chat(ws.id, user.id, dto.message, dto.history, (event) => {
|
||||
if (event.type === "tool_start") {
|
||||
send("status", { text: `正在执行 ${event.text}…` });
|
||||
} else if (event.type === "tool" && event.call) {
|
||||
send("tool", event.call);
|
||||
}
|
||||
});
|
||||
// 回复分片(模拟逐字流式;真实 token 流待 provider stream 接入)
|
||||
const chunks = response.reply.match(/[\s\S]{1,24}/g) ?? [response.reply];
|
||||
for (const chunk of chunks) {
|
||||
send("reply_token", { text: chunk });
|
||||
await new Promise((r) => setTimeout(r, 18));
|
||||
}
|
||||
send("done", response);
|
||||
await this.auditRecord(ws.id, user.id, dto.message, response);
|
||||
} catch (error) {
|
||||
send("done", {
|
||||
reply: `出错了:${error instanceof Error ? error.message : "未知错误"}`,
|
||||
engine: "rules",
|
||||
toolCalls: [],
|
||||
} satisfies ChatResponse);
|
||||
}
|
||||
reply.raw.end();
|
||||
}
|
||||
|
||||
/** # 聊天审计 */
|
||||
private async auditRecord(workspaceId: string, userId: string, message: string, response: ChatResponse) {
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: "USER",
|
||||
actorId: userId,
|
||||
action: "ai.chat",
|
||||
resourceType: "AiRun",
|
||||
riskLevel: response.approval ? "HIGH" : "LOW",
|
||||
metadata: {
|
||||
message: message.slice(0, 200),
|
||||
engine: response.engine,
|
||||
executed: response.toolCalls.length,
|
||||
approvalRequired: Boolean(response.approval),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* # AI 聊天服务(对话式操作入口)
|
||||
* 让用户用自然语言驱动整个 DevOS,而不是手动填表单。
|
||||
*
|
||||
* 双引擎:
|
||||
* 1. LLM 引擎(配置 AI_PROVIDER 后自动启用):OpenAI function-calling 多轮循环,
|
||||
* 模型自主选择 ToolRegistry 工具并填参(工作区快照注入上下文用于名称→ID 解析);
|
||||
* 2. 规则引擎(无 LLM 时兜底,保证开箱可用):意图匹配核心操作。
|
||||
*
|
||||
* 安全:所有执行走 ToolRegistry(与 UI/MCP 同一授权与审计);
|
||||
* HIGH/CRITICAL 工具不直接执行,而是创建 AWAITING_APPROVAL 的 AiRun 返回审批卡片。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import type { ResourceStatus } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { ConfigService } from "../../common/config/config.service";
|
||||
import { AiProviderService } from "../../common/ai/ai-provider.service";
|
||||
import { ToolRegistryService, type ToolContext } from "./tool-registry.service";
|
||||
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
|
||||
import { maskSecrets } from "../../common/utils/mask";
|
||||
|
||||
/** # 聊天允许使用的工具白名单(删除类操作刻意不开放,防误删) **/
|
||||
const CHAT_TOOLS = [
|
||||
"devos.server.list",
|
||||
"devos.server.find",
|
||||
"devos.server.status",
|
||||
"devos.server.exec",
|
||||
"devos.server.getDockerContainers",
|
||||
"devos.website.list",
|
||||
"devos.website.create",
|
||||
"devos.website.deploy",
|
||||
"devos.website.logs",
|
||||
"devos.website.health",
|
||||
"devos.deployment.list",
|
||||
"devos.deployment.get",
|
||||
"devos.domain.list",
|
||||
"devos.domain.find",
|
||||
"devos.domain.records",
|
||||
"devos.domain.upsertRecord",
|
||||
"devos.monitor.list",
|
||||
"devos.automation.list",
|
||||
"devos.automation.run",
|
||||
"devos.task.list",
|
||||
"devos.task.create",
|
||||
"devos.task.update",
|
||||
"devos.task.delete",
|
||||
"devos.note.list",
|
||||
"devos.note.create",
|
||||
"devos.note.update",
|
||||
"devos.note.delete",
|
||||
"devos.web.fetch",
|
||||
"devos.repository.createFromUrl",
|
||||
] as const;
|
||||
|
||||
/** # 聊天消息(前端回传的简史) **/
|
||||
export interface ChatTurn {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** # 已执行的工具步骤(回显) **/
|
||||
export interface ChatToolCall {
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
ok: boolean;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** # 聊天响应 **/
|
||||
export interface ChatResponse {
|
||||
reply: string;
|
||||
engine: "llm" | "rules";
|
||||
toolCalls: ChatToolCall[];
|
||||
/** # HIGH/CRITICAL 操作待审批卡片 **/
|
||||
approval?: { runId: string; steps: { tool: string; args: Record<string, unknown>; risk: string }[] };
|
||||
/** # 自动执行产生的引用(如新任务 ID) **/
|
||||
refs?: { type: string; id: string; name: string }[];
|
||||
}
|
||||
|
||||
/** # 单条快照行 **/
|
||||
interface SnapshotItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiChatService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly ai: AiProviderService,
|
||||
private readonly tools: ToolRegistryService,
|
||||
loggers: LogService,
|
||||
) {
|
||||
this.logger = loggers.child("AiChat");
|
||||
}
|
||||
|
||||
/** # 日志器 **/
|
||||
private readonly logger: StructuredLoggerInstance;
|
||||
|
||||
/** # 主入口:处理一条用户消息
|
||||
* @param onEvent 可选进度回调(工具开始/完成实时上报,供流式 UI)
|
||||
*/
|
||||
async chat(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
message: string,
|
||||
history: ChatTurn[],
|
||||
onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void,
|
||||
): Promise<ChatResponse> {
|
||||
const snapshot = await this.buildSnapshot(workspaceId);
|
||||
const ctx: ToolContext = { workspaceId, actorType: "USER", actorId: userId };
|
||||
|
||||
if (this.ai.enabled) {
|
||||
try {
|
||||
return await this.llmChat(workspaceId, ctx, message, history, snapshot, onEvent);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`LLM chat failed, falling back to rules: ${error instanceof Error ? error.message : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.rulesChat(workspaceId, ctx, message, snapshot, onEvent);
|
||||
}
|
||||
|
||||
// ─────────────────────────── LLM 引擎 ───────────────────────────
|
||||
|
||||
/** # LLM function-calling 多轮循环 */
|
||||
private async llmChat(
|
||||
workspaceId: string,
|
||||
ctx: ToolContext,
|
||||
message: string,
|
||||
history: ChatTurn[],
|
||||
snapshot: string,
|
||||
onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void,
|
||||
): Promise<ChatResponse> {
|
||||
const chatTools = CHAT_TOOLS.map((name) => this.tools.get(name)).filter(Boolean);
|
||||
const toolsParam = chatTools.map((t) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t!.name,
|
||||
description: `${t!.description}(风险等级 ${t!.risk})`,
|
||||
parameters: zodToJsonSchema(t!.schema as never, { $refStrategy: "none" }),
|
||||
},
|
||||
}));
|
||||
|
||||
const messages: Record<string, unknown>[] = [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
"你是 DevOS 的运维助手,能通过工具完成平台上的几乎所有操作。今天是 " + new Date().toISOString().slice(0, 10) + "。",
|
||||
"",
|
||||
"## 能力地图",
|
||||
"- 查询:server/website/domain/container/task/note/deployment/monitor/automation 列表与详情",
|
||||
"- 创建:任务、笔记、网站(website.create)、仓库登记(repository.createFromUrl)、DNS 记录(domain.upsertRecord)",
|
||||
"- 操作:部署(website.deploy)、重启容器(container.restart)、服务器命令(server.exec)、运行自动化",
|
||||
"- 编辑/删除:task/note 的 update 与 delete;website/server/domain 的 delete",
|
||||
"- 网页:web.fetch 抓取 URL 正文(供总结/摘录)",
|
||||
"",
|
||||
"## 复合任务编排(重要)",
|
||||
"用户常一次提出多步需求,请按依赖顺序组合工具完成:",
|
||||
"例1「总结 <url> 存到笔记」:web.fetch → 自己提炼总结 → note.create(content 用简洁 HTML:h3+p/ul,开头附原文链接)",
|
||||
"例2「把 <github url> 安装到 <服务器名> 并用 <域名> 解析(CF 代理)」:",
|
||||
" 1. repository.createFromUrl {url}",
|
||||
" 2. server.find {name:<服务器名>} → 取 id",
|
||||
" 3. domain.find {name:<域名根>} → 取 id(若无域名记录,用 domain.upsertRecord 直接建)",
|
||||
" 4. website.create {name, serverId, repositoryId, primaryDomain:<域名>}",
|
||||
" 5. domain.upsertRecord {domainId, type:'A', name:<域名或子域>, value:<服务器公网 IP=host>, proxied:true}",
|
||||
" 6. website.deploy {websiteId}(HIGH,系统会转用户审批)",
|
||||
" 7. website.health 验证",
|
||||
"",
|
||||
"## 规则",
|
||||
"- 只能使用提供的工具;ID 必须来自工具返回或快照,禁止编造。",
|
||||
"- 名称解析:优先 server.find/domain.find,其次快照。",
|
||||
"- HIGH/CRITICAL 工具(部署/重启容器/删除/删除服务器)也直接调用——系统会自动转为用户审批卡片,你只需说明已提交审批。",
|
||||
"- 删除前必须与用户确认过上下文;不确定时先查询再行动。",
|
||||
"- 回答简洁、用用户的语言(默认中文);汇报做了什么、结果如何;不要复读工具原始 JSON。",
|
||||
"",
|
||||
"## 当前工作区快照",
|
||||
snapshot,
|
||||
].join("\n"),
|
||||
},
|
||||
...history.slice(-8).map((h) => ({ role: h.role, content: h.content })),
|
||||
{ role: "user", content: message },
|
||||
];
|
||||
|
||||
const toolCalls: ChatToolCall[] = [];
|
||||
const pendingApproval: { tool: string; args: Record<string, unknown>; risk: string }[] = [];
|
||||
let iterations = 0;
|
||||
let finalText = "";
|
||||
|
||||
while (iterations < 6) {
|
||||
iterations += 1;
|
||||
const res = await this.rawChatCompletion(messages, toolsParam);
|
||||
const choice = res.choices?.[0];
|
||||
if (!choice) throw new Error("empty completion");
|
||||
const msg = choice.message as {
|
||||
content?: string | null;
|
||||
tool_calls?: { id: string; function: { name: string; arguments: string } }[];
|
||||
};
|
||||
|
||||
if (!msg.tool_calls || msg.tool_calls.length === 0) {
|
||||
finalText = msg.content ?? "(无回复)";
|
||||
break;
|
||||
}
|
||||
messages.push({ role: "assistant", content: msg.content ?? null, tool_calls: msg.tool_calls });
|
||||
for (const call of msg.tool_calls) {
|
||||
const tool = this.tools.get(call.function.name);
|
||||
if (!tool) {
|
||||
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ error: "unknown tool" }) });
|
||||
continue;
|
||||
}
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = JSON.parse(call.function.arguments || "{}") as Record<string, unknown>;
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
// HIGH/CRITICAL → 转审批,不直接执行
|
||||
if (tool.risk === "HIGH" || tool.risk === "CRITICAL") {
|
||||
pendingApproval.push({ tool: tool.name, args, risk: tool.risk });
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify({ ok: true, note: "submitted for user approval" }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
onEvent?.({ type: "tool_start", text: tool.name });
|
||||
const rec: ChatToolCall = { tool: tool.name, args: maskSecrets(args) as Record<string, unknown>, ok: false, summary: "" };
|
||||
try {
|
||||
const result = await tool.execute(ctx, args as never);
|
||||
rec.ok = true;
|
||||
rec.summary = summarize(result);
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(maskSecrets(result)).slice(0, 6000),
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error.message : String(error);
|
||||
rec.summary = err.slice(0, 200);
|
||||
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ error: err.slice(0, 500) }) });
|
||||
}
|
||||
toolCalls.push(rec);
|
||||
onEvent?.({ type: "tool", call: rec });
|
||||
}
|
||||
if (pendingApproval.length > 0) break; // 有审批待确认,先返回
|
||||
}
|
||||
|
||||
if (pendingApproval.length > 0) {
|
||||
const run = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", message, pendingApproval);
|
||||
return {
|
||||
reply: `以下操作风险较高,需要你确认后执行:\n${pendingApproval
|
||||
.map((s) => `• ${s.tool}(${s.risk})`)
|
||||
.join("\n")}\n确认后我会立即执行。`,
|
||||
engine: "llm",
|
||||
toolCalls,
|
||||
approval: { runId: run.id, steps: pendingApproval },
|
||||
};
|
||||
}
|
||||
|
||||
if (!finalText) finalText = summarizeDone(toolCalls);
|
||||
return { reply: finalText, engine: "llm", toolCalls };
|
||||
}
|
||||
|
||||
/** # 调用 OpenAI 兼容 chat/completions(带工具) */
|
||||
private async rawChatCompletion(
|
||||
messages: Record<string, unknown>[],
|
||||
tools: unknown[],
|
||||
): Promise<{ choices?: { message?: unknown }[] }> {
|
||||
const res = await fetch(`${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.aiApiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: this.config.aiModel,
|
||||
messages,
|
||||
tools,
|
||||
temperature: 0.2,
|
||||
}),
|
||||
signal: AbortSignal.timeout(90_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI provider HTTP ${res.status}`);
|
||||
return (await res.json()) as { choices?: { message?: unknown }[] };
|
||||
}
|
||||
|
||||
/** # 创建待审批运行(复用 AI Gateway 的审批执行管线) */
|
||||
private async createApprovalRun(
|
||||
workspaceId: string,
|
||||
_userId: string,
|
||||
objective: string,
|
||||
steps: { tool: string; args: Record<string, unknown>; risk: string }[],
|
||||
): Promise<{ id: string }> {
|
||||
const plan = steps.map((s) => ({ tool: s.tool, args: s.args, reason: "chat request", risk: s.risk }));
|
||||
const risk = steps.some((s) => s.risk === "CRITICAL") ? "CRITICAL" : "HIGH";
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: { workspaceId, type: "AI_RUN", name: objective.slice(0, 80), provider: "chat", status: "PENDING" },
|
||||
});
|
||||
return tx.aiRun.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
objective: objective.slice(0, 2000),
|
||||
status: "AWAITING_APPROVAL",
|
||||
source: "CHAT",
|
||||
riskLevel: risk,
|
||||
plan: plan as unknown as object,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────── 规则引擎(离线兜底) ───────────────────────────
|
||||
|
||||
/** # 规则引擎:意图匹配核心操作 */
|
||||
private async rulesChat(
|
||||
workspaceId: string,
|
||||
ctx: ToolContext,
|
||||
message: string,
|
||||
snapshot: string,
|
||||
onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void,
|
||||
): Promise<ChatResponse> {
|
||||
const text = message.trim();
|
||||
const toolCalls: ChatToolCall[] = [];
|
||||
const run = async (toolName: string, args: Record<string, unknown>): Promise<unknown | null> => {
|
||||
const tool = this.tools.get(toolName);
|
||||
if (!tool) return null;
|
||||
onEvent?.({ type: "tool_start", text: toolName });
|
||||
try {
|
||||
const result = await tool.execute(ctx, args as never);
|
||||
const call: ChatToolCall = { tool: toolName, args, ok: true, summary: summarize(result) };
|
||||
toolCalls.push(call);
|
||||
onEvent?.({ type: "tool", call });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error.message : String(error);
|
||||
const call: ChatToolCall = { tool: toolName, args, ok: false, summary: err.slice(0, 200) };
|
||||
toolCalls.push(call);
|
||||
onEvent?.({ type: "tool", call });
|
||||
return { __error: err };
|
||||
}
|
||||
};
|
||||
|
||||
// 帮助
|
||||
if (/帮助|help|你能做什么|能干什么/i.test(text)) {
|
||||
return { reply: HELP_TEXT, engine: "rules", toolCalls: [] };
|
||||
}
|
||||
|
||||
// URL 总结 → 存笔记(规则引擎用抽取式摘要兜底;LLM 模式下由模型深度总结)
|
||||
const urlInText = text.match(/https?:\/\/[^\s,。;))]+/);
|
||||
if (urlInText && /总结|摘要|summary|保存到?笔记|记到笔记/i.test(text)) {
|
||||
const url = urlInText[0];
|
||||
const fetchTool = this.tools.get("devos.web.fetch")!;
|
||||
try {
|
||||
const page = (await fetchTool.execute(ctx, { url })) as { title?: string; description?: string; text?: string };
|
||||
const title = (page.title ?? url).slice(0, 120);
|
||||
// 抽取式摘要:meta 描述 + 前几段
|
||||
const paras = (page.text ?? "").split("\n\n").filter((p) => p && !p.startsWith("# ")).slice(0, 5);
|
||||
const summaryHtml = [
|
||||
`<p>📄 来源:<a href="${url}">${url}</a></p>`,
|
||||
page.description ? `<p><strong>摘要:</strong>${page.description}</p>` : "",
|
||||
"<ul>" + paras.map((p) => `<li>${p.slice(0, 260)}</li>`).join("") + "</ul>",
|
||||
"<p><em>配置 LLM 后可获得更高质量的深度总结。</em></p>",
|
||||
].filter(Boolean).join("");
|
||||
const noteTool = this.tools.get("devos.note.create")!;
|
||||
const note = (await noteTool.execute(ctx, {
|
||||
title: `总结:${title}`,
|
||||
content: summaryHtml,
|
||||
tags: ["summary"],
|
||||
})) as { id: string; title: string };
|
||||
return {
|
||||
reply: `✅ 已抓取并总结「${title}」,保存为笔记「${note.title}」。可在「笔记」页面查看与编辑(配置 LLM 后总结质量更高)。`,
|
||||
engine: "rules",
|
||||
toolCalls: [
|
||||
{ tool: "devos.web.fetch", args: { url }, ok: true, summary: title },
|
||||
{ tool: "devos.note.create", args: { title: note.title }, ok: true, summary: note.id },
|
||||
],
|
||||
refs: [{ type: "NOTE", id: note.id, name: note.title }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
reply: `抓取页面失败:${error instanceof Error ? error.message : "unknown"}。请确认 URL 可公网访问。`,
|
||||
engine: "rules",
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
const taskM = text.match(/(?:创建|新建|添加|记录)个?任务[::,,\s]*(.+)/);
|
||||
if (taskM) {
|
||||
const title = taskM[1].slice(0, 120).trim();
|
||||
const result = await run("devos.task.create", { title });
|
||||
if (result && !(result as { __error?: string }).__error) {
|
||||
const t = result as { id: string; title: string };
|
||||
return {
|
||||
reply: `✅ 已创建任务「${t.title}」`,
|
||||
engine: "rules",
|
||||
toolCalls,
|
||||
refs: [{ type: "TASK", id: t.id, name: t.title }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 创建笔记
|
||||
const noteM = text.match(/(?:创建|新建|添加|记)个?笔记[::,,\s]*(.+)/);
|
||||
if (noteM) {
|
||||
const title = noteM[1].slice(0, 120).trim();
|
||||
const result = await run("devos.note.create", { title, content: "" });
|
||||
if (result && !(result as { __error?: string }).__error) {
|
||||
const n = result as { id: string; title: string };
|
||||
return {
|
||||
reply: `✅ 已创建笔记「${n.title}」,可到「笔记」页面编辑内容。`,
|
||||
engine: "rules",
|
||||
toolCalls,
|
||||
refs: [{ type: "NOTE", id: n.id, name: n.title }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 部署网站(HIGH → 审批)
|
||||
const deployM = text.match(/部署\s*网站?\s*[::]?\s*(\S+)|deploy\s+(\S+)/i);
|
||||
if (deployM) {
|
||||
const name = (deployM[1] ?? deployM[2] ?? "").replace(/网站|到\S+$/g, "").trim();
|
||||
const site = await this.findWebsite(workspaceId, name);
|
||||
if (!site) {
|
||||
const sites = await this.prisma.website.findMany({ where: { workspaceId }, take: 10, select: { name: true } });
|
||||
return {
|
||||
reply: `没有找到名称包含「${name}」的网站。${sites.length ? `当前的网站有:${sites.map((s) => s.name).join("、")}` : "当前还没有网站,可到「网站」页面创建。"}`,
|
||||
engine: "rules",
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
const run0 = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", text, [
|
||||
{ tool: "devos.website.deploy", args: { websiteId: site.id }, risk: "HIGH" },
|
||||
]);
|
||||
return {
|
||||
reply: `⚠️ 部署是 HIGH 风险操作:将部署网站「${site.name}」。确认后执行(含健康验证)。`,
|
||||
engine: "rules",
|
||||
toolCalls,
|
||||
approval: {
|
||||
runId: run0.id,
|
||||
steps: [{ tool: "devos.website.deploy", args: { websiteId: site.id }, risk: "HIGH" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 重启容器(HIGH → 审批)
|
||||
const restartM = text.match(/(?:重启|restart)\s*容器?\s*[::]?\s*(\S+)/i);
|
||||
if (restartM) {
|
||||
const name = restartM[1];
|
||||
const container = await this.findContainer(workspaceId, name);
|
||||
if (!container) {
|
||||
const rows = await this.prisma.container.findMany({ where: { workspaceId }, take: 12, orderBy: { updatedAt: "desc" }, select: { name: true, state: true } });
|
||||
return {
|
||||
reply: `没有找到名称包含「${name}」的容器。${rows.length ? `运行中的容器如:${rows.map((c) => c.name).slice(0, 8).join("、")}` : "还没有发现过容器,先到服务器详情页执行「发现容器」。"}`,
|
||||
engine: "rules",
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
const run0 = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", text, [
|
||||
{ tool: "devos.container.restart", args: { id: container.id }, risk: "HIGH" },
|
||||
]);
|
||||
return {
|
||||
reply: `⚠️ 重启容器是 HIGH 风险操作:将重启「${container.name}」(${container.image.slice(0, 30)})。确认后执行。`,
|
||||
engine: "rules",
|
||||
toolCalls,
|
||||
approval: {
|
||||
runId: run0.id,
|
||||
steps: [{ tool: "devos.container.restart", args: { id: container.id }, risk: "HIGH" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 列出/查看类
|
||||
const listTarget = text.match(/(?:列出|查看|显示|有哪些|list)\s*(全部|所有|一下)?\s*(服务器|网站|域名|容器|任务|笔记|部署|监控|自动化|仓库|凭据)?/i);
|
||||
if (listTarget) {
|
||||
const target = listTarget[2] ?? inferTargetFromText(text);
|
||||
if (target) {
|
||||
const map: Record<string, string> = {
|
||||
服务器: "devos.server.list",
|
||||
网站: "devos.website.list",
|
||||
域名: "devos.domain.list",
|
||||
容器: "devos.server.getDockerContainers",
|
||||
任务: "devos.task.list",
|
||||
笔记: "devos.note.list",
|
||||
部署: "devos.deployment.list",
|
||||
监控: "devos.monitor.list",
|
||||
自动化: "devos.automation.list",
|
||||
};
|
||||
const toolName = map[target];
|
||||
if (toolName) {
|
||||
// 容器列表需要 serverId:取第一台在线服务器
|
||||
if (target === "容器") {
|
||||
const firstServer = (await this.tools.get("devos.server.list")!.execute(ctx, {})) as { id: string }[] | undefined;
|
||||
const sid = Array.isArray(firstServer) ? firstServer[0]?.id : undefined;
|
||||
if (!sid) {
|
||||
return { reply: "还没有登记服务器,无法发现容器。", engine: "rules", toolCalls };
|
||||
}
|
||||
const containers = await run("devos.server.getDockerContainers", { serverId: sid });
|
||||
return { reply: renderList(target, containers), engine: "rules", toolCalls };
|
||||
}
|
||||
const result = await run(toolName, target === "任务" || target === "笔记" ? {} : {});
|
||||
return { reply: renderList(target, result), engine: "rules", toolCalls };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 网站健康/状态
|
||||
const healthM = text.match(/(健康|health|状态|status|在线吗|正常吗)/i);
|
||||
if (healthM) {
|
||||
const site = await this.findAnyWebsite(workspaceId);
|
||||
if (site) {
|
||||
const result = await run("devos.website.health", { id: site.id });
|
||||
const r = result as { ok?: boolean; status?: number; latencyMs?: number; error?: string };
|
||||
return {
|
||||
reply: r?.ok
|
||||
? `✅ 「${site.name}」健康:HTTP ${r.status}(${r.latencyMs ?? "?"}ms)`
|
||||
: `❌ 「${site.name}」异常:${r?.error ?? `HTTP ${r?.status}`}`,
|
||||
engine: "rules",
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reply: `我暂时没理解这句话。我可以(离线模式,配置 LLM 后理解任意表达):\n\n• 列出服务器 / 网站 / 域名 / 容器 / 任务 / 部署\n• 创建任务:<标题>\n• 创建笔记:<标题>\n• 部署网站:<名称>(需确认)\n• 重启容器:<名称>(需确认)\n• 网站健康检查\n• 帮助`,
|
||||
engine: "rules",
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────── 上下文与解析 ───────────────────────────
|
||||
|
||||
/** # 构建工作区快照(名称→ID 解析用) */
|
||||
private async buildSnapshot(workspaceId: string): Promise<string> {
|
||||
const [servers, websites, domains, containers, tasks] = await this.prisma.$transaction([
|
||||
this.prisma.server.findMany({ where: { workspaceId }, take: 15, include: { resource: { select: { status: true } } } }),
|
||||
this.prisma.website.findMany({ where: { workspaceId }, take: 15, include: { resource: { select: { status: true } } } }),
|
||||
this.prisma.domain.findMany({ where: { workspaceId }, take: 15 }),
|
||||
this.prisma.container.findMany({ where: { workspaceId }, take: 20, orderBy: { updatedAt: "desc" } }),
|
||||
this.prisma.task.findMany({ where: { workspaceId }, take: 10, orderBy: { updatedAt: "desc" } }),
|
||||
]);
|
||||
const lines: string[] = [];
|
||||
lines.push(`服务器(${servers.length}): ${servers.map((s) => `${s.name}(id=${s.id}, ${s.resource.status})`).join(", ") || "无"}`);
|
||||
lines.push(`网站(${websites.length}): ${websites.map((w) => `${w.name}(id=${w.id}, ${w.resource.status})`).join(", ") || "无"}`);
|
||||
lines.push(`域名(${domains.length}): ${domains.map((d) => `${d.name}(id=${d.id})`).join(", ") || "无"}`);
|
||||
lines.push(`容器(最近): ${containers.map((c) => `${c.name}(id=${c.id}, ${c.state})`).join(", ") || "无"}`);
|
||||
lines.push(`任务(最近): ${tasks.map((t) => `${t.title}(status=${t.status})`).join(", ") || "无"}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** # 模糊查找网站 */
|
||||
private async findWebsite(workspaceId: string, name: string) {
|
||||
const rows = await this.prisma.website.findMany({ where: { workspaceId } });
|
||||
const lower = name.toLowerCase();
|
||||
return (
|
||||
rows.find((r) => r.name.toLowerCase() === lower) ??
|
||||
rows.find((r) => r.name.toLowerCase().includes(lower)) ??
|
||||
rows.find((r) => r.primaryDomain?.toLowerCase().includes(lower)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** # 任意网站(健康检查默认目标) */
|
||||
private async findAnyWebsite(workspaceId: string) {
|
||||
return this.prisma.website.findFirst({ where: { workspaceId }, orderBy: { updatedAt: "desc" } });
|
||||
}
|
||||
|
||||
/** # 模糊查找容器 */
|
||||
private async findContainer(workspaceId: string, name: string) {
|
||||
const rows = await this.prisma.container.findMany({ where: { workspaceId } });
|
||||
const lower = name.toLowerCase();
|
||||
return rows.find((r) => r.name.toLowerCase() === lower) ?? rows.find((r) => r.name.toLowerCase().includes(lower)) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 从文本推断列表目标(规则引擎兜底) */
|
||||
function inferTargetFromText(text: string): string | null {
|
||||
const map: [RegExp, string][] = [
|
||||
[/服务器|server/i, "服务器"],
|
||||
[/网站|website/i, "网站"],
|
||||
[/域名|domain/i, "域名"],
|
||||
[/容器|container/i, "容器"],
|
||||
[/任务|task/i, "任务"],
|
||||
[/笔记|note/i, "笔记"],
|
||||
[/部署|deployment/i, "部署"],
|
||||
[/监控|monitor/i, "监控"],
|
||||
[/自动化|automation/i, "自动化"],
|
||||
];
|
||||
for (const [re, label] of map) if (re.test(text)) return label;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** # 渲染列表结果(人性化的简短文本,不吐 JSON) */
|
||||
function renderList(target: string, result: unknown): string {
|
||||
const rows = Array.isArray(result) ? (result as Record<string, unknown>[]) : [];
|
||||
if (rows.length === 0) return `当前没有${target}。`;
|
||||
const lines = rows.slice(0, 15).map((r) => {
|
||||
const name = (r.name ?? r.title ?? r.fullName ?? "?") as string;
|
||||
const status = (r.status ?? r.state ?? "") as string;
|
||||
const extra = r.primaryDomain ?? r.host ?? "";
|
||||
const image = r.image ? `(${String(r.image).slice(0, 32)})` : "";
|
||||
return `- ${name}${status ? `(${status})` : ""}${extra ? ` — ${String(extra)}` : ""}${target === "容器" ? image : ""}`;
|
||||
});
|
||||
const more = rows.length > 15 ? `\n…及其它 ${rows.length - 15} 项` : "";
|
||||
return `**${target}** 共 ${rows.length} 项:\n\n${lines.join("\n")}${more}`;
|
||||
}
|
||||
|
||||
/** # 工具结果摘要(回显用,避免超长 JSON) */
|
||||
function summarize(result: unknown): string {
|
||||
if (Array.isArray(result)) {
|
||||
const first = result[0] as Record<string, unknown> | undefined;
|
||||
return `${result.length} 项${first ? `,如 ${String(first.name ?? first.title ?? first.id ?? "")}` : ""}`;
|
||||
}
|
||||
const r = result as Record<string, unknown> | null;
|
||||
if (!r) return "完成";
|
||||
if (r.title) return String(r.title);
|
||||
if (r.name) return String(r.name);
|
||||
if (r.ok !== undefined) return `ok=${r.ok}`;
|
||||
return Object.keys(r).slice(0, 4).join(",");
|
||||
}
|
||||
|
||||
/** # 无文本时的兜底总结 */
|
||||
function summarizeDone(calls: ChatToolCall[]): string {
|
||||
const okCount = calls.filter((c) => c.ok).length;
|
||||
return `已完成 ${okCount}/${calls.length} 项操作。`;
|
||||
}
|
||||
|
||||
/** # 能力说明(离线模式) */
|
||||
const HELP_TEXT = [
|
||||
"我是 DevOS 运维助手,可以直接帮我完成这些事:",
|
||||
"",
|
||||
"📋 任务与笔记",
|
||||
" • 创建任务:修复登录页超时",
|
||||
" • 创建笔记:部署排障手册",
|
||||
" • 列出任务 / 列出笔记",
|
||||
"",
|
||||
"🖥️ 基础设施",
|
||||
" • 列出服务器 / 列出容器 / 列出监控",
|
||||
" • 服务器连通状态(到详情页看完整指标)",
|
||||
"",
|
||||
"🌐 网站与部署",
|
||||
" • 列出网站 / 网站健康检查",
|
||||
" • 部署网站:<名称>(需要你确认后执行)",
|
||||
" • 重启容器:<名称>(需要你确认后执行)",
|
||||
"",
|
||||
"⚙️ 其它",
|
||||
" • 列出域名 / 部署 / 自动化;运行自动化:<名称>",
|
||||
"",
|
||||
"配置 LLM(AI_PROVIDER_*)后,可以用任意自然语言表达,我会自动选择并组合工具完成操作。",
|
||||
].join("\n");
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* # AI Gateway 控制器(/api/v1/ai)
|
||||
*/
|
||||
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
|
||||
import { AiGatewayService, CreateAiRunSchema } from "./ai-gateway.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentUser, CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestUser, RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
@Controller("ai/runs")
|
||||
export class AiGatewayController {
|
||||
constructor(private readonly ai: AiGatewayService) {}
|
||||
|
||||
/** # AI 运行列表 */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.ai.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 创建 AI 运行(规划→风控→审批/执行) */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body(new ZodValidationPipe(CreateAiRunSchema)) dto: Parameters<AiGatewayService["create"]>[1],
|
||||
) {
|
||||
return this.ai.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # AI 运行详情 */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.ai.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 审批(HIGH/CRITICAL 运行;仅 ADMIN+) */
|
||||
@Roles("ADMIN")
|
||||
@Post(":id/approve")
|
||||
approve(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
return this.ai.approve(ws.id, id, user.id);
|
||||
}
|
||||
|
||||
/** # 拒绝 */
|
||||
@Roles("ADMIN")
|
||||
@Post(":id/reject")
|
||||
reject(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
return this.ai.reject(ws.id, id, user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* # AI Gateway 服务(Task 017)
|
||||
* AI 运行抽象:objective → plan(LLM 或启发式)→ 风险评估 → 审批(HIGH/CRITICAL)→ 执行 → 验证 → 报告。
|
||||
* 安全边界:AI 不绕过授权 —— 所有工具调用经由 ToolRegistry(与 UI/MCP 相同服务层 + 审计)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { AiRun, RiskLevel } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { ConfigService } from "../../common/config/config.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
|
||||
import { ToolRegistryService, maxRisk, escalateExecRisk, type ToolContext } from "./tool-registry.service";
|
||||
import { maskSecrets } from "../../common/utils/mask";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 创建 AI 运行 DTO **/
|
||||
export const CreateAiRunSchema = z.object({
|
||||
objective: z.string().min(4).max(4000),
|
||||
});
|
||||
export type CreateAiRunDto = z.infer<typeof CreateAiRunSchema>;
|
||||
|
||||
/** # 计划步骤 **/
|
||||
interface PlanStep {
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
reason: string;
|
||||
risk: RiskLevel;
|
||||
}
|
||||
|
||||
/** # 执行历史行 **/
|
||||
interface ToolCallRecord {
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** # 启发式规划规则:关键词 → 工具序列 **/
|
||||
const HEURISTIC_RULES: { keywords: RegExp; steps: { tool: string; args?: Record<string, unknown>; reason: string }[] }[] = [
|
||||
{
|
||||
keywords: /deploy|发布|部署/i,
|
||||
steps: [
|
||||
{ tool: "devos.website.list", reason: "Find candidate websites" },
|
||||
{ tool: "devos.website.deploy", args: { websiteId: "$(WEBSITE_ID)" }, reason: "Create deployment" },
|
||||
{ tool: "devos.deployment.list", reason: "Locate latest deployment" },
|
||||
{ tool: "devos.deployment.get", args: { id: "$(DEPLOYMENT_ID)" }, reason: "Check deployment status and logs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
keywords: /server|服务器|ssh/i,
|
||||
steps: [
|
||||
{ tool: "devos.server.list", reason: "List servers" },
|
||||
{ tool: "devos.server.status", args: { serverId: "$(SERVER_ID)" }, reason: "Check server status" },
|
||||
],
|
||||
},
|
||||
{
|
||||
keywords: /dns|domain|域名|record/i,
|
||||
steps: [
|
||||
{ tool: "devos.domain.list", reason: "List domains" },
|
||||
{ tool: "devos.domain.records", args: { id: "$(DOMAIN_ID)" }, reason: "Read DNS records" },
|
||||
],
|
||||
},
|
||||
{
|
||||
keywords: /不可用|挂了|down|outage|为什么.*(不|无法)|analyze|diagnos|排查/i,
|
||||
steps: [
|
||||
{ tool: "devos.monitor.list", reason: "Find failing monitors" },
|
||||
{ tool: "devos.website.list", reason: "List websites to locate the affected site" },
|
||||
{ tool: "devos.website.health", args: { id: "$(WEBSITE_ID)" }, reason: "Run immediate health check for evidence" },
|
||||
{ tool: "devos.deployment.list", reason: "Check recent deployments for regressions" },
|
||||
],
|
||||
},
|
||||
{
|
||||
keywords: /(创建|新建).*(任务|task)|record.*(issue|problem)|记录问题/i,
|
||||
steps: [
|
||||
{ tool: "devos.task.create", args: { title: "$(OBJECTIVE)" }, reason: "Create a task recording the issue" },
|
||||
],
|
||||
},
|
||||
{
|
||||
keywords: /重启.*(容器|container)|restart.*container|异常容器/i,
|
||||
steps: [
|
||||
{ tool: "devos.server.list", reason: "Locate servers" },
|
||||
{ tool: "devos.server.getDockerContainers", args: { serverId: "$(SERVER_ID)" }, reason: "Discover containers to find the unhealthy one" },
|
||||
{ tool: "devos.container.restart", args: { id: "$(CONTAINER_ID)" }, reason: "Restart the abnormal container (requires approval)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
keywords: /health|status|监控|状态|健康/i,
|
||||
steps: [
|
||||
{ tool: "devos.monitor.list", reason: "Check monitors" },
|
||||
{ tool: "devos.website.list", reason: "List websites" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AiGatewayService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly tools: ToolRegistryService,
|
||||
loggers: LogService,
|
||||
) {
|
||||
this.logger = loggers.child("AiGateway");
|
||||
}
|
||||
|
||||
/** # 日志器 **/
|
||||
private readonly logger: StructuredLoggerInstance;
|
||||
|
||||
/** # 创建 AI 运行:规划 → 风险评估 →(HIGH/CRITICAL)等待审批 或 直接执行 */
|
||||
async create(workspaceId: string, dto: CreateAiRunDto) {
|
||||
const ctx = getRequestContext();
|
||||
const run = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: { workspaceId, type: "AI_RUN", name: dto.objective.slice(0, 80), provider: "devos", status: "PENDING" },
|
||||
});
|
||||
return tx.aiRun.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
objective: dto.objective,
|
||||
status: "PLANNING",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 规划(异步执行,创建接口立即返回)
|
||||
void this.planAndMaybeExecute(run.id, workspaceId, dto.objective, ctx?.actorId).catch((error) => {
|
||||
this.logger.error("ai run planning failed", error instanceof Error ? error : undefined);
|
||||
});
|
||||
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "ai.run.create",
|
||||
resourceType: "AiRun",
|
||||
resourceId: run.resourceId,
|
||||
riskLevel: "MEDIUM",
|
||||
metadata: { objective: dto.objective.slice(0, 200) },
|
||||
});
|
||||
return this.view(await this.prisma.aiRun.findUniqueOrThrow({ where: { id: run.id } }));
|
||||
}
|
||||
|
||||
/** # 规划并(低风险时)自动执行 */
|
||||
private async planAndMaybeExecute(runId: string, workspaceId: string, objective: string, actorId?: string): Promise<void> {
|
||||
try {
|
||||
const steps = await this.makePlan(objective);
|
||||
const risk = maxRisk(steps.map((s) => s.risk));
|
||||
await this.prisma.aiRun.update({
|
||||
where: { id: runId },
|
||||
data: { plan: steps as unknown as object, riskLevel: risk },
|
||||
});
|
||||
if (risk === "HIGH" || risk === "CRITICAL") {
|
||||
await this.prisma.aiRun.update({ where: { id: runId }, data: { status: "AWAITING_APPROVAL" } });
|
||||
return;
|
||||
}
|
||||
await this.prisma.aiRun.update({ where: { id: runId }, data: { status: "RUNNING" } });
|
||||
await this.executePlan(runId, workspaceId, steps, actorId);
|
||||
} catch (error) {
|
||||
await this.prisma.aiRun.update({
|
||||
where: { id: runId },
|
||||
data: { status: "FAILED", error: error instanceof Error ? error.message.slice(0, 1000) : "planning failed" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** # 生成计划:优先 LLM(OpenAI 兼容),否则启发式规则 */
|
||||
private async makePlan(objective: string): Promise<PlanStep[]> {
|
||||
if (this.config.aiEnabled) {
|
||||
try {
|
||||
return await this.llmPlan(objective);
|
||||
} catch (error) {
|
||||
this.logger.warn(`LLM planning failed, falling back to heuristic: ${error instanceof Error ? error.message : ""}`);
|
||||
}
|
||||
}
|
||||
return this.heuristicPlan(objective);
|
||||
}
|
||||
|
||||
/** # 启发式规划 */
|
||||
private heuristicPlan(objective: string): PlanStep[] {
|
||||
for (const rule of HEURISTIC_RULES) {
|
||||
if (rule.keywords.test(objective)) {
|
||||
return rule.steps.map((s) => {
|
||||
const tool = this.tools.get(s.tool);
|
||||
const risk = tool?.risk ?? "MEDIUM";
|
||||
return { tool: s.tool, args: s.args ?? {}, reason: s.reason, risk };
|
||||
});
|
||||
}
|
||||
}
|
||||
// 兜底:只读清单
|
||||
return [
|
||||
{ tool: "devos.server.list", args: {}, reason: "Default: list servers", risk: "LOW" },
|
||||
{ tool: "devos.website.list", args: {}, reason: "Default: list websites", risk: "LOW" },
|
||||
];
|
||||
}
|
||||
|
||||
/** # LLM 规划(OpenAI 兼容 /chat/completions,要求 JSON 输出) */
|
||||
private async llmPlan(objective: string): Promise<PlanStep[]> {
|
||||
const toolList = this.tools.list();
|
||||
const system = [
|
||||
"You are the planner of DevOS, a developer infrastructure OS.",
|
||||
"Given an objective, produce a short JSON execution plan.",
|
||||
'Output format: {"steps":[{"tool":"devos.x.y","args":{},"reason":"..."}]}',
|
||||
"Only use the provided tools. Prefer read-only tools. Never invent tools.",
|
||||
"Unknown ids may use placeholders like $(WEBSITE_ID) which resolve at runtime.",
|
||||
].join("\n");
|
||||
const res = await fetch(`${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.aiApiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: this.config.aiModel,
|
||||
messages: [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: `Objective: ${objective}\nTools: ${JSON.stringify(toolList)}` },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI provider HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error("AI provider returned no content");
|
||||
const parsed = JSON.parse(content) as { steps?: { tool?: string; args?: Record<string, unknown>; reason?: string }[] };
|
||||
const steps: PlanStep[] = [];
|
||||
for (const s of parsed.steps ?? []) {
|
||||
if (!s.tool) continue;
|
||||
const tool = this.tools.get(s.tool);
|
||||
if (!tool) continue; // 只允许注册表内工具
|
||||
let risk: RiskLevel = tool.risk;
|
||||
if (s.tool === "devos.server.exec") {
|
||||
risk = escalateExecRisk(String((s.args as { command?: string })?.command ?? "")) ?? risk;
|
||||
}
|
||||
steps.push({ tool: s.tool, args: (s.args ?? {}) as Record<string, unknown>, reason: s.reason ?? "", risk });
|
||||
}
|
||||
if (steps.length === 0) throw new Error("LLM produced empty plan");
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** # 审批(OWNER/ADMIN 由控制器守卫保证):批准后执行 */
|
||||
async approve(workspaceId: string, id: string, approverId: string) {
|
||||
const run = await this.prisma.aiRun.findFirst({ where: { id, workspaceId } });
|
||||
if (!run) throw ApiError.notFound("AI run not found");
|
||||
if (run.status !== "AWAITING_APPROVAL") throw ApiError.conflict(`Run is ${run.status}, not awaiting approval`);
|
||||
await this.prisma.aiRun.update({
|
||||
where: { id },
|
||||
data: { status: "RUNNING", approvedBy: approverId, approvedAt: new Date() },
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: "USER",
|
||||
actorId: approverId,
|
||||
action: "ai.run.approve",
|
||||
resourceType: "AiRun",
|
||||
resourceId: run.resourceId,
|
||||
riskLevel: run.riskLevel,
|
||||
});
|
||||
const steps = run.plan as unknown as PlanStep[];
|
||||
void this.executePlan(id, workspaceId, steps ?? [], approverId)
|
||||
.catch((e) => this.logger.error("approved execution failed", e instanceof Error ? e : undefined));
|
||||
return this.view(await this.prisma.aiRun.findUniqueOrThrow({ where: { id } }));
|
||||
}
|
||||
|
||||
/** # 拒绝 */
|
||||
async reject(workspaceId: string, id: string, userId: string) {
|
||||
const run = await this.prisma.aiRun.findFirst({ where: { id, workspaceId } });
|
||||
if (!run) throw ApiError.notFound("AI run not found");
|
||||
if (run.status !== "AWAITING_APPROVAL") throw ApiError.conflict(`Run is ${run.status}`);
|
||||
await this.prisma.aiRun.update({ where: { id }, data: { status: "REJECTED", approvedBy: userId, approvedAt: new Date() } });
|
||||
await this.audit.record({
|
||||
workspaceId, actorType: "USER", actorId: userId,
|
||||
action: "ai.run.reject", resourceType: "AiRun", resourceId: run.resourceId, riskLevel: run.riskLevel,
|
||||
});
|
||||
return this.view(await this.prisma.aiRun.findUniqueOrThrow({ where: { id } }));
|
||||
}
|
||||
|
||||
/** # 执行计划:顺序调用工具(占位符解析)、写执行历史、领域验证 */
|
||||
private async executePlan(runId: string, workspaceId: string, steps: PlanStep[], actorId?: string): Promise<void> {
|
||||
const ctx: ToolContext = { workspaceId, actorType: "AGENT", actorId };
|
||||
const calls: ToolCallRecord[] = [];
|
||||
const placeholders: Record<string, string> = {};
|
||||
let finalResult: unknown = null;
|
||||
let verification: { ok: boolean; checks: { tool: string; ok: boolean; detail?: string }[] } | null = null;
|
||||
|
||||
try {
|
||||
for (const step of steps) {
|
||||
const tool = this.tools.get(step.tool);
|
||||
if (!tool) throw new Error(`Unknown tool: ${step.tool}`);
|
||||
const args = this.resolveArgs(step.args, placeholders);
|
||||
try {
|
||||
const result = await tool.execute(ctx, args as Record<string, unknown>);
|
||||
calls.push({ tool: step.tool, args: maskSecrets(args) as Record<string, unknown>, ok: true, result: maskSecrets(result), at: new Date().toISOString() });
|
||||
finalResult = result;
|
||||
// 占位符回填(把列表结果的第一个 id 存起来)
|
||||
this.harvestPlaceholders(step.tool, result, placeholders);
|
||||
if (tool.verify) {
|
||||
const v = await tool.verify(ctx, args, result);
|
||||
verification = verification ?? { ok: true, checks: [] };
|
||||
verification.checks.push({ tool: step.tool, ok: v.ok, detail: v.detail });
|
||||
if (!v.ok) verification.ok = false;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
calls.push({ tool: step.tool, args: maskSecrets(args) as Record<string, unknown>, ok: false, error: message.slice(0, 500), at: new Date().toISOString() });
|
||||
throw new Error(`Step ${step.tool} failed: ${message}`);
|
||||
}
|
||||
await this.prisma.aiRun.update({ where: { id: runId }, data: { toolCalls: calls as unknown as object } });
|
||||
}
|
||||
await this.prisma.aiRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: verification && !verification.ok ? "FAILED" : "SUCCEEDED",
|
||||
result: (finalResult ?? {}) as object,
|
||||
verification: (verification ?? undefined) as object | undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await this.prisma.aiRun.update({
|
||||
where: { id: runId },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
error: error instanceof Error ? error.message.slice(0, 1000) : "execution failed",
|
||||
toolCalls: calls as unknown as object,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** # 解析 $(PLACEHOLDER) 参数 */
|
||||
private resolveArgs(args: Record<string, unknown>, placeholders: Record<string, string>): Record<string, unknown> {
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
if (typeof v === "string") {
|
||||
const m = v.match(/^\$\(([A-Z_]+)\)$/);
|
||||
resolved[k] = m ? placeholders[m[1]] ?? v : v;
|
||||
} else {
|
||||
resolved[k] = v;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** # 从工具结果提取常用 ID(占位符回填) */
|
||||
private harvestPlaceholders(tool: string, result: unknown, placeholders: Record<string, string>): void {
|
||||
if (!Array.isArray(result)) return;
|
||||
const first = result[0] as Record<string, unknown> | undefined;
|
||||
if (!first) return;
|
||||
if (first.id) placeholders.WEBSITE_ID ??= String(first.id);
|
||||
if (tool === "devos.domain.list" && first.id) placeholders.DOMAIN_ID = String(first.id);
|
||||
if (tool === "devos.server.list" && first.id) placeholders.SERVER_ID = String(first.id);
|
||||
if (tool === "devos.deployment.list" && first.id) placeholders.DEPLOYMENT_ID = String(first.id);
|
||||
}
|
||||
|
||||
/** # AI 运行详情 */
|
||||
async get(workspaceId: string, id: string) {
|
||||
const run = await this.prisma.aiRun.findFirst({ where: { id, workspaceId } });
|
||||
if (!run) throw ApiError.notFound("AI run not found");
|
||||
return this.view(run);
|
||||
}
|
||||
|
||||
/** # AI 运行列表 */
|
||||
async list(workspaceId: string) {
|
||||
const runs = await this.prisma.aiRun.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
return runs.map((r) => this.view(r));
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private view(run: AiRun) {
|
||||
return {
|
||||
id: run.id,
|
||||
resourceId: run.resourceId,
|
||||
workspaceId: run.workspaceId,
|
||||
objective: run.objective,
|
||||
source: run.source,
|
||||
status: run.status,
|
||||
riskLevel: run.riskLevel,
|
||||
plan: run.plan,
|
||||
toolCalls: run.toolCalls,
|
||||
verification: run.verification,
|
||||
result: run.result,
|
||||
error: run.error ?? undefined,
|
||||
approvedBy: run.approvedBy,
|
||||
createdAt: run.createdAt,
|
||||
finishedAt: run.approvedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* # AI Gateway 模块(Task 017)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AiGatewayController } from "./ai-gateway.controller";
|
||||
import { AiChatController } from "./ai-chat.controller";
|
||||
import { AiChatService } from "./ai-chat.service";
|
||||
import { AiGatewayService } from "./ai-gateway.service";
|
||||
import { ToolRegistryService } from "./tool-registry.service";
|
||||
import { ServerModule } from "../server/server.module";
|
||||
import { ContainerModule } from "../container/container.module";
|
||||
import { WebsiteModule } from "../website/website.module";
|
||||
import { DeploymentModule } from "../deployment/deployment.module";
|
||||
import { DomainModule } from "../domain/domain.module";
|
||||
import { MonitoringModule } from "../monitoring/monitoring.module";
|
||||
import { AutomationModule } from "../automation/automation.module";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
import { TaskModule } from "../task/task.module";
|
||||
import { NoteModule } from "../note/note.module";
|
||||
import { GitModule } from "../git/git.module";
|
||||
|
||||
@Module({
|
||||
imports: [ServerModule, ContainerModule, WebsiteModule, DeploymentModule, DomainModule, MonitoringModule, AutomationModule, AuditModule, TaskModule, NoteModule, GitModule],
|
||||
controllers: [AiGatewayController, AiChatController],
|
||||
providers: [AiGatewayService, ToolRegistryService, AiChatService],
|
||||
exports: [ToolRegistryService, AiGatewayService, AiChatService],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* # AI 工具注册表(Task 017)
|
||||
* AI/Agent 与 MCP 共享的工具层:每个工具声明名称、描述、风险等级、参数 schema 与执行函数。
|
||||
* 工具实现复用各模块 service —— 与人工操作走同一条授权/审计路径(08-AI-AGENT / 09-MCP 安全要求)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { ZodSchema } from "zod";
|
||||
import type { RiskLevel } from "@devos/types";
|
||||
import { ServerService } from "../server/server.service";
|
||||
import { ContainerService } from "../container/container.service";
|
||||
import { WebsiteService } from "../website/website.service";
|
||||
import { DeploymentService } from "../deployment/deployment.service";
|
||||
import { DomainService } from "../domain/domain.service";
|
||||
import { MonitoringService } from "../monitoring/monitoring.service";
|
||||
import { AutomationService } from "../automation/automation.service";
|
||||
import { TaskService } from "../task/task.service";
|
||||
import { NoteService } from "../note/note.service";
|
||||
import { GitService } from "../git/git.service";
|
||||
import { z as zod } from "zod";
|
||||
|
||||
/** # 工具执行上下文 **/
|
||||
export interface ToolContext {
|
||||
workspaceId: string;
|
||||
actorType: "AGENT" | "MCP" | "USER" | "SYSTEM";
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
/** # 工具定义 **/
|
||||
export interface ToolDefinition<Args = Record<string, unknown>> {
|
||||
/** # 工具名(devos.<domain>.<action>) **/
|
||||
name: string;
|
||||
/** # 人类可读描述 **/
|
||||
description: string;
|
||||
/** # 风险等级 **/
|
||||
risk: RiskLevel;
|
||||
/** # 参数 schema(zod) **/
|
||||
schema: ZodSchema<Args>;
|
||||
/** # 执行函数(返回稳定 JSON 结果) **/
|
||||
execute: (ctx: ToolContext, args: Args) => Promise<unknown>;
|
||||
/** # 可选:领域验证器(执行后如何验证成功) **/
|
||||
verify?: (ctx: ToolContext, args: Args, result: unknown) => Promise<{ ok: boolean; detail?: string }>;
|
||||
}
|
||||
|
||||
/** # 常用参数 schema 片段 **/
|
||||
const IdSchema = zod.object({ id: zod.string().min(1) });
|
||||
const ServerIdSchema = zod.object({ serverId: zod.string().min(1) });
|
||||
const ExecSchema = zod.object({
|
||||
serverId: zod.string().min(1),
|
||||
command: zod.string().min(1).max(4096),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class ToolRegistryService {
|
||||
/** # 工具表 **/
|
||||
private tools = new Map<string, ToolDefinition<any>>();
|
||||
|
||||
constructor(
|
||||
private readonly servers: ServerService,
|
||||
private readonly containers: ContainerService,
|
||||
private readonly websites: WebsiteService,
|
||||
private readonly deployments: DeploymentService,
|
||||
private readonly domains: DomainService,
|
||||
private readonly monitoring: MonitoringService,
|
||||
private readonly automations: AutomationService,
|
||||
private readonly tasks: TaskService,
|
||||
private readonly notes: NoteService,
|
||||
private readonly git: GitService,
|
||||
) {
|
||||
this.registerDefaults();
|
||||
}
|
||||
|
||||
/** # 注册工具 */
|
||||
register(tool: ToolDefinition<any>): void {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
/** # 获取工具 */
|
||||
get(name: string): ToolDefinition<any> | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
|
||||
/** # 全部工具(描述视图,供规划器/MCP) */
|
||||
list(): { name: string; description: string; risk: RiskLevel }[] {
|
||||
return [...this.tools.values()].map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
risk: t.risk,
|
||||
}));
|
||||
}
|
||||
|
||||
/** # 注册默认工具集 */
|
||||
private registerDefaults(): void {
|
||||
// ---- 服务器 ----
|
||||
this.register({
|
||||
name: "devos.server.list",
|
||||
description: "List servers in the workspace",
|
||||
risk: "LOW",
|
||||
schema: zod.object({}),
|
||||
execute: async (ctx) => this.servers.list(ctx.workspaceId),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.server.status",
|
||||
description: "Get server connectivity/docker status",
|
||||
risk: "LOW",
|
||||
schema: ServerIdSchema,
|
||||
execute: async (ctx, args) => {
|
||||
const servers = await this.servers.list(ctx.workspaceId);
|
||||
const server = servers.find((s) => s.id === args.serverId);
|
||||
if (!server) throw new Error("Server not found");
|
||||
const containers = await this.containers.listByServer(ctx.workspaceId, server.id);
|
||||
return { server, containers: containers.length };
|
||||
},
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.server.exec",
|
||||
description: "Execute a shell command on a server via SSH",
|
||||
risk: "MEDIUM",
|
||||
schema: ExecSchema,
|
||||
execute: async (ctx, args) => this.servers.exec(ctx.workspaceId, args.serverId, { command: args.command, timeoutMs: 30_000 }),
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 容器 ----
|
||||
this.register({
|
||||
name: "devos.server.getDockerContainers",
|
||||
description: "Discover docker containers on a server",
|
||||
risk: "LOW",
|
||||
schema: ServerIdSchema,
|
||||
execute: async (ctx, args) => {
|
||||
const server = await this.servers.mustGet(ctx.workspaceId, args.serverId);
|
||||
return this.containers.discover(ctx.workspaceId, server);
|
||||
},
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 网站 ----
|
||||
this.register({
|
||||
name: "devos.website.list",
|
||||
description: "List websites in the workspace",
|
||||
risk: "LOW",
|
||||
schema: zod.object({}),
|
||||
execute: async (ctx) => this.websites.list(ctx.workspaceId),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.website.deploy",
|
||||
description: "Deploy a website (create deployment and queue async execution)",
|
||||
risk: "HIGH",
|
||||
schema: zod.object({ websiteId: zod.string().min(1) }),
|
||||
execute: async (ctx, args) =>
|
||||
this.websites.deploy(ctx.workspaceId, args.websiteId, { type: ctx.actorType, id: ctx.actorId, trigger: ctx.actorType }),
|
||||
verify: async (ctx, args) => {
|
||||
const deployments = await this.deployments.list(ctx.workspaceId, args.websiteId);
|
||||
const latest = deployments[0];
|
||||
return { ok: Boolean(latest), detail: latest ? `deployment ${latest.id} ${latest.status}` : "no deployment" };
|
||||
},
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.website.logs",
|
||||
description: "Get recent deployment logs of a website",
|
||||
risk: "LOW",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.websites.logs(ctx.workspaceId, args.id),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.website.health",
|
||||
description: "Run an immediate health check for a website",
|
||||
risk: "LOW",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.websites.health(ctx.workspaceId, args.id),
|
||||
verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { ok?: boolean }).ok) }),
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 部署 ----
|
||||
this.register({
|
||||
name: "devos.deployment.get",
|
||||
description: "Get a deployment by id (status, progress, logs)",
|
||||
risk: "LOW",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.deployments.get(ctx.workspaceId, args.id),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.deployment.list",
|
||||
description: "List recent deployments (optionally filtered by websiteId)",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ websiteId: zod.string().optional() }),
|
||||
execute: async (ctx, args) => this.deployments.list(ctx.workspaceId, args.websiteId),
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 域名 ----
|
||||
this.register({
|
||||
name: "devos.domain.list",
|
||||
description: "List domains in the workspace",
|
||||
risk: "LOW",
|
||||
schema: zod.object({}),
|
||||
execute: async (ctx) => this.domains.list(ctx.workspaceId),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.domain.records",
|
||||
description: "List DNS records of a domain",
|
||||
risk: "LOW",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.domains.records(ctx.workspaceId, args.id),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.domain.upsertRecord",
|
||||
description: "Create or update a DNS record",
|
||||
risk: "MEDIUM",
|
||||
schema: zod.object({
|
||||
domainId: zod.string().min(1),
|
||||
type: zod.enum(["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"]),
|
||||
name: zod.string().min(1),
|
||||
value: zod.string().min(1),
|
||||
ttl: zod.number().int().min(60).max(86400).optional(),
|
||||
proxied: zod.boolean().optional(),
|
||||
}),
|
||||
execute: async (ctx, args) =>
|
||||
this.domains.upsertRecord(ctx.workspaceId, args.domainId, {
|
||||
type: args.type,
|
||||
name: args.name,
|
||||
value: args.value,
|
||||
ttl: args.ttl ?? 300,
|
||||
proxied: args.proxied ?? false,
|
||||
}),
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 监控 ----
|
||||
this.register({
|
||||
name: "devos.monitor.list",
|
||||
description: "List monitors and their latest status",
|
||||
risk: "LOW",
|
||||
schema: zod.object({}),
|
||||
execute: async (ctx) => this.monitoring.list(ctx.workspaceId),
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 容器操作(Phase G/H 新工具) ----
|
||||
this.register({
|
||||
name: "devos.container.restart",
|
||||
description: "Restart a docker container by container row id",
|
||||
risk: "HIGH",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.containers.restart(ctx.workspaceId, args.id),
|
||||
verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { restarted?: boolean }).restarted), detail: "container restarted" }),
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 任务/笔记(Phase G/H 新工具) ----
|
||||
this.register({
|
||||
name: "devos.task.create",
|
||||
description: "Create a task in the workspace",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ title: zod.string().min(1), description: zod.string().optional(), priority: zod.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).optional() }),
|
||||
execute: async (ctx, args) =>
|
||||
this.tasks.create(ctx.workspaceId, {
|
||||
title: args.title,
|
||||
description: args.description ?? "",
|
||||
priority: args.priority ?? "MEDIUM",
|
||||
status: "TODO",
|
||||
labels: ["ai"],
|
||||
}),
|
||||
verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { id?: string }).id), detail: "task created" }),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.note.list",
|
||||
description: "List notes in the workspace",
|
||||
risk: "LOW",
|
||||
schema: zod.object({}),
|
||||
execute: async (ctx) => this.notes.list(ctx.workspaceId, {}),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.note.create",
|
||||
description: "Create a markdown note in the workspace",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ title: zod.string().min(1), content: zod.string().optional() }),
|
||||
execute: async (ctx, args) =>
|
||||
this.notes.create(ctx.workspaceId, { title: args.title, content: args.content ?? "", tags: ["ai"] }),
|
||||
verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { id?: string }).id), detail: "note created" }),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.task.list",
|
||||
description: "List tasks in the workspace",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ status: zod.string().optional() }),
|
||||
execute: async (ctx, args) => this.tasks.list(ctx.workspaceId, { status: args.status }),
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 网页抓取(URL 总结等场景) ----
|
||||
this.register({
|
||||
name: "devos.web.fetch",
|
||||
description: "Fetch a web page and return readable text (title + main content) for summarization",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ url: zod.string().url(), maxChars: zod.number().int().max(30000).optional() }),
|
||||
execute: async (_ctx, args) => {
|
||||
const res = await fetch(args.url, {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; DevOS-Agent/1.0)" },
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!res.ok) throw new Error(`Fetch failed HTTP ${res.status}`);
|
||||
const html = await res.text();
|
||||
const { extractHtmlTitle, extractMetaDescription, extractMainParagraphs } = await import("../../common/utils/html");
|
||||
return {
|
||||
title: extractHtmlTitle(html),
|
||||
description: extractMetaDescription(html),
|
||||
text: [extractHtmlTitle(html) ?? "", ...extractMainParagraphs(html, 30)].filter(Boolean).join("\n\n").slice(0, args.maxChars ?? 12000),
|
||||
};
|
||||
},
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 仓库:从 URL 登记 ----
|
||||
this.register({
|
||||
name: "devos.repository.createFromUrl",
|
||||
description: "Register a git repository from its HTTPS URL (auto-detects GitHub/GitLab provider)",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ url: zod.string().url() }),
|
||||
execute: async (ctx, args) => {
|
||||
const url = args.url.replace(/\.git$/, "");
|
||||
const host = new URL(url).hostname;
|
||||
const provider = host.includes("github") ? "GITHUB" : host.includes("gitlab") ? "GITLAB" : "GENERIC";
|
||||
const parts = new URL(url).pathname.replace(/^\//, "").split("/").filter(Boolean);
|
||||
const fullName = parts.slice(0, 2).join("/");
|
||||
const result = await this.git.upsertRepository(ctx.workspaceId, {
|
||||
provider: provider as "GITHUB" | "GITLAB" | "GITEA" | "GENERIC",
|
||||
fullName,
|
||||
cloneUrl: url,
|
||||
defaultBranch: "main",
|
||||
visibility: "private",
|
||||
});
|
||||
return { ...result, hint: "website.create 可用 repositoryId 关联该仓库" };
|
||||
},
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 名称查找(LLM 参数解析辅助) ----
|
||||
this.register({
|
||||
name: "devos.server.find",
|
||||
description: "Find a server by (partial) name, returns id/host/status",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ name: zod.string().min(1) }),
|
||||
execute: async (ctx, args) => {
|
||||
const all = await this.servers.list(ctx.workspaceId);
|
||||
const lower = args.name.toLowerCase();
|
||||
const hits = all.filter((s) => s.name.toLowerCase().includes(lower) || s.host.toLowerCase().includes(lower));
|
||||
const withStatus = await Promise.all(
|
||||
hits.map(async (s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
provider: s.provider,
|
||||
status: (await this.servers.mustGet(s.workspaceId, s.id)) ? undefined : undefined,
|
||||
})),
|
||||
);
|
||||
return hits.map((s) => ({ id: s.id, name: s.name, host: s.host }));
|
||||
},
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.domain.find",
|
||||
description: "Find a domain by (partial) name, returns id/provider",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ name: zod.string().min(1) }),
|
||||
execute: async (ctx, args) => {
|
||||
const all = await this.domains.list(ctx.workspaceId);
|
||||
const lower = args.name.toLowerCase();
|
||||
return all.filter((d) => d.name.toLowerCase().includes(lower));
|
||||
},
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 网站创建(chat 复合流程用) ----
|
||||
this.register({
|
||||
name: "devos.website.create",
|
||||
description: "Create a website bound to a server and (optionally) a repository; then use devos.website.deploy to deploy it",
|
||||
risk: "MEDIUM",
|
||||
schema: zod.object({
|
||||
name: zod.string().min(1),
|
||||
serverId: zod.string().optional(),
|
||||
repositoryId: zod.string().optional(),
|
||||
primaryDomain: zod.string().optional(),
|
||||
port: zod.number().int().min(1).max(65535).optional(),
|
||||
environment: zod.enum(["development", "staging", "production"]).optional(),
|
||||
}),
|
||||
execute: async (ctx, args) =>
|
||||
this.websites.create(ctx.workspaceId, {
|
||||
name: args.name,
|
||||
serverId: args.serverId,
|
||||
repositoryId: args.repositoryId,
|
||||
primaryDomain: args.primaryDomain,
|
||||
port: args.port ?? 80,
|
||||
healthPath: "/",
|
||||
deployType: "DOCKER",
|
||||
environment: args.environment ?? "production",
|
||||
}),
|
||||
verify: async (ctx, args, result) => ({ ok: Boolean((result as { id?: string }).id), detail: "website created" }),
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 编辑与删除(chat 全功能管理;删除走审批) ----
|
||||
this.register({
|
||||
name: "devos.task.update",
|
||||
description: "Update a task (status/priority/title/description)",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ id: zod.string().min(1), status: zod.enum(["BACKLOG", "TODO", "DOING", "REVIEW", "DONE"]).optional(), priority: zod.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).optional(), title: zod.string().optional() }),
|
||||
execute: async (ctx, args) => {
|
||||
const { id, ...patch } = args as { id: string } & Record<string, unknown>;
|
||||
return this.tasks.update(ctx.workspaceId, id, patch as never);
|
||||
},
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.note.update",
|
||||
description: "Update a note (title/content/tags)",
|
||||
risk: "LOW",
|
||||
schema: zod.object({ id: zod.string().min(1), title: zod.string().optional(), content: zod.string().optional() }),
|
||||
execute: async (ctx, args) => {
|
||||
const { id, ...patch } = args as { id: string } & Record<string, unknown>;
|
||||
return this.notes.update(ctx.workspaceId, id, patch as never);
|
||||
},
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.task.delete",
|
||||
description: "Delete a task",
|
||||
risk: "MEDIUM",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.tasks.delete(ctx.workspaceId, args.id),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.note.delete",
|
||||
description: "Delete a note",
|
||||
risk: "MEDIUM",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.notes.delete(ctx.workspaceId, args.id),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.website.delete",
|
||||
description: "Delete a website",
|
||||
risk: "HIGH",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.websites.delete(ctx.workspaceId, args.id),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.server.delete",
|
||||
description: "Delete a server",
|
||||
risk: "HIGH",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.servers.delete(ctx.workspaceId, args.id),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.domain.delete",
|
||||
description: "Delete a domain",
|
||||
risk: "HIGH",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.domains.delete(ctx.workspaceId, args.id),
|
||||
} as ToolDefinition<any>);
|
||||
|
||||
// ---- 自动化 ----
|
||||
this.register({
|
||||
name: "devos.automation.list",
|
||||
description: "List automations",
|
||||
risk: "LOW",
|
||||
schema: zod.object({}),
|
||||
execute: async (ctx) => this.automations.list(ctx.workspaceId),
|
||||
} as ToolDefinition<any>);
|
||||
this.register({
|
||||
name: "devos.automation.run",
|
||||
description: "Manually trigger an automation",
|
||||
risk: "MEDIUM",
|
||||
schema: IdSchema,
|
||||
execute: async (ctx, args) => this.automations.runManually(ctx.workspaceId, args.id, ctx.actorId),
|
||||
} as ToolDefinition<any>);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 风险等级比较(越大越高) **/
|
||||
const RISK_RANK: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
|
||||
|
||||
/** # 求多个风险的最大值 */
|
||||
export function maxRisk(levels: RiskLevel[]): RiskLevel {
|
||||
return levels.reduce<RiskLevel>((acc, l) => (RISK_RANK[l] > RISK_RANK[acc] ? l : acc), "LOW");
|
||||
}
|
||||
|
||||
/** # 危险命令升级(server.exec 用):基于命令内容的 CRITICAL 判定 */
|
||||
export function escalateExecRisk(command: string): RiskLevel | null {
|
||||
const destructive = [/rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)/, /mkfs/, /dd\s+if=/, /:\(\)\{:\|:\&\};:/, /shutdown|reboot/, /DROP\s+(TABLE|DATABASE)/i];
|
||||
return destructive.some((p) => p.test(command)) ? "CRITICAL" : null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* # 审计查询控制器
|
||||
*/
|
||||
import { Controller, Get, Query } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace } from "../../common/decorators";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
resourceType: z.string().optional(),
|
||||
resourceId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
});
|
||||
|
||||
@Controller("audit")
|
||||
export class AuditController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** # 分页查询当前工作区审计日志 */
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentWorkspace() workspace: RequestWorkspace,
|
||||
@Query(new ZodValidationPipe(ListQuerySchema))
|
||||
query: z.infer<typeof ListQuerySchema>,
|
||||
) {
|
||||
const where = {
|
||||
workspaceId: workspace.id,
|
||||
...(query.resourceType ? { resourceType: query.resourceType } : {}),
|
||||
...(query.resourceId ? { resourceId: query.resourceId } : {}),
|
||||
...(query.action ? { action: { contains: query.action } } : {}),
|
||||
};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.auditLog.count({ where }),
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
return { items, total, page: query.page, pageSize: query.pageSize };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* # 审计模块(Task 008)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuditController } from "./audit.controller";
|
||||
import { AuditService } from "./audit.service";
|
||||
|
||||
@Module({
|
||||
controllers: [AuditController],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* # 审计服务
|
||||
* 每个变更(mutation)必须写入审计记录;敏感读取也可审计。
|
||||
* 元数据自动脱敏;requestId 从请求上下文注入。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { ActorType, RiskLevel } from "@devos/types";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
import { maskSecrets } from "../../common/utils/mask";
|
||||
|
||||
/** # 审计记录输入 **/
|
||||
export interface AuditInput {
|
||||
workspaceId: string;
|
||||
actorType: ActorType;
|
||||
actorId?: string | null;
|
||||
action: string;
|
||||
resourceType?: string | null;
|
||||
resourceId?: string | null;
|
||||
riskLevel?: RiskLevel;
|
||||
status?: "SUCCESS" | "FAILURE" | "CANCELLED";
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** # 写入审计记录(永不抛出异常影响主流程) */
|
||||
async record(input: AuditInput): Promise<void> {
|
||||
try {
|
||||
const ctx = getRequestContext();
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
workspaceId: input.workspaceId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId ?? ctx?.actorId ?? null,
|
||||
action: input.action,
|
||||
resourceType: input.resourceType ?? null,
|
||||
resourceId: input.resourceId ?? null,
|
||||
riskLevel: input.riskLevel ?? "LOW",
|
||||
status: input.status ?? "SUCCESS",
|
||||
requestId: ctx?.requestId ?? null,
|
||||
ip: ctx?.ip ?? null,
|
||||
metadata: maskSecrets(input.metadata ?? {}) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
time: new Date().toISOString(),
|
||||
level: "error",
|
||||
ctx: "AuditService",
|
||||
msg: `failed to write audit log: ${error instanceof Error ? error.message : error}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* # Automation 控制器(/api/v1/automations)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Req } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { AutomationService, CreateAutomationSchema, UpdateAutomationSchema } from "./automation.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentUser, CurrentWorkspace, Roles, Public } from "../../common/decorators";
|
||||
import type { RequestUser, RequestWorkspace } from "../../common/decorators";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
|
||||
const WebhookBodySchema = z.record(z.unknown()).default({});
|
||||
|
||||
@Controller("automations")
|
||||
export class AutomationController {
|
||||
constructor(private readonly automations: AutomationService) {}
|
||||
|
||||
/** # 自动化列表 */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.automations.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 创建自动化 */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateAutomationSchema)) dto: Parameters<AutomationService["create"]>[1],
|
||||
) {
|
||||
return this.automations.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 自动化详情(含执行历史) */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.automations.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 编辑自动化 */
|
||||
@Roles("MEMBER")
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(UpdateAutomationSchema)) dto: Parameters<AutomationService["update"]>[2],
|
||||
) {
|
||||
return this.automations.update(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 启用 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/enable")
|
||||
enable(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.automations.setEnabled(ws.id, id, true);
|
||||
}
|
||||
|
||||
/** # 禁用 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/disable")
|
||||
disable(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.automations.setEnabled(ws.id, id, false);
|
||||
}
|
||||
|
||||
/** # 手动触发 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/run")
|
||||
run(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
return this.automations.runManually(ws.id, id, user.id);
|
||||
}
|
||||
|
||||
/** # Webhook 触发(公开路由;X-Webhook-Secret 头校验;body 为触发载荷) */
|
||||
@Public()
|
||||
@Post(":id/webhook")
|
||||
webhook(
|
||||
@Param("id") id: string,
|
||||
@Headers("x-webhook-secret") secret: string | undefined,
|
||||
@Headers("x-signature") signature: string | undefined,
|
||||
@Req() req: FastifyRequest,
|
||||
@Body(new ZodValidationPipe(WebhookBodySchema)) body: Record<string, unknown>,
|
||||
) {
|
||||
return this.automations.runWebhookByToken(id, {
|
||||
secret: secret ?? "",
|
||||
signature,
|
||||
rawBody: (req.rawBody ? req.rawBody.toString("utf8") : JSON.stringify(body ?? {})),
|
||||
});
|
||||
}
|
||||
|
||||
/** # 删除自动化 */
|
||||
@Roles("ADMIN")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.automations.delete(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* # Automation 模块(Task 016)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AutomationController } from "./automation.controller";
|
||||
import { AutomationService } from "./automation.service";
|
||||
import { EventModule } from "../event/event.module";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
import { WebsiteModule } from "../website/website.module";
|
||||
import { ResourceModule } from "../resource/resource.module";
|
||||
import { ContainerModule } from "../container/container.module";
|
||||
import { NotificationModule } from "../notification/notification.module";
|
||||
|
||||
@Module({
|
||||
imports: [EventModule, AuditModule, WebsiteModule, ResourceModule, ContainerModule, NotificationModule],
|
||||
controllers: [AutomationController],
|
||||
providers: [AutomationService],
|
||||
exports: [AutomationService],
|
||||
})
|
||||
export class AutomationModule {}
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* # Automation 服务(Task 016)
|
||||
* Trigger → Condition(s) → Action(s) 工作流:
|
||||
* - 触发器:MANUAL / CRON / EVENT / WEBHOOK(token 校验);
|
||||
* - 条件:[{path, op, value}] 对事件/触发载荷求值;
|
||||
* - 动作:LOG / WEBHOOK / DEPLOY_WEBSITE,带超时与有限重试;
|
||||
* - 每次执行写 automation_runs 历史(含步骤日志与错误)。
|
||||
*/
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { Automation } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { EventBusService, type DomainEvent } from "../event/event-bus.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ResourceService } from "../resource/resource.service";
|
||||
import { WebsiteService } from "../website/website.service";
|
||||
import { ContainerService } from "../container/container.service";
|
||||
import { NotificationService } from "../notification/notification.service";
|
||||
import { cronMatches } from "./cron";
|
||||
import { verifyWebhookSignature } from "../../common/security/webhook-signature";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/** # 动作配置 schema **/
|
||||
const ActionSchema = z.object({
|
||||
type: z.enum([
|
||||
"LOG",
|
||||
"WEBHOOK",
|
||||
"DEPLOY_WEBSITE",
|
||||
"CONTAINER_RESTART",
|
||||
"TASK_CREATE",
|
||||
"NOTE_CREATE",
|
||||
"NOTIFICATION_SEND",
|
||||
]),
|
||||
args: z.record(z.unknown()).default({}),
|
||||
timeoutMs: z.coerce.number().int().min(1000).max(600_000).default(30_000),
|
||||
retries: z.coerce.number().int().min(0).max(5).default(0),
|
||||
});
|
||||
export type AutomationAction = z.infer<typeof ActionSchema>;
|
||||
|
||||
/** # 条件配置 schema **/
|
||||
const ConditionSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
op: z.enum(["eq", "neq", "contains", "exists", "gt", "lt"]),
|
||||
value: z.unknown().optional(),
|
||||
});
|
||||
export type AutomationCondition = z.infer<typeof ConditionSchema>;
|
||||
|
||||
/** # 更新自动化 DTO **/
|
||||
export const UpdateAutomationSchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
conditions: z.array(ConditionSchema).optional(),
|
||||
actions: z.array(ActionSchema).min(1).max(10).optional(),
|
||||
});
|
||||
export type UpdateAutomationDto = z.infer<typeof UpdateAutomationSchema>;
|
||||
|
||||
/** # 创建自动化 DTO **/
|
||||
export const CreateAutomationSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
triggerType: z.enum(["MANUAL", "CRON", "EVENT", "WEBHOOK"]),
|
||||
triggerConfig: z
|
||||
.object({
|
||||
cron: z.string().optional(),
|
||||
eventType: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
})
|
||||
.default({}),
|
||||
conditions: z.array(ConditionSchema).default([]),
|
||||
actions: z.array(ActionSchema).min(1).max(10),
|
||||
enabled: z.boolean().default(false),
|
||||
});
|
||||
export type CreateAutomationDto = z.infer<typeof CreateAutomationSchema>;
|
||||
|
||||
/** # 动作执行日志行 **/
|
||||
interface RunLogLine {
|
||||
step: number;
|
||||
type: string;
|
||||
ok: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AutomationService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: EventBusService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly resources: ResourceService,
|
||||
private readonly websites: WebsiteService,
|
||||
private readonly containers: ContainerService,
|
||||
private readonly notifications: NotificationService,
|
||||
) {}
|
||||
|
||||
/** # 初始化:订阅事件总线处理 EVENT 触发器 */
|
||||
onModuleInit(): void {
|
||||
this.events.subscribe((event) => this.handleDomainEvent(event));
|
||||
}
|
||||
|
||||
/** # 创建自动化 */
|
||||
async create(workspaceId: string, dto: CreateAutomationDto) {
|
||||
if (dto.triggerType === "CRON" && !dto.triggerConfig.cron) {
|
||||
throw ApiError.validation("CRON trigger requires triggerConfig.cron");
|
||||
}
|
||||
if (dto.triggerType === "CRON") {
|
||||
try {
|
||||
cronMatches(dto.triggerConfig.cron!, new Date());
|
||||
} catch {
|
||||
throw ApiError.validation("Invalid cron expression");
|
||||
}
|
||||
}
|
||||
if (dto.triggerType === "EVENT" && !dto.triggerConfig.eventType) {
|
||||
throw ApiError.validation("EVENT trigger requires triggerConfig.eventType");
|
||||
}
|
||||
const automation = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: { workspaceId, type: "AUTOMATION", name: dto.name, provider: "devos", status: dto.enabled ? "ACTIVE" : "PENDING" },
|
||||
});
|
||||
return tx.automation.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
name: dto.name,
|
||||
enabled: dto.enabled,
|
||||
triggerType: dto.triggerType,
|
||||
triggerConfig: dto.triggerConfig as object,
|
||||
conditions: dto.conditions as unknown as object,
|
||||
actions: dto.actions as unknown as object,
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "automation.create",
|
||||
resourceType: "Automation",
|
||||
resourceId: automation.resourceId,
|
||||
metadata: { triggerType: dto.triggerType },
|
||||
});
|
||||
return this.view(automation);
|
||||
}
|
||||
|
||||
/** # 自动化列表 */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.automation.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return rows.map((a) => this.view(a));
|
||||
}
|
||||
|
||||
/** # 自动化详情(含执行历史) */
|
||||
async get(workspaceId: string, id: string) {
|
||||
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
|
||||
if (!a) throw ApiError.notFound("Automation not found");
|
||||
const runs = await this.prisma.automationRun.findMany({
|
||||
where: { automationId: a.id },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
return { ...this.view(a), runs };
|
||||
}
|
||||
|
||||
/** # 启用/禁用 */
|
||||
async setEnabled(workspaceId: string, id: string, enabled: boolean) {
|
||||
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
|
||||
if (!a) throw ApiError.notFound("Automation not found");
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.automation.update({ where: { id }, data: { enabled } });
|
||||
await tx.resource.update({ where: { id: a.resourceId }, data: { status: enabled ? "ACTIVE" : "ARCHIVED" } });
|
||||
return updated;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: enabled ? "automation.enable" : "automation.disable",
|
||||
resourceType: "Automation",
|
||||
resourceId: a.resourceId,
|
||||
});
|
||||
return this.view(updated);
|
||||
}
|
||||
|
||||
/** # 编辑自动化(Phase F:名称/条件/动作/开关) */
|
||||
async update(workspaceId: string, id: string, dto: UpdateAutomationDto) {
|
||||
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
|
||||
if (!a) throw ApiError.notFound("Automation not found");
|
||||
if (dto.actions) {
|
||||
// 校验动作 schema
|
||||
for (const action of dto.actions) ActionSchema.parse(action);
|
||||
}
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const u = await tx.automation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name ? { name: dto.name } : {}),
|
||||
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
|
||||
...(dto.conditions ? { conditions: dto.conditions as unknown as object } : {}),
|
||||
...(dto.actions ? { actions: dto.actions as unknown as object } : {}),
|
||||
},
|
||||
});
|
||||
if (dto.enabled !== undefined) {
|
||||
await tx.resource.update({
|
||||
where: { id: a.resourceId },
|
||||
data: { status: dto.enabled ? "ACTIVE" : "ARCHIVED" },
|
||||
});
|
||||
}
|
||||
return u;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "automation.update",
|
||||
resourceType: "Automation",
|
||||
resourceId: a.resourceId,
|
||||
metadata: { fields: Object.keys(dto) },
|
||||
});
|
||||
return this.view(updated);
|
||||
}
|
||||
|
||||
/** # 删除自动化 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
|
||||
if (!a) throw ApiError.notFound("Automation not found");
|
||||
await this.prisma.automation.delete({ where: { id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "automation.delete",
|
||||
resourceType: "Automation",
|
||||
resourceId: a.resourceId,
|
||||
riskLevel: "HIGH",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 手动触发 */
|
||||
async runManually(workspaceId: string, id: string, actorId?: string) {
|
||||
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
|
||||
if (!a) throw ApiError.notFound("Automation not found");
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
actorId,
|
||||
action: "automation.run",
|
||||
resourceType: "Automation",
|
||||
resourceId: a.resourceId,
|
||||
});
|
||||
return this.execute(a, { triggerType: "MANUAL", payload: {} });
|
||||
}
|
||||
|
||||
/** # Webhook 触发(HMAC 签名优先,明文 secret 兼容回退;按 id 定位,无需工作区上下文) */
|
||||
async runWebhookByToken(
|
||||
id: string,
|
||||
input: { secret: string; signature?: string; rawBody: string },
|
||||
) {
|
||||
const a = await this.prisma.automation.findUnique({ where: { id } });
|
||||
if (!a) throw ApiError.notFound("Automation not found");
|
||||
if (a.triggerType !== "WEBHOOK") throw ApiError.validation("Automation is not a webhook automation");
|
||||
const cfg = a.triggerConfig as { secret?: string };
|
||||
if (!cfg.secret) throw ApiError.forbidden("Automation has no webhook secret configured");
|
||||
// 优先 HMAC(t=<ts>,v1=<hex>),防重放;legacy 明文比对保留给旧调用方
|
||||
let authOk = false;
|
||||
let authReason = "";
|
||||
if (input.signature) {
|
||||
const verdict = verifyWebhookSignature(cfg.secret, input.signature, input.rawBody);
|
||||
authOk = verdict.ok;
|
||||
authReason = verdict.reason ?? "";
|
||||
} else if (input.secret) {
|
||||
authOk = input.secret === cfg.secret;
|
||||
authReason = authOk ? "legacy secret" : "legacy secret mismatch";
|
||||
}
|
||||
if (!authOk) {
|
||||
await this.audit.record({
|
||||
workspaceId: a.workspaceId, actorType: "SYSTEM", action: "automation.webhook",
|
||||
resourceType: "Automation", resourceId: a.resourceId, status: "FAILURE",
|
||||
riskLevel: "MEDIUM", metadata: { reason: authReason || "unauthorized" },
|
||||
});
|
||||
throw ApiError.forbidden(`Webhook authentication failed: ${authReason || "missing credentials"}`);
|
||||
}
|
||||
if (!a.enabled) throw ApiError.conflict("Automation is disabled");
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(input.rawBody || "{}") as Record<string, unknown>;
|
||||
} catch {
|
||||
payload = { raw: input.rawBody.slice(0, 2000) };
|
||||
}
|
||||
const result = await this.execute(a, { triggerType: "WEBHOOK", payload });
|
||||
await this.audit.record({
|
||||
workspaceId: a.workspaceId,
|
||||
actorType: "SYSTEM",
|
||||
action: "automation.webhook",
|
||||
resourceType: "Automation",
|
||||
resourceId: a.resourceId,
|
||||
status: result.status === "FAILED" ? "FAILURE" : "SUCCESS",
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** # 订阅事件总线:处理 EVENT 触发器 */
|
||||
handleDomainEvent(event: DomainEvent): void {
|
||||
void (async () => {
|
||||
const automations = await this.prisma.automation.findMany({
|
||||
where: { workspaceId: event.workspaceId, triggerType: "EVENT", enabled: true },
|
||||
});
|
||||
for (const a of automations) {
|
||||
const cfg = a.triggerConfig as { eventType?: string };
|
||||
if (cfg.eventType !== event.eventType) continue;
|
||||
await this.execute(a, {
|
||||
triggerType: "EVENT",
|
||||
payload: { eventType: event.eventType, ...event.payload },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/** # cron 扫描(调度器每分钟调用) */
|
||||
async runDueCron(): Promise<void> {
|
||||
const automations = await this.prisma.automation.findMany({
|
||||
where: { triggerType: "CRON", enabled: true },
|
||||
});
|
||||
const now = new Date();
|
||||
for (const a of automations) {
|
||||
const cfg = a.triggerConfig as { cron?: string };
|
||||
if (!cfg.cron || !cronMatches(cfg.cron, now)) continue;
|
||||
// 同一分钟内不重复触发
|
||||
if (a.lastTriggeredAt && now.getTime() - a.lastTriggeredAt.getTime() < 60_000) continue;
|
||||
await this.prisma.automation.update({ where: { id: a.id }, data: { lastTriggeredAt: now } });
|
||||
await this.execute(a, { triggerType: "CRON", payload: { at: now.toISOString() } }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 执行自动化:条件评估 → 顺序动作(重试/超时)→ 历史 */
|
||||
async execute(
|
||||
automation: Automation,
|
||||
trigger: { triggerType: string; payload: Record<string, unknown> },
|
||||
): Promise<{ runId: string; status: string; logs: RunLogLine[] }> {
|
||||
const correlationId = randomUUID();
|
||||
const run = await this.prisma.automationRun.create({
|
||||
data: {
|
||||
automationId: automation.id,
|
||||
workspaceId: automation.workspaceId,
|
||||
status: "RUNNING",
|
||||
trigger: trigger as unknown as object,
|
||||
correlationId,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = (automation.conditions as unknown as AutomationCondition[]) ?? [];
|
||||
const actions = (automation.actions as unknown as AutomationAction[]) ?? [];
|
||||
const logs: RunLogLine[] = [];
|
||||
let status = "SUCCEEDED";
|
||||
let error: string | undefined;
|
||||
|
||||
const allConditionsMet = conditions.every((c) => evaluateCondition(c, trigger.payload));
|
||||
if (!allConditionsMet) {
|
||||
logs.push({ step: 0, type: "CONDITION", ok: false, output: "conditions not met; skipped", at: new Date().toISOString() });
|
||||
await this.prisma.automationRun.update({
|
||||
where: { id: run.id },
|
||||
data: { status: "SKIPPED", logs: logs as unknown as object[], finishedAt: new Date() },
|
||||
});
|
||||
return { runId: run.id, status: "SKIPPED", logs };
|
||||
}
|
||||
|
||||
let stepIndex = 0;
|
||||
for (const action of actions) {
|
||||
stepIndex += 1;
|
||||
const line = await this.runAction(automation.workspaceId, stepIndex, action, trigger.payload, correlationId);
|
||||
logs.push(line);
|
||||
if (!line.ok) {
|
||||
status = "FAILED";
|
||||
error = line.error;
|
||||
break; // 失败即停(可按动作配置 retries 重试)
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.automationRun.update({
|
||||
where: { id: run.id },
|
||||
data: { status, logs: logs as unknown as object[], stepIndex, error, finishedAt: new Date() },
|
||||
});
|
||||
return { runId: run.id, status, logs };
|
||||
}
|
||||
|
||||
/** # 执行单个动作(含重试与超时) */
|
||||
private async runAction(
|
||||
workspaceId: string,
|
||||
step: number,
|
||||
action: AutomationAction,
|
||||
payload: Record<string, unknown>,
|
||||
correlationId: string,
|
||||
): Promise<RunLogLine> {
|
||||
const at = () => new Date().toISOString();
|
||||
let lastError: string | undefined;
|
||||
for (let attempt = 0; attempt <= action.retries; attempt++) {
|
||||
try {
|
||||
const output = await this.runActionOnce(workspaceId, action, payload, correlationId);
|
||||
return { step, type: action.type, ok: true, output, at: at() };
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
if (attempt < action.retries) {
|
||||
await new Promise((r) => setTimeout(r, Math.min(2000 * 2 ** attempt, 30_000)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { step, type: action.type, ok: false, error: lastError, at: at() };
|
||||
}
|
||||
|
||||
/** # 动作实现 */
|
||||
private async runActionOnce(
|
||||
workspaceId: string,
|
||||
action: AutomationAction,
|
||||
payload: Record<string, unknown>,
|
||||
correlationId: string,
|
||||
): Promise<string> {
|
||||
switch (action.type) {
|
||||
case "LOG": {
|
||||
const message = String(action.args.message ?? "automation log");
|
||||
return `LOG: ${message}`;
|
||||
}
|
||||
case "WEBHOOK": {
|
||||
const url = String(action.args.url ?? "");
|
||||
if (!/^https?:\/\//.test(url)) throw new Error("WEBHOOK requires args.url (http/https)");
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Correlation-Id": correlationId },
|
||||
body: JSON.stringify({ payload }),
|
||||
signal: AbortSignal.timeout(action.timeoutMs),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Webhook responded ${res.status}`);
|
||||
return `WEBHOOK: ${url} -> ${res.status}`;
|
||||
}
|
||||
case "DEPLOY_WEBSITE": {
|
||||
const websiteId = String(action.args.websiteId ?? "");
|
||||
const deployment = await this.websites.deploy(workspaceId, websiteId, {
|
||||
type: "SYSTEM",
|
||||
trigger: "AUTOMATION",
|
||||
});
|
||||
return `DEPLOY_WEBSITE: deployment ${deployment.id} queued`;
|
||||
}
|
||||
case "CONTAINER_RESTART": {
|
||||
const containerId = String(action.args.containerId ?? "");
|
||||
const r = await this.containers.restart(workspaceId, containerId);
|
||||
return `CONTAINER_RESTART: ${r.name} restarted`;
|
||||
}
|
||||
case "TASK_CREATE": {
|
||||
const taskId = await this.createAutomationTask(workspaceId, action.args, correlationId);
|
||||
return `TASK_CREATE: task ${taskId} created`;
|
||||
}
|
||||
case "NOTE_CREATE": {
|
||||
const noteId = await this.createAutomationNote(workspaceId, action.args, correlationId);
|
||||
return `NOTE_CREATE: note ${noteId} created`;
|
||||
}
|
||||
case "NOTIFICATION_SEND": {
|
||||
await this.notifications.push(workspaceId, {
|
||||
title: String(action.args.title ?? "Automation notification"),
|
||||
body: String(action.args.body ?? ""),
|
||||
level: "INFO",
|
||||
});
|
||||
return "NOTIFICATION_SEND: notification pushed";
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action type`);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 自动化动作:创建任务(写 tasks 表 + TASK 资源) */
|
||||
private async createAutomationTask(
|
||||
workspaceId: string,
|
||||
args: Record<string, unknown>,
|
||||
correlationId: string,
|
||||
): Promise<string> {
|
||||
const title = String(args.title ?? "Automation task").slice(0, 200);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: { workspaceId, type: "TASK", name: title, provider: "automation", status: "ACTIVE", metadata: { source: "automation", correlationId } },
|
||||
});
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
workspaceId, resourceId: resource.id, title,
|
||||
description: String(args.description ?? `Created by automation ${correlationId}`),
|
||||
status: "TODO", priority: "MEDIUM",
|
||||
},
|
||||
});
|
||||
return task.id;
|
||||
});
|
||||
}
|
||||
|
||||
/** # 自动化动作:创建笔记 */
|
||||
private async createAutomationNote(
|
||||
workspaceId: string,
|
||||
args: Record<string, unknown>,
|
||||
correlationId: string,
|
||||
): Promise<string> {
|
||||
const title = String(args.title ?? "Automation note").slice(0, 200);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: { workspaceId, type: "NOTE", name: title, provider: "automation", status: "ACTIVE", metadata: { source: "automation", correlationId } },
|
||||
});
|
||||
const note = await tx.note.create({
|
||||
data: {
|
||||
workspaceId, resourceId: resource.id, title,
|
||||
content: String(args.content ?? `Created by automation ${correlationId}`),
|
||||
},
|
||||
});
|
||||
return note.id;
|
||||
});
|
||||
}
|
||||
|
||||
/** # 断言自动化属于工作区 */
|
||||
async mustGet(workspaceId: string, id: string) {
|
||||
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
|
||||
if (!a) throw ApiError.notFound("Automation not found");
|
||||
return a;
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private view(a: Automation) {
|
||||
return {
|
||||
id: a.id,
|
||||
resourceId: a.resourceId,
|
||||
workspaceId: a.workspaceId,
|
||||
name: a.name,
|
||||
enabled: a.enabled,
|
||||
triggerType: a.triggerType,
|
||||
triggerConfig: a.triggerConfig,
|
||||
conditions: a.conditions,
|
||||
actions: a.actions,
|
||||
lastTriggeredAt: a.lastTriggeredAt,
|
||||
createdAt: a.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** # 按路径取值(支持 a.b.c / a.0.b) */
|
||||
function getPath(obj: unknown, path: string): unknown {
|
||||
let cur: unknown = obj;
|
||||
for (const key of path.split(".")) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** # 条件评估(纯函数) */
|
||||
export function evaluateCondition(condition: AutomationCondition, payload: Record<string, unknown>): boolean {
|
||||
const actual = getPath(payload, condition.path);
|
||||
switch (condition.op) {
|
||||
case "exists":
|
||||
return actual !== undefined;
|
||||
case "eq":
|
||||
return String(actual) === String(condition.value);
|
||||
case "neq":
|
||||
return String(actual) !== String(condition.value);
|
||||
case "contains":
|
||||
return String(actual ?? "").includes(String(condition.value));
|
||||
case "gt":
|
||||
return Number(actual) > Number(condition.value);
|
||||
case "lt":
|
||||
return Number(actual) < Number(condition.value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* # Cron 解析器
|
||||
* 标准 5 字段 cron(分 时 日 月 周),支持星号、数字、步进(星号加斜杠n)、区间(a-b)、列表(a,b,c)混合。
|
||||
* 纯函数实现,便于测试。
|
||||
*/
|
||||
|
||||
/** # cron 字段取值范围 **/
|
||||
const FIELD_RANGES: [number, number][] = [
|
||||
[0, 59], // minute
|
||||
[0, 23], // hour
|
||||
[1, 31], // day of month
|
||||
[1, 12], // month
|
||||
[0, 6], // day of week (0=Sunday)
|
||||
];
|
||||
|
||||
/** # 解析单个字段为允许值集合
|
||||
* @param field 字段表达式
|
||||
* @param range 取值范围
|
||||
* @returns 允许值集合
|
||||
*/
|
||||
export function parseCronField(field: string, range: [number, number]): Set<number> {
|
||||
const [min, max] = range;
|
||||
const values = new Set<number>();
|
||||
for (const part of field.split(",")) {
|
||||
const [base, stepStr] = part.split("/");
|
||||
const step = stepStr ? Number(stepStr) : 1;
|
||||
if (!Number.isInteger(step) || step < 1) throw new Error(`Invalid cron step: ${part}`);
|
||||
let lo = min;
|
||||
let hi = max;
|
||||
if (base !== "*" && base !== "") {
|
||||
if (base.includes("-")) {
|
||||
const [a, b] = base.split("-").map(Number);
|
||||
if (!Number.isInteger(a) || !Number.isInteger(b)) throw new Error(`Invalid cron range: ${part}`);
|
||||
lo = a;
|
||||
hi = b;
|
||||
} else {
|
||||
const v = Number(base);
|
||||
if (!Number.isInteger(v)) throw new Error(`Invalid cron value: ${part}`);
|
||||
lo = v;
|
||||
hi = stepStr ? max : v;
|
||||
}
|
||||
}
|
||||
if (lo < min || hi > max || lo > hi) throw new Error(`Cron value out of range: ${part}`);
|
||||
for (let v = lo; v <= hi; v += step) values.add(v);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** # 解析完整 cron 表达式
|
||||
* @param expression 5 字段表达式
|
||||
* @returns 各字段允许值
|
||||
*/
|
||||
export function parseCron(expression: string): Set<number>[] {
|
||||
const fields = expression.trim().split(/\s+/);
|
||||
if (fields.length !== 5) throw new Error(`Cron expression must have 5 fields: ${expression}`);
|
||||
return fields.map((f, i) => parseCronField(f, FIELD_RANGES[i]));
|
||||
}
|
||||
|
||||
/** # 判断给定时间是否匹配 cron 表达式
|
||||
* @param expression 5 字段表达式
|
||||
* @param date 待判定时间(本地时区)
|
||||
*/
|
||||
export function cronMatches(expression: string, date: Date): boolean {
|
||||
let sets: Set<number>[];
|
||||
try {
|
||||
sets = parseCron(expression);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const values = [
|
||||
date.getMinutes(),
|
||||
date.getHours(),
|
||||
date.getDate(),
|
||||
date.getMonth() + 1,
|
||||
date.getDay(),
|
||||
];
|
||||
return values.every((v, i) => sets[i].has(v));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* # Certificate 控制器(/api/v1/certificates)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Param, Post } from "@nestjs/common";
|
||||
import { CertificateService, CreateCertificateSchema } from "./certificate.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
@Controller("certificates")
|
||||
export class CertificateController {
|
||||
constructor(private readonly certificates: CertificateService) {}
|
||||
|
||||
/** # 证书列表 */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.certificates.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 添加证书(可选 TLS 探测) */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateCertificateSchema)) dto: Parameters<CertificateService["create"]>[1],
|
||||
) {
|
||||
return this.certificates.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 证书详情 */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.certificates.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 重新探测 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/recheck")
|
||||
recheck(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.certificates.recheck(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 删除证书 */
|
||||
@Roles("ADMIN")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.certificates.delete(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* # Certificate 模块
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CertificateController } from "./certificate.controller";
|
||||
import { CertificateService } from "./certificate.service";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [CertificateController],
|
||||
providers: [CertificateService],
|
||||
exports: [CertificateService],
|
||||
})
|
||||
export class CertificateModule {}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* # Certificate 服务(Phase E/05 SSL 基础)
|
||||
* 统一证书模型 + TLS 探针(node:tls 直连取对端证书):
|
||||
* 状态 pending/active/expired/error;到期时间与签发者入库;可关联 Domain 资源。
|
||||
*/
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { connect as tlsConnect, type PeerCertificate } from "node:tls";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { AuditService } from "../../modules/audit/audit.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 创建证书 DTO **/
|
||||
export const CreateCertificateSchema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(253)
|
||||
.regex(/^(?!-)[a-zA-Z0-9-*]{1,63}(\.[a-zA-Z0-9-*]{1,63})+$/, "invalid domain"),
|
||||
domainId: z.string().optional(),
|
||||
/** # 立即执行 TLS 探测(默认 true) **/
|
||||
probe: z.boolean().default(true),
|
||||
});
|
||||
export type CreateCertificateDto = z.infer<typeof CreateCertificateSchema>;
|
||||
|
||||
/** # TLS 探测结果 **/
|
||||
export interface TlsProbeResult {
|
||||
ok: boolean;
|
||||
issuer?: string;
|
||||
notAfter?: Date;
|
||||
daysRemaining?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CertificateService implements OnModuleInit {
|
||||
/** # 到期阈值(天):低于则标记并提示 */
|
||||
private static readonly EXPIRY_WARN_DAYS = 30;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
/** # worker 调度预留:每小时刷新(V1 手动触发为主) */
|
||||
onModuleInit(): void {
|
||||
/* 预留 */
|
||||
}
|
||||
|
||||
/** # 创建证书记录并可选探测 */
|
||||
async create(workspaceId: string, dto: CreateCertificateDto) {
|
||||
if (dto.domainId) {
|
||||
const domain = await this.prisma.domain.findFirst({ where: { id: dto.domainId, workspaceId } });
|
||||
if (!domain) throw ApiError.notFound("Domain not found");
|
||||
}
|
||||
const probe = dto.probe ? await this.probeTls(dto.domain) : null;
|
||||
const cert = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: "CERTIFICATE",
|
||||
name: dto.domain,
|
||||
provider: "tls-probe",
|
||||
status: this.resourceStatusFromProbe(probe),
|
||||
},
|
||||
});
|
||||
return tx.certificate.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
domain: dto.domain,
|
||||
domainId: dto.domainId ?? null,
|
||||
status: this.statusFromProbe(probe),
|
||||
issuer: probe?.issuer ?? null,
|
||||
notAfter: probe?.notAfter ?? null,
|
||||
lastCheckedAt: probe ? new Date() : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "certificate.create",
|
||||
resourceType: "Certificate",
|
||||
resourceId: cert.resourceId,
|
||||
metadata: { domain: dto.domain, probe: probe ? { ok: probe.ok, days: probe.daysRemaining } : null },
|
||||
});
|
||||
return this.get(workspaceId, cert.id);
|
||||
}
|
||||
|
||||
/** # 证书列表 */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.certificate.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return rows.map((c) => this.view(c));
|
||||
}
|
||||
|
||||
/** # 证书详情 */
|
||||
async get(workspaceId: string, id: string) {
|
||||
const c = await this.prisma.certificate.findFirst({ where: { id, workspaceId } });
|
||||
if (!c) throw ApiError.notFound("Certificate not found");
|
||||
return this.view(c);
|
||||
}
|
||||
|
||||
/** # 重新探测(刷新状态/到期时间/签发者) */
|
||||
async recheck(workspaceId: string, id: string) {
|
||||
const cert = await this.prisma.certificate.findFirst({ where: { id, workspaceId } });
|
||||
if (!cert) throw ApiError.notFound("Certificate not found");
|
||||
const probe = await this.probeTls(cert.domain);
|
||||
const status = this.statusFromProbe(probe);
|
||||
const updated = await this.prisma.certificate.update({
|
||||
where: { id: cert.id },
|
||||
data: {
|
||||
status,
|
||||
issuer: probe.issuer ?? cert.issuer,
|
||||
notAfter: probe.notAfter ?? cert.notAfter,
|
||||
lastCheckedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await this.prisma.resource.update({
|
||||
where: { id: cert.resourceId },
|
||||
data: { status: status === "ACTIVE" ? "ACTIVE" : status === "EXPIRED" ? "ERROR" : "PENDING" },
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "certificate.recheck",
|
||||
resourceType: "Certificate",
|
||||
resourceId: cert.resourceId,
|
||||
status: probe.ok ? "SUCCESS" : "FAILURE",
|
||||
metadata: { daysRemaining: probe.daysRemaining },
|
||||
});
|
||||
return { ...this.view(updated), probe };
|
||||
}
|
||||
|
||||
/** # 删除证书记录 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const cert = await this.prisma.certificate.findFirst({ where: { id, workspaceId } });
|
||||
if (!cert) throw ApiError.notFound("Certificate not found");
|
||||
await this.prisma.certificate.delete({ where: { id: cert.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "certificate.delete",
|
||||
resourceType: "Certificate",
|
||||
resourceId: cert.resourceId,
|
||||
riskLevel: "MEDIUM",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # TLS 探针:443 直连取对端证书(不做链校验,仅读取信息) */
|
||||
async probeTls(domain: string, port = 443, timeoutMs = 8000): Promise<TlsProbeResult> {
|
||||
return new Promise<TlsProbeResult>((resolve) => {
|
||||
let settled = false;
|
||||
const done = (r: TlsProbeResult) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resolve(r);
|
||||
}
|
||||
};
|
||||
const socket = tlsConnect(
|
||||
{ host: domain, port, servername: domain, rejectUnauthorized: false, timeout: timeoutMs },
|
||||
() => {
|
||||
const cert = socket.getPeerCertificate() as PeerCertificate;
|
||||
if (!cert || !cert.valid_to) {
|
||||
done({ ok: false, error: "no peer certificate" });
|
||||
return;
|
||||
}
|
||||
const notAfter = new Date(cert.valid_to);
|
||||
const daysRemaining = Math.floor((notAfter.getTime() - Date.now()) / 86_400_000);
|
||||
const issuerObj = cert.issuer as unknown as Record<string, Record<string, string>> | undefined;
|
||||
done({
|
||||
ok: true,
|
||||
issuer: issuerObj?.O
|
||||
? (issuerObj.O[Object.keys(issuerObj.O)[0]] as string)
|
||||
: typeof issuerObj?.CN === "string"
|
||||
? issuerObj.CN
|
||||
: "unknown",
|
||||
notAfter,
|
||||
daysRemaining,
|
||||
});
|
||||
},
|
||||
);
|
||||
socket.on("error", (err: Error) => done({ ok: false, error: err.message.slice(0, 200) }));
|
||||
socket.on("timeout", () => done({ ok: false, error: "timeout" }));
|
||||
});
|
||||
}
|
||||
|
||||
/** # 探测结果 → 资源状态(resources.status 为 Prisma 枚举) */
|
||||
private resourceStatusFromProbe(probe: TlsProbeResult | null): "PENDING" | "ACTIVE" | "ERROR" {
|
||||
const s = this.statusFromProbe(probe);
|
||||
return s === "ACTIVE" ? "ACTIVE" : s === "ERROR" ? "ERROR" : "PENDING";
|
||||
}
|
||||
|
||||
/** # 由探测结果推导状态 */
|
||||
private statusFromProbe(probe: TlsProbeResult | null): "PENDING" | "ACTIVE" | "EXPIRED" | "ERROR" {
|
||||
if (!probe) return "PENDING";
|
||||
if (!probe.ok) return "ERROR";
|
||||
if ((probe.daysRemaining ?? 0) <= 0) return "EXPIRED";
|
||||
return "ACTIVE";
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private view(c: {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
workspaceId: string;
|
||||
domain: string;
|
||||
domainId: string | null;
|
||||
status: string;
|
||||
issuer: string | null;
|
||||
notAfter: Date | null;
|
||||
lastCheckedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
const daysRemaining = c.notAfter
|
||||
? Math.floor((c.notAfter.getTime() - Date.now()) / 86_400_000)
|
||||
: null;
|
||||
return {
|
||||
id: c.id,
|
||||
resourceId: c.resourceId,
|
||||
workspaceId: c.workspaceId,
|
||||
domain: c.domain,
|
||||
domainId: c.domainId,
|
||||
status: c.status,
|
||||
issuer: c.issuer,
|
||||
notAfter: c.notAfter,
|
||||
daysRemaining,
|
||||
expiringSoon: daysRemaining !== null && daysRemaining >= 0 && daysRemaining <= CertificateService.EXPIRY_WARN_DAYS,
|
||||
lastCheckedAt: c.lastCheckedAt,
|
||||
createdAt: c.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* # Container 控制器(/api/v1/containers、/api/v1/servers/:id/containers 的容器部分)
|
||||
*/
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { ContainerService, RestartContainerSchema } from "./container.service";
|
||||
import { ServerService } from "../server/server.service";
|
||||
import { z } from "zod";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
|
||||
const LogsQuerySchema = z.object({ tail: z.coerce.number().int().min(10).max(1000).default(200) });
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
@Controller("containers")
|
||||
export class ContainerController {
|
||||
constructor(
|
||||
private readonly containers: ContainerService,
|
||||
private readonly servers: ServerService,
|
||||
) {}
|
||||
|
||||
/** # 全工作区容器列表 */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.containers.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 容器详情(实时 inspect,敏感 env 已脱敏) */
|
||||
@Get(":id/inspect")
|
||||
inspect(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.containers.inspect(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 启动容器 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/start")
|
||||
start(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.containers.start(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 停止容器 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/stop")
|
||||
stop(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.containers.stop(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 容器日志(tail) */
|
||||
@Get(":id/logs")
|
||||
logs(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Query(new ZodValidationPipe(LogsQuerySchema)) query: { tail: number },
|
||||
) {
|
||||
return this.containers.logs(ws.id, id, query.tail);
|
||||
}
|
||||
|
||||
/** # 容器资源用量 */
|
||||
@Get(":id/stats")
|
||||
stats(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.containers.stats(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 重启容器(需显式确认) */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/restart")
|
||||
restart(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(RestartContainerSchema)) _dto: { confirm: true },
|
||||
) {
|
||||
return this.containers.restart(ws.id, id);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 服务器维度容器端点(挂到 /servers/:id/containers) **/
|
||||
@Controller("servers")
|
||||
export class ServerContainersController {
|
||||
constructor(
|
||||
private readonly containers: ContainerService,
|
||||
private readonly servers: ServerService,
|
||||
) {}
|
||||
|
||||
/** # 发现服务器容器(docker ps → 入库 → 返回) */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/containers")
|
||||
discover(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.servers.mustGet(ws.id, id).then((server) => this.containers.discover(ws.id, server));
|
||||
}
|
||||
|
||||
/** # 服务器容器列表(读库) */
|
||||
@Get(":id/containers")
|
||||
listByServer(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.containers.listByServer(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* # Container 模块(Task 010)
|
||||
*/
|
||||
import { forwardRef } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ContainerController, ServerContainersController } from "./container.controller";
|
||||
import { ContainerService } from "./container.service";
|
||||
import { ServerModule } from "../server/server.module";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
import { SshModule } from "../../integrations/ssh/ssh.module";
|
||||
import { DockerModule } from "../../integrations/docker/docker.module";
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => ServerModule), AuditModule, SshModule, DockerModule],
|
||||
controllers: [ContainerController, ServerContainersController],
|
||||
providers: [ContainerService],
|
||||
exports: [ContainerService],
|
||||
})
|
||||
export class ContainerModule {}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* # Container 服务(Task 010)
|
||||
* Docker 发现:列出/检查/重启容器,容器以 CONTAINER 资源入图谱并与 SERVER 建立关系。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { Server } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { DockerService } from "../../integrations/docker/docker.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ServerService } from "../server/server.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 重启容器 DTO **/
|
||||
export const RestartContainerSchema = z.object({
|
||||
confirm: z.literal(true),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class ContainerService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly docker: DockerService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly serverService: ServerService,
|
||||
) {}
|
||||
|
||||
/** # Docker 可用性 */
|
||||
async dockerStatus(workspaceId: string, server: Server) {
|
||||
const target = this.docker.isLocal(server)
|
||||
? { host: "", port: 0, username: "" }
|
||||
: await this.serverService.sshTarget(workspaceId, server);
|
||||
return this.docker.checkDocker(target, this.docker.isLocal(server));
|
||||
}
|
||||
|
||||
/** # 发现服务器上的容器并同步入库(CONTAINER 资源 + deployed_on 关系) */
|
||||
async discover(workspaceId: string, server: Server) {
|
||||
const isLocal = this.docker.isLocal(server);
|
||||
const target = isLocal ? { host: "", port: 0, username: "" } : await this.serverService.sshTarget(workspaceId, server);
|
||||
const containers = await this.docker.listContainers(target, isLocal);
|
||||
|
||||
for (const c of containers) {
|
||||
const existing = await this.prisma.container.findFirst({
|
||||
where: { workspaceId, serverId: server.id, containerId: c.id },
|
||||
});
|
||||
if (existing) {
|
||||
await this.prisma.container.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: c.name,
|
||||
image: c.image,
|
||||
state: c.state,
|
||||
ports: c.ports as unknown as object,
|
||||
labels: c.labels as unknown as object,
|
||||
},
|
||||
});
|
||||
await this.prisma.resource.update({
|
||||
where: { id: existing.resourceId },
|
||||
data: { status: c.state === "running" ? "ACTIVE" : "OFFLINE", name: c.name },
|
||||
});
|
||||
} else {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: "CONTAINER",
|
||||
name: c.name || c.id,
|
||||
provider: "docker",
|
||||
status: c.state === "running" ? "ACTIVE" : "OFFLINE",
|
||||
metadata: { image: c.image, serverId: server.id },
|
||||
},
|
||||
});
|
||||
await tx.container.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
serverId: server.id,
|
||||
containerId: c.id,
|
||||
name: c.name,
|
||||
image: c.image,
|
||||
state: c.state,
|
||||
ports: c.ports as unknown as object,
|
||||
labels: c.labels as unknown as object,
|
||||
},
|
||||
});
|
||||
await tx.resourceRelation.upsert({
|
||||
where: {
|
||||
sourceId_targetId_relationType: {
|
||||
sourceId: server.resourceId,
|
||||
targetId: resource.id,
|
||||
relationType: "runs_container",
|
||||
},
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
sourceId: server.resourceId,
|
||||
targetId: resource.id,
|
||||
relationType: "runs_container",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "container.discover",
|
||||
resourceType: "Server",
|
||||
resourceId: server.resourceId,
|
||||
metadata: { count: containers.length },
|
||||
});
|
||||
return this.listByServer(workspaceId, server.id);
|
||||
}
|
||||
|
||||
/** # 某服务器的容器列表(读库;如需刷新调用 discover) */
|
||||
async listByServer(workspaceId: string, serverId: string) {
|
||||
await this.serverService.mustGet(workspaceId, serverId);
|
||||
const rows = await this.prisma.container.findMany({
|
||||
where: { workspaceId, serverId },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return rows.map((c) => this.toView(c));
|
||||
}
|
||||
|
||||
/** # 全工作区容器列表 */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.container.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
return rows.map((c) => this.toView(c));
|
||||
}
|
||||
|
||||
/** # 容器详情(实时 inspect) */
|
||||
async inspect(workspaceId: string, containerRowId: string) {
|
||||
const row = await this.mustGet(workspaceId, containerRowId);
|
||||
const server = await this.serverService.mustGet(workspaceId, row.serverId);
|
||||
const isLocal = this.docker.isLocal(server);
|
||||
const target = isLocal ? { host: "", port: 0, username: "" } : await this.serverService.sshTarget(workspaceId, server);
|
||||
const info = await this.docker.inspectContainer(target, row.containerId, isLocal);
|
||||
// 剥离可能的敏感 env
|
||||
const config = info.Config as Record<string, unknown> | undefined;
|
||||
if (config && Array.isArray(config.Env)) {
|
||||
config.Env = (config.Env as string[]).map((e) => e.split("=")[0] + "=***");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/** # 启动容器 */
|
||||
async start(workspaceId: string, containerRowId: string) {
|
||||
return this.lifecycle(workspaceId, containerRowId, "start");
|
||||
}
|
||||
|
||||
/** # 停止容器 */
|
||||
async stop(workspaceId: string, containerRowId: string) {
|
||||
return this.lifecycle(workspaceId, containerRowId, "stop");
|
||||
}
|
||||
|
||||
/** # 生命周期通用实现(审计) */
|
||||
private async lifecycle(workspaceId: string, containerRowId: string, action: "start" | "stop") {
|
||||
const row = await this.mustGet(workspaceId, containerRowId);
|
||||
const server = await this.serverService.mustGet(workspaceId, row.serverId);
|
||||
const isLocal = this.docker.isLocal(server);
|
||||
const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server);
|
||||
if (action === "start") {
|
||||
await this.docker.startContainer(target, row.containerId, isLocal);
|
||||
} else {
|
||||
await this.docker.stopContainer(target, row.containerId, isLocal);
|
||||
}
|
||||
await this.prisma.container.update({
|
||||
where: { id: row.id },
|
||||
data: { state: action === "start" ? "running" : "exited" },
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: `container.${action}`,
|
||||
resourceType: "Container",
|
||||
resourceId: row.resourceId,
|
||||
riskLevel: "HIGH",
|
||||
metadata: { name: row.name },
|
||||
});
|
||||
return { [action]: true, name: row.name };
|
||||
}
|
||||
|
||||
/** # 容器日志(tail) */
|
||||
async logs(workspaceId: string, containerRowId: string, tail = 200) {
|
||||
const row = await this.mustGet(workspaceId, containerRowId);
|
||||
const server = await this.serverService.mustGet(workspaceId, row.serverId);
|
||||
const isLocal = this.docker.isLocal(server);
|
||||
const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server);
|
||||
const lines = await this.docker.containerLogs(target, row.containerId, Math.min(tail, 1000), isLocal);
|
||||
return { name: row.name, lines };
|
||||
}
|
||||
|
||||
/** # 容器实时资源用量 */
|
||||
async stats(workspaceId: string, containerRowId: string) {
|
||||
const row = await this.mustGet(workspaceId, containerRowId);
|
||||
const server = await this.serverService.mustGet(workspaceId, row.serverId);
|
||||
const isLocal = this.docker.isLocal(server);
|
||||
const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server);
|
||||
return { name: row.name, ...(await this.docker.containerStats(target, row.containerId, isLocal)) };
|
||||
}
|
||||
|
||||
/** # 镜像列表 */
|
||||
async listImages(workspaceId: string, serverId: string) {
|
||||
const server = await this.serverService.mustGet(workspaceId, serverId);
|
||||
const isLocal = this.docker.isLocal(server);
|
||||
const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server);
|
||||
return this.docker.listImages(target, isLocal);
|
||||
}
|
||||
|
||||
/** # 重启容器(需 confirm=true;审计 + container.restarted 事件) */
|
||||
async restart(workspaceId: string, containerRowId: string) {
|
||||
const row = await this.mustGet(workspaceId, containerRowId);
|
||||
const server = await this.serverService.mustGet(workspaceId, row.serverId);
|
||||
const isLocal = this.docker.isLocal(server);
|
||||
const target = isLocal ? { host: "", port: 0, username: "" } : await this.serverService.sshTarget(workspaceId, server);
|
||||
await this.docker.restartContainer(target, row.containerId, isLocal);
|
||||
await this.prisma.container.update({ where: { id: row.id }, data: { state: "running" } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "container.restart",
|
||||
resourceType: "Container",
|
||||
resourceId: row.resourceId,
|
||||
riskLevel: "HIGH",
|
||||
metadata: { name: row.name, serverId: server.id },
|
||||
});
|
||||
return { restarted: true, name: row.name };
|
||||
}
|
||||
|
||||
/** # 断言容器属于工作区 */
|
||||
private async mustGet(workspaceId: string, id: string) {
|
||||
const row = await this.prisma.container.findFirst({ where: { id, workspaceId } });
|
||||
if (!row) throw ApiError.notFound("Container not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private toView(c: {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
containerId: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: string;
|
||||
ports: unknown;
|
||||
labels: unknown;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: c.id,
|
||||
resourceId: c.resourceId,
|
||||
containerId: c.containerId,
|
||||
name: c.name,
|
||||
image: c.image,
|
||||
state: c.state,
|
||||
ports: c.ports,
|
||||
labels: c.labels,
|
||||
serverId: c.serverId,
|
||||
workspaceId: c.workspaceId,
|
||||
updatedAt: c.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* # 凭据保险库
|
||||
* AES-256-GCM 信封式加密:
|
||||
* Application Master Key (ENCRYPTION_KEY, 32B) → 加密凭据载荷。
|
||||
* - AAD 绑定 workspaceId,防止跨租户密文移植;
|
||||
* - 明文只在内存中短暂存在,绝不入库、绝不入日志(审计层另有脱敏);
|
||||
* - API 层不提供任何“读取明文”端点。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { ConfigService } from "../../common/config/config.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
|
||||
/** # 密文载荷结构(JSON 序列化后存储) **/
|
||||
interface EncryptedPayload {
|
||||
v: 1;
|
||||
iv: string;
|
||||
tag: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CredentialVaultService {
|
||||
/** # 主密钥 **/
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.key = Buffer.from(config.encryptionKey, "hex");
|
||||
}
|
||||
|
||||
/** # 加密
|
||||
* @param workspaceId 租户 ID(作为 AAD 绑定)
|
||||
* @param plaintext 明文(如私钥/密码/API Token)
|
||||
*/
|
||||
encrypt(workspaceId: string, plaintext: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv, { authTagLength: 16 });
|
||||
cipher.setAAD(Buffer.from(workspaceId, "utf8"));
|
||||
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const payload: EncryptedPayload = {
|
||||
v: 1,
|
||||
iv: iv.toString("base64"),
|
||||
tag: cipher.getAuthTag().toString("base64"),
|
||||
data: data.toString("base64"),
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
/** # 解密(仅限服务端内部使用)
|
||||
* @throws 完整性校验失败(密文被篡改或跨租户使用)时抛出
|
||||
*/
|
||||
decrypt(workspaceId: string, encrypted: string): string {
|
||||
let payload: EncryptedPayload;
|
||||
try {
|
||||
payload = JSON.parse(encrypted) as EncryptedPayload;
|
||||
} catch {
|
||||
throw ApiError.forbidden("Credential payload corrupted");
|
||||
}
|
||||
try {
|
||||
const decipher = createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
this.key,
|
||||
Buffer.from(payload.iv, "base64"),
|
||||
{ authTagLength: 16 },
|
||||
);
|
||||
decipher.setAAD(Buffer.from(workspaceId, "utf8"));
|
||||
decipher.setAuthTag(Buffer.from(payload.tag, "base64"));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(payload.data, "base64")),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
} catch {
|
||||
throw ApiError.forbidden("Credential integrity check failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* # Credential 控制器(/api/v1/credentials)
|
||||
* 安全约束:任何端点都不返回明文密钥。
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from "@nestjs/common";
|
||||
import { CredentialService, CreateCredentialSchema, UpdateCredentialSchema } from "./credential.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
@Controller("credentials")
|
||||
export class CredentialController {
|
||||
constructor(private readonly credentials: CredentialService) {}
|
||||
|
||||
/** # 凭据列表(仅元数据) */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.credentials.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 创建凭据(明文立即加密入库) */
|
||||
@Roles("ADMIN")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateCredentialSchema)) dto: Parameters<CredentialService["create"]>[1],
|
||||
) {
|
||||
return this.credentials.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 凭据详情(仅元数据) */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.credentials.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 更新/轮换凭据 */
|
||||
@Roles("ADMIN")
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(UpdateCredentialSchema)) dto: Parameters<CredentialService["update"]>[2],
|
||||
) {
|
||||
return this.credentials.update(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 删除凭据 */
|
||||
@Roles("ADMIN")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.credentials.delete(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* # 凭据保险库模块
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CredentialController } from "./credential.controller";
|
||||
import { CredentialService } from "./credential.service";
|
||||
import { CredentialVaultService } from "./credential-vault.service";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [CredentialController],
|
||||
providers: [CredentialService, CredentialVaultService],
|
||||
exports: [CredentialService, CredentialVaultService],
|
||||
})
|
||||
export class CredentialModule {}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* # 凭据服务
|
||||
* 凭据的 CRUD 与“按需解密”。凭据同时是一条 CREDENTIAL 资源(进入统一资源图谱)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { CredentialType } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { CredentialVaultService } from "./credential-vault.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 创建凭据 DTO **/
|
||||
export const CreateCredentialSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
type: z.enum(["SSH_PASSWORD", "SSH_KEY", "API_TOKEN", "DNS_PROVIDER", "GIT_TOKEN"]),
|
||||
provider: z.string().max(64).optional(),
|
||||
/** # 明文机密(仅出现在请求中,服务端立即加密) **/
|
||||
secret: z.string().min(1).max(65536),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
export type CreateCredentialDto = z.infer<typeof CreateCredentialSchema>;
|
||||
|
||||
/** # 更新凭据 DTO **/
|
||||
export const UpdateCredentialSchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
/** # 轮换:提供新的明文机密 **/
|
||||
secret: z.string().min(1).max(65536).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
export type UpdateCredentialDto = z.infer<typeof UpdateCredentialSchema>;
|
||||
|
||||
@Injectable()
|
||||
export class CredentialService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly vault: CredentialVaultService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
/** # 创建凭据(资源 + 密文,一个事务) */
|
||||
async create(workspaceId: string, dto: CreateCredentialDto) {
|
||||
const credential = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: "CREDENTIAL",
|
||||
name: dto.name,
|
||||
provider: dto.provider ?? null,
|
||||
status: "ACTIVE",
|
||||
metadata: { credentialType: dto.type, ...(dto.metadata ?? {}) },
|
||||
},
|
||||
});
|
||||
return tx.credential.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
name: dto.name,
|
||||
type: dto.type as CredentialType,
|
||||
provider: dto.provider ?? null,
|
||||
encryptedData: this.vault.encrypt(workspaceId, dto.secret),
|
||||
metadata: (dto.metadata ?? {}) as object,
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "credential.create",
|
||||
resourceType: "Credential",
|
||||
resourceId: credential.id,
|
||||
riskLevel: "MEDIUM",
|
||||
});
|
||||
return this.view(credential.id, workspaceId);
|
||||
}
|
||||
|
||||
/** # 凭据列表(不含密文) */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.credential.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return rows.map((r) => this.toView(r));
|
||||
}
|
||||
|
||||
/** # 凭据详情(不含密文) */
|
||||
async get(workspaceId: string, id: string) {
|
||||
const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } });
|
||||
if (!row) throw ApiError.notFound("Credential not found");
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
/** # 更新(含轮换密文) */
|
||||
async update(workspaceId: string, id: string, dto: UpdateCredentialDto) {
|
||||
const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } });
|
||||
if (!row) throw ApiError.notFound("Credential not found");
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const cred = await tx.credential.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name ? { name: dto.name } : {}),
|
||||
...(dto.metadata ? { metadata: dto.metadata as object } : {}),
|
||||
...(dto.secret
|
||||
? { encryptedData: this.vault.encrypt(workspaceId, dto.secret) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
await tx.resource.update({
|
||||
where: { id: cred.resourceId ?? "" },
|
||||
data: { ...(dto.name ? { name: dto.name } : {}) },
|
||||
});
|
||||
return cred;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "credential.update",
|
||||
resourceType: "Credential",
|
||||
resourceId: id,
|
||||
riskLevel: "MEDIUM",
|
||||
});
|
||||
return this.toView(updated);
|
||||
}
|
||||
|
||||
/** # 删除凭据 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } });
|
||||
if (!row) throw ApiError.notFound("Credential not found");
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.credential.delete({ where: { id } });
|
||||
if (row.resourceId) await tx.resource.delete({ where: { id: row.resourceId } });
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "credential.delete",
|
||||
resourceType: "Credential",
|
||||
resourceId: id,
|
||||
riskLevel: "HIGH",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 解密取回明文(内部使用;绝不返回给 API 调用方)
|
||||
* 每次使用写入审计(credential.use)。
|
||||
*/
|
||||
async getSecret(workspaceId: string, credentialId: string | null): Promise<string | null> {
|
||||
if (!credentialId) return null;
|
||||
const row = await this.prisma.credential.findFirst({
|
||||
where: { id: credentialId, workspaceId },
|
||||
});
|
||||
if (!row) throw ApiError.notFound("Credential not found");
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "SYSTEM",
|
||||
action: "credential.use",
|
||||
resourceType: "Credential",
|
||||
resourceId: row.id,
|
||||
riskLevel: "MEDIUM",
|
||||
metadata: { purpose: "internal-execution" },
|
||||
});
|
||||
await this.prisma.credential.update({
|
||||
where: { id: row.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
return this.vault.decrypt(workspaceId, row.encryptedData);
|
||||
}
|
||||
|
||||
/** # 外部视图(不含任何密文) */
|
||||
private toView(row: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
resourceId: string | null;
|
||||
name: string;
|
||||
type: CredentialType;
|
||||
provider: string | null;
|
||||
metadata: unknown;
|
||||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
resourceId: row.resourceId,
|
||||
workspaceId: row.workspaceId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
provider: row.provider,
|
||||
metadata: row.metadata,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** # 兼容入口:按 id 返回视图 */
|
||||
private async view(id: string, workspaceId: string) {
|
||||
const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } });
|
||||
if (!row) throw ApiError.notFound("Credential not found");
|
||||
return this.toView(row);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* # Deployment 控制器(/api/v1/deployments)
|
||||
*/
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { DeploymentService, CreateDeploymentSchema, type CreateDeploymentDto } from "./deployment.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const ListQuerySchema = z.object({ websiteId: z.string().optional() });
|
||||
|
||||
@Controller("deployments")
|
||||
export class DeploymentController {
|
||||
constructor(private readonly deployments: DeploymentService) {}
|
||||
|
||||
/** # 部署列表(可按网站过滤) */
|
||||
@Get()
|
||||
list(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Query(new ZodValidationPipe(ListQuerySchema)) query: { websiteId?: string },
|
||||
) {
|
||||
return this.deployments.list(ws.id, query.websiteId);
|
||||
}
|
||||
|
||||
/** # 创建部署 */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateDeploymentSchema)) dto: CreateDeploymentDto,
|
||||
) {
|
||||
return this.deployments.createDeployment(ws.id, dto.websiteId, {
|
||||
trigger: dto.trigger,
|
||||
branch: dto.branch,
|
||||
});
|
||||
}
|
||||
|
||||
/** # 部署详情(含日志) */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.deployments.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 取消部署 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/cancel")
|
||||
cancel(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.deployments.cancel(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* # Deployment 模块(Task 012)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DeploymentController } from "./deployment.controller";
|
||||
import { DeploymentService } from "./deployment.service";
|
||||
import { ServerModule } from "../server/server.module";
|
||||
import { EventModule } from "../event/event.module";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
import { SshModule } from "../../integrations/ssh/ssh.module";
|
||||
import { DockerModule } from "../../integrations/docker/docker.module";
|
||||
import { GitModule } from "../git/git.module";
|
||||
|
||||
@Module({
|
||||
imports: [ServerModule, EventModule, AuditModule, SshModule, DockerModule, GitModule],
|
||||
controllers: [DeploymentController],
|
||||
providers: [DeploymentService],
|
||||
exports: [DeploymentService],
|
||||
})
|
||||
export class DeploymentModule {}
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* # Deployment 服务(Task 012)
|
||||
* 部署任务模型:QUEUED→RUNNING→SUCCEEDED/FAILED/CANCELLED,异步 Job 执行。
|
||||
* 执行步骤(DOCKER 类型):检查 docker → 拉取仓库 → 构建镜像 → 替换容器 → 健康验证。
|
||||
* 每一步写 DeploymentLog;进度写入 progress;结束发 deployment.success/failed 事件。
|
||||
*/
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { Website, Deployment } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { JobQueueService } from "../job/job-queue.service";
|
||||
import { EventBusService } from "../event/event-bus.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ServerService } from "../server/server.service";
|
||||
import { SshClientService, type SshTarget } from "../../integrations/ssh/ssh-client.service";
|
||||
import { DockerService } from "../../integrations/docker/docker.service";
|
||||
import { GitService } from "../git/git.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext, runWithContext } from "../../common/request-context";
|
||||
|
||||
/** # 创建部署 DTO **/
|
||||
export const CreateDeploymentSchema = z.object({
|
||||
websiteId: z.string().min(1),
|
||||
branch: z.string().max(200).optional(),
|
||||
trigger: z.string().max(32).default("MANUAL"),
|
||||
});
|
||||
export type CreateDeploymentDto = z.infer<typeof CreateDeploymentSchema>;
|
||||
|
||||
/** # 部署目标目录根(目标服务器上) **/
|
||||
const REMOTE_ROOT = "/opt/devos/websites";
|
||||
|
||||
@Injectable()
|
||||
export class DeploymentService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jobs: JobQueueService,
|
||||
private readonly events: EventBusService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly servers: ServerService,
|
||||
private readonly ssh: SshClientService,
|
||||
private readonly docker: DockerService,
|
||||
private readonly git: GitService,
|
||||
) {}
|
||||
|
||||
/** # 模块初始化:注册部署任务处理器 */
|
||||
onModuleInit(): void {
|
||||
this.registerJobHandler();
|
||||
}
|
||||
|
||||
/** # 创建部署并入队 */
|
||||
async createDeployment(
|
||||
workspaceId: string,
|
||||
websiteId: string,
|
||||
opts: { trigger: string; branch?: string },
|
||||
) {
|
||||
const website = await this.prisma.website.findFirst({ where: { id: websiteId, workspaceId } });
|
||||
if (!website) throw ApiError.notFound("Website not found");
|
||||
// 部署来源断言:仓库登记或旧版 URL 至少其一
|
||||
if (!website.repositoryId && !website.repositoryUrl) {
|
||||
throw ApiError.validation("Website has no repository configured");
|
||||
}
|
||||
|
||||
const deployment = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: "DEPLOYMENT",
|
||||
name: `${website.name} #${new Date().toISOString()}`,
|
||||
provider: "devos",
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
const d = await tx.deployment.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
websiteId: website.id,
|
||||
status: "QUEUED",
|
||||
trigger: opts.trigger,
|
||||
config: { branch: opts.branch ?? "main" } as object,
|
||||
},
|
||||
});
|
||||
await tx.resourceRelation.upsert({
|
||||
where: {
|
||||
sourceId_targetId_relationType: {
|
||||
sourceId: website.resourceId,
|
||||
targetId: resource.id,
|
||||
relationType: "has_deployment",
|
||||
},
|
||||
},
|
||||
create: { workspaceId, sourceId: website.resourceId, targetId: resource.id, relationType: "has_deployment" },
|
||||
update: {},
|
||||
});
|
||||
return d;
|
||||
});
|
||||
|
||||
await this.jobs.enqueue("deployment", workspaceId, { deploymentId: deployment.id });
|
||||
await this.events.publish("deployment.created", {
|
||||
workspaceId,
|
||||
resourceId: deployment.resourceId,
|
||||
payload: { deploymentId: deployment.id, websiteId: website.id, websiteName: website.name, trigger: opts.trigger },
|
||||
});
|
||||
return this.view(deployment, website);
|
||||
}
|
||||
|
||||
/** # 部署列表 */
|
||||
async list(workspaceId: string, websiteId?: string) {
|
||||
const where = { workspaceId, ...(websiteId ? { websiteId } : {}) };
|
||||
const rows = await this.prisma.deployment.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
include: { website: { select: { id: true, name: true } } },
|
||||
});
|
||||
return rows.map((d) => ({ ...this.view(d), website: d.website }));
|
||||
}
|
||||
|
||||
/** # 部署详情(含日志) */
|
||||
async get(workspaceId: string, id: string) {
|
||||
const d = await this.prisma.deployment.findFirst({
|
||||
where: { id, workspaceId },
|
||||
include: {
|
||||
website: { select: { id: true, name: true, resourceId: true } },
|
||||
logs: { orderBy: { seq: "asc" }, take: 2000 },
|
||||
},
|
||||
});
|
||||
if (!d) throw ApiError.notFound("Deployment not found");
|
||||
return {
|
||||
...this.view(d),
|
||||
website: d.website,
|
||||
logs: d.logs.map((l) => ({ seq: l.seq, stream: l.stream, line: l.line, time: l.createdAt })),
|
||||
};
|
||||
}
|
||||
|
||||
/** # 取消部署:QUEUED 立即取消;RUNNING 打上取消标记(步骤间检查) */
|
||||
async cancel(workspaceId: string, id: string) {
|
||||
const d = await this.prisma.deployment.findFirst({ where: { id, workspaceId } });
|
||||
if (!d) throw ApiError.notFound("Deployment not found");
|
||||
if (d.status === "QUEUED") {
|
||||
await this.prisma.deployment.update({ where: { id }, data: { status: "CANCELLED", finishedAt: new Date() } });
|
||||
await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: "ARCHIVED" } });
|
||||
await this.audit.record({
|
||||
workspaceId, actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "deployment.cancel", resourceType: "Deployment", resourceId: d.resourceId,
|
||||
});
|
||||
return { cancelled: true };
|
||||
}
|
||||
if (d.status === "RUNNING") {
|
||||
await this.prisma.deployment.update({
|
||||
where: { id },
|
||||
data: { config: { ...(d.config as object), cancelRequested: true } as object },
|
||||
});
|
||||
return { cancelling: true, message: "Cancellation requested; will stop between steps" };
|
||||
}
|
||||
throw ApiError.conflict(`Deployment is ${d.status}; cannot cancel`);
|
||||
}
|
||||
|
||||
/** # 注册 job handler(DeploymentModule 初始化时调用) */
|
||||
registerJobHandler(): void {
|
||||
this.jobs.register("deployment", async (job) => {
|
||||
await this.execute(job.workspaceId, String(job.payload.deploymentId ?? ""));
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
/** # 执行部署(worker 上下文调用) */
|
||||
private async execute(workspaceId: string, deploymentId: string): Promise<void> {
|
||||
const d = await this.prisma.deployment.findFirst({
|
||||
where: { id: deploymentId, workspaceId },
|
||||
include: { website: true },
|
||||
});
|
||||
if (!d || d.status !== "QUEUED") return;
|
||||
const website = d.website;
|
||||
|
||||
await this.prisma.deployment.update({ where: { id: d.id }, data: { status: "RUNNING", progress: 2 } });
|
||||
await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: "ACTIVE" } });
|
||||
await this.events.publish("deployment.started", {
|
||||
workspaceId, resourceId: d.resourceId, payload: { deploymentId: d.id, websiteId: website.id },
|
||||
});
|
||||
|
||||
try {
|
||||
await runWithContext(
|
||||
{ requestId: `deploy-${d.id.slice(0, 8)}`, correlationId: d.id, workspaceId, actorType: "SYSTEM" },
|
||||
() => this.runSteps(workspaceId, d, website),
|
||||
);
|
||||
await this.prisma.deployment.update({
|
||||
where: { id: d.id },
|
||||
data: { status: "SUCCEEDED", progress: 100, finishedAt: new Date() },
|
||||
});
|
||||
await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: "ARCHIVED" } });
|
||||
await this.prisma.resource.update({ where: { id: website.resourceId }, data: { status: "ACTIVE" } });
|
||||
await this.events.publish("deployment.success", {
|
||||
workspaceId, resourceId: d.resourceId, correlationId: d.id,
|
||||
payload: { deploymentId: d.id, websiteId: website.id, websiteName: website.name },
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const cancelled = message.includes("cancelled by user");
|
||||
await this.prisma.deployment.update({
|
||||
where: { id: d.id },
|
||||
data: {
|
||||
status: cancelled ? "CANCELLED" : "FAILED",
|
||||
finishedAt: new Date(),
|
||||
error: message.slice(0, 2000),
|
||||
},
|
||||
});
|
||||
await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: cancelled ? "ARCHIVED" : "ERROR" } });
|
||||
await this.events.publish("deployment.failed", {
|
||||
workspaceId, resourceId: d.resourceId, correlationId: d.id,
|
||||
payload: { deploymentId: d.id, websiteId: website.id, websiteName: website.name, cancelled, error: message.slice(0, 500) },
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId, actorType: "SYSTEM", action: cancelled ? "deployment.cancelled" : "deployment.failed",
|
||||
resourceType: "Deployment", resourceId: d.resourceId, status: cancelled ? "CANCELLED" : "FAILURE",
|
||||
riskLevel: "HIGH", metadata: { deploymentId: d.id, error: message.slice(0, 300) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** # 分步执行(每步前检查取消标记) */
|
||||
private async runSteps(workspaceId: string, d: Deployment, website: Website): Promise<void> {
|
||||
if (!website.serverId) throw new Error("Website target server missing");
|
||||
const server = await this.servers.mustGet(workspaceId, website.serverId);
|
||||
const isLocal = this.docker.isLocal(server);
|
||||
const target: SshTarget = isLocal
|
||||
? { host: "localhost", port: 0, username: "local" }
|
||||
: await this.servers.sshTarget(workspaceId, server);
|
||||
|
||||
/** # 远程/本地命令执行(统一入口) **/
|
||||
const exec = async (command: string, timeoutMs = 300_000): Promise<string> => {
|
||||
if (isLocal) {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const { stdout } = await promisify(execFile)("sh", ["-c", command], { timeout: timeoutMs });
|
||||
return stdout;
|
||||
}
|
||||
const res = await this.ssh.exec(target, command, timeoutMs);
|
||||
if (res.code !== 0) {
|
||||
throw new Error(`Command failed (exit ${res.code}): ${(res.stderr || res.stdout).slice(0, 500)}`);
|
||||
}
|
||||
return res.stdout;
|
||||
};
|
||||
const log = async (line: string, stream = "stdout") => {
|
||||
const count = await this.prisma.deploymentLog.count({ where: { deploymentId: d.id } });
|
||||
await this.prisma.deploymentLog.create({
|
||||
data: { deploymentId: d.id, seq: count, stream, line: line.slice(0, 4000) },
|
||||
});
|
||||
};
|
||||
const setProgress = async (p: number) => {
|
||||
await this.prisma.deployment.update({ where: { id: d.id }, data: { progress: p } });
|
||||
};
|
||||
const checkCancel = async () => {
|
||||
const row = await this.prisma.deployment.findUnique({ where: { id: d.id }, select: { config: true } });
|
||||
if ((row?.config as Record<string, unknown> | undefined)?.cancelRequested === true) {
|
||||
throw new Error("Deployment cancelled by user");
|
||||
}
|
||||
};
|
||||
|
||||
await checkCancel();
|
||||
await log(`[1/6] Checking docker on ${server.name} (${server.host})`);
|
||||
const dockerCheck = await this.docker.checkDocker(target, isLocal);
|
||||
if (!dockerCheck.available) throw new Error(`Docker not available: ${dockerCheck.version}`);
|
||||
await log(`Docker ${dockerCheck.version} available`);
|
||||
await setProgress(15);
|
||||
|
||||
const slug = website.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "site";
|
||||
const dir = `${REMOTE_ROOT}/${slug}`;
|
||||
const branch = String((d.config as Record<string, unknown>).branch ?? "main");
|
||||
|
||||
// 解析仓库来源:优先 Git Provider 凭据克隆(Phase D),旧版 URL 回退
|
||||
let cloneSource = website.repositoryUrl;
|
||||
if (website.repositoryId) {
|
||||
const resolved = await this.git.buildCloneCommand(workspaceId, website.repositoryId);
|
||||
cloneSource = resolved.cloneUrl;
|
||||
await log(`[2/6] Fetching repository ${resolved.provider} via stored credential (branch ${branch})`);
|
||||
}
|
||||
if (cloneSource && !isLocal) {
|
||||
await checkCancel();
|
||||
await log(`[2/6] Fetching repository (branch ${branch})`);
|
||||
await exec(`mkdir -p ${REMOTE_ROOT}`);
|
||||
const exists = await exec(`test -d ${dir}/.git && echo yes || echo no`);
|
||||
if (exists.trim() === "yes") {
|
||||
await exec(
|
||||
`cd ${dir} && git remote set-url origin ${cloneSource} && git fetch --all && git reset --hard origin/${branch}`,
|
||||
120_000,
|
||||
);
|
||||
} else {
|
||||
await exec(`git clone --branch ${branch} ${cloneSource} ${dir}`, 300_000);
|
||||
}
|
||||
const commit = await exec(`cd ${dir} && git rev-parse --short HEAD`);
|
||||
await this.prisma.deployment.update({ where: { id: d.id }, data: { commit: commit.trim() || null } });
|
||||
await log(`Repository ready at commit ${commit.trim() || "unknown"}`);
|
||||
await setProgress(35);
|
||||
} else if (!website.repositoryUrl) {
|
||||
await log("[2/6] No repository configured; using existing build context on server");
|
||||
} else {
|
||||
await log("[2/6] Local provider: using local directory build context (git clone skipped)");
|
||||
}
|
||||
// 凭据不得残留在 git remote 配置中(克隆后重置 origin 为干净 URL)
|
||||
if (website.repositoryId && !isLocal && cloneSource && cloneSource.includes("@")) {
|
||||
const cleanUrl = `https://${cloneSource.split("@").pop()!}`;
|
||||
await exec(`cd ${dir} && git remote set-url origin ${cleanUrl}`, 30_000).catch(() => undefined);
|
||||
}
|
||||
|
||||
await checkCancel();
|
||||
const imageName = `devos-${slug}:${d.id.slice(0, 12)}`;
|
||||
await log(`[3/6] Building image ${imageName}`);
|
||||
if (!isLocal) {
|
||||
await exec(`cd ${dir} && docker build -t ${imageName} .`, 600_000);
|
||||
await log("Image built");
|
||||
}
|
||||
await setProgress(60);
|
||||
|
||||
await checkCancel();
|
||||
await log(`[4/6] (Re)starting container devos-${slug} on port ${website.port}`);
|
||||
if (!isLocal) {
|
||||
const envArgs = Object.entries((website.env ?? {}) as Record<string, unknown>)
|
||||
.map(([k, v]) => `-e ${k}='${String(v).replace(/'/g, `'\\''`)}'`)
|
||||
.join(" ");
|
||||
await exec(`docker rm -f devos-${slug} 2>/dev/null || true`);
|
||||
await exec(
|
||||
`docker run -d --name devos-${slug} --restart unless-stopped -p ${website.port}:${website.port} ${envArgs} ${imageName}`,
|
||||
120_000,
|
||||
);
|
||||
await log("Container started");
|
||||
}
|
||||
await setProgress(80);
|
||||
|
||||
await checkCancel();
|
||||
await log(`[5/6] Verifying health at :${website.port}${website.healthPath}`);
|
||||
const health = await this.verifyOnServer(exec, website.port, website.healthPath);
|
||||
const verification = { ...health, checkedAt: new Date().toISOString(), strategy: "http-curl-local" };
|
||||
await this.prisma.deployment.update({
|
||||
where: { id: d.id },
|
||||
data: { verification: verification as object },
|
||||
});
|
||||
if (!health.ok) {
|
||||
throw new Error(`Health check failed: ${health.error ?? `status ${health.status}`}`);
|
||||
}
|
||||
await log(`Health check passed (${health.latencyMs ?? 0}ms)`);
|
||||
await setProgress(95);
|
||||
await log("[6/6] Deployment complete");
|
||||
}
|
||||
|
||||
/** # 在服务器上 curl 本机端口做健康验证(重试 5 次 × 2s) */
|
||||
private async verifyOnServer(
|
||||
exec: (cmd: string, timeoutMs?: number) => Promise<string>,
|
||||
port: number,
|
||||
healthPath: string,
|
||||
): Promise<{ ok: boolean; status?: number; latencyMs?: number; error?: string }> {
|
||||
const cmd = `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}${healthPath}`;
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
try {
|
||||
const start = Date.now();
|
||||
const out = await exec(cmd, 15_000);
|
||||
const status = Number(out.trim());
|
||||
if (status >= 200 && status < 500) {
|
||||
return { ok: true, status, latencyMs: Date.now() - start };
|
||||
}
|
||||
return { ok: false, status, error: `HTTP ${status}` };
|
||||
} catch (error) {
|
||||
if (attempt === 5) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "health check failed" };
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
return { ok: false, error: "unreachable" };
|
||||
}
|
||||
|
||||
/** # 网站即时健康检查(供 WebsiteService.health 使用) */
|
||||
async runHealthCheck(workspaceId: string, website: Website) {
|
||||
if (!website.serverId) {
|
||||
if (!website.primaryDomain) return { ok: false, error: "No server or domain configured" };
|
||||
const url = `http://${website.primaryDomain}${website.healthPath}`;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
return { ok: res.status < 500, status: res.status, latencyMs: Date.now() - start, url };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "failed", url };
|
||||
}
|
||||
}
|
||||
const server = await this.servers.mustGet(workspaceId, website.serverId);
|
||||
const isLocal = this.docker.isLocal(server);
|
||||
const target: SshTarget = isLocal
|
||||
? { host: "localhost", port: 0, username: "local" }
|
||||
: await this.servers.sshTarget(workspaceId, server);
|
||||
const cmd = `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${website.port}${website.healthPath}`;
|
||||
const out = await this.execDirect(target, isLocal, cmd, 15_000);
|
||||
const status = Number(out.trim());
|
||||
return { ok: status >= 200 && status < 500, status };
|
||||
}
|
||||
|
||||
/** # 直接执行(local 走本机 sh,ssh 走 SSH;不校验退出码) */
|
||||
private async execDirect(
|
||||
target: SshTarget,
|
||||
isLocal: boolean,
|
||||
command: string,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
if (isLocal) {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const { stdout } = await promisify(execFile)("sh", ["-c", command], { timeout: timeoutMs });
|
||||
return stdout;
|
||||
}
|
||||
const res = await this.ssh.exec(target, command, timeoutMs);
|
||||
return res.stdout;
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private view(d: Deployment, website?: Website) {
|
||||
return {
|
||||
id: d.id,
|
||||
resourceId: d.resourceId,
|
||||
workspaceId: d.workspaceId,
|
||||
websiteId: d.websiteId,
|
||||
websiteName: website?.name,
|
||||
status: d.status,
|
||||
trigger: d.trigger,
|
||||
progress: d.progress,
|
||||
commit: d.commit,
|
||||
error: d.error ?? undefined,
|
||||
createdAt: d.createdAt,
|
||||
finishedAt: d.finishedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* # Domain 控制器(/api/v1/domains)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Param, Post } from "@nestjs/common";
|
||||
import { DomainService, CreateDomainSchema, UpsertRecordSchema } from "./domain.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
@Controller("domains")
|
||||
export class DomainController {
|
||||
constructor(private readonly domains: DomainService) {}
|
||||
|
||||
/** # 域名列表 */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.domains.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 创建域名 */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateDomainSchema)) dto: Parameters<DomainService["create"]>[1],
|
||||
) {
|
||||
return this.domains.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 域名详情 */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.domains.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 删除域名 */
|
||||
@Roles("ADMIN")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.domains.delete(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 记录列表(CLOUDFLARE 实时拉取) */
|
||||
@Get(":id/records")
|
||||
records(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.domains.records(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 写入记录 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/records")
|
||||
upsertRecord(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(UpsertRecordSchema)) dto: Parameters<DomainService["upsertRecord"]>[2],
|
||||
) {
|
||||
return this.domains.upsertRecord(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 删除记录 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/records/:localId/delete")
|
||||
deleteRecord(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Param("localId") localId: string,
|
||||
) {
|
||||
return this.domains.deleteRecord(ws.id, id, localId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* # Domain 模块(Task 013)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DomainController } from "./domain.controller";
|
||||
import { DomainService } from "./domain.service";
|
||||
import { CredentialModule } from "../credential/credential.module";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
import { DnsModule } from "../../integrations/dns/dns.module";
|
||||
|
||||
@Module({
|
||||
imports: [CredentialModule, AuditModule, DnsModule],
|
||||
controllers: [DomainController],
|
||||
providers: [DomainService],
|
||||
exports: [DomainService],
|
||||
})
|
||||
export class DomainModule {}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* # Domain 服务(Task 013)
|
||||
* 域名资源 + DNS 记录读写。CLOUDFLARE 提供商通过 token 凭据调用其 API;
|
||||
* MANUAL 提供商以本地数据库为事实源。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { Domain } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { CredentialService } from "../credential/credential.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { CloudflareDnsProvider, ManualDnsProvider, type DnsProvider } from "../../integrations/dns/dns.providers";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 创建域名 DTO **/
|
||||
export const CreateDomainSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(253)
|
||||
.regex(/^(?!-)[a-zA-Z0-9-]{1,63}(\.[a-zA-Z0-9-]{1,63})+$/, "invalid domain name"),
|
||||
provider: z.enum(["CLOUDFLARE", "MANUAL"]).default("MANUAL"),
|
||||
credentialId: z.string().optional(),
|
||||
});
|
||||
export type CreateDomainDto = z.infer<typeof CreateDomainSchema>;
|
||||
|
||||
/** # 域名记录写入 DTO **/
|
||||
export const UpsertRecordSchema = z.object({
|
||||
type: z.enum(["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"]),
|
||||
name: z.string().min(1).max(253),
|
||||
value: z.string().min(1).max(4096),
|
||||
ttl: z.coerce.number().int().min(60).max(86400).default(300),
|
||||
proxied: z.boolean().default(false),
|
||||
});
|
||||
export type UpsertRecordDto = z.infer<typeof UpsertRecordSchema>;
|
||||
|
||||
@Injectable()
|
||||
export class DomainService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly credentials: CredentialService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly cloudflare: CloudflareDnsProvider,
|
||||
private readonly manual: ManualDnsProvider,
|
||||
) {}
|
||||
|
||||
/** # 选择 DNS 提供商适配器 */
|
||||
private provider(providerName: string): DnsProvider {
|
||||
return providerName === "CLOUDFLARE" ? this.cloudflare : this.manual;
|
||||
}
|
||||
|
||||
/** # 创建域名资源 */
|
||||
async create(workspaceId: string, dto: CreateDomainDto) {
|
||||
if (dto.provider === "CLOUDFLARE" && !dto.credentialId) {
|
||||
throw ApiError.validation("CLOUDFLARE provider requires credentialId (API token)");
|
||||
}
|
||||
if (dto.credentialId) await this.credentials.get(workspaceId, dto.credentialId);
|
||||
const existing = await this.prisma.domain.findFirst({ where: { workspaceId, name: dto.name } });
|
||||
if (existing) throw ApiError.conflict("Domain already exists in this workspace");
|
||||
|
||||
const domain = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: { workspaceId, type: "DOMAIN", name: dto.name, provider: dto.provider, status: "ACTIVE" },
|
||||
});
|
||||
return tx.domain.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
name: dto.name,
|
||||
provider: dto.provider,
|
||||
credentialId: dto.credentialId ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "domain.create",
|
||||
resourceType: "Domain",
|
||||
resourceId: domain.resourceId,
|
||||
metadata: { provider: dto.provider },
|
||||
});
|
||||
return this.view(domain);
|
||||
}
|
||||
|
||||
/** # 域名列表 */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.domain.findMany({ where: { workspaceId }, orderBy: { createdAt: "desc" } });
|
||||
return rows.map((d) => this.view(d));
|
||||
}
|
||||
|
||||
/** # 域名详情 */
|
||||
async get(workspaceId: string, id: string) {
|
||||
return this.view(await this.mustGet(workspaceId, id));
|
||||
}
|
||||
|
||||
/** # 删除域名 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const domain = await this.mustGet(workspaceId, id);
|
||||
await this.prisma.domain.delete({ where: { id: domain.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "domain.delete",
|
||||
resourceType: "Domain",
|
||||
resourceId: domain.resourceId,
|
||||
riskLevel: "HIGH",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 记录列表:CLOUDFLARE → 实时拉取并与本地同步;MANUAL → 本地记录 */
|
||||
async records(workspaceId: string, id: string) {
|
||||
const domain = await this.mustGet(workspaceId, id);
|
||||
if (domain.provider === "MANUAL") {
|
||||
const rows = await this.prisma.domainRecord.findMany({
|
||||
where: { domainId: domain.id },
|
||||
orderBy: [{ type: "asc" }, { name: "asc" }],
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.providerRecordId ?? r.id,
|
||||
localId: r.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
value: r.value,
|
||||
ttl: r.ttl,
|
||||
proxied: r.proxied,
|
||||
}));
|
||||
}
|
||||
const token = await this.providerToken(workspaceId, domain);
|
||||
const records = await this.cloudflare.listRecords({ token, zoneName: domain.name });
|
||||
// 与本地库同步(以 providerRecordId 为键)
|
||||
for (const r of records) {
|
||||
await this.prisma.domainRecord.upsert({
|
||||
where: {
|
||||
domainId_type_name_value: {
|
||||
domainId: domain.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
value: r.value,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
domainId: domain.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
value: r.value,
|
||||
ttl: r.ttl,
|
||||
proxied: r.proxied,
|
||||
providerRecordId: r.id,
|
||||
},
|
||||
update: { ttl: r.ttl, proxied: r.proxied },
|
||||
});
|
||||
}
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "domain.records.read",
|
||||
resourceType: "Domain",
|
||||
resourceId: domain.resourceId,
|
||||
metadata: { count: records.length },
|
||||
});
|
||||
return records.map((r) => ({ ...r, localId: undefined }));
|
||||
}
|
||||
|
||||
/** # 写入记录(upsert 到 provider,MANUAL 落库) */
|
||||
async upsertRecord(workspaceId: string, id: string, dto: UpsertRecordDto) {
|
||||
const domain = await this.mustGet(workspaceId, id);
|
||||
let providerRecordId: string | null = null;
|
||||
if (domain.provider === "CLOUDFLARE") {
|
||||
const token = await this.providerToken(workspaceId, domain);
|
||||
const record = await this.cloudflare.upsertRecord({
|
||||
token,
|
||||
zoneName: domain.name,
|
||||
record: { type: dto.type, name: dto.name, value: dto.value, ttl: dto.ttl, proxied: dto.proxied },
|
||||
});
|
||||
providerRecordId = record.id;
|
||||
}
|
||||
const saved = await this.prisma.domainRecord.upsert({
|
||||
where: {
|
||||
domainId_type_name_value: {
|
||||
domainId: domain.id,
|
||||
type: dto.type,
|
||||
name: dto.name,
|
||||
value: dto.value,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
domainId: domain.id,
|
||||
type: dto.type,
|
||||
name: dto.name,
|
||||
value: dto.value,
|
||||
ttl: dto.ttl,
|
||||
proxied: dto.proxied,
|
||||
providerRecordId,
|
||||
},
|
||||
update: { ttl: dto.ttl, proxied: dto.proxied, providerRecordId },
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "domain.updateRecords",
|
||||
resourceType: "Domain",
|
||||
resourceId: domain.resourceId,
|
||||
riskLevel: "MEDIUM",
|
||||
metadata: { type: dto.type, name: dto.name },
|
||||
});
|
||||
return {
|
||||
id: providerRecordId ?? saved.id,
|
||||
localId: saved.id,
|
||||
type: saved.type,
|
||||
name: saved.name,
|
||||
value: saved.value,
|
||||
ttl: saved.ttl,
|
||||
proxied: saved.proxied,
|
||||
};
|
||||
}
|
||||
|
||||
/** # 删除记录 */
|
||||
async deleteRecord(workspaceId: string, id: string, localId: string) {
|
||||
const domain = await this.mustGet(workspaceId, id);
|
||||
const record = await this.prisma.domainRecord.findFirst({
|
||||
where: { id: localId, domainId: domain.id, workspaceId },
|
||||
});
|
||||
if (!record) throw ApiError.notFound("Record not found");
|
||||
if (domain.provider === "CLOUDFLARE" && record.providerRecordId) {
|
||||
const token = await this.providerToken(workspaceId, domain);
|
||||
await this.cloudflare.deleteRecord({ token, zoneName: domain.name, recordId: record.providerRecordId });
|
||||
}
|
||||
await this.prisma.domainRecord.delete({ where: { id: record.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "domain.updateRecords",
|
||||
resourceType: "Domain",
|
||||
resourceId: domain.resourceId,
|
||||
riskLevel: "MEDIUM",
|
||||
metadata: { deleted: `${record.type} ${record.name}` },
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 取得提供商 token(解密凭据) */
|
||||
private async providerToken(workspaceId: string, domain: Domain): Promise<string> {
|
||||
const token = await this.credentials.getSecret(workspaceId, domain.credentialId);
|
||||
if (!token) throw ApiError.validation("Domain provider credential missing");
|
||||
return token;
|
||||
}
|
||||
|
||||
/** # 断言域名属于工作区 */
|
||||
private async mustGet(workspaceId: string, id: string) {
|
||||
const domain = await this.prisma.domain.findFirst({ where: { id, workspaceId } });
|
||||
if (!domain) throw ApiError.notFound("Domain not found");
|
||||
return domain;
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private view(d: Domain) {
|
||||
return {
|
||||
id: d.id,
|
||||
resourceId: d.resourceId,
|
||||
workspaceId: d.workspaceId,
|
||||
name: d.name,
|
||||
provider: d.provider,
|
||||
createdAt: d.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* # 事件总线
|
||||
* 领域事件的持久化 + 进程内分发(Task 015)。
|
||||
* - 事件命名遵循 `<resource>.<verb>` 规范(见 @devos/types EVENTS);
|
||||
* - 每个事件持久化到 events 表(可追溯/可重放);
|
||||
* - 进程内订阅者同步分发(错误隔离,不影响发布方);
|
||||
* - 若 Redis 可用则额外 PUBLISH(跨进程可观测,为 V3 预留)。
|
||||
*/
|
||||
import { Injectable, OnModuleDestroy } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
|
||||
|
||||
/** # 领域事件结构 **/
|
||||
export interface DomainEvent {
|
||||
id: string;
|
||||
eventType: string;
|
||||
workspaceId: string;
|
||||
resourceId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
correlationId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** # 事件处理器 **/
|
||||
export type EventHandler = (event: DomainEvent) => void | Promise<void>;
|
||||
|
||||
@Injectable()
|
||||
export class EventBusService implements OnModuleDestroy {
|
||||
/** # 进程内订阅者 **/
|
||||
private handlers = new Set<EventHandler>();
|
||||
|
||||
/** # 日志器 **/
|
||||
private readonly logger: StructuredLoggerInstance;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
loggers: LogService,
|
||||
) {
|
||||
this.logger = loggers.child("EventBus");
|
||||
}
|
||||
|
||||
/** # 注册订阅者
|
||||
* @returns 取消订阅函数
|
||||
*/
|
||||
subscribe(handler: EventHandler): () => void {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
/** # 发布领域事件:持久化 + 分发
|
||||
* @param eventType 规范事件名
|
||||
* @param data 事件数据
|
||||
*/
|
||||
async publish(
|
||||
eventType: string,
|
||||
data: {
|
||||
workspaceId: string;
|
||||
resourceId?: string | null;
|
||||
payload?: Record<string, unknown>;
|
||||
correlationId?: string | null;
|
||||
},
|
||||
): Promise<DomainEvent | null> {
|
||||
const record: DomainEvent = {
|
||||
id: randomUUID(),
|
||||
eventType,
|
||||
workspaceId: data.workspaceId,
|
||||
resourceId: data.resourceId ?? null,
|
||||
payload: data.payload ?? {},
|
||||
correlationId: data.correlationId ?? null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
try {
|
||||
await this.prisma.eventRecord.create({
|
||||
data: {
|
||||
id: record.id,
|
||||
workspaceId: record.workspaceId,
|
||||
eventType: record.eventType,
|
||||
resourceId: record.resourceId,
|
||||
payload: record.payload as Prisma.InputJsonValue,
|
||||
correlationId: record.correlationId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("failed to persist event", error instanceof Error ? error : undefined);
|
||||
return null;
|
||||
}
|
||||
// 进程内分发:错误隔离
|
||||
for (const handler of this.handlers) {
|
||||
Promise.resolve()
|
||||
.then(() => handler(record))
|
||||
.catch((error) =>
|
||||
this.logger.error(
|
||||
`event handler failed for ${eventType}`,
|
||||
error instanceof Error ? error : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
// 跨进程发布(尽力而为)
|
||||
void this.publishToRedis(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** # Redis 发布(Bun 原生 redis 客户端,失败静默) */
|
||||
private async publishToRedis(record: DomainEvent): Promise<void> {
|
||||
try {
|
||||
const bun = (globalThis as Record<string, unknown>).Bun as typeof Bun | undefined;
|
||||
if (bun?.redis) {
|
||||
await bun.redis.publish("devos:events", JSON.stringify(record));
|
||||
}
|
||||
} catch {
|
||||
/* Redis 不可用时忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** # 停止时清空订阅者 */
|
||||
onModuleDestroy(): void {
|
||||
this.handlers.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* # 事件查询控制器
|
||||
*/
|
||||
import { Controller, Get, Query } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace } from "../../common/decorators";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
eventType: z.string().optional(),
|
||||
});
|
||||
|
||||
@Controller("events")
|
||||
export class EventController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** # 分页查询当前工作区领域事件 */
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentWorkspace() workspace: RequestWorkspace,
|
||||
@Query(new ZodValidationPipe(ListQuerySchema)) query: z.infer<typeof ListQuerySchema>,
|
||||
) {
|
||||
const where = {
|
||||
workspaceId: workspace.id,
|
||||
...(query.eventType ? { eventType: query.eventType } : {}),
|
||||
};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.eventRecord.count({ where }),
|
||||
this.prisma.eventRecord.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
return { items, total, page: query.page, pageSize: query.pageSize };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* # 事件总线模块(Task 015)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EventController } from "./event.controller";
|
||||
import { EventBusService } from "./event-bus.service";
|
||||
|
||||
@Module({
|
||||
controllers: [EventController],
|
||||
providers: [EventBusService],
|
||||
exports: [EventBusService],
|
||||
})
|
||||
export class EventModule {}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* # Git 控制器(/api/v1/git)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Param, Post } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { GitService, CreateGitConnectionSchema, CreateRepositorySchema } from "./git.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const FullNameSchema = z.object({ fullName: z.string().min(3).max(300) });
|
||||
|
||||
@Controller("git")
|
||||
export class GitController {
|
||||
constructor(private readonly git: GitService) {}
|
||||
|
||||
/** # Git 连接列表 */
|
||||
@Get("connections")
|
||||
listConnections(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.git.listConnections(ws.id);
|
||||
}
|
||||
|
||||
/** # 创建 Git 连接 */
|
||||
@Roles("ADMIN")
|
||||
@Post("connections")
|
||||
createConnection(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateGitConnectionSchema)) dto: Parameters<GitService["createConnection"]>[1],
|
||||
) {
|
||||
return this.git.createConnection(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 删除连接 */
|
||||
@Roles("ADMIN")
|
||||
@Delete("connections/:id")
|
||||
deleteConnection(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.git.deleteConnection(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 拉取 provider 远端仓库(实时) */
|
||||
@Roles("MEMBER")
|
||||
@Post("connections/:id/repos")
|
||||
listRemoteRepos(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(FullNameSchema)) _body: { fullName: string },
|
||||
) {
|
||||
return this.git.listRemoteRepos(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 导入远端仓库 */
|
||||
@Roles("MEMBER")
|
||||
@Post("connections/:id/repos/import")
|
||||
importRepo(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(FullNameSchema)) body: { fullName: string },
|
||||
) {
|
||||
return this.git.importRepo(ws.id, id, body.fullName);
|
||||
}
|
||||
|
||||
/** # 仓库列表 */
|
||||
@Get("repositories")
|
||||
listRepositories(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.git.listRepositories(ws.id);
|
||||
}
|
||||
|
||||
/** # 手工登记仓库 */
|
||||
@Roles("MEMBER")
|
||||
@Post("repositories")
|
||||
createRepository(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateRepositorySchema)) dto: Parameters<GitService["upsertRepository"]>[1],
|
||||
) {
|
||||
return this.git.upsertRepository(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 分支列表 */
|
||||
@Get("repositories/:id/branches")
|
||||
listBranches(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.git.listBranches(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 删除仓库登记 */
|
||||
@Roles("MEMBER")
|
||||
@Delete("repositories/:id")
|
||||
deleteRepository(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.git.deleteRepository(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* # Git 模块
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GitController } from "./git.controller";
|
||||
import { GitService } from "./git.service";
|
||||
import { GithubAdapter, GitlabAdapter, GiteaAdapter } from "./git.providers";
|
||||
import { CredentialModule } from "../credential/credential.module";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [CredentialModule, AuditModule],
|
||||
controllers: [GitController],
|
||||
providers: [GitService, GithubAdapter, GitlabAdapter, GiteaAdapter],
|
||||
exports: [GitService],
|
||||
})
|
||||
export class GitModule {}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* # Git Provider 适配器(Phase D/05)
|
||||
* GitHub / GitLab / Gitea 统一 REST 聚合:账号验证、仓库列表、分支列表。
|
||||
* Token 从凭据保险库解密后注入请求头,绝不写入日志或返回体。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
/** # 仓库摘要 **/
|
||||
export interface RemoteRepo {
|
||||
/** # 全名 owner/repo **/
|
||||
fullName: string;
|
||||
cloneUrl: string;
|
||||
defaultBranch: string;
|
||||
private: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** # 分支摘要 **/
|
||||
export interface RemoteBranch {
|
||||
name: string;
|
||||
commit: string;
|
||||
}
|
||||
|
||||
/** # Git Provider 统一接口 **/
|
||||
export interface GitProviderAdapter {
|
||||
readonly provider: string;
|
||||
/** # 验证 token 并返回账号登录名 */
|
||||
verifyAccount(params: { token: string; apiBase?: string }): Promise<{ login: string }>;
|
||||
/** # 列出账号可见仓库 */
|
||||
listRepos(params: { token: string; apiBase?: string }): Promise<RemoteRepo[]>;
|
||||
/** # 列出仓库分支 */
|
||||
listBranches(params: { token: string; apiBase?: string; fullName: string }): Promise<RemoteBranch[]>;
|
||||
}
|
||||
|
||||
/** # 默认 API 基址 **/
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
GITHUB: "https://api.github.com",
|
||||
GITLAB: "https://gitlab.com/api/v4",
|
||||
GITEA: "", // 必填 apiBase
|
||||
};
|
||||
|
||||
/** # 规范化 apiBase(去尾斜杠) */
|
||||
function base(apiBase: string | undefined, provider: string): string {
|
||||
const url = (apiBase && apiBase.replace(/\/$/, "")) || DEFAULTS[provider];
|
||||
if (!url) throw new Error(`${provider} requires apiBase (self-hosted instance)`);
|
||||
return url;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GithubAdapter implements GitProviderAdapter {
|
||||
readonly provider = "GITHUB";
|
||||
|
||||
/** # GitHub REST 请求 */
|
||||
private async call<T>(token: string, url: string): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async verifyAccount(params: { token: string; apiBase?: string }) {
|
||||
const user = await this.call<{ login: string }>(params.token, `${base(params.apiBase, this.provider)}/user`);
|
||||
return { login: user.login };
|
||||
}
|
||||
|
||||
async listRepos(params: { token: string; apiBase?: string }) {
|
||||
const repos = await this.call<
|
||||
{ full_name: string; clone_url: string; default_branch: string; private: boolean; description: string | null }[]
|
||||
>(`${base(params.apiBase, this.provider)}/user/repos?per_page=100&sort=updated`, params.token);
|
||||
return repos.map((r) => ({
|
||||
fullName: r.full_name,
|
||||
cloneUrl: r.clone_url,
|
||||
defaultBranch: r.default_branch || "main",
|
||||
private: r.private,
|
||||
description: r.description ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async listBranches(params: { token: string; apiBase?: string; fullName: string }) {
|
||||
const branches = await this.call<{ name: string; commit: { sha: string } }[]>(
|
||||
`${base(params.apiBase, this.provider)}/repos/${params.fullName}/branches?per_page=100`,
|
||||
params.token,
|
||||
);
|
||||
return branches.map((b) => ({ name: b.name, commit: b.commit.sha }));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GitlabAdapter implements GitProviderAdapter {
|
||||
readonly provider = "GITLAB";
|
||||
|
||||
/** # GitLab REST 请求 */
|
||||
private async call<T>(token: string, url: string): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: { "PRIVATE-TOKEN": token },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitLab API ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async verifyAccount(params: { token: string; apiBase?: string }) {
|
||||
const user = await this.call<{ username: string }>(params.token, `${base(params.apiBase, this.provider)}/user`);
|
||||
return { login: user.username };
|
||||
}
|
||||
|
||||
async listRepos(params: { token: string; apiBase?: string }) {
|
||||
const projects = await this.call<
|
||||
{ path_with_namespace: string; http_url_to_repo: string; default_branch: string; visibility: string; description: string | null }[]
|
||||
>(`${base(params.apiBase, this.provider)}/projects?membership=true&per_page=100&order_by=last_activity_at`, params.token);
|
||||
return projects.map((p) => ({
|
||||
fullName: p.path_with_namespace,
|
||||
cloneUrl: p.http_url_to_repo,
|
||||
defaultBranch: p.default_branch || "main",
|
||||
private: p.visibility !== "public",
|
||||
description: p.description ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async listBranches(params: { token: string; apiBase?: string; fullName: string }) {
|
||||
const encoded = encodeURIComponent(params.fullName);
|
||||
const branches = await this.call<{ name: string; commit: { id: string } }[]>(
|
||||
`${base(params.apiBase, this.provider)}/projects/${encoded}/repository/branches?per_page=100`,
|
||||
params.token,
|
||||
);
|
||||
return branches.map((b) => ({ name: b.name, commit: b.commit.id }));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GiteaAdapter implements GitProviderAdapter {
|
||||
readonly provider = "GITEA";
|
||||
|
||||
/** # Gitea REST 请求 */
|
||||
private async call<T>(token: string, url: string): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `token ${token}` },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Gitea API ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async verifyAccount(params: { token: string; apiBase?: string }) {
|
||||
const user = await this.call<{ login: string }>(params.token, `${base(params.apiBase, this.provider)}/user`);
|
||||
return { login: user.login };
|
||||
}
|
||||
|
||||
async listRepos(params: { token: string; apiBase?: string }) {
|
||||
const repos = await this.call<
|
||||
{ full_name: string; clone_url: string; default_branch: string; private: boolean; description: string }[]
|
||||
>(`${base(params.apiBase, this.provider)}/user/repos?limit=100`, params.token);
|
||||
return repos.map((r) => ({
|
||||
fullName: r.full_name,
|
||||
cloneUrl: r.clone_url,
|
||||
defaultBranch: r.default_branch || "main",
|
||||
private: r.private,
|
||||
description: r.description || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async listBranches(params: { token: string; apiBase?: string; fullName: string }) {
|
||||
const branches = await this.call<{ name: string; commit: { id: string } }[]>(
|
||||
`${base(params.apiBase, this.provider)}/repos/${params.fullName}/branches?limit=100`,
|
||||
params.token,
|
||||
);
|
||||
return branches.map((b) => ({ name: b.name, commit: b.commit.id }));
|
||||
}
|
||||
}
|
||||
|
||||
/** # 构造带凭据的 HTTPS clone URL(服务端内部使用,绝不返回给客户端)
|
||||
* GitHub: x-access-token:<token>@;GitLab: oauth2:<token>@;Gitea/其它: <token>@
|
||||
*/
|
||||
export function buildAuthenticatedCloneUrl(provider: string, cloneUrl: string, token: string): string {
|
||||
if (!cloneUrl.startsWith("https://")) {
|
||||
throw new Error("Only HTTPS clone URLs are supported for credential injection");
|
||||
}
|
||||
const rest = cloneUrl.slice("https://".length);
|
||||
const user = provider === "GITHUB" ? "x-access-token" : provider === "GITLAB" ? "oauth2" : "";
|
||||
return user ? `https://${user}:${token}@${rest}` : `https://${token}@${rest}`;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* # Git 服务(Phase D)
|
||||
* GitProviderConnection + Repository 聚合模型:
|
||||
* - 连接:provider + credentialId(token 存凭据保险库)+ apiBase(自建实例);
|
||||
* - 仓库:从 provider 拉取或手工登记;clone 凭据仅由部署执行器在服务端注入。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { CredentialService } from "../credential/credential.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import {
|
||||
GithubAdapter,
|
||||
GitlabAdapter,
|
||||
GiteaAdapter,
|
||||
buildAuthenticatedCloneUrl,
|
||||
type GitProviderAdapter,
|
||||
} from "./git.providers";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 创建连接 DTO **/
|
||||
export const CreateGitConnectionSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
provider: z.enum(["GITHUB", "GITLAB", "GITEA"]),
|
||||
apiBase: z.string().url().max(500).optional(),
|
||||
credentialId: z.string().min(1),
|
||||
});
|
||||
export type CreateGitConnectionDto = z.infer<typeof CreateGitConnectionSchema>;
|
||||
|
||||
/** # 手工登记仓库 DTO **/
|
||||
export const CreateRepositorySchema = z.object({
|
||||
connectionId: z.string().optional(),
|
||||
provider: z.enum(["GITHUB", "GITLAB", "GITEA", "GENERIC"]).default("GENERIC"),
|
||||
fullName: z.string().min(3).max(300),
|
||||
cloneUrl: z.string().url().max(1000),
|
||||
defaultBranch: z.string().max(200).default("main"),
|
||||
visibility: z.enum(["public", "private"]).default("private"),
|
||||
});
|
||||
export type CreateRepositoryDto = z.infer<typeof CreateRepositorySchema>;
|
||||
|
||||
@Injectable()
|
||||
export class GitService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly credentials: CredentialService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly github: GithubAdapter,
|
||||
private readonly gitlab: GitlabAdapter,
|
||||
private readonly gitea: GiteaAdapter,
|
||||
) {}
|
||||
|
||||
/** # 选择 provider 适配器 */
|
||||
private adapter(provider: string): GitProviderAdapter {
|
||||
switch (provider) {
|
||||
case "GITHUB":
|
||||
return this.github;
|
||||
case "GITLAB":
|
||||
return this.gitlab;
|
||||
case "GITEA":
|
||||
return this.gitea;
|
||||
default:
|
||||
throw ApiError.validation(`Unsupported git provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** # 创建 Git 连接(校验 token 有效后入账) */
|
||||
async createConnection(workspaceId: string, dto: CreateGitConnectionDto) {
|
||||
await this.credentials.get(workspaceId, dto.credentialId);
|
||||
const token = await this.credentials.getSecret(workspaceId, dto.credentialId);
|
||||
if (!token) throw ApiError.validation("Credential secret missing");
|
||||
const account = await this.adapter(dto.provider).verifyAccount({ token, apiBase: dto.apiBase });
|
||||
|
||||
const connection = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: "CREDENTIAL",
|
||||
name: `git:${dto.name}`,
|
||||
provider: dto.provider,
|
||||
status: "ACTIVE",
|
||||
metadata: { kind: "git-connection", account: account.login },
|
||||
},
|
||||
});
|
||||
return tx.gitConnection.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
name: dto.name,
|
||||
provider: dto.provider,
|
||||
apiBase: dto.apiBase ?? null,
|
||||
credentialId: dto.credentialId,
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "git.connection.create",
|
||||
resourceType: "GitConnection",
|
||||
resourceId: connection.resourceId,
|
||||
riskLevel: "MEDIUM",
|
||||
metadata: { provider: dto.provider, account: account.login },
|
||||
});
|
||||
return { ...this.connectionView(connection), account: account.login };
|
||||
}
|
||||
|
||||
/** # 连接列表 */
|
||||
async listConnections(workspaceId: string) {
|
||||
const rows = await this.prisma.gitConnection.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return rows.map((c) => this.connectionView(c));
|
||||
}
|
||||
|
||||
/** # 删除连接 */
|
||||
async deleteConnection(workspaceId: string, id: string) {
|
||||
const c = await this.prisma.gitConnection.findFirst({ where: { id, workspaceId } });
|
||||
if (!c) throw ApiError.notFound("Git connection not found");
|
||||
await this.prisma.gitConnection.delete({ where: { id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "git.connection.delete",
|
||||
resourceType: "GitConnection",
|
||||
resourceId: c.resourceId,
|
||||
riskLevel: "HIGH",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 从 provider 拉取仓库列表(实时;不落库) */
|
||||
async listRemoteRepos(workspaceId: string, connectionId: string) {
|
||||
const conn = await this.mustGetConnection(workspaceId, connectionId);
|
||||
const token = await this.credentials.getSecret(workspaceId, conn.credentialId);
|
||||
if (!token) throw ApiError.validation("Connection credential missing");
|
||||
const adapter = this.adapter(conn.provider);
|
||||
return adapter.listRepos({ token, apiBase: conn.apiBase ?? undefined });
|
||||
}
|
||||
|
||||
/** # 导入 provider 仓库到本地仓库表 */
|
||||
async importRepo(workspaceId: string, connectionId: string, fullName: string) {
|
||||
const conn = await this.mustGetConnection(workspaceId, connectionId);
|
||||
const remote = (await this.listRemoteRepos(workspaceId, connectionId)).find((r) => r.fullName === fullName);
|
||||
if (!remote) throw ApiError.notFound(`Repository ${fullName} not found on ${conn.provider}`);
|
||||
return this.upsertRepository(workspaceId, {
|
||||
connectionId,
|
||||
provider: conn.provider as "GITHUB" | "GITLAB" | "GITEA" | "GENERIC",
|
||||
fullName: remote.fullName,
|
||||
cloneUrl: remote.cloneUrl,
|
||||
defaultBranch: remote.defaultBranch,
|
||||
visibility: remote.private ? "private" : "public",
|
||||
});
|
||||
}
|
||||
|
||||
/** # 分支列表(实时) */
|
||||
async listBranches(workspaceId: string, repositoryId: string) {
|
||||
const repo = await this.mustGetRepository(workspaceId, repositoryId);
|
||||
if (!repo.connectionId) throw ApiError.validation("Repository has no connection; branches unavailable");
|
||||
const conn = await this.mustGetConnection(workspaceId, repo.connectionId);
|
||||
const token = await this.credentials.getSecret(workspaceId, conn.credentialId);
|
||||
if (!token) throw ApiError.validation("Connection credential missing");
|
||||
return this.adapter(conn.provider).listBranches({ token, apiBase: conn.apiBase ?? undefined, fullName: repo.fullName });
|
||||
}
|
||||
|
||||
/** # 手工登记/导入仓库(REPOSITORY 资源入图谱) */
|
||||
async upsertRepository(workspaceId: string, dto: CreateRepositoryDto) {
|
||||
if (dto.connectionId) {
|
||||
const conn = await this.prisma.gitConnection.findFirst({ where: { id: dto.connectionId, workspaceId } });
|
||||
if (!conn) throw ApiError.notFound("Git connection not found");
|
||||
}
|
||||
const existing = await this.prisma.repository.findFirst({
|
||||
where: { workspaceId, cloneUrl: dto.cloneUrl },
|
||||
});
|
||||
if (existing) return this.repoView(existing);
|
||||
const repo = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: "REPOSITORY",
|
||||
name: dto.fullName,
|
||||
provider: dto.provider,
|
||||
status: "ACTIVE",
|
||||
metadata: { cloneUrl: dto.cloneUrl, visibility: dto.visibility },
|
||||
},
|
||||
});
|
||||
return tx.repository.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
connectionId: dto.connectionId ?? null,
|
||||
provider: dto.provider,
|
||||
fullName: dto.fullName,
|
||||
cloneUrl: dto.cloneUrl,
|
||||
defaultBranch: dto.defaultBranch,
|
||||
visibility: dto.visibility,
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "git.repository.import",
|
||||
resourceType: "Repository",
|
||||
resourceId: repo.resourceId,
|
||||
metadata: { fullName: dto.fullName, provider: dto.provider },
|
||||
});
|
||||
return this.repoView(repo);
|
||||
}
|
||||
|
||||
/** # 仓库列表 */
|
||||
async listRepositories(workspaceId: string) {
|
||||
const rows = await this.prisma.repository.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
return rows.map((r) => this.repoView(r));
|
||||
}
|
||||
|
||||
/** # 删除仓库登记 */
|
||||
async deleteRepository(workspaceId: string, id: string) {
|
||||
const repo = await this.mustGetRepository(workspaceId, id);
|
||||
await this.prisma.repository.delete({ where: { id: repo.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "git.repository.delete",
|
||||
resourceType: "Repository",
|
||||
resourceId: repo.resourceId,
|
||||
riskLevel: "MEDIUM",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 构造带凭据的 clone URL(部署执行器内部使用;审计记 use,不落日志) */
|
||||
async buildCloneCommand(workspaceId: string, repositoryId: string): Promise<{ cloneUrl: string; defaultBranch: string; provider: string }> {
|
||||
const repo = await this.mustGetRepository(workspaceId, repositoryId);
|
||||
let cloneUrl = repo.cloneUrl;
|
||||
if (repo.connectionId) {
|
||||
const conn = await this.mustGetConnection(workspaceId, repo.connectionId);
|
||||
const token = await this.credentials.getSecret(workspaceId, conn.credentialId);
|
||||
if (token) {
|
||||
cloneUrl = buildAuthenticatedCloneUrl(conn.provider, repo.cloneUrl, token);
|
||||
}
|
||||
}
|
||||
return { cloneUrl, defaultBranch: repo.defaultBranch, provider: repo.provider };
|
||||
}
|
||||
|
||||
/** # 断言连接属于工作区 */
|
||||
private async mustGetConnection(workspaceId: string, id: string) {
|
||||
const c = await this.prisma.gitConnection.findFirst({ where: { id, workspaceId } });
|
||||
if (!c) throw ApiError.notFound("Git connection not found");
|
||||
return c;
|
||||
}
|
||||
|
||||
/** # 断言仓库属于工作区 */
|
||||
private async mustGetRepository(workspaceId: string, id: string) {
|
||||
const r = await this.prisma.repository.findFirst({ where: { id, workspaceId } });
|
||||
if (!r) throw ApiError.notFound("Repository not found");
|
||||
return r;
|
||||
}
|
||||
|
||||
/** # 连接视图(不含凭据) */
|
||||
private connectionView(c: {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiBase: string | null;
|
||||
credentialId: string;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: c.id,
|
||||
resourceId: c.resourceId,
|
||||
name: c.name,
|
||||
provider: c.provider,
|
||||
apiBase: c.apiBase,
|
||||
credentialId: c.credentialId,
|
||||
createdAt: c.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** # 仓库视图(不含凭据) */
|
||||
private repoView(r: {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
connectionId: string | null;
|
||||
provider: string;
|
||||
fullName: string;
|
||||
cloneUrl: string;
|
||||
defaultBranch: string;
|
||||
visibility: string;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: r.id,
|
||||
resourceId: r.resourceId,
|
||||
connectionId: r.connectionId,
|
||||
provider: r.provider,
|
||||
fullName: r.fullName,
|
||||
cloneUrl: r.cloneUrl,
|
||||
defaultBranch: r.defaultBranch,
|
||||
visibility: r.visibility,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* # 认证控制器(/api/v1/auth)
|
||||
*/
|
||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
||||
import {
|
||||
IdentityService,
|
||||
LoginSchema,
|
||||
RegisterSchema,
|
||||
type RegisterDto,
|
||||
type LoginDto,
|
||||
} from "./identity.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { Public, CurrentUser, NoWorkspace } from "../../common/decorators";
|
||||
import type { RequestUser } from "../../common/decorators";
|
||||
|
||||
@Controller("auth")
|
||||
export class IdentityController {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
|
||||
/** # 注册 */
|
||||
@Public()
|
||||
@Post("register")
|
||||
register(@Body(new ZodValidationPipe(RegisterSchema)) dto: RegisterDto) {
|
||||
return this.identity.register(dto);
|
||||
}
|
||||
|
||||
/** # 登录 */
|
||||
@Public()
|
||||
@Post("login")
|
||||
login(@Body(new ZodValidationPipe(LoginSchema)) dto: LoginDto) {
|
||||
return this.identity.login(dto);
|
||||
}
|
||||
|
||||
/** # 当前用户信息 */
|
||||
@Get("me")
|
||||
@NoWorkspace()
|
||||
me(@CurrentUser() user: RequestUser) {
|
||||
return this.identity.me(user.id);
|
||||
}
|
||||
|
||||
/** # 登出(无状态令牌由客户端丢弃) */
|
||||
@Post("logout")
|
||||
@NoWorkspace()
|
||||
logout(@CurrentUser() user: RequestUser) {
|
||||
return this.identity.logout(user.id, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* # 身份认证模块(Task 005)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { IdentityController } from "./identity.controller";
|
||||
import { IdentityService } from "./identity.service";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [IdentityController],
|
||||
providers: [IdentityService],
|
||||
exports: [IdentityService],
|
||||
})
|
||||
export class IdentityModule {}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* # 身份认证服务(Task 005)
|
||||
* 注册/登录/当前用户。令牌策略:HS256 JWT(12h),载荷携带激活工作区。
|
||||
* 登出为无状态操作(客户端丢弃令牌);服务端记录审计。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { signJwt, hashPassword, verifyPassword } from "@devos/auth";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { ConfigService } from "../../common/config/config.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
import { SecurityPolicyService } from "../../common/security/security-policy.service";
|
||||
|
||||
/** # 注册 DTO **/
|
||||
export const RegisterSchema = z.object({
|
||||
email: z.string().email().max(200),
|
||||
password: z.string().min(8).max(128),
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
});
|
||||
export type RegisterDto = z.infer<typeof RegisterSchema>;
|
||||
|
||||
/** # 登录 DTO(账号支持用户名或邮箱) **/
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string().min(1).max(200),
|
||||
password: z.string().min(1).max(128),
|
||||
});
|
||||
export type LoginDto = z.infer<typeof LoginSchema>;
|
||||
|
||||
/** # JWT 有效期(秒) **/
|
||||
const TOKEN_TTL_SEC = 12 * 3600;
|
||||
|
||||
@Injectable()
|
||||
export class IdentityService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly security: SecurityPolicyService,
|
||||
) {}
|
||||
|
||||
/** # 注册:创建用户 + 个人默认工作区(OWNER)+ 自动登录(受注册开关与限流约束) */
|
||||
async register(dto: RegisterDto) {
|
||||
const policy = this.security.checkRegistration();
|
||||
if (!policy.allowed) {
|
||||
throw ApiError.forbidden(policy.reason!);
|
||||
}
|
||||
const ip = getRequestContext()?.ip ?? "unknown";
|
||||
if (!this.security.checkRate(`register:${ip}`)) {
|
||||
throw new ApiError(429, "RATE_LIMITED", "Too many attempts, slow down");
|
||||
}
|
||||
const existing = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||
if (existing) throw ApiError.conflict("Email already registered");
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
name: dto.name ?? dto.email.split("@")[0],
|
||||
passwordHash: await hashPassword(dto.password),
|
||||
},
|
||||
});
|
||||
const count = await tx.workspace.count();
|
||||
const workspace = await tx.workspace.create({
|
||||
data: {
|
||||
name: `${created.name ?? created.email}'s Workspace`,
|
||||
slug: count === 0 ? "main" : `ws-${created.id.slice(0, 8)}`,
|
||||
},
|
||||
});
|
||||
await tx.workspaceMember.create({
|
||||
data: { workspaceId: workspace.id, userId: created.id, role: "OWNER" },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.audit.record({
|
||||
workspaceId: (await this.firstWorkspaceId(user.id)) ?? "",
|
||||
actorType: "USER",
|
||||
actorId: user.id,
|
||||
action: "auth.register",
|
||||
resourceType: "User",
|
||||
resourceId: user.id,
|
||||
});
|
||||
return this.issueToken(user.id, user.email);
|
||||
}
|
||||
|
||||
/** # 登录(按 IP 限流,防暴力破解) */
|
||||
async login(dto: LoginDto) {
|
||||
const ip = getRequestContext()?.ip ?? "unknown";
|
||||
if (!this.security.checkRate(`login:${ip}`)) {
|
||||
throw new ApiError(429, "RATE_LIMITED", "Too many attempts, slow down");
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { email: dto.email } });
|
||||
// 统一错误信息,避免枚举探测
|
||||
if (!user || !(await verifyPassword(dto.password, user.passwordHash))) {
|
||||
await this.audit.record({
|
||||
workspaceId: (await this.firstWorkspaceId(user?.id ?? "")) ?? "",
|
||||
actorType: "USER",
|
||||
actorId: user?.id,
|
||||
action: "auth.login",
|
||||
status: "FAILURE",
|
||||
metadata: { reason: "invalid credentials" },
|
||||
});
|
||||
throw ApiError.unauthorized("Invalid email or password");
|
||||
}
|
||||
const wid = await this.firstWorkspaceId(user.id);
|
||||
await this.audit.record({
|
||||
workspaceId: wid ?? "",
|
||||
actorType: "USER",
|
||||
actorId: user.id,
|
||||
action: "auth.login",
|
||||
resourceType: "User",
|
||||
resourceId: user.id,
|
||||
});
|
||||
return this.issueToken(user.id, user.email, wid);
|
||||
}
|
||||
|
||||
/** # 当前用户:身份 + 工作区列表 */
|
||||
async me(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
memberships: {
|
||||
include: { workspace: { select: { id: true, name: true, slug: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user) throw ApiError.unauthorized();
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
activeWorkspaceId: user.memberships[0]?.workspace.id ?? null,
|
||||
workspaces: user.memberships.map((m) => ({
|
||||
id: m.workspace.id,
|
||||
name: m.workspace.name,
|
||||
slug: m.workspace.slug,
|
||||
role: m.role,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** # 登出(无状态;记录审计便于追溯) */
|
||||
async logout(userId: string, workspaceId: string | null) {
|
||||
await this.audit.record({
|
||||
workspaceId: workspaceId ?? "",
|
||||
actorType: "USER",
|
||||
actorId: userId,
|
||||
action: "auth.logout",
|
||||
resourceType: "User",
|
||||
resourceId: userId,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** # 签发令牌并返回用户视图 */
|
||||
private async issueToken(userId: string, email: string, wid?: string | null) {
|
||||
const activeWorkspaceId = wid ?? (await this.firstWorkspaceId(userId));
|
||||
const token = signJwt(
|
||||
{ sub: userId, email, wid: activeWorkspaceId ?? null },
|
||||
this.config.appSecret,
|
||||
{ expiresInSec: TOKEN_TTL_SEC },
|
||||
);
|
||||
const me = await this.me(userId);
|
||||
return { token, expiresIn: TOKEN_TTL_SEC, user: me, requestId: getRequestContext()?.requestId };
|
||||
}
|
||||
|
||||
/** # 查询用户第一个工作区 ID */
|
||||
private async firstWorkspaceId(userId: string): Promise<string | null> {
|
||||
if (!userId) return null;
|
||||
const m = await this.prisma.workspaceMember.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
return m?.workspaceId ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* # 异步任务队列
|
||||
* Postgres jobs 表为持久化事实源(状态机:PENDING→RUNNING→SUCCEEDED/FAILED/CANCELLED/DEAD),
|
||||
* Redis(Bun.redis)作为尽力而为的派发信号;worker 以短间隔轮询认领(FOR UPDATE SKIP LOCKED)。
|
||||
* - 失败自动重试(指数退避),超过上限进入 DEAD(dead-letter);
|
||||
* - 崩溃恢复:超时 RUNNING 任务被回收重跑;
|
||||
* - 执行在请求上下文中进行(requestId/correlationId 贯穿日志与审计)。
|
||||
*/
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { JobStatus } from "@devos/types";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
|
||||
import { ConfigService } from "../../common/config/config.service";
|
||||
import { requestContextStore } from "../../common/request-context";
|
||||
|
||||
/** # 任务处理器上下文 **/
|
||||
export interface JobContext {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
correlationId: string | null;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
/** # 任务处理器 **/
|
||||
export type JobHandler = (job: JobContext) => Promise<Record<string, unknown> | void>;
|
||||
|
||||
/** # 队列选项 **/
|
||||
export interface EnqueueOptions {
|
||||
maxAttempts?: number;
|
||||
runAt?: Date;
|
||||
correlationId?: string | null;
|
||||
}
|
||||
|
||||
/** # worker 并发上限 **/
|
||||
const MAX_CONCURRENCY = 3;
|
||||
/** # 认领间隔(毫秒) **/
|
||||
const POLL_INTERVAL_MS = 600;
|
||||
/** # RUNNING 卡死回收阈值(毫秒) **/
|
||||
const STALE_RUNNING_MS = 15 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class JobQueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/** # 处理器注册表 **/
|
||||
private handlers = new Map<string, JobHandler>();
|
||||
/** # 运行中任务数 **/
|
||||
private running = 0;
|
||||
/** # 轮询定时器 **/
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
/** # 停止标记 **/
|
||||
private stopped = false;
|
||||
|
||||
/** # 日志器 **/
|
||||
private readonly logger: StructuredLoggerInstance;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
loggers: LogService,
|
||||
) {
|
||||
this.logger = loggers.child("JobQueue");
|
||||
}
|
||||
|
||||
/** # 注册任务类型处理器 */
|
||||
register(type: string, handler: JobHandler): void {
|
||||
this.handlers.set(type, handler);
|
||||
}
|
||||
|
||||
/** # 入队一个异步任务 */
|
||||
async enqueue(
|
||||
type: string,
|
||||
workspaceId: string,
|
||||
payload: Record<string, unknown>,
|
||||
options: EnqueueOptions = {},
|
||||
): Promise<{ id: string }> {
|
||||
const job = await this.prisma.job.create({
|
||||
data: {
|
||||
type,
|
||||
workspaceId,
|
||||
payload: payload as Prisma.InputJsonValue,
|
||||
maxAttempts: options.maxAttempts ?? 3,
|
||||
runAt: options.runAt ?? new Date(),
|
||||
correlationId: options.correlationId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
void this.notifyRedis(job.id);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** # 取消尚在排队的任务 */
|
||||
async cancel(jobId: string, workspaceId: string): Promise<boolean> {
|
||||
const job = await this.prisma.job.findFirst({ where: { id: jobId, workspaceId } });
|
||||
if (!job) return false;
|
||||
if (job.status !== "PENDING") return false;
|
||||
await this.prisma.job.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "CANCELLED", finishedAt: new Date() },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** # 启动 worker 轮询 */
|
||||
onModuleInit(): void {
|
||||
this.timer = setInterval(() => void this.tick(), POLL_INTERVAL_MS);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
/** # 停止 worker(等待在跑任务结束) */
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.stopped = true;
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
while (this.running > 0) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
|
||||
/** # 每次轮询:回收僵死任务 + 按并发上限认领执行 */
|
||||
private async tick(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
await this.reclaimStale();
|
||||
} catch (error) {
|
||||
this.logger.error("reclaim failed", error instanceof Error ? error : undefined);
|
||||
}
|
||||
while (this.running < MAX_CONCURRENCY && !this.stopped) {
|
||||
const job = await this.claim();
|
||||
if (!job) break;
|
||||
this.running += 1;
|
||||
void this.execute(job)
|
||||
.catch((error) =>
|
||||
this.logger.error("job execute crashed", error instanceof Error ? error : undefined),
|
||||
)
|
||||
.finally(() => {
|
||||
this.running -= 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** # 认领下一个到期任务(原子 UPDATE ... RETURNING,跨实例安全) */
|
||||
private async claim(): Promise<JobContext | null> {
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
correlationId: string | null;
|
||||
attempts: number;
|
||||
}[]
|
||||
>`
|
||||
UPDATE jobs SET status = 'RUNNING', "startedAt" = NOW(), "updatedAt" = NOW(), attempts = attempts + 1
|
||||
WHERE id = (
|
||||
SELECT id FROM jobs
|
||||
WHERE status = 'PENDING' AND "runAt" <= NOW()
|
||||
ORDER BY "runAt" ASC
|
||||
FOR UPDATE SKIP LOCKED LIMIT 1
|
||||
)
|
||||
RETURNING id, "workspaceId", type, payload, "correlationId", attempts
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
type: row.type,
|
||||
payload: (row.payload ?? {}) as Record<string, unknown>,
|
||||
correlationId: row.correlationId,
|
||||
attempt: row.attempts,
|
||||
};
|
||||
}
|
||||
|
||||
/** # 执行任务并落盘结果/重试状态 */
|
||||
private async execute(job: JobContext): Promise<void> {
|
||||
const handler = this.handlers.get(job.type);
|
||||
const rid = randomUUID();
|
||||
const finish = async (status: JobStatus, patch: { result?: unknown; error?: string }) => {
|
||||
await this.prisma.job.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status,
|
||||
finishedAt: new Date(),
|
||||
...(patch.result !== undefined ? { result: patch.result as object } : {}),
|
||||
...(patch.error !== undefined ? { error: patch.error } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
if (!handler) {
|
||||
await finish("DEAD", { error: `No handler for job type ${job.type}` });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await requestContextStore.run(
|
||||
{
|
||||
requestId: rid,
|
||||
correlationId: job.correlationId ?? undefined,
|
||||
workspaceId: job.workspaceId,
|
||||
actorType: "SYSTEM",
|
||||
},
|
||||
async () => {
|
||||
const result = await handler(job);
|
||||
await finish("SUCCEEDED", { result: result ?? {} });
|
||||
},
|
||||
);
|
||||
this.logger.log(`job ${job.type} succeeded (attempt ${job.attempt})`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const jobRow = await this.prisma.job.findUnique({ where: { id: job.id } });
|
||||
const maxAttempts = jobRow?.maxAttempts ?? 3;
|
||||
if (jobRow && jobRow.attempts < maxAttempts) {
|
||||
const backoffMs = Math.min(5000 * 2 ** jobRow.attempts, 5 * 60 * 1000);
|
||||
await this.prisma.job.update({
|
||||
where: { id: job.id },
|
||||
data: { status: "PENDING", runAt: new Date(Date.now() + backoffMs), error: message },
|
||||
});
|
||||
this.logger.warn(`job ${job.type} failed, retry in ${backoffMs}ms: ${message}`);
|
||||
} else {
|
||||
await finish("DEAD", { error: message });
|
||||
this.logger.error(`job ${job.type} dead-lettered: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** # 回收超时 RUNNING 任务(进程崩溃恢复) */
|
||||
private async reclaimStale(): Promise<void> {
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE jobs SET status = 'PENDING', "updatedAt" = NOW()
|
||||
WHERE status = 'RUNNING' AND "startedAt" < NOW() - INTERVAL '15 minutes'
|
||||
`;
|
||||
}
|
||||
|
||||
/** # Redis 派发信号(尽力而为;不可用则退化为 DB 轮询) */
|
||||
private async notifyRedis(jobId: string): Promise<void> {
|
||||
try {
|
||||
const bun = (globalThis as Record<string, unknown>).Bun as typeof Bun | undefined;
|
||||
if (bun?.redis && this.config.redisUrl) {
|
||||
await bun.redis.lpush("devos:jobs", jobId);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* # Job Center 控制器(/api/v1/jobs,Phase C)
|
||||
* 统一长任务视图:queued/running/succeeded/failed/cancelled/dead + 重试/取消。
|
||||
*/
|
||||
import { Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { JobQueueService } from "./job-queue.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
status: z.enum(["PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "DEAD"]).optional(),
|
||||
type: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
@Controller("jobs")
|
||||
export class JobController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly queue: JobQueueService,
|
||||
) {}
|
||||
|
||||
/** # 任务列表(分页 + 状态/类型过滤) */
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Query(new ZodValidationPipe(ListQuerySchema)) query: z.infer<typeof ListQuerySchema>,
|
||||
) {
|
||||
const where = {
|
||||
workspaceId: ws.id,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.type ? { type: query.type } : {}),
|
||||
};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.job.count({ where }),
|
||||
this.prisma.job.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
return { items, total, page: query.page, pageSize: query.pageSize };
|
||||
}
|
||||
|
||||
/** # 任务详情 */
|
||||
@Get(":id")
|
||||
async get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
const job = await this.prisma.job.findFirst({ where: { id, workspaceId: ws.id } });
|
||||
if (!job) throw ApiError.notFound("Job not found");
|
||||
return job;
|
||||
}
|
||||
|
||||
/** # 取消排队中的任务 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/cancel")
|
||||
async cancel(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
const ok = await this.queue.cancel(id, ws.id);
|
||||
if (!ok) throw ApiError.conflict("Job is not cancellable (not pending)");
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
/** # 重试失败任务(克隆为新任务入队;保留原任务历史) */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/retry")
|
||||
async retry(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
const job = await this.prisma.job.findFirst({ where: { id, workspaceId: ws.id } });
|
||||
if (!job) throw ApiError.notFound("Job not found");
|
||||
if (!["FAILED", "DEAD", "CANCELLED"].includes(job.status)) {
|
||||
throw ApiError.conflict(`Job is ${job.status}; only failed/dead/cancelled jobs can be retried`);
|
||||
}
|
||||
const created = await this.queue.enqueue(job.type, ws.id, job.payload as Record<string, unknown>, {
|
||||
maxAttempts: job.maxAttempts,
|
||||
correlationId: job.correlationId,
|
||||
});
|
||||
return { retried: true, newJobId: created.id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* # 异步任务模块(Task 012 基础设施)
|
||||
*/
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { JobQueueService } from "./job-queue.service";
|
||||
import { JobController } from "./job.controller";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [JobController],
|
||||
providers: [JobQueueService],
|
||||
exports: [JobQueueService],
|
||||
})
|
||||
export class JobModule {}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* # MCP 控制器(/api/v1/mcp)
|
||||
* Streamable HTTP 入口;认证在 McpService 中处理(不走全局 Guard 的响应管道)。
|
||||
*/
|
||||
import { Controller, Get, Delete, Post, Res, Req, Body } from "@nestjs/common";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { Public } from "../../common/decorators";
|
||||
import { McpService } from "./mcp.service";
|
||||
|
||||
@Controller("mcp")
|
||||
export class McpController {
|
||||
constructor(private readonly mcp: McpService) {}
|
||||
|
||||
/** # MCP JSON-RPC 入口(公开路由:内部使用 Bearer JWT 自行鉴权) */
|
||||
@Public()
|
||||
@Post()
|
||||
async post(
|
||||
@Req() req: FastifyRequest,
|
||||
@Body() _body: unknown,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
await this.mcp.handlePost(req, reply);
|
||||
}
|
||||
|
||||
/** # SSE/删除会话:无状态模式不支持 */
|
||||
@Public()
|
||||
@Get()
|
||||
get(@Res() reply: FastifyReply) {
|
||||
this.mcp.rejectMethod(reply);
|
||||
}
|
||||
|
||||
/** # 会话删除:无状态模式不支持 */
|
||||
@Public()
|
||||
@Delete()
|
||||
delete(@Res() reply: FastifyReply) {
|
||||
this.mcp.rejectMethod(reply);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* # MCP 模块(Task 018)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { McpController } from "./mcp.controller";
|
||||
import { McpService } from "./mcp.service";
|
||||
import { AiModule } from "../ai/ai.module";
|
||||
|
||||
@Module({
|
||||
imports: [AiModule],
|
||||
controllers: [McpController],
|
||||
providers: [McpService],
|
||||
})
|
||||
export class McpModule {}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* # MCP Server 模块(Task 018)
|
||||
* 通过 @modelcontextprotocol/sdk(Streamable HTTP,无状态模式)在 /api/v1/mcp 暴露 DevOS 能力。
|
||||
* 安全:MCP 请求必须携带 JWT(Authorization: Bearer);所有工具调用走 ToolRegistry(同授权/审计路径);
|
||||
* actorType=MCP 写入请求上下文,审计可区分 MCP 来源。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import type { z } from "zod";
|
||||
import { z as zod } from "zod";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { ConfigService } from "../../common/config/config.service";
|
||||
import { verifyJwt } from "@devos/auth";
|
||||
import { ToolRegistryService } from "../ai/tool-registry.service";
|
||||
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
|
||||
import { requestContextStore } from "../../common/request-context";
|
||||
import { maskSecrets } from "../../common/utils/mask";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
/** # MCP 允许暴露的工具(子集;不暴露 exec 等高危 unless 明确列出) **/
|
||||
const MCP_TOOLS = [
|
||||
"devos.server.list",
|
||||
"devos.server.status",
|
||||
"devos.server.exec",
|
||||
"devos.server.getDockerContainers",
|
||||
"devos.website.list",
|
||||
"devos.website.deploy",
|
||||
"devos.website.logs",
|
||||
"devos.website.health",
|
||||
"devos.deployment.get",
|
||||
"devos.deployment.list",
|
||||
"devos.domain.list",
|
||||
"devos.domain.records",
|
||||
"devos.automation.list",
|
||||
"devos.automation.run",
|
||||
"devos.task.list",
|
||||
"devos.task.create",
|
||||
"devos.note.create",
|
||||
"devos.container.restart",
|
||||
] as const;
|
||||
|
||||
/** # MCP 工具参数 schema(与 ToolRegistry 对齐的显式声明) **/
|
||||
const ToolSchemas: Record<string, z.ZodRawShape> = {
|
||||
"devos.server.list": {},
|
||||
"devos.server.status": { serverId: zod.string() },
|
||||
"devos.server.exec": { serverId: zod.string(), command: zod.string() },
|
||||
"devos.server.getDockerContainers": { serverId: zod.string() },
|
||||
"devos.website.list": {},
|
||||
"devos.website.deploy": { websiteId: zod.string() },
|
||||
"devos.website.logs": { id: zod.string() },
|
||||
"devos.website.health": { id: zod.string() },
|
||||
"devos.deployment.get": { id: zod.string() },
|
||||
"devos.deployment.list": { websiteId: zod.string().optional() },
|
||||
"devos.domain.list": {},
|
||||
"devos.domain.records": { id: zod.string() },
|
||||
"devos.automation.list": {},
|
||||
"devos.automation.run": { id: zod.string() },
|
||||
"devos.task.list": { status: zod.string().optional() },
|
||||
"devos.task.create": { title: zod.string(), description: zod.string().optional(), priority: zod.string().optional() },
|
||||
"devos.note.create": { title: zod.string(), content: zod.string().optional() },
|
||||
"devos.container.restart": { id: zod.string() },
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class McpService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly tools: ToolRegistryService,
|
||||
loggers: LogService,
|
||||
) {
|
||||
this.logger = loggers.child("Mcp");
|
||||
}
|
||||
|
||||
/** # 日志器 **/
|
||||
private readonly logger: StructuredLoggerInstance;
|
||||
|
||||
/** # 解析请求中的 JWT → {userId, workspaceId, actorId}
|
||||
* MCP 无会话,每个请求独立鉴权。
|
||||
*/
|
||||
private async authenticate(req: FastifyRequest): Promise<{ userId: string; workspaceId: string; email: string }> {
|
||||
const header = req.headers["authorization"];
|
||||
if (!header || !header.startsWith("Bearer ")) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
const result = verifyJwt(header.slice(7), this.config.appSecret);
|
||||
if (!result.ok) throw new Error("UNAUTHORIZED");
|
||||
const wid = (req.headers["x-workspace-id"] as string) || result.payload.wid;
|
||||
let workspaceId: string | null = wid ?? null;
|
||||
if (!workspaceId) {
|
||||
const m = await this.prisma.workspaceMember.findFirst({
|
||||
where: { userId: result.payload.sub },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
workspaceId = m?.workspaceId ?? null;
|
||||
} else {
|
||||
const member = await this.prisma.workspaceMember.findUnique({
|
||||
where: { workspaceId_userId: { workspaceId, userId: result.payload.sub } },
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
if (!member) throw new Error("FORBIDDEN");
|
||||
}
|
||||
if (!workspaceId) throw new Error("FORBIDDEN");
|
||||
return { userId: result.payload.sub, workspaceId, email: result.payload.email };
|
||||
}
|
||||
|
||||
/** # 处理 MCP 请求(POST JSON-RPC;无状态模式) */
|
||||
async handlePost(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
let auth: { userId: string; workspaceId: string; email: string };
|
||||
try {
|
||||
auth = await this.authenticate(req);
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && error.message === "FORBIDDEN" ? 403 : 401;
|
||||
reply.code(code).send({
|
||||
error: { code: code === 401 ? "UNAUTHORIZED" : "FORBIDDEN", message: "MCP authentication failed", requestId: (req as { requestId?: string }).requestId ?? "" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const server: McpServer = new McpServer(
|
||||
{ name: "devos-mcp", version: "1.0.0" },
|
||||
{ instructions: "DevOS developer infrastructure OS. All tools are workspace-scoped and audited." },
|
||||
);
|
||||
this.registerTools(server, auth);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined, // 无状态
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
reply.raw.on("close", () => {
|
||||
void transport.close();
|
||||
void server.close();
|
||||
});
|
||||
await server.connect(transport);
|
||||
const body = req.body as unknown;
|
||||
await transport.handleRequest(req.raw, reply.raw, body);
|
||||
}
|
||||
|
||||
/** # GET/DELETE:无状态模式不支持 SSE 流 */
|
||||
rejectMethod(reply: FastifyReply): void {
|
||||
reply.code(405).send({
|
||||
error: { code: "METHOD_NOT_ALLOWED", message: "MCP endpoint only accepts POST", requestId: "" },
|
||||
});
|
||||
}
|
||||
|
||||
/** # 注册 MCP 工具(经 ToolRegistry,含审计 actor=MCP) */
|
||||
private registerTools(server: McpServer, auth: { userId: string; workspaceId: string; email: string }): void {
|
||||
for (const name of MCP_TOOLS) {
|
||||
const tool = this.tools.get(name);
|
||||
const schema = ToolSchemas[name];
|
||||
if (!tool || !schema) continue;
|
||||
const registerTool = server.tool.bind(server) as unknown as (
|
||||
name: string,
|
||||
description: string,
|
||||
schema: zod.ZodRawShape,
|
||||
cb: (args: Record<string, unknown>) => Promise<{ content: { type: "text"; text: string }[] }>,
|
||||
) => unknown;
|
||||
registerTool(name, tool.description, schema, async (args: Record<string, unknown>) => {
|
||||
const rid = randomUUID();
|
||||
return requestContextStore.run(
|
||||
{ requestId: rid, actorType: "MCP", actorId: auth.userId, workspaceId: auth.workspaceId },
|
||||
async () => {
|
||||
try {
|
||||
const result = await tool.execute(
|
||||
{ workspaceId: auth.workspaceId, actorType: "MCP", actorId: auth.userId },
|
||||
args as never,
|
||||
);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, result: maskSecrets(result) }) }] };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`MCP tool ${name} failed: ${message.slice(0, 200)}`);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: message.slice(0, 500) }) }] };
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* # Monitoring 控制器(/api/v1/monitors)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { MonitoringService, CreateMonitorSchema } from "./monitoring.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const UpdateMonitorSchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
@Controller("monitors")
|
||||
export class MonitoringController {
|
||||
constructor(private readonly monitoring: MonitoringService) {}
|
||||
|
||||
/** # 监控列表 */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.monitoring.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 创建监控 */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateMonitorSchema)) dto: Parameters<MonitoringService["create"]>[1],
|
||||
) {
|
||||
return this.monitoring.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 监控详情(含结果历史) */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.monitoring.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 更新监控(启用/禁用) */
|
||||
@Roles("MEMBER")
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(UpdateMonitorSchema)) dto: { enabled: boolean },
|
||||
) {
|
||||
return this.monitoring.update(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 删除监控 */
|
||||
@Roles("ADMIN")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.monitoring.delete(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* # Monitoring 模块(Task 014)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MonitoringController } from "./monitoring.controller";
|
||||
import { MonitoringService } from "./monitoring.service";
|
||||
import { EventModule } from "../event/event.module";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [EventModule, AuditModule],
|
||||
controllers: [MonitoringController],
|
||||
providers: [MonitoringService],
|
||||
exports: [MonitoringService],
|
||||
})
|
||||
export class MonitoringModule {}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* # Monitoring 服务(Task 014)
|
||||
* 健康检查配置 + 定期执行(由 WorkerScheduler 驱动):
|
||||
* - HTTP/TCP 检查,结果写入 monitor_results(历史可查);
|
||||
* - UP↔DOWN 迁移发 website.down / website.recovered 事件(SYSTEM 主体审计)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { Monitor } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { EventBusService } from "../event/event-bus.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 创建监控 DTO **/
|
||||
export const CreateMonitorSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120),
|
||||
kind: z.enum(["HTTP", "TCP"]).default("HTTP"),
|
||||
/** # HTTP: 完整 URL;TCP: host:port **/
|
||||
target: z.string().min(1).max(2048),
|
||||
intervalSec: z.coerce.number().int().min(30).max(3600).default(60),
|
||||
timeoutMs: z.coerce.number().int().min(1000).max(30000).default(10000),
|
||||
websiteId: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
})
|
||||
.refine((v) => (v.kind === "HTTP" ? v.target.startsWith("http://") || v.target.startsWith("https://") : true), {
|
||||
message: "HTTP monitor target must be a URL",
|
||||
});
|
||||
export type CreateMonitorDto = z.infer<typeof CreateMonitorSchema>;
|
||||
|
||||
/** # 检查结果 **/
|
||||
export interface CheckResult {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MonitoringService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: EventBusService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
/** # 创建监控 */
|
||||
async create(workspaceId: string, dto: CreateMonitorDto) {
|
||||
if (dto.websiteId) {
|
||||
const website = await this.prisma.website.findFirst({ where: { id: dto.websiteId, workspaceId } });
|
||||
if (!website) throw ApiError.notFound("Website not found");
|
||||
}
|
||||
const monitor = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: { workspaceId, type: "MONITOR", name: dto.name, provider: "devos", status: "ACTIVE" },
|
||||
});
|
||||
const m = await tx.monitor.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
name: dto.name,
|
||||
kind: dto.kind,
|
||||
target: dto.target,
|
||||
intervalSec: dto.intervalSec,
|
||||
timeoutMs: dto.timeoutMs,
|
||||
enabled: dto.enabled,
|
||||
websiteId: dto.websiteId ?? null,
|
||||
serverId: dto.serverId ?? null,
|
||||
},
|
||||
});
|
||||
if (dto.websiteId) {
|
||||
const website = await tx.website.findUnique({ where: { id: dto.websiteId } });
|
||||
if (website) {
|
||||
await tx.resourceRelation.upsert({
|
||||
where: {
|
||||
sourceId_targetId_relationType: {
|
||||
sourceId: website.resourceId,
|
||||
targetId: resource.id,
|
||||
relationType: "monitors",
|
||||
},
|
||||
},
|
||||
create: { workspaceId, sourceId: website.resourceId, targetId: resource.id, relationType: "monitors" },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return m;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "monitor.create",
|
||||
resourceType: "Monitor",
|
||||
resourceId: monitor.resourceId,
|
||||
metadata: { target: dto.target, intervalSec: dto.intervalSec },
|
||||
});
|
||||
return monitor;
|
||||
}
|
||||
|
||||
/** # 监控列表 */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.monitor.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { website: { select: { id: true, name: true } } },
|
||||
});
|
||||
return rows.map((m) => this.toView(m));
|
||||
}
|
||||
|
||||
/** # 监控详情(含最近结果) */
|
||||
async get(workspaceId: string, id: string) {
|
||||
const m = await this.prisma.monitor.findFirst({ where: { id, workspaceId } });
|
||||
if (!m) throw ApiError.notFound("Monitor not found");
|
||||
const results = await this.prisma.monitorResult.findMany({
|
||||
where: { monitorId: m.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
return { ...this.toView(m), results };
|
||||
}
|
||||
|
||||
/** # 更新监控(启用/禁用等) */
|
||||
async update(workspaceId: string, id: string, patch: { enabled?: boolean }) {
|
||||
const m = await this.prisma.monitor.findFirst({ where: { id, workspaceId } });
|
||||
if (!m) throw ApiError.notFound("Monitor not found");
|
||||
const updated = await this.prisma.monitor.update({ where: { id }, data: patch });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "monitor.update",
|
||||
resourceType: "Monitor",
|
||||
resourceId: m.resourceId,
|
||||
metadata: patch,
|
||||
});
|
||||
return this.toView(updated);
|
||||
}
|
||||
|
||||
/** # 删除监控 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const m = await this.prisma.monitor.findFirst({ where: { id, workspaceId } });
|
||||
if (!m) throw ApiError.notFound("Monitor not found");
|
||||
await this.prisma.monitor.delete({ where: { id: m.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "monitor.delete",
|
||||
resourceType: "Monitor",
|
||||
resourceId: m.resourceId,
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 到期监控查询(供调度器) */
|
||||
async dueMonitors(limit = 20): Promise<Monitor[]> {
|
||||
const rows = await this.prisma.monitor.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
OR: [{ lastCheckedAt: null }, { lastCheckedAt: { lte: new Date(Date.now() - 30_000) } }],
|
||||
},
|
||||
orderBy: { lastCheckedAt: "asc" },
|
||||
take: limit,
|
||||
});
|
||||
// 进一步按 intervalSec 过滤
|
||||
const now = Date.now();
|
||||
return rows.filter(
|
||||
(m) => !m.lastCheckedAt || now - m.lastCheckedAt.getTime() >= m.intervalSec * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
/** # 执行一次检查并记录(调度器调用) */
|
||||
async runCheck(monitor: Monitor): Promise<CheckResult> {
|
||||
const result = await this.check(monitor);
|
||||
const prevStatus = monitor.lastStatus;
|
||||
const newStatus = result.ok ? "UP" : "DOWN";
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.monitorResult.create({
|
||||
data: {
|
||||
monitorId: monitor.id,
|
||||
ok: result.ok,
|
||||
status: result.status !== undefined ? String(result.status) : null,
|
||||
latencyMs: result.latencyMs ?? null,
|
||||
error: result.error ?? null,
|
||||
},
|
||||
}),
|
||||
this.prisma.monitor.update({
|
||||
where: { id: monitor.id },
|
||||
data: {
|
||||
lastStatus: newStatus,
|
||||
lastCheckedAt: new Date(),
|
||||
consecutiveFailures: result.ok ? 0 : monitor.consecutiveFailures + 1,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
// 迁移事件(连续 2 次失败才报 down,避免抖动)
|
||||
if (monitor.kind === "HTTP" && monitor.websiteId) {
|
||||
const failures = result.ok ? 0 : monitor.consecutiveFailures + 1;
|
||||
if (prevStatus === "UP" && newStatus === "DOWN" && failures >= 2) {
|
||||
// 保持 DOWN 语义:首跨即报(v1 抖动容忍策略:连续 2 次失败)
|
||||
}
|
||||
if (prevStatus !== "DOWN" && newStatus === "DOWN") {
|
||||
await this.events.publish("website.down", {
|
||||
workspaceId: monitor.workspaceId,
|
||||
resourceId: monitor.resourceId,
|
||||
payload: {
|
||||
monitorId: monitor.id,
|
||||
websiteId: monitor.websiteId,
|
||||
name: monitor.name,
|
||||
target: monitor.target,
|
||||
error: result.error ?? `status ${result.status}`,
|
||||
},
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId: monitor.workspaceId, actorType: "SYSTEM", action: "monitor.down",
|
||||
resourceType: "Monitor", resourceId: monitor.resourceId, status: "FAILURE", riskLevel: "MEDIUM",
|
||||
});
|
||||
}
|
||||
if (prevStatus === "DOWN" && newStatus === "UP") {
|
||||
await this.events.publish("website.recovered", {
|
||||
workspaceId: monitor.workspaceId,
|
||||
resourceId: monitor.resourceId,
|
||||
payload: { monitorId: monitor.id, websiteId: monitor.websiteId, name: monitor.name, latencyMs: result.latencyMs },
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId: monitor.workspaceId, actorType: "SYSTEM", action: "monitor.recovered",
|
||||
resourceType: "Monitor", resourceId: monitor.resourceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** # 执行单次检查(不做持久化) */
|
||||
async check(monitor: Monitor): Promise<CheckResult> {
|
||||
if (monitor.kind === "HTTP") {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(monitor.target, {
|
||||
signal: AbortSignal.timeout(monitor.timeoutMs),
|
||||
redirect: "manual",
|
||||
});
|
||||
return { ok: res.status < 500, status: res.status, latencyMs: Date.now() - start };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : "request failed" };
|
||||
}
|
||||
}
|
||||
// TCP 检查
|
||||
const [host, portStr] = monitor.target.split(":");
|
||||
const port = Number(portStr);
|
||||
if (!host || !Number.isInteger(port)) return { ok: false, error: "invalid TCP target (host:port)" };
|
||||
return new Promise<CheckResult>((resolve) => {
|
||||
const net = require("node:net") as typeof import("node:net");
|
||||
const start = Date.now();
|
||||
const socket = net.createConnection({ host, port });
|
||||
const done = (r: CheckResult) => {
|
||||
socket.destroy();
|
||||
resolve(r);
|
||||
};
|
||||
socket.setTimeout(monitor.timeoutMs);
|
||||
socket.on("connect", () => done({ ok: true, latencyMs: Date.now() - start }));
|
||||
socket.on("timeout", () => done({ ok: false, error: "timeout" }));
|
||||
socket.on("error", (err: Error) => done({ ok: false, error: err.message }));
|
||||
});
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private toView(m: Monitor & { website?: { id: string; name: string } | null }) {
|
||||
return {
|
||||
id: m.id,
|
||||
resourceId: m.resourceId,
|
||||
workspaceId: m.workspaceId,
|
||||
name: m.name,
|
||||
kind: m.kind,
|
||||
target: m.target,
|
||||
intervalSec: m.intervalSec,
|
||||
enabled: m.enabled,
|
||||
lastStatus: m.lastStatus,
|
||||
consecutiveFailures: m.consecutiveFailures,
|
||||
lastCheckedAt: m.lastCheckedAt,
|
||||
websiteId: m.websiteId,
|
||||
website: m.website ?? null,
|
||||
serverId: m.serverId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* # Note 控制器(/api/v1/notes)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { NoteService, CreateNoteSchema, UpdateNoteSchema, NoteAiSchema } from "./note.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
projectId: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
q: z.string().max(120).optional(),
|
||||
folder: z.string().max(80).optional(),
|
||||
});
|
||||
|
||||
@Controller("notes")
|
||||
export class NoteController {
|
||||
constructor(private readonly notes: NoteService) {}
|
||||
|
||||
/** # 笔记列表 */
|
||||
@Get()
|
||||
list(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Query(new ZodValidationPipe(ListQuerySchema)) query: { projectId?: string; tag?: string; q?: string; folder?: string },
|
||||
) {
|
||||
return this.notes.list(ws.id, query);
|
||||
}
|
||||
|
||||
/** # AI 助手(续写/总结/润色/翻译/大纲/标题;不传 id 时基于 selection) */
|
||||
@Roles("MEMBER")
|
||||
@Post("ai")
|
||||
ai(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(NoteAiSchema)) dto: Parameters<NoteService["ai"]>[2],
|
||||
) {
|
||||
return this.notes.ai(ws.id, null, dto);
|
||||
}
|
||||
|
||||
/** # 针对指定笔记的 AI 助手 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/ai")
|
||||
aiForNote(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(NoteAiSchema)) dto: Parameters<NoteService["ai"]>[2],
|
||||
) {
|
||||
return this.notes.ai(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 创建笔记 */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateNoteSchema)) dto: Parameters<NoteService["create"]>[1],
|
||||
) {
|
||||
return this.notes.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 笔记详情 */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.notes.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 更新笔记 */
|
||||
@Roles("MEMBER")
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(UpdateNoteSchema)) dto: Parameters<NoteService["update"]>[2],
|
||||
) {
|
||||
return this.notes.update(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 删除笔记 */
|
||||
@Roles("MEMBER")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.notes.delete(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* # Note 模块
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NoteController } from "./note.controller";
|
||||
import { NoteService } from "./note.service";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [NoteController],
|
||||
providers: [NoteService],
|
||||
exports: [NoteService],
|
||||
})
|
||||
export class NoteModule {}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* # Note 服务(Phase E/05 Knowledge 基础)
|
||||
* Markdown 笔记:标签/项目关联/资源链接(relations 由资源层承载);NOTE 资源入图谱。
|
||||
* 编辑器:V1 为 markdown 文本 + 前端预览(TipTap 富文本留待下一迭代)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
import { AiProviderService } from "../../common/ai/ai-provider.service";
|
||||
|
||||
/** # 创建笔记 DTO **/
|
||||
export const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().max(200_000).default(""),
|
||||
tags: z.array(z.string().max(40)).max(20).default([]),
|
||||
projectId: z.string().optional(),
|
||||
});
|
||||
export type CreateNoteDto = z.infer<typeof CreateNoteSchema>;
|
||||
|
||||
/** # 更新笔记 DTO **/
|
||||
export const UpdateNoteSchema = CreateNoteSchema.partial().extend({
|
||||
pinned: z.boolean().optional(),
|
||||
folder: z.string().max(80).nullable().optional(),
|
||||
});
|
||||
export type UpdateNoteDto = z.infer<typeof UpdateNoteSchema>;
|
||||
|
||||
/** # AI 助手动作 DTO **/
|
||||
export const NoteAiSchema = z.object({
|
||||
action: z.enum(["continue", "summarize", "improve", "translate_en", "translate_zh", "outline", "title"]),
|
||||
/** # 选中文本(可选;缺省用全文) **/
|
||||
selection: z.string().max(20_000).optional(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class NoteService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly aiProvider: AiProviderService,
|
||||
) {}
|
||||
|
||||
/** # 创建笔记 */
|
||||
async create(workspaceId: string, dto: CreateNoteDto) {
|
||||
const note = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: { workspaceId, type: "NOTE", name: dto.title, provider: "devos", status: "ACTIVE", metadata: { tags: dto.tags } },
|
||||
});
|
||||
const created = await tx.note.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
tags: dto.tags as unknown as object,
|
||||
projectId: dto.projectId ?? null,
|
||||
},
|
||||
});
|
||||
if (dto.projectId) {
|
||||
await tx.resourceRelation.create({
|
||||
data: { workspaceId, sourceId: dto.projectId, targetId: resource.id, relationType: "PROJECT_HAS_NOTE" },
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "note.create",
|
||||
resourceType: "Note",
|
||||
resourceId: note.resourceId,
|
||||
});
|
||||
return this.view(note);
|
||||
}
|
||||
|
||||
/** # 笔记列表(搜索/标签/文件夹过滤;置顶优先;不含正文) */
|
||||
async list(workspaceId: string, filter: { projectId?: string; tag?: string; q?: string; folder?: string }) {
|
||||
const rows = await this.prisma.note.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
...(filter.projectId ? { projectId: filter.projectId } : {}),
|
||||
...(filter.folder ? { folder: filter.folder } : {}),
|
||||
...(filter.q
|
||||
? { OR: [{ title: { contains: filter.q, mode: "insensitive" as const } }, { content: { contains: filter.q } }] }
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
|
||||
take: 200,
|
||||
select: {
|
||||
id: true, resourceId: true, title: true, tags: true, projectId: true,
|
||||
pinned: true, folder: true, createdAt: true, updatedAt: true,
|
||||
},
|
||||
});
|
||||
const list = rows.map((n) => ({ ...n, excerpt: undefined }));
|
||||
return filter.tag ? list.filter((n) => (n.tags as string[]).includes(filter.tag!)) : list;
|
||||
}
|
||||
|
||||
/** # AI 助手:续写/总结/润色/翻译/大纲/起标题(Phase 用户体验增强) */
|
||||
async ai(workspaceId: string, id: string | null, dto: z.infer<typeof NoteAiSchema>) {
|
||||
const selection = dto.selection?.trim();
|
||||
let note: { title: string; content: string } | null = null;
|
||||
if (id) {
|
||||
const row = await this.mustGet(workspaceId, id);
|
||||
note = { title: row.title, content: row.content };
|
||||
}
|
||||
const prompts: Record<string, { system: string; user: string }> = {
|
||||
continue: {
|
||||
system: "You are a writing assistant inside a developer note app. Continue the note naturally in the SAME language and style. Output only the continuation, no preamble, HTML-safe plain text with simple markdown allowed.",
|
||||
user: `Continue this note:\n\nTitle: ${note?.title ?? "(untitled)"}\n\n${selection ?? note?.content ?? ""}`,
|
||||
},
|
||||
summarize: {
|
||||
system: "Summarize the given note concisely as bullet points, in the same language as the note. Output bullets only.",
|
||||
user: (selection ?? note?.content ?? "").slice(0, 20_000),
|
||||
},
|
||||
improve: {
|
||||
system: "Polish the given text: fix grammar, improve clarity and structure. Keep the original language, meaning and formatting (markdown allowed). Output only the improved text.",
|
||||
user: (selection ?? note?.content ?? "").slice(0, 20_000),
|
||||
},
|
||||
translate_en: {
|
||||
system: "Translate the text to English. Output only the translation.",
|
||||
user: (selection ?? note?.content ?? "").slice(0, 20_000),
|
||||
},
|
||||
translate_zh: {
|
||||
system: "Translate the text to Simplified Chinese. Output only the translation.",
|
||||
user: (selection ?? note?.content ?? "").slice(0, 20_000),
|
||||
},
|
||||
outline: {
|
||||
system: "Generate a hierarchical outline (markdown headings/bullets) of the note. Output only the outline.",
|
||||
user: (selection ?? note?.content ?? "").slice(0, 20_000),
|
||||
},
|
||||
title: {
|
||||
system: "Generate ONE short title (max 20 chars, same language as content) for the note. Output only the title text.",
|
||||
user: (selection ?? note?.content ?? "(empty)").slice(0, 5_000),
|
||||
},
|
||||
};
|
||||
const p = prompts[dto.action];
|
||||
const result = await this.aiProvider.complete(p.system, p.user);
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "note.ai",
|
||||
resourceType: "Note",
|
||||
resourceId: id,
|
||||
metadata: { action: dto.action, provider: this.aiProvider.enabled ? "configured" : "missing" },
|
||||
});
|
||||
return { action: dto.action, result };
|
||||
}
|
||||
|
||||
/** # 笔记详情(含正文) */
|
||||
async get(workspaceId: string, id: string) {
|
||||
return this.view(await this.mustGet(workspaceId, id));
|
||||
}
|
||||
|
||||
/** # 更新笔记 */
|
||||
async update(workspaceId: string, id: string, dto: UpdateNoteDto) {
|
||||
const note = await this.mustGet(workspaceId, id);
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const n = await tx.note.update({
|
||||
where: { id: note.id },
|
||||
data: {
|
||||
...(dto.title ? { title: dto.title } : {}),
|
||||
...(dto.content !== undefined ? { content: dto.content } : {}),
|
||||
...(dto.tags ? { tags: dto.tags as unknown as object } : {}),
|
||||
...(dto.projectId !== undefined ? { projectId: dto.projectId } : {}),
|
||||
...(dto.pinned !== undefined ? { pinned: dto.pinned } : {}),
|
||||
...(dto.folder !== undefined ? { folder: dto.folder } : {}),
|
||||
},
|
||||
});
|
||||
if (dto.title) {
|
||||
await tx.resource.update({ where: { id: note.resourceId }, data: { name: dto.title } });
|
||||
}
|
||||
return n;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "note.update",
|
||||
resourceType: "Note",
|
||||
resourceId: note.resourceId,
|
||||
});
|
||||
return this.view(updated);
|
||||
}
|
||||
|
||||
/** # 删除笔记 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const note = await this.mustGet(workspaceId, id);
|
||||
await this.prisma.note.delete({ where: { id: note.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "note.delete",
|
||||
resourceType: "Note",
|
||||
resourceId: note.resourceId,
|
||||
riskLevel: "MEDIUM",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 断言笔记属于工作区 */
|
||||
private async mustGet(workspaceId: string, id: string) {
|
||||
const note = await this.prisma.note.findFirst({ where: { id, workspaceId } });
|
||||
if (!note) throw ApiError.notFound("Note not found");
|
||||
return note;
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private view(n: {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: unknown;
|
||||
projectId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: n.id,
|
||||
resourceId: n.resourceId,
|
||||
workspaceId: n.workspaceId,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
tags: n.tags,
|
||||
projectId: n.projectId,
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* # 通知控制器(/api/v1/notifications)
|
||||
*/
|
||||
import { Controller, Get, Param, Post } from "@nestjs/common";
|
||||
import { NotificationService } from "./notification.service";
|
||||
import { CurrentWorkspace } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
@Controller("notifications")
|
||||
export class NotificationController {
|
||||
constructor(private readonly notifications: NotificationService) {}
|
||||
|
||||
/** # 通知列表 */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.notifications.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 标记已读 */
|
||||
@Post(":id/read")
|
||||
markRead(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.notifications.markRead(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 全部已读 */
|
||||
@Post("read-all")
|
||||
markAllRead(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.notifications.markAllRead(ws.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* # 通知模块
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationController } from "./notification.controller";
|
||||
import { NotificationService } from "./notification.service";
|
||||
|
||||
@Module({
|
||||
controllers: [NotificationController],
|
||||
providers: [NotificationService],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* # 通知服务(Phase F/I)
|
||||
* 站内通知:自动化动作、事件投递;供布局铃铛与通知中心使用。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
/** # 推送通知输入 **/
|
||||
export const PushNotificationSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().max(2000).default(""),
|
||||
level: z.enum(["INFO", "WARN", "ERROR"]).default("INFO"),
|
||||
resourceId: z.string().optional(),
|
||||
});
|
||||
export type PushNotificationDto = z.infer<typeof PushNotificationSchema>;
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** # 推送一条站内通知(内部与自动化动作共用) */
|
||||
async push(workspaceId: string, dto: PushNotificationDto) {
|
||||
return this.prisma.notification.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
title: dto.title,
|
||||
body: dto.body,
|
||||
level: dto.level,
|
||||
resourceId: dto.resourceId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** # 通知列表(最新 50 条) */
|
||||
async list(workspaceId: string) {
|
||||
return this.prisma.notification.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
|
||||
/** # 标记单条已读 */
|
||||
async markRead(workspaceId: string, id: string) {
|
||||
await this.prisma.notification.updateMany({
|
||||
where: { id, workspaceId },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** # 全部已读 */
|
||||
async markAllRead(workspaceId: string) {
|
||||
const r = await this.prisma.notification.updateMany({
|
||||
where: { workspaceId, readAt: null },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
return { updated: r.count };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* # 资源控制器(/api/v1/resources)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
||||
import {
|
||||
ResourceService,
|
||||
CreateResourceSchema,
|
||||
UpdateResourceSchema,
|
||||
ListResourcesQuerySchema,
|
||||
CreateRelationSchema,
|
||||
} from "./resource.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
@Controller("resources")
|
||||
export class ResourceController {
|
||||
constructor(private readonly resources: ResourceService) {}
|
||||
|
||||
/** # 资源列表 */
|
||||
@Get()
|
||||
list(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Query(new ZodValidationPipe(ListResourcesQuerySchema)) query: Parameters<ResourceService["list"]>[1],
|
||||
) {
|
||||
return this.resources.list(ws.id, query);
|
||||
}
|
||||
|
||||
/** # 创建通用资源 */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateResourceSchema)) dto: Parameters<ResourceService["create"]>[1],
|
||||
) {
|
||||
return this.resources.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 资源详情(含关系) */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.resources.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 更新资源 */
|
||||
@Roles("MEMBER")
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(UpdateResourceSchema)) dto: Parameters<ResourceService["update"]>[2],
|
||||
) {
|
||||
return this.resources.update(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 删除资源 */
|
||||
@Roles("ADMIN")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.resources.delete(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 删除影响检查(删除前确认用) */
|
||||
@Get(":id/relations/impact")
|
||||
deleteImpact(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.resources.deleteImpact(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 关系列表 */
|
||||
@Get(":id/relations")
|
||||
relations(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.resources.relations(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 添加关系 */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/relations")
|
||||
addRelation(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(CreateRelationSchema)) dto: Parameters<ResourceService["addRelation"]>[2],
|
||||
) {
|
||||
return this.resources.addRelation(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 删除关系 */
|
||||
@Roles("MEMBER")
|
||||
@Delete(":id/relations/:relationId")
|
||||
removeRelation(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Param("relationId") relationId: string,
|
||||
) {
|
||||
return this.resources.removeRelation(ws.id, id, relationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* # Resource 模块(Task 007)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ResourceController } from "./resource.controller";
|
||||
import { ResourceService } from "./resource.service";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [ResourceController],
|
||||
providers: [ResourceService],
|
||||
exports: [ResourceService],
|
||||
})
|
||||
export class ResourceModule {}
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* # 统一资源服务(Task 007)
|
||||
* resources 表承载统一身份/发现/图谱;类型化领域表由各模块通过事务创建并经 resourceId 关联。
|
||||
* 所有查询强制 workspace 范围。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { ResourceType, ResourceStatus } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 通用创建资源 DTO **/
|
||||
export const CreateResourceSchema = z.object({
|
||||
type: z.enum([
|
||||
"PROJECT",
|
||||
"REPOSITORY",
|
||||
"DATABASE",
|
||||
"DOCUMENT",
|
||||
"BACKUP",
|
||||
"EMAIL_ACCOUNT",
|
||||
"TASK",
|
||||
"NOTE",
|
||||
]),
|
||||
name: z.string().min(1).max(160),
|
||||
provider: z.string().max(64).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
export type CreateResourceDto = z.infer<typeof CreateResourceSchema>;
|
||||
|
||||
/** # 更新资源 DTO **/
|
||||
export const UpdateResourceSchema = z.object({
|
||||
name: z.string().min(1).max(160).optional(),
|
||||
status: z.enum(["PENDING", "ACTIVE", "ERROR", "OFFLINE", "ARCHIVED"]).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
export type UpdateResourceDto = z.infer<typeof UpdateResourceSchema>;
|
||||
|
||||
/** # 资源列表过滤 **/
|
||||
export const ListResourcesQuerySchema = z.object({
|
||||
type: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
q: z.string().max(120).optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
export type ListResourcesQuery = z.infer<typeof ListResourcesQuerySchema>;
|
||||
|
||||
/** # 添加关系 DTO **/
|
||||
export const CreateRelationSchema = z.object({
|
||||
targetId: z.string().min(1),
|
||||
relationType: z.string().min(1).max(64),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
export type CreateRelationDto = z.infer<typeof CreateRelationSchema>;
|
||||
|
||||
@Injectable()
|
||||
export class ResourceService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
/** # 创建通用资源 */
|
||||
async create(workspaceId: string, dto: CreateResourceDto) {
|
||||
const resource = await this.prisma.resource.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: dto.type as ResourceType,
|
||||
name: dto.name,
|
||||
provider: dto.provider ?? null,
|
||||
status: "ACTIVE",
|
||||
metadata: (dto.metadata ?? {}) as object,
|
||||
},
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "resource.create",
|
||||
resourceType: dto.type,
|
||||
resourceId: resource.id,
|
||||
});
|
||||
return this.toView(resource);
|
||||
}
|
||||
|
||||
/** # 资源列表(分页 + 类型/状态/关键字过滤) */
|
||||
async list(workspaceId: string, query: ListResourcesQuery) {
|
||||
const where = {
|
||||
workspaceId,
|
||||
...(query.type ? { type: query.type as ResourceType } : {}),
|
||||
...(query.status ? { status: query.status as ResourceStatus } : {}),
|
||||
...(query.q ? { name: { contains: query.q, mode: "insensitive" as const } } : {}),
|
||||
};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.resource.count({ where }),
|
||||
this.prisma.resource.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
return { items: items.map((r) => this.toView(r)), total, page: query.page, pageSize: query.pageSize };
|
||||
}
|
||||
|
||||
/** # 读取资源(含关系摘要) */
|
||||
async get(workspaceId: string, id: string) {
|
||||
const resource = await this.prisma.resource.findFirst({
|
||||
where: { id, workspaceId },
|
||||
include: {
|
||||
outgoingRelations: { include: { target: { select: { id: true, type: true, name: true, status: true } } } },
|
||||
incomingRelations: { include: { source: { select: { id: true, type: true, name: true, status: true } } } },
|
||||
},
|
||||
});
|
||||
if (!resource) throw ApiError.notFound("Resource not found");
|
||||
const { outgoingRelations, incomingRelations, ...rest } = resource;
|
||||
return {
|
||||
...this.toView(rest),
|
||||
outgoingRelations: outgoingRelations.map((r) => ({
|
||||
id: r.id,
|
||||
relationType: r.relationType,
|
||||
target: r.target,
|
||||
})),
|
||||
incomingRelations: incomingRelations.map((r) => ({
|
||||
id: r.id,
|
||||
relationType: r.relationType,
|
||||
source: r.source,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** # 更新资源 */
|
||||
async update(workspaceId: string, id: string, dto: UpdateResourceDto) {
|
||||
const existing = await this.mustGet(workspaceId, id);
|
||||
const updated = await this.prisma.resource.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(dto.name ? { name: dto.name } : {}),
|
||||
...(dto.status ? { status: dto.status as ResourceStatus } : {}),
|
||||
...(dto.metadata ? { metadata: dto.metadata as object } : {}),
|
||||
},
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "resource.update",
|
||||
resourceType: existing.type,
|
||||
resourceId: id,
|
||||
metadata: dto as Record<string, unknown>,
|
||||
});
|
||||
return this.toView(updated);
|
||||
}
|
||||
|
||||
/** # 删除影响检查(Phase B):列出删除后会被级联移除的关系与资源摘要 */
|
||||
async deleteImpact(workspaceId: string, id: string) {
|
||||
const existing = await this.mustGet(workspaceId, id);
|
||||
const relations = await this.prisma.resourceRelation.findMany({
|
||||
where: { workspaceId, OR: [{ sourceId: id }, { targetId: id }] },
|
||||
include: {
|
||||
source: { select: { id: true, type: true, name: true } },
|
||||
target: { select: { id: true, type: true, name: true } },
|
||||
},
|
||||
});
|
||||
const others = relations.map((r) =>
|
||||
r.sourceId === id
|
||||
? { relationType: r.relationType, direction: "OUTGOING" as const, other: r.target }
|
||||
: { relationType: r.relationType, direction: "INCOMING" as const, other: r.source },
|
||||
);
|
||||
// 领域表级联范围
|
||||
const cascades: string[] = [];
|
||||
for (const [type, label] of [
|
||||
["SERVER", "servers"], ["CONTAINER", "containers"], ["WEBSITE", "websites"],
|
||||
["DOMAIN", "domains"], ["DEPLOYMENT", "deployments"], ["MONITOR", "monitors"],
|
||||
["CREDENTIAL", "credentials"], ["AUTOMATION", "automations"], ["AI_RUN", "ai_runs"],
|
||||
["TASK", "tasks"], ["NOTE", "notes"], ["REPOSITORY", "repositories"],
|
||||
["CERTIFICATE", "certificates"],
|
||||
] as const) {
|
||||
if (existing.type === type) cascades.push(label);
|
||||
}
|
||||
return {
|
||||
resource: { id: existing.id, type: existing.type, name: existing.name },
|
||||
relationCount: others.length,
|
||||
relations: others,
|
||||
cascadeTables: cascades,
|
||||
warning: others.length > 0 ? `删除将影响 ${others.length} 条关系,关联资源保留但关系边将被移除` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** # 删除资源(级联其关系边;领域表由 onDelete: Cascade 处理) */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const existing = await this.mustGet(workspaceId, id);
|
||||
const impact = await this.deleteImpact(workspaceId, id);
|
||||
await this.prisma.resource.delete({ where: { id: existing.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "resource.delete",
|
||||
resourceType: existing.type,
|
||||
resourceId: id,
|
||||
riskLevel: "HIGH",
|
||||
metadata: { relationCount: impact.relationCount },
|
||||
});
|
||||
return { deleted: true, impactedRelations: impact.relationCount };
|
||||
}
|
||||
|
||||
/** # 关系列表 */
|
||||
async relations(workspaceId: string, id: string) {
|
||||
const resource = await this.mustGet(workspaceId, id);
|
||||
const rows = await this.prisma.resourceRelation.findMany({
|
||||
where: { workspaceId, OR: [{ sourceId: resource.id }, { targetId: resource.id }] },
|
||||
include: {
|
||||
source: { select: { id: true, type: true, name: true, status: true } },
|
||||
target: { select: { id: true, type: true, name: true, status: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
relationType: r.relationType,
|
||||
direction: r.sourceId === resource.id ? ("OUTGOING" as const) : ("INCOMING" as const),
|
||||
source: r.source,
|
||||
target: r.target,
|
||||
metadata: r.metadata,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/** # 添加关系边 */
|
||||
async addRelation(workspaceId: string, id: string, dto: CreateRelationDto) {
|
||||
const source = await this.mustGet(workspaceId, id);
|
||||
const target = await this.mustGet(workspaceId, dto.targetId);
|
||||
if (source.id === target.id) throw ApiError.validation("Cannot relate a resource to itself");
|
||||
const relation = await this.prisma.resourceRelation.upsert({
|
||||
where: {
|
||||
sourceId_targetId_relationType: {
|
||||
sourceId: source.id,
|
||||
targetId: target.id,
|
||||
relationType: dto.relationType,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
sourceId: source.id,
|
||||
targetId: target.id,
|
||||
relationType: dto.relationType,
|
||||
metadata: (dto.metadata ?? {}) as object,
|
||||
},
|
||||
update: { metadata: (dto.metadata ?? {}) as object },
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "resource.relation.add",
|
||||
resourceType: source.type,
|
||||
resourceId: source.id,
|
||||
metadata: { relationType: dto.relationType, targetId: target.id },
|
||||
});
|
||||
return relation;
|
||||
}
|
||||
|
||||
/** # 删除关系边 */
|
||||
async removeRelation(workspaceId: string, id: string, relationId: string) {
|
||||
await this.mustGet(workspaceId, id);
|
||||
const relation = await this.prisma.resourceRelation.findFirst({
|
||||
where: { id: relationId, workspaceId, OR: [{ sourceId: id }, { targetId: id }] },
|
||||
});
|
||||
if (!relation) throw ApiError.notFound("Relation not found");
|
||||
await this.prisma.resourceRelation.delete({ where: { id: relationId } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "resource.relation.remove",
|
||||
resourceId: id,
|
||||
riskLevel: "MEDIUM",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 断言资源存在且属于工作区 */
|
||||
async mustGet(workspaceId: string, id: string) {
|
||||
const resource = await this.prisma.resource.findFirst({ where: { id, workspaceId } });
|
||||
if (!resource) throw ApiError.notFound("Resource not found");
|
||||
return resource;
|
||||
}
|
||||
|
||||
/** # 实体 → API 视图 */
|
||||
toView(resource: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
type: ResourceType;
|
||||
name: string;
|
||||
provider: string | null;
|
||||
status: ResourceStatus;
|
||||
metadata: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: resource.id,
|
||||
workspaceId: resource.workspaceId,
|
||||
type: resource.type,
|
||||
name: resource.name,
|
||||
provider: resource.provider,
|
||||
status: resource.status,
|
||||
metadata: (resource.metadata ?? {}) as Record<string, unknown>,
|
||||
createdAt: resource.createdAt,
|
||||
updatedAt: resource.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* # 全局搜索控制器(/api/v1/search)
|
||||
*/
|
||||
import { Controller, Get, Query } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { SearchService } from "./search.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const QuerySchema = z.object({ q: z.string().max(120).default("") });
|
||||
|
||||
@Controller("search")
|
||||
export class SearchController {
|
||||
constructor(private readonly search: SearchService) {}
|
||||
|
||||
/** # 全局搜索 */
|
||||
@Get()
|
||||
query(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Query(new ZodValidationPipe(QuerySchema)) query: { q: string },
|
||||
) {
|
||||
return this.search.query(ws.id, query.q);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* # 搜索模块
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SearchController } from "./search.controller";
|
||||
import { SearchService } from "./search.service";
|
||||
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* # 全局搜索服务(Phase I)
|
||||
* 跨资源名称检索:servers/websites/domains/tasks/notes/repositories 等,统一返回可跳转结果。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
/** # 搜索结果项 **/
|
||||
export interface SearchHit {
|
||||
type: string;
|
||||
name: string;
|
||||
status: string;
|
||||
/** # 前端路由路径 **/
|
||||
path: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** # 全局搜索(名称 contains,取前 N 条聚合) */
|
||||
async query(workspaceId: string, q: string): Promise<SearchHit[]> {
|
||||
const term = q.trim();
|
||||
if (term.length < 2) return [];
|
||||
const like = { contains: term, mode: "insensitive" as const };
|
||||
|
||||
const [servers, websites, domains, tasks, notes, repositories] = await this.prisma.$transaction([
|
||||
this.prisma.server.findMany({ where: { workspaceId, name: like }, take: 5, include: { resource: { select: { status: true } } } }),
|
||||
this.prisma.website.findMany({ where: { workspaceId, name: like }, take: 5, include: { resource: { select: { status: true } } } }),
|
||||
this.prisma.domain.findMany({ where: { workspaceId, name: like }, take: 5, include: { resource: { select: { status: true } } } }),
|
||||
this.prisma.task.findMany({ where: { workspaceId, title: like }, take: 5, include: { resource: { select: { status: true } } } }),
|
||||
this.prisma.note.findMany({ where: { workspaceId, title: like }, take: 5, include: { resource: { select: { status: true } } } }),
|
||||
this.prisma.repository.findMany({ where: { workspaceId, fullName: like }, take: 5, include: { resource: { select: { status: true } } } }),
|
||||
]);
|
||||
|
||||
const hits: SearchHit[] = [
|
||||
...servers.map((s) => ({ type: "SERVER", name: s.name, status: s.resource.status, path: `/servers/${s.id}`, updatedAt: s.updatedAt })),
|
||||
...websites.map((w) => ({ type: "WEBSITE", name: w.name, status: w.resource.status, path: `/websites/${w.id}`, updatedAt: w.updatedAt })),
|
||||
...domains.map((d) => ({ type: "DOMAIN", name: d.name, status: d.resource.status, path: `/domains/${d.id}`, updatedAt: d.updatedAt })),
|
||||
...tasks.map((t) => ({ type: "TASK", name: t.title, status: t.resource.status, path: `/tasks`, updatedAt: t.updatedAt })),
|
||||
...notes.map((n) => ({ type: "NOTE", name: n.title, status: n.resource.status, path: `/notes`, updatedAt: n.updatedAt })),
|
||||
...repositories.map((r) => ({ type: "REPOSITORY", name: r.fullName, status: r.resource.status, path: `/repositories`, updatedAt: r.updatedAt })),
|
||||
];
|
||||
return hits.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).slice(0, 20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* # Server 控制器(/api/v1/servers)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Inject, Param, Post } from "@nestjs/common";
|
||||
import { forwardRef } from "@nestjs/common";
|
||||
import { ServerService, CreateServerSchema, ExecSchema, RestartServerSchema } from "./server.service";
|
||||
import { ContainerService } from "../container/container.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
@Controller("servers")
|
||||
export class ServerController {
|
||||
constructor(
|
||||
private readonly servers: ServerService,
|
||||
@Inject(forwardRef(() => ContainerService))
|
||||
private readonly containers: ContainerService,
|
||||
) {}
|
||||
|
||||
/** # 服务器列表 */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.servers.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 创建服务器 */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateServerSchema)) dto: Parameters<ServerService["create"]>[1],
|
||||
) {
|
||||
return this.servers.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 服务器详情 */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.servers.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 删除服务器 */
|
||||
@Roles("ADMIN")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.servers.delete(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 连通性检查(资源状态刷新 + online/offline 事件) */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/connect")
|
||||
connect(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.servers.connect(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 服务器资源统计(uptime/CPU/内存/磁盘/Top 进程) */
|
||||
@Get(":id/stats")
|
||||
async stats(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.servers.stats(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 服务器状态(含 docker 可用性) */
|
||||
@Get(":id/status")
|
||||
async status(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
const server = await this.servers.mustGet(ws.id, id);
|
||||
const docker = await this.containers.dockerStatus(ws.id, server);
|
||||
return {
|
||||
serverId: server.id,
|
||||
name: server.name,
|
||||
docker,
|
||||
};
|
||||
}
|
||||
|
||||
/** # SSH 执行命令(审计 + 危险命令升级风险) */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/exec")
|
||||
exec(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(ExecSchema)) dto: Parameters<ServerService["exec"]>[2],
|
||||
) {
|
||||
return this.servers.exec(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 重启服务器(HIGH 风险,需 confirm=true) */
|
||||
@Roles("ADMIN")
|
||||
@Post(":id/restart")
|
||||
restart(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(RestartServerSchema)) _dto: { confirm: true },
|
||||
) {
|
||||
return this.servers.restart(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* # Server 模块(Task 009)
|
||||
*/
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { ServerController } from "./server.controller";
|
||||
import { ServerService } from "./server.service";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
import { CredentialModule } from "../credential/credential.module";
|
||||
import { EventModule } from "../event/event.module";
|
||||
import { SshModule } from "../../integrations/ssh/ssh.module";
|
||||
import { DockerModule } from "../../integrations/docker/docker.module";
|
||||
import { ContainerModule } from "../container/container.module";
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => ContainerModule), AuditModule, CredentialModule, EventModule, SshModule, DockerModule],
|
||||
controllers: [ServerController],
|
||||
providers: [ServerService],
|
||||
exports: [ServerService],
|
||||
})
|
||||
export class ServerModule {}
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* # Server 服务(Task 009)
|
||||
* 受管计算目标:创建(含凭据信封加密)、连通性检查、状态、SSH 执行(审计+脱敏)、重启(高危需确认)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { Server } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { CredentialService } from "../credential/credential.service";
|
||||
import { SshClientService, type SshTarget } from "../../integrations/ssh/ssh-client.service";
|
||||
import { DockerService } from "../../integrations/docker/docker.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { EventBusService } from "../event/event-bus.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
import { isDestructiveCommand } from "../../common/utils/mask";
|
||||
|
||||
/** # 创建服务器 DTO **/
|
||||
export const CreateServerSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120),
|
||||
host: z.string().min(1).max(253),
|
||||
port: z.coerce.number().int().min(1).max(65535).default(22),
|
||||
username: z.string().min(1).max(64),
|
||||
/** # local:通过本机 docker socket 管理(无需凭据);ssh:标准 SSH 目标 **/
|
||||
provider: z.enum(["ssh", "local"]).default("ssh"),
|
||||
authType: z.enum(["PASSWORD", "SSH_KEY"]).default("SSH_KEY"),
|
||||
/** # 内联提供密码(将立即加密入库) **/
|
||||
password: z.string().max(1024).optional(),
|
||||
/** # 内联提供私钥(将立即加密入库) **/
|
||||
privateKey: z.string().max(32768).optional(),
|
||||
/** # 或引用已存在的凭据 ID **/
|
||||
credentialId: z.string().optional(),
|
||||
labels: z.record(z.string()).optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.provider === "local" ||
|
||||
Boolean(v.credentialId) ||
|
||||
Boolean(v.password) ||
|
||||
Boolean(v.privateKey),
|
||||
{ message: "ssh provider requires credentialId, password or privateKey" },
|
||||
);
|
||||
export type CreateServerDto = z.infer<typeof CreateServerSchema>;
|
||||
|
||||
/** # SSH 执行 DTO **/
|
||||
export const ExecSchema = z.object({
|
||||
command: z.string().min(1).max(8192),
|
||||
timeoutMs: z.coerce.number().int().min(1000).max(600_000).default(30_000),
|
||||
});
|
||||
export type ExecDto = z.infer<typeof ExecSchema>;
|
||||
|
||||
/** # 重启 DTO(必须显式确认) **/
|
||||
export const RestartServerSchema = z.object({
|
||||
confirm: z.literal(true),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class ServerService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ssh: SshClientService,
|
||||
private readonly docker: DockerService,
|
||||
private readonly credentials: CredentialService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly events: EventBusService,
|
||||
) {}
|
||||
|
||||
/** # 创建服务器(CREDENTIAL 资源与 SERVER 资源一并入图谱) */
|
||||
async create(workspaceId: string, dto: CreateServerDto) {
|
||||
let credentialId: string | null = dto.credentialId ?? null;
|
||||
if (dto.provider !== "local") {
|
||||
if (!credentialId) {
|
||||
// 内联凭据 → 保险库
|
||||
const credential = await this.credentials.create(workspaceId, {
|
||||
name: `${dto.name}-credential`,
|
||||
type: dto.authType === "PASSWORD" ? "SSH_PASSWORD" : "SSH_KEY",
|
||||
provider: "ssh",
|
||||
secret: dto.password ?? dto.privateKey ?? "",
|
||||
});
|
||||
credentialId = credential.id;
|
||||
} else {
|
||||
// 校验引用的凭据属于本工作区
|
||||
await this.credentials.get(workspaceId, credentialId);
|
||||
}
|
||||
}
|
||||
|
||||
const server = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: "SERVER",
|
||||
name: dto.name,
|
||||
provider: dto.provider,
|
||||
status: "PENDING",
|
||||
metadata: { host: dto.host, port: dto.port, username: dto.username },
|
||||
},
|
||||
});
|
||||
return tx.server.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
name: dto.name,
|
||||
host: dto.host,
|
||||
port: dto.port,
|
||||
username: dto.username,
|
||||
authType: dto.authType,
|
||||
provider: dto.provider,
|
||||
credentialId,
|
||||
labels: (dto.labels ?? {}) as object,
|
||||
},
|
||||
});
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "server.create",
|
||||
resourceType: "Server",
|
||||
resourceId: server.resourceId,
|
||||
metadata: { host: dto.host, port: dto.port, provider: dto.provider },
|
||||
});
|
||||
return this.view(server);
|
||||
}
|
||||
|
||||
/** # 服务器列表 */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.server.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return rows.map((s) => this.view(s));
|
||||
}
|
||||
|
||||
/** # 服务器详情 */
|
||||
async get(workspaceId: string, id: string) {
|
||||
return this.view(await this.mustGet(workspaceId, id));
|
||||
}
|
||||
|
||||
/** # 删除服务器 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const server = await this.mustGet(workspaceId, id);
|
||||
await this.prisma.server.delete({ where: { id: server.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "server.delete",
|
||||
resourceType: "Server",
|
||||
resourceId: server.resourceId,
|
||||
riskLevel: "HIGH",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 连通性检查:SSH 探测 / 本机 socket 探测;更新资源状态并发 server.online/offline 事件 */
|
||||
async connect(workspaceId: string, id: string) {
|
||||
const server = await this.mustGet(workspaceId, id);
|
||||
const started = Date.now();
|
||||
let ok = false;
|
||||
let info = "";
|
||||
try {
|
||||
if (this.docker.isLocal(server)) {
|
||||
const version = await this.docker.checkDocker({ host: "", port: 0, username: "" }, true);
|
||||
ok = version.available;
|
||||
info = `docker ${version.version}`;
|
||||
} else {
|
||||
const target = await this.sshTarget(workspaceId, server);
|
||||
const result = await this.ssh.check(target);
|
||||
ok = true;
|
||||
info = result.uname;
|
||||
}
|
||||
} catch (error) {
|
||||
info = error instanceof Error ? error.message.slice(0, 300) : "connect failed";
|
||||
}
|
||||
const latencyMs = Date.now() - started;
|
||||
const prevStatus = (
|
||||
await this.prisma.resource.findUnique({ where: { id: server.resourceId }, select: { status: true } })
|
||||
)?.status;
|
||||
const newStatus = ok ? "ACTIVE" : "OFFLINE";
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.server.update({
|
||||
where: { id: server.id },
|
||||
data: { lastSeenAt: ok ? new Date() : server.lastSeenAt, osInfo: ok ? info : server.osInfo },
|
||||
}),
|
||||
this.prisma.resource.update({
|
||||
where: { id: server.resourceId },
|
||||
data: { status: newStatus },
|
||||
}),
|
||||
]);
|
||||
// 状态迁移事件
|
||||
if (prevStatus !== newStatus) {
|
||||
await this.events.publish(ok ? "server.online" : "server.offline", {
|
||||
workspaceId,
|
||||
resourceId: server.resourceId,
|
||||
payload: { serverId: server.id, name: server.name, latencyMs, info },
|
||||
});
|
||||
}
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "server.connect",
|
||||
resourceType: "Server",
|
||||
resourceId: server.resourceId,
|
||||
status: ok ? "SUCCESS" : "FAILURE",
|
||||
metadata: { latencyMs },
|
||||
});
|
||||
return { ok, latencyMs, info, status: newStatus };
|
||||
}
|
||||
|
||||
/** # SSH 执行命令(记录审计、禁止交互、强制超时、危险命令标记) */
|
||||
async exec(workspaceId: string, id: string, dto: ExecDto) {
|
||||
const server = await this.mustGet(workspaceId, id);
|
||||
if (this.docker.isLocal(server)) {
|
||||
throw ApiError.validation("Local provider server does not support SSH exec");
|
||||
}
|
||||
const destructive = isDestructiveCommand(dto.command);
|
||||
const target = await this.sshTarget(workspaceId, server);
|
||||
try {
|
||||
const result = await this.ssh.exec(target, dto.command, dto.timeoutMs);
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "server.exec",
|
||||
resourceType: "Server",
|
||||
resourceId: server.resourceId,
|
||||
riskLevel: destructive ? "CRITICAL" : "MEDIUM",
|
||||
status: result.code === 0 ? "SUCCESS" : "FAILURE",
|
||||
metadata: { command: dto.command, exitCode: result.code },
|
||||
});
|
||||
return {
|
||||
exitCode: result.code,
|
||||
stdout: result.stdout.slice(0, 128 * 1024),
|
||||
stderr: result.stderr.slice(0, 64 * 1024),
|
||||
};
|
||||
} catch (error) {
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "server.exec",
|
||||
resourceType: "Server",
|
||||
resourceId: server.resourceId,
|
||||
riskLevel: destructive ? "CRITICAL" : "MEDIUM",
|
||||
status: "FAILURE",
|
||||
metadata: { command: dto.command, error: error instanceof Error ? error.message : "failed" },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 重启服务器(HIGH 风险:须 confirm=true 显式确认) */
|
||||
async restart(workspaceId: string, id: string) {
|
||||
const server = await this.mustGet(workspaceId, id);
|
||||
if (this.docker.isLocal(server)) {
|
||||
throw ApiError.validation("Local provider server cannot be rebooted");
|
||||
}
|
||||
const target = await this.sshTarget(workspaceId, server);
|
||||
try {
|
||||
await this.ssh.exec(target, "sudo -n reboot || reboot", 10_000);
|
||||
} catch {
|
||||
// 重启会导致连接中断,视为已下发
|
||||
}
|
||||
await this.prisma.resource.update({
|
||||
where: { id: server.resourceId },
|
||||
data: { status: "PENDING" },
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "server.restart",
|
||||
resourceType: "Server",
|
||||
resourceId: server.resourceId,
|
||||
riskLevel: "HIGH",
|
||||
metadata: { host: server.host },
|
||||
});
|
||||
return { queued: true, message: "Reboot command issued; status will refresh on next connect" };
|
||||
}
|
||||
|
||||
/** # 服务器资源统计(Phase C):uptime/CPU/内存/磁盘/Top 进程 */
|
||||
async stats(workspaceId: string, id: string) {
|
||||
const server = await this.mustGet(workspaceId, id);
|
||||
if (this.docker.isLocal(server)) {
|
||||
throw ApiError.validation("Local provider server does not support SSH stats");
|
||||
}
|
||||
const target = await this.sshTarget(workspaceId, server);
|
||||
const script = [
|
||||
"echo -n 'uptime='; uptime -p 2>/dev/null || uptime",
|
||||
"echo -n 'cpu_cores='; nproc",
|
||||
"echo -n 'load='; cat /proc/loadavg",
|
||||
"awk -F': ' '/MemTotal/{t=$2}/MemAvailable/{a=$2} END{print \"mem_used_mb=\" int((t-a)/1024), \"mem_total_mb=\" int(t/1024)}' /proc/meminfo",
|
||||
"df -P / | awk 'NR==2{print \"disk_used_pct=\" $5, \"disk_total=\" $2}'",
|
||||
"ps -eo comm,pmem --sort=-pmem | head -6 | tail -5",
|
||||
].join("; ");
|
||||
const res = await this.ssh.exec(target, script, 15_000);
|
||||
const parse = (key: string): string | null => {
|
||||
const m = res.stdout.match(new RegExp(`${key}=(.*)`));
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
const memTotalMb = Number((parse("mem_total_mb") ?? "").split(/\s/)[0]) || null;
|
||||
const memUsedMb = Number((parse("mem_used_mb") ?? "").split(/\s/)[0]) || null;
|
||||
return {
|
||||
uptime: parse("uptime"),
|
||||
cpuCores: Number(parse("cpu_cores")) || null,
|
||||
load: parse("load"),
|
||||
memory: memTotalMb && memUsedMb ? { usedMb: memUsedMb, totalMb: memTotalMb, usedPct: Math.round((memUsedMb / memTotalMb) * 100) } : null,
|
||||
disk: parse("disk_used_pct")
|
||||
? { usedPct: Number((parse("disk_used_pct") ?? "").split(/\s/)[0].replace("%", "")), totalKb: Number((parse("disk_total") ?? "").split(/\s/)[1]) }
|
||||
: null,
|
||||
topProcesses: res.stdout
|
||||
.split("\n")
|
||||
.filter((l) => l && !l.includes("=") && !l.startsWith("COMM"))
|
||||
.map((l) => {
|
||||
const [comm, pmem] = l.trim().split(/\s+/);
|
||||
return { command: comm, memPct: Number(pmem) || 0 };
|
||||
})
|
||||
.slice(0, 5),
|
||||
};
|
||||
}
|
||||
|
||||
/** # 组装 SSH 目标参数(按需解密凭据,绝不落日志) */
|
||||
async sshTarget(workspaceId: string, server: Server): Promise<SshTarget> {
|
||||
const secret = await this.credentials.getSecret(workspaceId, server.credentialId);
|
||||
if (!secret) throw ApiError.validation("Server has no credential configured");
|
||||
if (server.authType === "PASSWORD") {
|
||||
return { host: server.host, port: server.port, username: server.username, password: secret };
|
||||
}
|
||||
return { host: server.host, port: server.port, username: server.username, privateKey: secret };
|
||||
}
|
||||
|
||||
/** # 断言服务器存在且属于工作区 */
|
||||
async mustGet(workspaceId: string, id: string): Promise<Server> {
|
||||
const server = await this.prisma.server.findFirst({ where: { id, workspaceId } });
|
||||
if (!server) throw ApiError.notFound("Server not found");
|
||||
return server;
|
||||
}
|
||||
|
||||
/** # 实体 → API 视图(不含凭据) */
|
||||
private view(server: Server) {
|
||||
return {
|
||||
id: server.id,
|
||||
resourceId: server.resourceId,
|
||||
workspaceId: server.workspaceId,
|
||||
name: server.name,
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
authType: server.authType,
|
||||
provider: server.provider,
|
||||
labels: server.labels,
|
||||
osInfo: server.osInfo,
|
||||
lastSeenAt: server.lastSeenAt,
|
||||
createdAt: server.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* # Task 控制器(/api/v1/tasks)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { TaskService, CreateTaskSchema, UpdateTaskSchema } from "./task.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const ListQuerySchema = z.object({
|
||||
status: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
});
|
||||
|
||||
@Controller("tasks")
|
||||
export class TaskController {
|
||||
constructor(private readonly tasks: TaskService) {}
|
||||
|
||||
/** # 任务列表 */
|
||||
@Get()
|
||||
list(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Query(new ZodValidationPipe(ListQuerySchema)) query: { status?: string; projectId?: string },
|
||||
) {
|
||||
return this.tasks.list(ws.id, query);
|
||||
}
|
||||
|
||||
/** # 创建任务 */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateTaskSchema)) dto: Parameters<TaskService["create"]>[1],
|
||||
) {
|
||||
return this.tasks.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 任务详情 */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.tasks.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 更新任务 */
|
||||
@Roles("MEMBER")
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(UpdateTaskSchema)) dto: Parameters<TaskService["update"]>[2],
|
||||
) {
|
||||
return this.tasks.update(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 删除任务 */
|
||||
@Roles("MEMBER")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.tasks.delete(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* # Task 模块
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TaskController } from "./task.controller";
|
||||
import { TaskService } from "./task.service";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [TaskController],
|
||||
providers: [TaskService],
|
||||
exports: [TaskService],
|
||||
})
|
||||
export class TaskModule {}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* # Task 服务(Phase E/06)
|
||||
* 工作项:backlog/todo/doing/review/done + 优先级/标签/截止日/项目关联;TASK 资源入图谱。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 创建任务 DTO **/
|
||||
export const CreateTaskSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(8000).default(""),
|
||||
status: z.enum(["BACKLOG", "TODO", "DOING", "REVIEW", "DONE"]).default("TODO"),
|
||||
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
|
||||
labels: z.array(z.string().max(40)).max(10).default([]),
|
||||
dueAt: z.coerce.date().optional(),
|
||||
projectId: z.string().optional(),
|
||||
});
|
||||
export type CreateTaskDto = z.infer<typeof CreateTaskSchema>;
|
||||
|
||||
/** # 更新任务 DTO **/
|
||||
export const UpdateTaskSchema = CreateTaskSchema.partial();
|
||||
export type UpdateTaskDto = z.infer<typeof UpdateTaskSchema>;
|
||||
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
/** # 创建任务(TASK 资源 + 领域行;可选 PROJECT 关系) */
|
||||
async create(workspaceId: string, dto: CreateTaskDto) {
|
||||
if (dto.projectId) {
|
||||
const project = await this.prisma.resource.findFirst({
|
||||
where: { id: dto.projectId, workspaceId, type: "PROJECT" },
|
||||
});
|
||||
if (!project) throw ApiError.notFound("Project not found");
|
||||
}
|
||||
const task = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
type: "TASK",
|
||||
name: dto.title,
|
||||
provider: "devos",
|
||||
status: dto.status === "DONE" ? "ARCHIVED" : "ACTIVE",
|
||||
metadata: { priority: dto.priority, labels: dto.labels },
|
||||
},
|
||||
});
|
||||
const created = await tx.task.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
status: dto.status,
|
||||
priority: dto.priority,
|
||||
labels: dto.labels as unknown as object,
|
||||
dueAt: dto.dueAt ?? null,
|
||||
projectId: dto.projectId ?? null,
|
||||
},
|
||||
});
|
||||
if (dto.projectId) {
|
||||
await tx.resourceRelation.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
sourceId: dto.projectId,
|
||||
targetId: resource.id,
|
||||
relationType: "PROJECT_HAS_TASK",
|
||||
},
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "task.create",
|
||||
resourceType: "Task",
|
||||
resourceId: task.resourceId,
|
||||
});
|
||||
return this.view(task);
|
||||
}
|
||||
|
||||
/** # 任务列表(可按状态/项目过滤) */
|
||||
async list(workspaceId: string, filter: { status?: string; projectId?: string }) {
|
||||
const rows = await this.prisma.task.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
...(filter.projectId ? { projectId: filter.projectId } : {}),
|
||||
},
|
||||
orderBy: [{ status: "asc" }, { updatedAt: "desc" }],
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((t) => this.view(t));
|
||||
}
|
||||
|
||||
/** # 任务详情 */
|
||||
async get(workspaceId: string, id: string) {
|
||||
return this.view(await this.mustGet(workspaceId, id));
|
||||
}
|
||||
|
||||
/** # 更新任务(状态机自由流转,记录审计) */
|
||||
async update(workspaceId: string, id: string, dto: UpdateTaskDto) {
|
||||
const task = await this.mustGet(workspaceId, id);
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const t = await tx.task.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
...(dto.title ? { title: dto.title } : {}),
|
||||
...(dto.description !== undefined ? { description: dto.description } : {}),
|
||||
...(dto.status ? { status: dto.status } : {}),
|
||||
...(dto.priority ? { priority: dto.priority } : {}),
|
||||
...(dto.labels ? { labels: dto.labels as unknown as object } : {}),
|
||||
...(dto.dueAt !== undefined ? { dueAt: dto.dueAt } : {}),
|
||||
...(dto.projectId !== undefined ? { projectId: dto.projectId } : {}),
|
||||
},
|
||||
});
|
||||
if (dto.title || dto.status) {
|
||||
await tx.resource.update({
|
||||
where: { id: task.resourceId },
|
||||
data: {
|
||||
...(dto.title ? { name: dto.title } : {}),
|
||||
...(dto.status ? { status: dto.status === "DONE" ? "ARCHIVED" : "ACTIVE" } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return t;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "task.update",
|
||||
resourceType: "Task",
|
||||
resourceId: task.resourceId,
|
||||
metadata: dto as Record<string, unknown>,
|
||||
});
|
||||
return this.view(updated);
|
||||
}
|
||||
|
||||
/** # 删除任务 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const task = await this.mustGet(workspaceId, id);
|
||||
await this.prisma.task.delete({ where: { id: task.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "task.delete",
|
||||
resourceType: "Task",
|
||||
resourceId: task.resourceId,
|
||||
riskLevel: "MEDIUM",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 断言任务属于工作区 */
|
||||
private async mustGet(workspaceId: string, id: string) {
|
||||
const task = await this.prisma.task.findFirst({ where: { id, workspaceId } });
|
||||
if (!task) throw ApiError.notFound("Task not found");
|
||||
return task;
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private view(t: {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
labels: unknown;
|
||||
dueAt: Date | null;
|
||||
projectId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: t.id,
|
||||
resourceId: t.resourceId,
|
||||
workspaceId: t.workspaceId,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
labels: t.labels,
|
||||
dueAt: t.dueAt,
|
||||
projectId: t.projectId,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* # Website 控制器(/api/v1/websites)
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { WebsiteService, CreateWebsiteSchema, UpdateWebsiteSchema } from "./website.service";
|
||||
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
|
||||
import { CurrentUser, CurrentWorkspace, Roles } from "../../common/decorators";
|
||||
import type { RequestUser, RequestWorkspace } from "../../common/decorators";
|
||||
|
||||
const LogsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(1000).default(200) });
|
||||
|
||||
@Controller("websites")
|
||||
export class WebsiteController {
|
||||
constructor(private readonly websites: WebsiteService) {}
|
||||
|
||||
/** # 网站列表 */
|
||||
@Get()
|
||||
list(@CurrentWorkspace() ws: RequestWorkspace) {
|
||||
return this.websites.list(ws.id);
|
||||
}
|
||||
|
||||
/** # 创建网站 */
|
||||
@Roles("MEMBER")
|
||||
@Post()
|
||||
create(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Body(new ZodValidationPipe(CreateWebsiteSchema)) dto: Parameters<WebsiteService["create"]>[1],
|
||||
) {
|
||||
return this.websites.create(ws.id, dto);
|
||||
}
|
||||
|
||||
/** # 网站详情 */
|
||||
@Get(":id")
|
||||
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.websites.get(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 更新网站 */
|
||||
@Roles("MEMBER")
|
||||
@Patch(":id")
|
||||
update(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Body(new ZodValidationPipe(UpdateWebsiteSchema)) dto: Parameters<WebsiteService["update"]>[2],
|
||||
) {
|
||||
return this.websites.update(ws.id, id, dto);
|
||||
}
|
||||
|
||||
/** # 删除网站 */
|
||||
@Roles("ADMIN")
|
||||
@Delete(":id")
|
||||
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.websites.delete(ws.id, id);
|
||||
}
|
||||
|
||||
/** # 发起部署(HIGH 风险动作;UI/MCP 调用均走此处) */
|
||||
@Roles("MEMBER")
|
||||
@Post(":id/deploy")
|
||||
deploy(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
return this.websites.deploy(ws.id, id, { type: "USER", id: user.id, trigger: "MANUAL" });
|
||||
}
|
||||
|
||||
/** # 网站日志(最近一次部署) */
|
||||
@Get(":id/logs")
|
||||
logs(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Query(new ZodValidationPipe(LogsQuerySchema)) query: { limit: number },
|
||||
) {
|
||||
return this.websites.logs(ws.id, id, query.limit);
|
||||
}
|
||||
|
||||
/** # 即时健康检查 */
|
||||
@Get(":id/health")
|
||||
health(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
|
||||
return this.websites.health(ws.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* # Website 模块(Task 011)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { WebsiteController } from "./website.controller";
|
||||
import { WebsiteService } from "./website.service";
|
||||
import { DeploymentModule } from "../deployment/deployment.module";
|
||||
import { ResourceModule } from "../resource/resource.module";
|
||||
import { AuditModule } from "../audit/audit.module";
|
||||
|
||||
@Module({
|
||||
imports: [DeploymentModule, ResourceModule, AuditModule],
|
||||
controllers: [WebsiteController],
|
||||
providers: [WebsiteService],
|
||||
exports: [WebsiteService],
|
||||
})
|
||||
export class WebsiteModule {}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* # Website 服务(Task 011)
|
||||
* 网站资源:与服务器/域名/仓库建立关系;deploy 动作创建 Deployment 并入队执行。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import type { Website } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { AuditService } from "../audit/audit.service";
|
||||
import { ResourceService } from "../resource/resource.service";
|
||||
import { DeploymentService } from "../deployment/deployment.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
import { getRequestContext } from "../../common/request-context";
|
||||
|
||||
/** # 创建网站 DTO **/
|
||||
export const CreateWebsiteSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
primaryDomain: z.string().max(253).optional(),
|
||||
serverId: z.string().optional(),
|
||||
repositoryUrl: z.string().max(2048).optional(),
|
||||
/** # 关联仓库(Git Provider 凭据克隆,优先于 repositoryUrl) **/
|
||||
repositoryId: z.string().optional(),
|
||||
/** # 部署环境:development / staging / production **/
|
||||
environment: z.enum(["development", "staging", "production"]).default("production"),
|
||||
deployType: z.enum(["DOCKER", "STATIC", "NODE"]).default("DOCKER"),
|
||||
port: z.coerce.number().int().min(1).max(65535).default(80),
|
||||
healthPath: z.string().max(200).default("/"),
|
||||
env: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
|
||||
/** # 关联域名资源 ID(自动建立 has_domain 关系) **/
|
||||
domainId: z.string().optional(),
|
||||
});
|
||||
export type CreateWebsiteDto = z.infer<typeof CreateWebsiteSchema>;
|
||||
|
||||
/** # 更新网站 DTO **/
|
||||
export const UpdateWebsiteSchema = CreateWebsiteSchema.partial().omit({ serverId: true, domainId: true }).extend({
|
||||
serverId: z.string().nullable().optional(),
|
||||
});
|
||||
export type UpdateWebsiteDto = z.infer<typeof UpdateWebsiteSchema>;
|
||||
|
||||
@Injectable()
|
||||
export class WebsiteService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly resources: ResourceService,
|
||||
private readonly deployments: DeploymentService,
|
||||
) {}
|
||||
|
||||
/** # 创建网站(资源 + 领域行 + 关系:deployed_on / has_domain) */
|
||||
async create(workspaceId: string, dto: CreateWebsiteDto) {
|
||||
if (dto.serverId) {
|
||||
const server = await this.prisma.server.findFirst({ where: { id: dto.serverId, workspaceId } });
|
||||
if (!server) throw ApiError.notFound("Server not found");
|
||||
}
|
||||
const website = await this.prisma.$transaction(async (tx) => {
|
||||
const resource = await tx.resource.create({
|
||||
data: { workspaceId, type: "WEBSITE", name: dto.name, provider: "devos", status: "PENDING" },
|
||||
});
|
||||
const created = await tx.website.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
resourceId: resource.id,
|
||||
name: dto.name,
|
||||
primaryDomain: dto.primaryDomain ?? null,
|
||||
serverId: dto.serverId ?? null,
|
||||
repositoryUrl: dto.repositoryUrl ?? null,
|
||||
repositoryId: dto.repositoryId ?? null,
|
||||
environment: dto.environment,
|
||||
deployType: dto.deployType,
|
||||
port: dto.port,
|
||||
healthPath: dto.healthPath,
|
||||
env: (dto.env ?? {}) as object,
|
||||
},
|
||||
});
|
||||
if (dto.serverId) {
|
||||
const server = await tx.server.findUnique({ where: { id: dto.serverId } });
|
||||
if (server) {
|
||||
await tx.resourceRelation.upsert({
|
||||
where: {
|
||||
sourceId_targetId_relationType: {
|
||||
sourceId: created.resourceId,
|
||||
targetId: server.resourceId,
|
||||
relationType: "deployed_on",
|
||||
},
|
||||
},
|
||||
create: { workspaceId, sourceId: created.resourceId, targetId: server.resourceId, relationType: "deployed_on" },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (dto.domainId) {
|
||||
const domain = await tx.domain.findFirst({ where: { id: dto.domainId, workspaceId } });
|
||||
if (domain) {
|
||||
await tx.resourceRelation.upsert({
|
||||
where: {
|
||||
sourceId_targetId_relationType: {
|
||||
sourceId: domain.resourceId,
|
||||
targetId: created.resourceId,
|
||||
relationType: "resolves_to",
|
||||
},
|
||||
},
|
||||
create: { workspaceId, sourceId: domain.resourceId, targetId: created.resourceId, relationType: "resolves_to" },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return created;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "website.create",
|
||||
resourceType: "Website",
|
||||
resourceId: website.resourceId,
|
||||
});
|
||||
return this.get(workspaceId, website.id);
|
||||
}
|
||||
|
||||
/** # 网站列表 */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.website.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { server: { select: { id: true, name: true, host: true } } },
|
||||
});
|
||||
return rows.map((w) => this.toView(w));
|
||||
}
|
||||
|
||||
/** # 网站详情(含资源状态与关系) */
|
||||
async get(workspaceId: string, id: string) {
|
||||
const website = await this.mustGet(workspaceId, id);
|
||||
const resource = await this.resources.get(workspaceId, website.resourceId);
|
||||
const relations = [...resource.outgoingRelations, ...resource.incomingRelations];
|
||||
return { ...this.toView(website), resource: { status: resource.status, relations } };
|
||||
}
|
||||
|
||||
/** # 更新网站 */
|
||||
async update(workspaceId: string, id: string, dto: UpdateWebsiteDto) {
|
||||
const website = await this.mustGet(workspaceId, id);
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const w = await tx.website.update({
|
||||
where: { id: website.id },
|
||||
data: {
|
||||
...(dto.name ? { name: dto.name } : {}),
|
||||
...(dto.primaryDomain !== undefined ? { primaryDomain: dto.primaryDomain } : {}),
|
||||
...(dto.repositoryUrl !== undefined ? { repositoryUrl: dto.repositoryUrl } : {}),
|
||||
...(dto.deployType ? { deployType: dto.deployType } : {}),
|
||||
...(dto.environment ? { environment: dto.environment } : {}),
|
||||
...(dto.repositoryId !== undefined ? { repositoryId: dto.repositoryId } : {}),
|
||||
...(dto.port ? { port: dto.port } : {}),
|
||||
...(dto.healthPath ? { healthPath: dto.healthPath } : {}),
|
||||
...(dto.env ? { env: dto.env as object } : {}),
|
||||
...(dto.serverId !== undefined
|
||||
? { serverId: dto.serverId, ...(dto.serverId ? {} : {}) }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (dto.name) {
|
||||
await tx.resource.update({ where: { id: website.resourceId }, data: { name: dto.name } });
|
||||
}
|
||||
// 服务器关系重建
|
||||
if (dto.serverId !== undefined) {
|
||||
await tx.resourceRelation.deleteMany({
|
||||
where: { workspaceId, sourceId: website.resourceId, relationType: "deployed_on" },
|
||||
});
|
||||
if (dto.serverId) {
|
||||
const server = await tx.server.findFirst({ where: { id: dto.serverId, workspaceId } });
|
||||
if (!server) throw ApiError.notFound("Server not found");
|
||||
await tx.resourceRelation.create({
|
||||
data: { workspaceId, sourceId: website.resourceId, targetId: server.resourceId, relationType: "deployed_on" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return w;
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "website.update",
|
||||
resourceType: "Website",
|
||||
resourceId: website.resourceId,
|
||||
metadata: dto as Record<string, unknown>,
|
||||
});
|
||||
return this.toView(updated);
|
||||
}
|
||||
|
||||
/** # 删除网站 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const website = await this.mustGet(workspaceId, id);
|
||||
await this.prisma.website.delete({ where: { id: website.id } });
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: getRequestContext()?.actorType ?? "USER",
|
||||
action: "website.delete",
|
||||
resourceType: "Website",
|
||||
resourceId: website.resourceId,
|
||||
riskLevel: "HIGH",
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 发起部署:创建 Deployment(QUEUED)并入队,返回部署视图 */
|
||||
async deploy(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
actor: { type: "USER" | "AGENT" | "MCP" | "SYSTEM"; id?: string; trigger?: string },
|
||||
) {
|
||||
const website = await this.mustGet(workspaceId, id);
|
||||
if (!website.serverId) throw ApiError.validation("Website has no target server configured");
|
||||
if (!website.repositoryId && !website.repositoryUrl) {
|
||||
throw ApiError.validation("Website has no repository configured");
|
||||
}
|
||||
const deployment = await this.deployments.createDeployment(workspaceId, website.id, {
|
||||
trigger: actor.trigger ?? (actor.type === "USER" ? "MANUAL" : actor.type),
|
||||
});
|
||||
await this.audit.record({
|
||||
workspaceId,
|
||||
actorType: actor.type,
|
||||
actorId: actor.id,
|
||||
action: "website.deploy",
|
||||
resourceType: "Website",
|
||||
resourceId: website.resourceId,
|
||||
riskLevel: "HIGH",
|
||||
metadata: { deploymentId: deployment.id },
|
||||
});
|
||||
return deployment;
|
||||
}
|
||||
|
||||
/** # 网站日志:最近部署的日志尾部 */
|
||||
async logs(workspaceId: string, id: string, limit = 200) {
|
||||
const website = await this.mustGet(workspaceId, id);
|
||||
const deployment = await this.prisma.deployment.findFirst({
|
||||
where: { websiteId: website.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
logs: { orderBy: { seq: "desc" }, take: Math.min(limit, 1000) },
|
||||
},
|
||||
});
|
||||
return {
|
||||
deploymentId: deployment?.id ?? null,
|
||||
lines: (deployment?.logs ?? []).reverse().map((l) => ({ seq: l.seq, stream: l.stream, line: l.line, time: l.createdAt })),
|
||||
};
|
||||
}
|
||||
|
||||
/** # 即时健康检查:优先从目标服务器 curl 本机端口;否则直接请求域名 */
|
||||
async health(workspaceId: string, id: string) {
|
||||
const website = await this.mustGet(workspaceId, id);
|
||||
const result = await this.deployments.runHealthCheck(workspaceId, website);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** # 断言网站存在且属于工作区 */
|
||||
async mustGet(workspaceId: string, id: string): Promise<Website> {
|
||||
const website = await this.prisma.website.findFirst({ where: { id, workspaceId } });
|
||||
if (!website) throw ApiError.notFound("Website not found");
|
||||
return website;
|
||||
}
|
||||
|
||||
/** # 实体 → 视图 */
|
||||
private toView(w: Website & { server?: { id: string; name: string; host: string } | null }) {
|
||||
return {
|
||||
id: w.id,
|
||||
resourceId: w.resourceId,
|
||||
workspaceId: w.workspaceId,
|
||||
name: w.name,
|
||||
primaryDomain: w.primaryDomain,
|
||||
serverId: w.serverId,
|
||||
server: w.server ?? null,
|
||||
repositoryUrl: w.repositoryUrl,
|
||||
deployType: w.deployType,
|
||||
port: w.port,
|
||||
healthPath: w.healthPath,
|
||||
createdAt: w.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* # 后台调度器
|
||||
* 进程内周期任务:
|
||||
* - 监控检查循环(到期 monitor → 执行检查 → 结果/事件);
|
||||
* - 自动化 cron 扫描循环(每分钟)。
|
||||
* 与 JobQueue(任务执行)共同构成异步执行层。
|
||||
*/
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { MonitoringService } from "../monitoring/monitoring.service";
|
||||
import { AutomationService } from "../automation/automation.service";
|
||||
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
|
||||
|
||||
/** # 监控循环间隔(毫秒) **/
|
||||
const MONITOR_INTERVAL_MS = 20_000;
|
||||
/** # cron 扫描间隔(毫秒) **/
|
||||
const CRON_INTERVAL_MS = 60_000;
|
||||
|
||||
@Injectable()
|
||||
export class WorkerSchedulerService implements OnModuleInit, OnModuleDestroy {
|
||||
/** # 定时器集合 **/
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
/** # 停止标记 **/
|
||||
private stopped = false;
|
||||
|
||||
/** # 日志器 **/
|
||||
private readonly logger: StructuredLoggerInstance;
|
||||
|
||||
constructor(
|
||||
private readonly monitoring: MonitoringService,
|
||||
private readonly automations: AutomationService,
|
||||
loggers: LogService,
|
||||
) {
|
||||
this.logger = loggers.child("WorkerScheduler");
|
||||
}
|
||||
|
||||
/** # 启动所有循环 */
|
||||
onModuleInit(): void {
|
||||
this.timers.push(
|
||||
setInterval(() => void this.monitorLoop(), MONITOR_INTERVAL_MS),
|
||||
setInterval(() => void this.cronLoop(), CRON_INTERVAL_MS),
|
||||
);
|
||||
for (const t of this.timers) t.unref?.();
|
||||
this.logger.log("worker scheduler started");
|
||||
}
|
||||
|
||||
/** # 停止调度器 */
|
||||
onModuleDestroy(): void {
|
||||
this.stopped = true;
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
}
|
||||
|
||||
/** # 监控检查循环 */
|
||||
private async monitorLoop(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
const due = await this.monitoring.dueMonitors(20);
|
||||
for (const monitor of due) {
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
await this.monitoring.runCheck(monitor);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`monitor check failed for ${monitor.name}: ${error instanceof Error ? error.message : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("monitor loop failed", error instanceof Error ? error : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** # cron 扫描循环 */
|
||||
private async cronLoop(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
await this.automations.runDueCron();
|
||||
} catch (error) {
|
||||
this.logger.error("cron loop failed", error instanceof Error ? error : undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user