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:
phptits
2026-09-10 06:35:55 +00:00
commit 0d6cd37579
209 changed files with 22783 additions and 0 deletions
+82
View File
@@ -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);
}
}
+45
View File
@@ -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 } : {}) },
});
}
}
}
+49
View File
@@ -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;
}
}
+30
View File
@@ -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 };
}
+71
View File
@@ -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,
})),
};
}
}
+75
View File
@@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&#(\d+);/g, (_, d: string) => String.fromCodePoint(Number(d)));
}
+52
View File
@@ -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 {}
+96
View File
@@ -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),
},
});
}
}
+657
View File
@@ -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,
};
}
}
+28
View File
@@ -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;
}
}
+78
View File
@@ -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);
}
}
+17
View File
@@ -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 {}
+187
View File
@@ -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}`;
}
+309
View File
@@ -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 };
}
}
+14
View File
@@ -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);
}
}
+14
View File
@@ -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 {}
+182
View File
@@ -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);
}
}
+15
View File
@@ -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 {}
+232
View File
@@ -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);
}
}
+15
View File
@@ -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 {}
+198
View File
@@ -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);
}
}
}
@@ -0,0 +1,13 @@
/**
* # Worker 调度模块(监控循环 / cron 循环)
*/
import { Module } from "@nestjs/common";
import { WorkerSchedulerService } from "./worker-scheduler.service";
import { MonitoringModule } from "../monitoring/monitoring.module";
import { AutomationModule } from "../automation/automation.module";
@Module({
imports: [MonitoringModule, AutomationModule],
providers: [WorkerSchedulerService],
})
export class WorkerModule {}

Some files were not shown because too many files have changed in this diff Show More