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:
phptits
2026-09-11 21:11:07 +00:00
parent 72012a7da3
commit 851ccf1d7f
28 changed files with 87763 additions and 0 deletions
+2
View File
@@ -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),
});
}
}
+225
View File
@@ -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,
};
}
}