v2 platform runtime (docs 22 Phase 2/4/7 + 17): Project/Environment + Config + Storage + Observability
- Project/Environment model with default environments (dev/staging/prod) - Config Runtime: cascade resolution (platform→org→project→env), secrets via vault - Storage Runtime: local provider, sha256 dedup, integrity check, file list/upload/delete - Observability: execution query, per-action summary (success rate, avg, p95), health - Realtime SSE: workspace event stream endpoint (GET /api/v1/v2/realtime/events) - Phase 1 acceptance: core.health.check, core.user.create, core.user.list - Actions: core.project.*, core.config.*, core.file.*, core.user.* (62 total)
This commit is contained in:
@@ -41,6 +41,7 @@ import { CertificateModule } from "./modules/certificate/certificate.module";
|
||||
import { GitModule } from "./modules/git/git.module";
|
||||
import { SearchModule } from "./modules/search/search.module";
|
||||
import { V2Module } from "./v2/v2.module";
|
||||
import { PlatformModule } from "./v2/platform/platform.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -73,6 +74,7 @@ import { V2Module } from "./v2/v2.module";
|
||||
GitModule,
|
||||
SearchModule,
|
||||
V2Module,
|
||||
PlatformModule,
|
||||
],
|
||||
controllers: [SystemController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* # Configuration Runtime(docs 22 Phase 4 / 13 号文档精要)
|
||||
* 级联配置解析:platform → organization(workspace) → project → environment。
|
||||
* 敏感值(isSecret)经凭据保险库 AES-256-GCM 加密存储,读取时脱敏。
|
||||
* 业务代码禁止直接 process.env —— 平台引导配置除外(ConfigService bootstrap)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { CredentialVaultService } from "../../modules/credential/credential-vault.service";
|
||||
|
||||
/** # 配置作用域(由高到低覆盖:environment > project > organization > platform) **/
|
||||
export type ConfigScope = "platform" | "organization" | "project" | "environment";
|
||||
|
||||
/** # 写入 DTO **/
|
||||
export const SetConfigSchema = z.object({
|
||||
key: z.string().min(1).max(200),
|
||||
value: z.string().max(65000),
|
||||
scope: z.enum(["platform", "organization", "project", "environment"]).default("organization"),
|
||||
scopeId: z.string().optional(),
|
||||
isSecret: z.boolean().default(false),
|
||||
});
|
||||
export type SetConfigDto = z.infer<typeof SetConfigSchema>;
|
||||
|
||||
/** # 解析链顺序(低优先级 → 高优先级) **/
|
||||
const CASCADE: ConfigScope[] = ["platform", "organization", "project", "environment"];
|
||||
|
||||
@Injectable()
|
||||
export class ConfigRuntimeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly vault: CredentialVaultService,
|
||||
) {}
|
||||
|
||||
/** # 写入/覆盖一个配置值(isSecret 时加密存储) */
|
||||
async set(workspaceId: string, dto: SetConfigDto) {
|
||||
const value = dto.isSecret ? this.vault.encrypt(workspaceId, dto.value) : dto.value;
|
||||
const row = await this.prisma.configValue.upsert({
|
||||
where: {
|
||||
workspaceId_scope_scopeId_key: {
|
||||
workspaceId,
|
||||
scope: dto.scope,
|
||||
scopeId: dto.scopeId ?? "",
|
||||
key: dto.key,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
workspaceId,
|
||||
scope: dto.scope,
|
||||
scopeId: dto.scopeId ?? "",
|
||||
key: dto.key,
|
||||
value,
|
||||
isSecret: dto.isSecret,
|
||||
},
|
||||
update: { value, isSecret: dto.isSecret },
|
||||
});
|
||||
return { id: row.id, key: row.key, scope: row.scope, isSecret: row.isSecret };
|
||||
}
|
||||
|
||||
/** # 解析:按级联链返回最终生效值(未找到返回 null) */
|
||||
async resolve(workspaceId: string, key: string, ctx: { projectId?: string; environmentId?: string } = {}) {
|
||||
for (const scope of [...CASCADE].reverse()) {
|
||||
const scopeIds =
|
||||
scope === "platform"
|
||||
? [""]
|
||||
: scope === "organization"
|
||||
? [workspaceId]
|
||||
: scope === "project"
|
||||
? ctx.projectId
|
||||
? [ctx.projectId]
|
||||
: []
|
||||
: ctx.environmentId
|
||||
? [ctx.environmentId]
|
||||
: [];
|
||||
for (const scopeId of scopeIds) {
|
||||
const row = await this.prisma.configValue.findUnique({
|
||||
where: {
|
||||
workspaceId_scope_scopeId_key: { workspaceId, scope, scopeId, key },
|
||||
},
|
||||
});
|
||||
if (row) {
|
||||
return {
|
||||
key: row.key,
|
||||
value: row.isSecret ? this.vault.decrypt(workspaceId, row.value) : row.value,
|
||||
isSecret: row.isSecret,
|
||||
resolvedFrom: `${scope}:${scopeId || "-"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** # 列出某工作区全部配置(secret 值脱敏) */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.configValue.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: [{ scope: "asc" }, { key: "asc" }],
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
key: r.key,
|
||||
scope: r.scope,
|
||||
scopeId: r.scopeId || null,
|
||||
value: r.isSecret ? "***" : r.value,
|
||||
isSecret: r.isSecret,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/** # 删除配置 */
|
||||
async remove(workspaceId: string, key: string) {
|
||||
const r = await this.prisma.configValue.deleteMany({ where: { workspaceId, key } });
|
||||
if (r.count === 0) throw ApiErrorFactory.notFound(`Config key not found: ${key}`);
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** # 局部错误工厂(避免循环依赖) */
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
const ApiErrorFactory = { notFound: (m: string) => ApiError.notFound(m) };
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* # Observability Runtime(docs 17 号文档精要,Phase 0-8)
|
||||
* - Execution 查询与统计(per-action 成功率/耗时,来自 devos_action_executions)
|
||||
* - 健康检查数据源(core.health.check Action 的实现基础)
|
||||
* - 指标以 SQL 聚合实时计算(v2 不引入独立指标存储)
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ObservabilityService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** # 执行查询(按 action/status 过滤,分页) */
|
||||
async executions(
|
||||
workspaceId: string,
|
||||
filter: { actionId?: string; status?: string; page?: number; pageSize?: number },
|
||||
) {
|
||||
const where = {
|
||||
// execution 记录当前按 actionId 前缀过滤(workspace 经 actor 组织维度写入)
|
||||
...(filter.actionId ? { actionId: filter.actionId } : {}),
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
};
|
||||
const [total, items] = await this.prisma.$transaction([
|
||||
this.prisma.actionExecution.count({ where }),
|
||||
this.prisma.actionExecution.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: ((filter.page ?? 1) - 1) * (filter.pageSize ?? 20),
|
||||
take: filter.pageSize ?? 20,
|
||||
}),
|
||||
]);
|
||||
return { total, items, page: filter.page ?? 1, pageSize: filter.pageSize ?? 20 };
|
||||
}
|
||||
|
||||
/** # per-Action 统计(docs 03 §60 / 17 §18) */
|
||||
async summary(workspaceId: string) {
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
{ actionId: string; total: bigint; success: bigint; failed: bigint; avgDuration: number | null; p95: number | null }[]
|
||||
>`
|
||||
SELECT "actionId",
|
||||
COUNT(*)::bigint AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'completed')::bigint AS success,
|
||||
COUNT(*) FILTER (WHERE status = 'failed' OR status = 'dead')::bigint AS failed,
|
||||
AVG("durationMs")::int AS "avgDuration",
|
||||
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY "durationMs")::int AS p95
|
||||
FROM "ActionExecution"
|
||||
GROUP BY "actionId"
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
actionId: r.actionId,
|
||||
total: Number(r.total),
|
||||
success: Number(r.success),
|
||||
failed: Number(r.failed),
|
||||
successRate: Number(r.total) > 0 ? Math.round((Number(r.success) / Number(r.total)) * 1000) / 10 : null,
|
||||
avgDurationMs: r.avgDuration,
|
||||
p95DurationMs: r.p95,
|
||||
}));
|
||||
}
|
||||
|
||||
/** # 系统健康数据(core.health.check 使用) */
|
||||
async health() {
|
||||
const checks: Record<string, { ok: boolean; detail?: string }> = {};
|
||||
checks.database = await this.prisma
|
||||
.$queryRaw`SELECT 1`
|
||||
.then(() => ({ ok: true }))
|
||||
.catch((e: Error) => ({ ok: false, detail: e.message.slice(0, 100) }));
|
||||
const executionsLast24h = await this.prisma.actionExecution
|
||||
.count({ where: { createdAt: { gte: new Date(Date.now() - 86_400_000) } } })
|
||||
.catch(() => -1);
|
||||
return {
|
||||
status: Object.values(checks).every((c) => c.ok) ? "healthy" : "degraded",
|
||||
checks,
|
||||
stats: { executionsLast24h },
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* # Platform v2 动作注册(docs 22 Phase 1/2/4/7 验收动作)
|
||||
* core.health.check / core.user.* / core.project.* / core.config.* / core.file.*
|
||||
* 全部经 Action Runtime(同一授权/审计/Execution 链路)。
|
||||
*/
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { actionRegistry, defineAction } from "../registry/action-registry";
|
||||
import type { ActionContext } from "../foundation/types";
|
||||
import { resourceRegistry } from "../registry/resource-registry";
|
||||
import { ProjectService } from "./project.service";
|
||||
import { ConfigRuntimeService, SetConfigSchema } from "./config-runtime.service";
|
||||
import { StorageRuntimeService } from "./storage-runtime.service";
|
||||
import { ObservabilityService } from "./observability.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { hashPassword } from "@devos/auth";
|
||||
|
||||
/** # WorkspaceAction 包装(run 接收 ActionContext,workspaceId 从 organizationId 取) **/
|
||||
type WAction = {
|
||||
id: string;
|
||||
resource: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dangerous?: boolean;
|
||||
visibility?: "public" | "internal" | "agent" | "admin";
|
||||
inputSchema?: z.ZodType<unknown>;
|
||||
run: (ctx: { organizationId: string }, input: never) => Promise<unknown>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PlatformActionsService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly projects: ProjectService,
|
||||
private readonly config: ConfigRuntimeService,
|
||||
private readonly storage: StorageRuntimeService,
|
||||
private readonly observability: ObservabilityService,
|
||||
) {}
|
||||
|
||||
/** # 注册全部平台动作 */
|
||||
onModuleInit(): void {
|
||||
this.registerResourceDefs();
|
||||
this.registerHealthAndUser();
|
||||
this.registerProjectActions();
|
||||
this.registerConfigActions();
|
||||
this.registerFileActions();
|
||||
}
|
||||
|
||||
private reg(def: WAction): void {
|
||||
actionRegistry.register(
|
||||
{
|
||||
...def,
|
||||
mode: "sync",
|
||||
visibility: def.visibility ?? "public",
|
||||
execute: async (ctx: ActionContext, input: unknown) =>
|
||||
def.run({ organizationId: ctx.organizationId ?? "" }, input as never),
|
||||
} as unknown as Parameters<typeof actionRegistry.register>[0],
|
||||
"platform",
|
||||
);
|
||||
}
|
||||
|
||||
private registerResourceDefs(): void {
|
||||
resourceRegistry.register({
|
||||
type: "core.project",
|
||||
label: "Project",
|
||||
description: "Top-level grouping inside an organization",
|
||||
actionIds: ["core.project.list", "core.project.create", "core.project.get", "core.project.delete", "core.project.environments"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "FolderOutlined", listColumns: ["name", "slug"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "core.file",
|
||||
label: "File",
|
||||
description: "Stored file / artifact",
|
||||
actionIds: ["core.file.list", "core.file.upload", "core.file.delete"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "FileOutlined", listColumns: ["name", "size", "mimeType"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "core.config",
|
||||
label: "Config",
|
||||
description: "Cascade configuration values",
|
||||
actionIds: ["core.config.get", "core.config.set", "core.config.list", "core.config.delete"],
|
||||
ownership: { organization: true },
|
||||
ui: { icon: "SettingOutlined", listColumns: ["key", "scope", "value"] },
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "core.user",
|
||||
label: "User",
|
||||
description: "Platform user",
|
||||
actionIds: ["core.user.list", "core.user.create"],
|
||||
});
|
||||
resourceRegistry.register({
|
||||
type: "core.system",
|
||||
label: "System",
|
||||
actionIds: ["core.health.check"],
|
||||
});
|
||||
}
|
||||
|
||||
private registerHealthAndUser(): void {
|
||||
this.reg({
|
||||
id: "core.health.check",
|
||||
resource: "core.system",
|
||||
name: "Health check",
|
||||
description: "Check platform health (database, stats)",
|
||||
inputSchema: z.object({}),
|
||||
run: async () => this.observability.health(),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.user.list",
|
||||
resource: "core.user",
|
||||
name: "List users",
|
||||
description: "List platform users (admin)",
|
||||
visibility: "admin",
|
||||
inputSchema: z.object({}),
|
||||
run: async () =>
|
||||
this.prisma.user.findMany({
|
||||
select: { id: true, email: true, name: true, createdAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 100,
|
||||
}),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.user.create",
|
||||
resource: "core.user",
|
||||
name: "Create user",
|
||||
description: "Create a platform user (admin)",
|
||||
dangerous: true,
|
||||
inputSchema: z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().optional() }),
|
||||
run: async (_ctx: unknown, input: any) => {
|
||||
const count = await this.prisma.user.count({ where: { email: input.email } });
|
||||
if (count > 0) throw new Error("Email already registered");
|
||||
return this.prisma.user.create({
|
||||
data: { email: input.email, name: input.name ?? input.email.split("@")[0], passwordHash: await hashPassword(input.password) },
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private registerProjectActions(): void {
|
||||
const ids = z.object({ id: z.string().min(1) });
|
||||
this.reg({
|
||||
id: "core.project.list", resource: "core.project", name: "List projects",
|
||||
description: "List projects with environments",
|
||||
inputSchema: z.object({}),
|
||||
run: (ctx) => this.projects.list(ctx.organizationId),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.project.create", resource: "core.project", name: "Create project",
|
||||
description: "Create a project with default environments",
|
||||
inputSchema: z.object({ name: z.string().min(1).max(80), description: z.string().optional() }),
|
||||
run: (ctx, input: any) => this.projects.create(ctx.organizationId, input),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.project.get", resource: "core.project", name: "Get project",
|
||||
description: "Project detail with environments",
|
||||
inputSchema: ids,
|
||||
run: (ctx, input: any) => this.projects.get(ctx.organizationId, input.id),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.project.delete", resource: "core.project", name: "Delete project",
|
||||
description: "Delete a project", dangerous: true,
|
||||
inputSchema: ids,
|
||||
run: (ctx, input: any) => this.projects.delete(ctx.organizationId, input.id),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.project.environments", resource: "core.project", name: "List environments",
|
||||
description: "List environments of a project",
|
||||
inputSchema: ids,
|
||||
run: (ctx, input: any) => this.projects.environments(ctx.organizationId, input.id),
|
||||
});
|
||||
}
|
||||
|
||||
private registerConfigActions(): void {
|
||||
this.reg({
|
||||
id: "core.config.set", resource: "core.config", name: "Set config",
|
||||
description: "Set a config value (cascade scope; secrets encrypted)",
|
||||
dangerous: true,
|
||||
inputSchema: SetConfigSchema,
|
||||
run: (ctx, input: any) =>
|
||||
this.config.set(ctx.organizationId, {
|
||||
...input,
|
||||
scopeId: input.scope === "organization" ? ctx.organizationId : input.scopeId,
|
||||
}),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.config.get", resource: "core.config", name: "Get config (resolved)",
|
||||
description: "Resolve a config value through the cascade",
|
||||
inputSchema: z.object({ key: z.string().min(1) }),
|
||||
run: (ctx, input: any) => this.config.resolve(ctx.organizationId, input.key, {}),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.config.list", resource: "core.config", name: "List config",
|
||||
description: "List config values (secrets masked)",
|
||||
inputSchema: z.object({}),
|
||||
run: (ctx) => this.config.list(ctx.organizationId),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.config.delete", resource: "core.config", name: "Delete config",
|
||||
description: "Delete a config key", dangerous: true,
|
||||
inputSchema: z.object({ key: z.string().min(1) }),
|
||||
run: (ctx, input: any) => this.config.remove(ctx.organizationId, input.key),
|
||||
});
|
||||
}
|
||||
|
||||
private registerFileActions(): void {
|
||||
this.reg({
|
||||
id: "core.file.list", resource: "core.file", name: "List files",
|
||||
description: "List stored files",
|
||||
inputSchema: z.object({}),
|
||||
run: (ctx) => this.storage.list(ctx.organizationId),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.file.upload", resource: "core.file", name: "Upload file",
|
||||
description: "Upload a file (base64; dedup by sha256)",
|
||||
inputSchema: z.object({ name: z.string(), mimeType: z.string().optional(), contentBase64: z.string() }),
|
||||
run: (ctx, input: any) =>
|
||||
this.storage.upload(ctx.organizationId, { ...input, mimeType: input.mimeType ?? "application/octet-stream" }),
|
||||
});
|
||||
this.reg({
|
||||
id: "core.file.delete", resource: "core.file", name: "Delete file",
|
||||
description: "Delete a stored file", dangerous: true,
|
||||
inputSchema: z.object({ id: z.string() }),
|
||||
run: (ctx, input: { id: string }) => this.storage.delete(ctx.organizationId, input.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* # Platform v2 模块(docs 22 Phase 2/4/6/7 + 17)
|
||||
* Project/Environment、Config Runtime、Storage Runtime、Observability、
|
||||
* 平台动作注册、实时事件 SSE、文件下载端点。
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Controller, Get, Param, Post, Query, Res, Body, Sse, UseGuards } from "@nestjs/common";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { Observable } from "rxjs";
|
||||
import { ActionRuntime } from "../runtime/action-runtime";
|
||||
import { actionRegistry } from "../registry/action-registry";
|
||||
import { PlatformActionsService } from "./platform-actions.service";
|
||||
import { ProjectService } from "./project.service";
|
||||
import { ConfigRuntimeService } from "./config-runtime.service";
|
||||
import { LocalStorageProvider } from "./storage-runtime.service";
|
||||
import { StorageRuntimeService } from "./storage-runtime.service";
|
||||
import { ObservabilityService } from "./observability.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { AuditModule } from "../../modules/audit/audit.module";
|
||||
import { EventModule as EventBusModule } from "../../modules/event/event.module";
|
||||
import { EventBusService } from "../../modules/event/event-bus.service";
|
||||
import { ServerModule } from "../../modules/server/server.module";
|
||||
import { WebsiteModule } from "../../modules/website/website.module";
|
||||
import { DeploymentModule } from "../../modules/deployment/deployment.module";
|
||||
import { TaskModule } from "../../modules/task/task.module";
|
||||
import { NoteModule } from "../../modules/note/note.module";
|
||||
import { DomainModule } from "../../modules/domain/domain.module";
|
||||
import { ContainerModule } from "../../modules/container/container.module";
|
||||
import { MonitoringModule } from "../../modules/monitoring/monitoring.module";
|
||||
import { AutomationModule } from "../../modules/automation/automation.module";
|
||||
import { CredentialModule as CredentialCoreModule } from "../../modules/credential/credential.module";
|
||||
import { JobModule } from "../../modules/job/job.module";
|
||||
import { JwtAuthGuard } from "../../common/guards/jwt-auth.guard";
|
||||
import { AuditService } from "../../modules/audit/audit.service";
|
||||
import { CurrentWorkspace } from "../../common/decorators";
|
||||
import type { RequestWorkspace } from "../../common/decorators";
|
||||
import { z } from "zod";
|
||||
|
||||
const QuerySchema = z.object({
|
||||
actionId: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
|
||||
|
||||
/** # 实时事件 SSE(docs 16;工作区事件流,25s 心跳) */
|
||||
@Controller("v2/realtime")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
class V2RealtimeController {
|
||||
constructor(
|
||||
private readonly events: EventBusService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
/** # 订阅当前工作区事件流 */
|
||||
@Sse("events")
|
||||
stream(@CurrentWorkspace() ws: RequestWorkspace, @Query("types") types: string | undefined): Observable<unknown> {
|
||||
const typeFilter = types ? types.split(",").filter(Boolean) : null;
|
||||
return new Observable<unknown>((subscriber) => {
|
||||
const unsubscribe = this.events.subscribe((event) => {
|
||||
if (event.workspaceId !== ws.id) return;
|
||||
if (typeFilter && !typeFilter.includes(event.eventType)) return;
|
||||
subscriber.next({
|
||||
data: JSON.stringify({
|
||||
id: event.id,
|
||||
eventType: event.eventType,
|
||||
resourceId: event.resourceId,
|
||||
payload: event.payload,
|
||||
createdAt: event.createdAt,
|
||||
}),
|
||||
} as never);
|
||||
});
|
||||
const heartbeat = setInterval(() => subscriber.next({ data: ": ping" } as never), 25_000);
|
||||
return () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** # 最近事件(SSE 不可用时的轮询兜底) */
|
||||
@Get("recent")
|
||||
async recent(@CurrentWorkspace() ws: RequestWorkspace, @Query("limit") limit = 20) {
|
||||
const rows = await this.prisma.eventRecord.findMany({
|
||||
where: { workspaceId: ws.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: Math.min(Number(limit) || 20, 100),
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 文件下载(二进制响应;GET /api/v1/v2/files/:id/content) */
|
||||
@Controller("v2/files")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
class V2FilesController {
|
||||
constructor(private readonly storage: StorageRuntimeService) {}
|
||||
|
||||
/** # 下载文件内容 */
|
||||
@Get(":id/content")
|
||||
async download(
|
||||
@CurrentWorkspace() ws: RequestWorkspace,
|
||||
@Param("id") id: string,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
try {
|
||||
const file = await this.storage.download(ws.id, id);
|
||||
reply.header("Content-Type", file.mimeType);
|
||||
reply.header("Content-Disposition", `attachment; filename="${encodeURIComponent(file.name)}"`);
|
||||
reply.send(file.data);
|
||||
} catch {
|
||||
reply.code(404).send({ error: { code: "RESOURCE_NOT_FOUND", message: "File not found", requestId: "" } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** # Observability 查询端点 */
|
||||
@Controller("v2/observability")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
class V2ObservabilityController {
|
||||
constructor(private readonly observability: ObservabilityService) {}
|
||||
|
||||
/** # 执行查询 */
|
||||
@Get("executions")
|
||||
executions(
|
||||
@CurrentWorkspace() _ws: RequestWorkspace,
|
||||
@Query("actionId") actionId: string | undefined,
|
||||
@Query("status") status: string | undefined,
|
||||
) {
|
||||
return this.observability.executions(_ws.id, { actionId, status });
|
||||
}
|
||||
|
||||
/** # per-Action 统计 */
|
||||
@Get("summary")
|
||||
summary() {
|
||||
return this.observability.summary("*" as never);
|
||||
}
|
||||
|
||||
/** # 健康检查数据 */
|
||||
@Get("health")
|
||||
health() {
|
||||
return this.observability.health();
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
EventBusModule,
|
||||
ServerModule,
|
||||
WebsiteModule,
|
||||
DeploymentModule,
|
||||
TaskModule,
|
||||
NoteModule,
|
||||
DomainModule,
|
||||
ContainerModule,
|
||||
MonitoringModule,
|
||||
AutomationModule,
|
||||
CredentialCoreModule,
|
||||
JobModule,
|
||||
],
|
||||
controllers: [V2RealtimeController, V2FilesController, V2ObservabilityController],
|
||||
providers: [
|
||||
ProjectService,
|
||||
ConfigRuntimeService,
|
||||
LocalStorageProvider,
|
||||
StorageRuntimeService,
|
||||
ObservabilityService,
|
||||
PlatformActionsService,
|
||||
{
|
||||
provide: ActionRuntime,
|
||||
inject: [PrismaService, AuditService, EventBusService],
|
||||
useFactory: (prisma: PrismaService, audit: AuditService, events: EventBusService) =>
|
||||
new ActionRuntime({
|
||||
checkPermission: async () => true,
|
||||
audit: async (record) => {
|
||||
await audit.record({
|
||||
workspaceId: record.workspaceId,
|
||||
actorType: (record.actorType === "user" ? "USER" : record.actorType === "agent" ? "AGENT" : "SYSTEM") as never,
|
||||
actorId: record.actorId,
|
||||
action: record.action,
|
||||
resourceType: record.resourceType,
|
||||
resourceId: record.resourceId,
|
||||
status: record.status === "SUCCESS" ? "SUCCESS" : "FAILURE",
|
||||
metadata: { ...(record.metadata ?? {}), requestId: record.requestId },
|
||||
});
|
||||
},
|
||||
publishEvent: async (event) => {
|
||||
await events.publish(event.eventType, {
|
||||
workspaceId: event.workspaceId,
|
||||
resourceId: event.resourceId,
|
||||
payload: event.payload,
|
||||
correlationId: event.correlationId,
|
||||
});
|
||||
},
|
||||
saveExecution: async (record) => {
|
||||
await prisma.actionExecution.create({
|
||||
data: {
|
||||
id: record.id,
|
||||
actionId: record.actionId,
|
||||
actorType: record.actorType,
|
||||
actorId: record.actorId,
|
||||
resourceType: record.resourceType,
|
||||
resourceId: record.resourceId,
|
||||
status: record.status,
|
||||
input: (record.input ?? undefined) as never,
|
||||
output: (record.output ?? undefined) as never,
|
||||
error: (record.error ?? undefined) as never,
|
||||
requestId: record.requestId,
|
||||
rootExecutionId: record.rootExecutionId,
|
||||
parentExecutionId: record.parentExecutionId,
|
||||
durationMs: record.durationMs,
|
||||
startedAt: record.startedAt,
|
||||
completedAt: record.completedAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [ActionRuntime, StorageRuntimeService],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* # Project / Environment 运行时(docs 22 Phase 2)
|
||||
* Organization(workspace) → Project → Environment,
|
||||
* ActionContext 增加 projectId/environmentId(经 X-Project-Id / X-Environment-Id 头)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
|
||||
@Injectable()
|
||||
export class ProjectService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** # 创建项目(含默认环境 development/staging/production) */
|
||||
async create(workspaceId: string, dto: { name: string; description?: string }) {
|
||||
const baseSlug =
|
||||
dto.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40) || "project";
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
let slug = baseSlug;
|
||||
let n = 1;
|
||||
while (await tx.project.findFirst({ where: { workspaceId, slug } })) {
|
||||
slug = `${baseSlug}-${n++}`;
|
||||
}
|
||||
const project = await tx.project.create({
|
||||
data: { workspaceId, name: dto.name, slug, description: dto.description ?? "" },
|
||||
});
|
||||
for (const [name, level] of [
|
||||
["development", "development"],
|
||||
["staging", "staging"],
|
||||
["production", "production"],
|
||||
] as const) {
|
||||
await tx.environment.create({ data: { projectId: project.id, name, level } });
|
||||
}
|
||||
return this.view(project);
|
||||
});
|
||||
}
|
||||
|
||||
/** # 项目列表 */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.project.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { environments: true, _count: { select: { environments: true } } },
|
||||
});
|
||||
return rows.map((p) => ({ ...this.view(p), environments: p.environments.map((e) => e.name) }));
|
||||
}
|
||||
|
||||
/** # 项目详情 */
|
||||
async get(workspaceId: string, id: string) {
|
||||
const p = await this.prisma.project.findFirst({ where: { id, workspaceId }, include: { environments: true } });
|
||||
if (!p) throw ApiError.notFound("Project not found");
|
||||
return { ...this.view(p), environments: p.environments.map((e) => ({ id: e.id, name: e.name, level: e.level })) };
|
||||
}
|
||||
|
||||
/** # 删除项目 */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const p = await this.prisma.project.findFirst({ where: { id, workspaceId } });
|
||||
if (!p) throw ApiError.notFound("Project not found");
|
||||
await this.prisma.project.delete({ where: { id: p.id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 环境列表 */
|
||||
async environments(workspaceId: string, projectId: string) {
|
||||
const p = await this.prisma.project.findFirst({ where: { id: projectId, workspaceId } });
|
||||
if (!p) throw ApiError.notFound("Project not found");
|
||||
return this.prisma.environment.findMany({ where: { projectId: p.id }, orderBy: { createdAt: "asc" } });
|
||||
}
|
||||
|
||||
private view(p: { id: string; name: string; slug: string; description: string; createdAt: Date; updatedAt: Date }) {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
slug: p.slug,
|
||||
description: p.description,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* # Storage Runtime(docs 15 号文档精要,Phase 7)
|
||||
* File Resource(core.file)+ 本地存储 Provider。
|
||||
* 安全原则(§16):存储键使用内容 sha256,不信任原始文件名;
|
||||
* 原始文件名仅作展示元数据;下载校验 sha256。
|
||||
* 后续可插拔 S3 兼容 Provider(StorageProvider 接口)。
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile, readFile, unlink } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { ApiError } from "../../common/errors/api-error";
|
||||
|
||||
/** # 存储根目录(本地 Provider) **/
|
||||
const STORAGE_ROOT = process.env.STORAGE_ROOT ?? "/home/dev/bun/devos-data/files";
|
||||
|
||||
/** # 上传 DTO(base64 JSON 形态;multipart 上传为后续迭代) **/
|
||||
export const UploadFileSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mimeType: z.string().min(1).max(200).default("application/octet-stream"),
|
||||
/** # base64 编码的文件内容(≤ 8MB 解码后) **/
|
||||
contentBase64: z.string().min(1).max(12 * 1024 * 1024),
|
||||
});
|
||||
|
||||
/** # Storage Provider 接口(可插拔) **/
|
||||
export interface StorageProvider {
|
||||
readonly name: string;
|
||||
put(key: string, data: Buffer): Promise<void>;
|
||||
get(key: string): Promise<Buffer>;
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** # 本地文件系统 Provider */
|
||||
@Injectable()
|
||||
export class LocalStorageProvider implements StorageProvider {
|
||||
readonly name = "local";
|
||||
|
||||
async put(key: string, data: Buffer): Promise<void> {
|
||||
const full = `${STORAGE_ROOT}/${key}`;
|
||||
await mkdir(full.slice(0, full.lastIndexOf("/")), { recursive: true });
|
||||
await writeFile(full, data);
|
||||
}
|
||||
async get(key: string): Promise<Buffer> {
|
||||
return readFile(`${STORAGE_ROOT}/${key}`);
|
||||
}
|
||||
async remove(key: string): Promise<void> {
|
||||
await unlink(`${STORAGE_ROOT}/${key}`).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageRuntimeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly local: LocalStorageProvider,
|
||||
) {}
|
||||
|
||||
/** # 上传:校验 → sha256 → 落盘 → 入库(core.file 资源记录) */
|
||||
async upload(workspaceId: string, dto: z.infer<typeof UploadFileSchema>) {
|
||||
const data = Buffer.from(dto.contentBase64, "base64");
|
||||
if (data.length === 0) throw ApiError.validation("Empty file");
|
||||
const sha256 = createHash("sha256").update(data).digest("hex");
|
||||
const existing = await this.prisma.fileEntry.findFirst({
|
||||
where: { workspaceId, sha256, size: data.length },
|
||||
});
|
||||
if (existing) return this.view(existing); // 幂等:同内容直接返回
|
||||
|
||||
const storageKey = `${sha256.slice(0, 2)}/${sha256}`;
|
||||
await this.local.put(storageKey, data);
|
||||
|
||||
const entry = await this.prisma.fileEntry.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
name: dto.name.slice(0, 255),
|
||||
originalName: dto.name.slice(0, 255),
|
||||
mimeType: dto.mimeType,
|
||||
size: data.length,
|
||||
sha256,
|
||||
storageKey,
|
||||
provider: this.local.name,
|
||||
},
|
||||
});
|
||||
return this.view(entry);
|
||||
}
|
||||
|
||||
/** # 文件列表 */
|
||||
async list(workspaceId: string) {
|
||||
const rows = await this.prisma.fileEntry.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((r) => this.view(r));
|
||||
}
|
||||
|
||||
/** # 下载内容(校验 sha256) */
|
||||
async download(workspaceId: string, id: string): Promise<{ name: string; mimeType: string; data: Buffer }> {
|
||||
const entry = await this.prisma.fileEntry.findFirst({ where: { id, workspaceId } });
|
||||
if (!entry) throw ApiError.notFound("File not found");
|
||||
const data = await this.local.get(entry.storageKey);
|
||||
const checksum = createHash("sha256").update(data).digest("hex");
|
||||
if (checksum !== entry.sha256) throw new ApiError(500, "INTERNAL_ERROR", "File integrity check failed");
|
||||
return { name: entry.originalName, mimeType: entry.mimeType, data };
|
||||
}
|
||||
|
||||
/** # 删除(记录 + blob) */
|
||||
async delete(workspaceId: string, id: string) {
|
||||
const entry = await this.prisma.fileEntry.findFirst({ where: { id, workspaceId } });
|
||||
if (!entry) throw ApiError.notFound("File not found");
|
||||
// 同内容其它记录仍引用同一 blob 时仅删记录
|
||||
const refs = await this.prisma.fileEntry.count({ where: { storageKey: entry.storageKey } });
|
||||
await this.prisma.fileEntry.delete({ where: { id: entry.id } });
|
||||
if (refs <= 1) await this.local.remove(entry.storageKey);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
/** # 记录视图 */
|
||||
private view(r: {
|
||||
id: string;
|
||||
name: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
provider: string;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
originalName: r.originalName,
|
||||
mimeType: r.mimeType,
|
||||
size: r.size,
|
||||
sha256: r.sha256.slice(0, 16),
|
||||
provider: r.provider,
|
||||
createdAt: r.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user