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:
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
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user