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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,586 @@
# 20_UI_SHELL_FRONTEND_RUNTIME.md
# DevOS UI Shell / Frontend Runtime
> DevOS 前端不是一个普通 Admin Dashboard。
>
> 它应该是一个可扩展的 **Application UI Shell + Resource UI Runtime + Plugin UI Runtime + Agent UI Runtime**。
>
> Plugin、MCP、Agent、Task、Resource、Action、Workflow 都必须能够以统一方式进入 DevOS UI,而不能各自重新实现 Layout、Router、Permission、Menu、Settings、Table、Detail、Form、Task、Notification、Realtime 等基础设施。
---
# 1. 目标
DevOS UI Runtime 必须解决以下问题:
```text
一个 DevOS
↓
多个 Plugin
↓
多个 Resource
↓
多个 Action
↓
多个 Agent / Skill
↓
多个 Task / Workflow / MCP
```
最终用户看到的应该仍然是:
```text
┌─────────────────────────────────────────────────────┐
│ DevOS │
├───────────────┬─────────────────────────────────────┤
│ Navigation │ Main Content │
│ │ │
│ Dashboard │ Resource / Task / Agent / Plugin │
│ Projects │ │
│ Resources │ │
│ Tasks │ │
│ Agents │ │
│ Plugins │ │
│ Settings │ │
└───────────────┴─────────────────────────────────────┘
```
而不是:
```text
Plugin A
└── 自己的 Layout
└── 自己的 Router
└── 自己的 Permission
Plugin B
└── 自己的 Layout
└── 自己的 Router
└── 自己的 Permission
```
DevOS 必须提供统一:
- App Shell
- Router
- Navigation
- Permission
- Resource UI
- Action UI
- Table
- Form
- Detail
- Search
- Filter
- Task UI
- Agent UI
- Plugin UI
- Settings
- Notification
- Realtime
- Modal / Drawer
- Command Palette
- Global Search
- Breadcrumb
- Page State
- Error Boundary
- Loading
- Empty State
- Responsive
- Theme
- i18n
---
# 2. 核心原则
## 2.1 UI 不拥有业务逻辑
禁止:
```text
React Component
↓
直接调用数据库
```
禁止:
```text
React Component
↓
直接调用第三方 API
```
禁止:
```text
Plugin Page
↓
自己实现权限
```
正确:
```text
UI
↓
Query / Action
↓
API
↓
DevOS Runtime
↓
Resource / Action Runtime
```
---
# 3. UI 总体架构
```text
DevOS UI
│
▼
┌────────────┐
│ UI Shell │
└─────┬──────┘
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Navigation Router Global UI
│ │ │
▼ ▼ ▼
Menu Registry Route Registry Search / Cmd
│ │
└───────────────┼────────────────┐
▼ ▼
Resource UI Plugin UI
│ │
▼ ▼
Action UI Agent UI
│ │
└───────┬────────┘
▼
API / Runtime
```
---
# 4. Frontend Package Architecture
推荐:
```text
packages/
├── ui/
├── ui-shell/
├── ui-router/
├── ui-navigation/
├── ui-resource/
├── ui-action/
├── ui-form/
├── ui-table/
├── ui-detail/
├── ui-search/
├── ui-task/
├── ui-agent/
├── ui-plugin/
├── ui-settings/
├── ui-notification/
├── ui-realtime/
├── ui-command/
├── ui-theme/
├── ui-i18n/
└── frontend-runtime/
```
应用:
```text
apps/
└── web/
├── src/
│ ├── bootstrap/
│ ├── app/
│ ├── routes/
│ ├── providers/
│ └── main.tsx
└── package.json
```
Plugin:
```text
plugins/
├── seo/
│ └── ui/
├── devops/
│ └── ui/
└── crm/
└── ui/
```
---
# 5. UI Runtime
核心 Runtime:
```ts
export interface UIRuntime {
registerRoute(route: UIRoute): void
registerNavigation(item: NavigationItem): void
registerResource(resource: ResourceUI): void
registerAction(action: ActionUI): void
registerWidget(widget: UIWidget): void
registerPage(page: UIPage): void
registerCommand(command: UICommand): void
registerSettings(settings: SettingsDefinition): void
resolveRoute(path: string): UIRoute | undefined
resolveNavigation(): NavigationItem[]
canRender(target: UIVisibilityContext): Promise<boolean>
}
```
---
# 6. UI Context
所有页面都应该拥有统一 Context:
```ts
export interface UIContext {
user: UserContext
organization?: OrganizationContext
project?: ProjectContext
environment?: EnvironmentContext
permissions: PermissionSet
locale: string
timezone: string
theme: ThemeMode
featureFlags: FeatureFlagSet
}
```
页面不得自行重新获取:
```text
currentUser
currentOrganization
permissions
environment
theme
locale
```
应该统一由 UI Runtime 提供。
---
# 7. Application Shell
Shell 是 DevOS 的最外层 UI。
```tsx
<DevOSApp>
<AuthProvider>
<UIRuntimeProvider>
<ThemeProvider>
<RealtimeProvider>
<NotificationProvider>
<AppShell>
<Navigation />
<MainContent />
</AppShell>
</NotificationProvider>
</RealtimeProvider>
</ThemeProvider>
</UIRuntimeProvider>
</AuthProvider>
</DevOSApp>
```
---
# 8. Shell Layout
建议:
```text
┌──────────────────────────────────────────────────────┐
│ Logo Global Search Command User │
├────────────┬─────────────────────────────────────────┤
│ │ Breadcrumb │
│ Navigation ├─────────────────────────────────────────┤
│ │ │
│ Dashboard │ Page │
│ Projects │ │
│ Resources │ │
│ Tasks │ │
│ Agents │ │
│ Plugins │ │
│ │ │
│ Settings │ │
└────────────┴─────────────────────────────────────────┘
```
支持:
- sidebar collapse
- mobile drawer
- top navigation
- nested navigation
- workspace navigation
- project navigation
- plugin navigation
---
# 9. Navigation Runtime
Plugin 不应该修改主 Layout。
Plugin 只注册 Navigation:
```ts
export interface NavigationItem {
id: string
label: string
icon?: string
path?: string
parentId?: string
order?: number
permission?: PermissionRequirement
featureFlag?: string
badge?: NavigationBadge
children?: NavigationItem[]
}
```
例如 SEO Plugin:
```ts
{
id: "seo.websites",
label: "Websites",
path: "/seo/websites",
parentId: "seo",
permission: {
action: "seo.website.read"
}
}
```
---
# 10. Navigation 不等于 Permission
Navigation:
```text
用户可以看到什么
```
Permission:
```text
用户可以做什么
```
因此:
```text
Navigation Filter
↓
Permission Filter
↓
Scope Filter
↓
Visible Navigation
```
但:
> UI 隐藏按钮不能代替后端授权。
---
# 11. Router Runtime
使用:
```text
React Router
```
统一 Route Registry:
```ts
export interface UIRoute {
id: string
path: string
component: React.ComponentType
parentId?: string
layout?: string
permission?: PermissionRequirement
metadata?: RouteMetadata
}
```
---
# 12. Plugin Route
Plugin 可以注册:
```ts
plugin.registerRoute({
id: "seo.website.list",
path: "/seo/websites",
component: WebsiteListPage,
permission: {
action: "seo.website.read"
}
})
```
但 Plugin 不允许:
```ts
createBrowserRouter(...)
```
Plugin 不允许重新创建:
```text
Router
Shell
Auth
Permission
Theme
Notification
```
---
# 13. Route Metadata
```ts
export interface RouteMetadata {
title?: string
icon?: string
breadcrumb?: BreadcrumbDefinition
keepAlive?: boolean
fullscreen?: boolean
searchable?: boolean
commands?: string[]
featureFlag?: string
}
```
---
# 14. Resource UI Runtime
这是 DevOS UI 的核心。
Resource 定义:
```text
Resource
├── Schema
├── Actions
├── Permissions
├── Relations
└── UI Definition
```
因此 Resource 可以自动生成:
```text
List
Detail
Create
Edit
Delete
Bulk Actions
Search
Filter
Sort
Relation
Timeline
Activity
```
---
# 15. Resource UI Definition
```ts
export interface ResourceUI {
resource: string
label: string
icon?: string
list?: ResourceListDefinition
detail?: ResourceDetailDefinition
form?: ResourceFormDefinition
search?: ResourceSearchDefinition
actions?: string[]
}
```
例如:
```ts
{
resource: "seo.website",
label: "Website
@@ -0,0 +1,761 @@
# 动态 FPS 截图推送模块
## 1. 目标
现有 Browser SSE 已经支持 Screenshot 推送,本次只增加**动态 Screenshot 推送频率**。
核心规则:
- 无操作时:每 **2 秒** 推送一张 Screenshot
- 用户发生浏览器操作后:进入高频模式
- 高频模式:每 **500ms** 推送一张 Screenshot
- 高频模式持续 **5 秒**
- 5 秒的计算基于**最后一次浏览器操作**
- 新操作发生时,只延长高频模式时间
- 操作本身**不立即触发 Screenshot**
- 不使用真正的视频流
- 不使用 WebRTC
- 不使用 VNC
- Screenshot 仍然是独立 JPEG 图片
- Screenshot 仍然通过现有 SSE 推送
---
# 2. 配置
默认配置:
```ts
const screenshotConfig = {
quality: 30,
normalInterval: 2000,
activeInterval: 500,
activeDuration: 5000
}
```
参数:
| 参数 | 默认值 | 说明 |
|---|---:|---|
| `quality` | `30` | JPEG 质量 |
| `normalInterval` | `2000` | 普通模式截图间隔,单位 ms |
| `activeInterval` | `500` | 活跃模式截图间隔,单位 ms |
| `activeDuration` | `5000` | 最后一次操作后保持活跃模式的时间,单位 ms |
所有时间参数统一使用 `ms`。
---
# 3. Screenshot Mode
只有两种状态:
```ts
type ScreenshotMode = 'normal' | 'active'
```
### Normal
```text
interval = 2000ms
```
即:
```text
Screenshot
↓
等待 2 秒
↓
Screenshot
↓
等待 2 秒
↓
...
```
### Active
```text
interval = 500ms
```
即:
```text
Screenshot
↓
等待 500ms
↓
Screenshot
↓
等待 500ms
↓
...
```
---
# 4. 状态
Scheduler 至少维护:
```ts
interface ScreenshotSchedulerState {
mode: 'normal' | 'active'
lastActivityAt: number
activeUntil: number
running: boolean
}
```
说明:
### `mode`
当前截图模式:
```text
normal
active
```
### `lastActivityAt`
最后一次成功 Browser Action 的时间。
### `activeUntil`
Active Mode 的结束时间。
例如:
```text
当前时间:10:00:00
用户操作
↓
activeUntil = 10:00:05
```
---
# 5. 操作触发 Active Mode
任何**成功的浏览器操作**完成后调用:
```ts
notifyActivity()
```
实现:
```ts
function notifyActivity() {
const now = Date.now()
lastActivityAt = now
activeUntil = now + activeDuration
}
```
同时:
```ts
mode = 'active'
```
---
# 6. 操作不立即 Screenshot
这是必须遵守的规则。
错误:
```text
Browser Action
↓
Screenshot
↓
SSE
```
正确:
```text
Browser Action
↓
Action 执行成功
↓
notifyActivity()
↓
切换 Active Mode
↓
Scheduler 按 500ms 推送 Screenshot
```
也就是说:
> **Browser Action 只修改 Screenshot Scheduler 状态,不主动调用 Screenshot。**
---
# 7. Active Mode 生命周期
例如:
```text
00:00
用户 click
↓
Active Until = 00:05
00:00.5 Screenshot
00:01.0 Screenshot
00:01.5 Screenshot
00:02.0 Screenshot
...
00:05.0
↓
恢复 Normal
```
恢复后:
```text
每 2000ms Screenshot
```
---
# 8. 连续操作
如果 Active Mode 中再次发生操作:
```text
00:00 click
↓
activeUntil = 00:05
00:02 fill
↓
activeUntil = 00:07
00:04 scroll
↓
activeUntil = 00:09
```
因此:
> **Active Mode 的 5 秒永远从最后一次操作重新计算。**
最终:
```text
00:04 最后一次操作
↓
保持 Active 到 00:09
↓
恢复 Normal
```
---
# 9. 多次操作不会创建多个 Timer
禁止:
```ts
setInterval(...)
setInterval(...)
setInterval(...)
```
也禁止每次操作创建一个新的 Screenshot Loop。
整个 Browser Session 只允许存在:
```text
1 个 Screenshot Scheduler
```
所有操作统一调用:
```ts
notifyActivity()
```
---
# 10. Scheduler
推荐采用单一循环。
示例:
```ts
class ScreenshotScheduler {
private running = false
private mode: 'normal' | 'active' = 'normal'
private activeUntil = 0
async start() {
if (this.running) {
return
}
this.running = true
while (this.running) {
const now = Date.now()
this.mode =
now < this.activeUntil
? 'active'
: 'normal'
await this.capture()
const interval =
this.mode === 'active'
? this.config.activeInterval
: this.config.normalInterval
await sleep(interval)
}
}
notifyActivity() {
const now = Date.now()
this.mode = 'active'
this.activeUntil =
now + this.config.activeDuration
}
stop() {
this.running = false
}
}
```
实际代码可以根据项目结构调整,但必须保持相同的行为。
---
# 11. Screenshot 执行顺序
Scheduler 每次执行:
```text
检查当前 Mode
↓
Screenshot
↓
SSE 推送
↓
根据当前 Mode 等待
↓
进入下一轮
```
例如 Normal:
```text
Screenshot
↓
wait 2000ms
↓
Screenshot
↓
wait 2000ms
```
Active:
```text
Screenshot
↓
wait 500ms
↓
Screenshot
↓
wait 500ms
```
---
# 12. Screenshot 不允许并发
必须保证同一 Browser Session:
```text
同时最多 1 个 Screenshot
```
禁止:
```text
Screenshot A
↓
还没完成
Screenshot B
↓
还没完成
Screenshot C
```
正确:
```text
Screenshot A
↓
完成
↓
等待 interval
↓
Screenshot B
```
原因:
- 避免 CPU 突增
- 避免内存占用增加
- 避免多个 Screenshot 同时进入 SSE
- 避免页面状态竞争
---
# 13. Browser Action 与 Scheduler 的关系
Browser Controller 在 Action 成功后:
```ts
await executeAction(action)
scheduler.notifyActivity()
```
不要:
```ts
await executeAction(action)
await screenshot()
```
也不要:
```ts
scheduler.start()
```
Browser Session 创建时只启动一次 Scheduler。
---
# 14. Action 失败
Action 失败:
```text
Browser Action
↓
失败
↓
不调用 notifyActivity()
```
因此不会进入 Active Mode。
例如:
```text
click element
↓
Element Not Found
↓
Action Failed
↓
保持 Normal Mode
```
---
# 15. Navigation Action
以下操作成功后同样进入 Active Mode:
```text
goto
reload
back
forward
```
例如:
```text
goto
↓
页面加载完成
↓
notifyActivity()
↓
Active Mode
↓
500ms Screenshot
```
---
# 16. Batch Action
Batch Action 只调用一次:
```ts
scheduler.notifyActivity()
```
例如:
```text
Action 1
↓
Action 2
↓
Action 3
↓
全部成功
↓
notifyActivity()
```
不要:
```text
Action 1 → notifyActivity()
Action 2 → notifyActivity()
Action 3 → notifyActivity()
```
Batch 本身只需要最终刷新一次 Active Mode 时间。
---
# 17. SSE
动态 FPS 不改变现有 SSE API。
仍然:
```http
GET /profiles/:profileId/browser/events
```
Screenshot Event 保持现有格式。
Scheduler 只决定:
> **什么时候生成下一张 Screenshot。**
SSE 只负责:
> **把 Screenshot 推送给前端。**
两者职责保持分离。
---
# 18. SSE State
建议在 `state` event 中增加:
```json
{
"mode": "active",
"normalInterval": 2000,
"activeInterval": 500,
"activeUntil": 1780000000000
}
```
前端可以知道当前 Screenshot Scheduler 状态。
如果现有 `state` 已经定义完成,也可以不修改协议,仅在服务端内部维护状态。
---
# 19. SSE Client 数量
如果当前没有 SSE Client:
```text
SSE Client = 0
```
可以暂停 Screenshot Scheduler。
重新建立 SSE:
```text
SSE Connected
↓
启动 Screenshot Scheduler
↓
发送当前 Screenshot
↓
进入 Normal Mode
```
如果当前项目已经有 Scheduler 生命周期管理,则只增加动态 interval,不修改现有连接管理。
---
# 20. Active Mode 不等于视频流
必须保持:
```text
Active Mode
↓
每 500ms
↓
生成一张 JPEG
```
而不是:
```text
Active Mode
↓
视频编码
↓
持续视频流
```
所以本功能仍然是:
> **Adaptive Screenshot Streaming**
而不是 Live View / Video Streaming。
---
# 21. 推荐默认参数
第一版固定默认:
```ts
{
quality: 30,
normalInterval: 2000,
activeInterval: 500,
activeDuration: 5000
}
```
即:
```text
无操作:
2 秒 / 张
操作后:
500ms / 张
最后一次操作后:
5 秒恢复正常
JPEG:
quality 30
```
后续如果需要,再将这些参数开放为系统配置。
---
# 22. 核心流程
```text
Browser Action
│
▼
Playwright
│
操作成功
│
▼
notifyActivity()
│
▼
Active Mode
interval=500ms
│
▼
Screenshot
│
▼
SSE
│
▼
Console
│
│
500ms 后
│
▼
Screenshot
│
...
│
最后操作 + 5 秒
│
▼
Normal Mode
interval=2000ms
│
▼
Screenshot
│
2 秒后
│
▼
Screenshot
```
---
# 23. 验收标准
- [ ] Normal Mode 默认 2000ms
- [ ] Active Mode 默认 500ms
- [ ] Active Duration 默认 5000ms
- [ ] Browser Action 成功后进入 Active Mode
- [ ] Browser Action 不立即触发 Screenshot
- [ ] Action 失败不进入 Active Mode
- [ ] Active Mode 从最后一次操作重新计时
- [ ] 连续操作不会创建多个 Timer
- [ ] 每个 Browser Session 只有一个 Screenshot Scheduler
- [ ] Screenshot 不允许并发执行
- [ ] Active Mode 结束后自动恢复 Normal Mode
- [ ] SSE 接口保持不变
- [ ] Screenshot 仍然使用 JPEG
- [ ] Screenshot quality 默认 30
- [ ] 不引入 WebSocket
- [ ] 不引入 WebRTC
- [ ] 不引入 VNC
- [ ] 不引入 CDP
- [ ] 不实现视频流
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -41,6 +41,7 @@ import { CertificateModule } from "./modules/certificate/certificate.module";
import { GitModule } from "./modules/git/git.module"; import { GitModule } from "./modules/git/git.module";
import { SearchModule } from "./modules/search/search.module"; import { SearchModule } from "./modules/search/search.module";
import { V2Module } from "./v2/v2.module"; import { V2Module } from "./v2/v2.module";
import { PlatformModule } from "./v2/platform/platform.module";
@Module({ @Module({
imports: [ imports: [
@@ -73,6 +74,7 @@ import { V2Module } from "./v2/v2.module";
GitModule, GitModule,
SearchModule, SearchModule,
V2Module, V2Module,
PlatformModule,
], ],
controllers: [SystemController], controllers: [SystemController],
providers: [ 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,
};
}
}
@@ -0,0 +1,77 @@
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Environment" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"level" TEXT NOT NULL DEFAULT 'development',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Environment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ConfigValue" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"scopeId" TEXT,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"isSecret" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConfigValue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FileEntry" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"sha256" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'local',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FileEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Project_workspaceId_idx" ON "Project"("workspaceId");
-- CreateIndex
CREATE UNIQUE INDEX "Project_workspaceId_slug_key" ON "Project"("workspaceId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name");
-- CreateIndex
CREATE INDEX "ConfigValue_workspaceId_key_idx" ON "ConfigValue"("workspaceId", "key");
-- CreateIndex
CREATE UNIQUE INDEX "ConfigValue_workspaceId_scope_scopeId_key_key" ON "ConfigValue"("workspaceId", "scope", "scopeId", "key");
-- CreateIndex
CREATE INDEX "FileEntry_workspaceId_createdAt_idx" ON "FileEntry"("workspaceId", "createdAt");
-- AddForeignKey
ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+70
View File
@@ -758,3 +758,73 @@ model ActionExecution {
@@index([actionId, createdAt]) @@index([actionId, createdAt])
@@index([status]) @@index([status])
} }
// ============ v2 P0/P1:Project / Environment / Config / Storage ============
/// # 数据库表 devos_projects:项目(组织内的工作与环境分组,docs 22 Phase 2)
model Project {
id String @id @default(uuid())
workspaceId String
name String
slug String
description String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
environments Environment[]
@@unique([workspaceId, slug])
@@index([workspaceId])
}
/// # 数据库表 devos_environments:项目环境(development/staging/production,docs 22 Phase 2)
model Environment {
id String @id @default(uuid())
projectId String
name String
/// # 数据库字段 环境级别:development / staging / production
level String @default("development")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([projectId, name])
}
/// # 数据库表 devos_config_values:配置运行时(docs 22 Phase 4,级联解析)
model ConfigValue {
id String @id @default(uuid())
workspaceId String
/// # 数据库字段 作用域:platform / organization / project / environment
scope String
/// # 数据库字段 作用域对象 ID(platform 为空)
scopeId String?
key String
value String
/// # 数据库字段 是否敏感(读取时脱敏,存储用保险库加密)
isSecret Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([workspaceId, scope, scopeId, key])
@@index([workspaceId, key])
}
/// # 数据库表 devos_files:存储运行时文件记录(docs 15,File Resource)
model FileEntry {
id String @id @default(uuid())
workspaceId String
name String
originalName String
mimeType String
size Int
sha256 String
/// # 数据库字段 存储键(存储桶内路径,不含原始文件名)
storageKey String
/// # 数据库字段 存储提供者:local(后续 S3 兼容)
provider String @default("local")
createdAt DateTime @default(now())
@@index([workspaceId, createdAt])
}