Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f37344d03 | ||
|
|
eb92a4e591 | ||
|
|
78ccca61c3 | ||
|
|
3cbe3a5996 | ||
|
|
dfdcd78f1a | ||
|
|
9c45a143b9 |
@@ -3,7 +3,7 @@
|
||||
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
|
||||
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
|
||||
|
||||
**当前版本:V2.0**(多 Agent 协作 / 自动评估 / 模板市场 / 企业版)
|
||||
**当前版本:V3.1**(自定义角色 / Worker 权限组 / 交付体系 / 精准权限 / 多 Agent 协作 / 企业版)
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +49,31 @@
|
||||
| ⚖️ 合规 | 全量数据导出(JSON)、审计 CSV 导出、数据保留期清理、PII 脱敏(邮箱/手机/身份证)、数据使用同意凭证 |
|
||||
| 🔒 私有化 | 单机 SQLite + 无 CDN 前端,完全离线可用;Docker 一键部署(见下) |
|
||||
|
||||
### 📦 交付体系(V3 新增)
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 📬 送达者邮箱 | 新建项目**必填**送达者(人)邮箱;项目**完成**或遇到**无法绕开的难关**时自动邮件及时通知 |
|
||||
| 🗂️ 项目工作目录 | 每个项目独立工作目录 `data/workspace/project_<id>/`,中间产物/交付物分类存放,互不污染;支持网页上传/下载/删除 |
|
||||
| 🌐 网页交付物 | 一键部署到 `data/demo/<id>/`,经 `/demo/<id>/` **免登录公开访问**,送达者直接打开链接查看 |
|
||||
| 🗜️ 打包交付 | 工作目录一键 zip 打包(`data/packages/`),随邮件附件发送给送达者 |
|
||||
| ✉️ 手动通知 | 难关说明/进展可随时手动邮件通知送达者;交付全流程留痕(交付记录) |
|
||||
|
||||
### 🔑 精准权限(V3 新增)
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 👥 用户管理 | 管理员增删改用户,管理用户的项目所属与 Worker 权限(企业版 → 用户与权限 → 🔑 授权) |
|
||||
| 📁 项目授权 | view 查看 / manage 管理(建任务/执行/上传交付物/发送)/ admin 管理员;创建者自动成为项目管理员 |
|
||||
| 🤖 Worker 授权 | view 查看档案 / use 使用(可指派任务)/ manage 管理;Worker 的注册/删除仍仅管理员 |
|
||||
| 🎭 角色体系 | 管理员=全部;审计员=全量**只读**;成员=仅可见被授权内容,仪表盘/报表/日志/协作/评估全部按权限过滤 |
|
||||
| 🗺️ 授权总览 | 一键查看所有用户的 项目×Worker 授权矩阵,杜绝越权 |
|
||||
|
||||
### 🎭 自定义角色 + 🤖 Worker 权限组(V3.1 新增)
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 🎭 角色管理 | 18 个权限功能点按模块分组勾选(仪表盘/项目/Worker/协作/评估/模板/报表/日志/告警/API/设置/企业版);**内置角色可编辑权限点**、自定义角色任意创建/删除/多用户绑定,权限自动合并即时生效 |
|
||||
| 🤖 Worker 权限组 | 把一批 Worker + 权限(查看/使用/管理)打包成组,整组授权给一个或多个用户;与直接授权并行生效取高值;组内 Worker 自动对绑定用户可见,批量管理不再逐条授权 |
|
||||
| 📱 权限驱动菜单 | 前端侧边栏按用户有效权限点自动显隐;未授权模块 API 返回 403(如无 agent.run 无法发起协作运行) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
@@ -84,22 +109,23 @@ docker run -d --name aiworker -p 16071:16071 \
|
||||
|
||||
```
|
||||
ai-worker-platform/
|
||||
├── app.py # Flask 应用 + REST API(V1 + V2 路由)
|
||||
├── app.py # Flask 应用 + REST API(V1 + V2 + V3 路由)
|
||||
├── config.py # 供应商/定价/鉴权/告警阈值/邮件 SMTP
|
||||
├── db.py # SQLite 数据层 + V2 迁移(users/audit/agent/eval/templates)
|
||||
├── engine.py # V1 执行引擎:DAG/自动触发/RAG 注入/告警
|
||||
├── db.py # SQLite 数据层 + V2/V3 迁移(users/audit/agent/eval/templates/deliverables/grants)
|
||||
├── engine.py # V1 执行引擎:DAG/自动触发/RAG 注入/告警/难关通知
|
||||
├── agents.py # V2 多 Agent 协作引擎:supervisor/review/debate
|
||||
├── eval.py # V2 自动评估:数据集/LLM 评委/沉淀/排行榜
|
||||
├── templates.py # V2 模板市场:三类模板 + 占位符渲染 + 应用
|
||||
├── enterprise.py # V2 企业版:用户/RBAC/OIDC/LDAP/审计/合规
|
||||
├── enterprise.py # V2/V3 企业版:用户/RBAC/SSO/审计/合规 + 项目/Worker 精准授权 + 自定义角色 + Worker 权限组
|
||||
├── delivery.py # V3 交付体系:工作目录/Demo 部署/打包/邮件送达/难关通知
|
||||
├── llm_gateway.py # 统一模型网关
|
||||
├── rag.py # 知识库:分块 + BM25 检索
|
||||
├── notify.py # 飞书/企微/邮件通知
|
||||
├── dag_verify.py # DAG 全链路验证脚本
|
||||
├── seed.py # 演示数据
|
||||
├── start.sh # 启停脚本
|
||||
├── static/ # 前端 SPA(含 V2 四页)
|
||||
└── data/ # SQLite 库;logs/ 运行日志
|
||||
├── static/ # 前端 SPA(含 V3 交付页/授权管理)
|
||||
└── data/ # SQLite 库;workspace/ 项目工作目录;demo/ 网页Demo;packages/ 打包件;logs/ 运行日志
|
||||
```
|
||||
|
||||
## V2 开放 API 摘要
|
||||
@@ -120,12 +146,21 @@ ai-worker-platform/
|
||||
|
||||
企业版:
|
||||
- `GET/POST /api/enterprise/users`(管理员)| `PUT/DELETE /api/enterprise/users/<id>`
|
||||
- `GET/PUT /api/enterprise/users/<id>/grants` 精准授权(项目/Worker/角色/权限组)| `GET /api/enterprise/grants/overview` 授权总览
|
||||
- `GET /api/enterprise/perm_points` 权限点目录 | `GET/POST /api/enterprise/roles` 角色 | `GET/PUT/DELETE /api/enterprise/roles/<id>`
|
||||
- `GET/POST /api/enterprise/worker_groups` Worker 权限组 | `GET/PUT/DELETE /api/enterprise/worker_groups/<id>`
|
||||
- `GET/PUT /api/enterprise/sso` SSO 配置 | `POST /api/enterprise/sso/oidc/login` 发起 OIDC | `GET /api/enterprise/sso/oidc/callback` 回调
|
||||
- `POST /api/enterprise/sso/ldap/test` LDAP 连通测试
|
||||
- `GET /api/enterprise/audit` 审计日志 | `GET /api/enterprise/audit/export` CSV 导出
|
||||
- `GET /api/enterprise/export` 全量数据导出(JSON)| `POST /api/enterprise/compliance` 保留期/脱敏设置
|
||||
|
||||
交付体系(V3):
|
||||
- `GET /api/projects/<pid>/workspace` 工作目录文件列表 | `POST .../workspace/upload` 上传(multipart)| `GET .../workspace/download?path=` 下载 | `DELETE .../workspace?path=` 删除
|
||||
- `POST /api/projects/<pid>/deploy` 网页交付物部署 Demo | `POST .../package` zip 打包 | `POST .../deliver` 邮件交付(Demo 链接 + 附件)
|
||||
- `POST /api/projects/<pid>/complete` 完成项目并交付 | `POST .../notify_deliverer` 手动通知送达者 | `GET /api/projects/<pid>/deliverables` 交付记录
|
||||
- `GET /demo/<pid>/` 公开 Demo 地址(送达者免登录访问)
|
||||
|
||||
## 路线图
|
||||
|
||||
- **V2.1**:Temporal 持久执行、多 Agent 协作接入项目任务、eval 回归对比视图
|
||||
- **V3**:多租户 SaaS 化、工作流画布(拖拽编排)、插件市场
|
||||
- **V3**:多租户 SaaS 化、工作流画布(拖拽编排)、插件市场 ✅ 交付体系 + 精准权限(V3.0)
|
||||
@@ -98,5 +98,8 @@ EMAIL = {
|
||||
'from_name': 'AI Worker 平台',
|
||||
}
|
||||
|
||||
# 公网访问地址(Demo 链接/邮件中的回链基准;留空则用请求 host)
|
||||
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://121.40.164.32:16071')
|
||||
|
||||
# 自动路由:按模型单价升序挑选可用 Worker
|
||||
AUTO_ROUTE_POOL = 'enabled' # enabled | all
|
||||
@@ -16,6 +16,12 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
acceptance_criteria TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'active', -- planning/active/done/archived
|
||||
budget_limit REAL DEFAULT 0, -- 项目预算上限(元),0=不限
|
||||
deliver_email TEXT DEFAULT '', -- 送达者(人)邮箱,新建项目必填
|
||||
deliver_type TEXT DEFAULT 'web', -- 交付物类型 web=网页 / file=文件包
|
||||
deliver_note TEXT DEFAULT '', -- 交付说明
|
||||
workspace_dir TEXT DEFAULT '', -- 项目工作目录(相对 data/ 的目录名)
|
||||
demo_url TEXT DEFAULT '', -- 网页交付物 Demo 访问地址
|
||||
delivered_at INTEGER, -- 最近一次交付/送达时间
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
@@ -258,6 +264,93 @@ CREATE TABLE IF NOT EXISTS enterprise_settings (
|
||||
value TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
-- ===================================================================
|
||||
-- V3 表结构:交付体系(工作目录/交付物/Demo/邮件送达) + 用户授权(项目/Worker 权限)
|
||||
-- ===================================================================
|
||||
CREATE TABLE IF NOT EXISTS project_deliverables (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT DEFAULT 'file', -- file/dir/webpage/package
|
||||
path TEXT DEFAULT '', -- 相对项目工作目录路径 / 打包文件名
|
||||
demo_url TEXT DEFAULT '', -- 网页交付物的 Demo 访问地址
|
||||
size INTEGER DEFAULT 0,
|
||||
note TEXT DEFAULT '',
|
||||
created_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
perm TEXT DEFAULT 'view', -- view 查看 / manage 管理 / admin 管理员
|
||||
created_at INTEGER,
|
||||
UNIQUE(user_id, project_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_workers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
worker_id INTEGER NOT NULL,
|
||||
perm TEXT DEFAULT 'view', -- view 查看 / use 使用(可指派任务)/ manage 管理(可改配置)
|
||||
created_at INTEGER,
|
||||
UNIQUE(user_id, worker_id)
|
||||
);
|
||||
|
||||
-- ===================================================================
|
||||
-- V3.1 表结构:自定义角色(权限功能点)+ Worker 权限组(批量授权)
|
||||
-- ===================================================================
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
perms TEXT DEFAULT '[]', -- JSON: 权限点 id 列表
|
||||
is_builtin INTEGER DEFAULT 0, -- 内置角色(admin/auditor/member)不可删除
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
UNIQUE(user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_perm_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_perm_group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL,
|
||||
worker_id INTEGER NOT NULL,
|
||||
perm TEXT DEFAULT 'view', -- 组内该 Worker 权限(可覆盖组默认)
|
||||
created_at INTEGER,
|
||||
UNIQUE(group_id, worker_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_worker_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
UNIQUE(user_id, group_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deliverables_project ON project_deliverables(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_projects_user ON user_projects(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_projects_project ON user_projects(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_workers_user ON user_workers(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_workers_worker ON user_workers(worker_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wpg_members_group ON worker_perm_group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_uwg_user ON user_worker_groups(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_steps_run ON agent_steps(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_eval_cases_ds ON eval_cases(dataset_id);
|
||||
@@ -282,6 +375,26 @@ def _migrate():
|
||||
if 'depends_on' not in cols:
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN depends_on TEXT DEFAULT '[]'")
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_tasks_depends ON tasks(depends_on)')
|
||||
if 'deleted' not in cols:
|
||||
conn.execute('ALTER TABLE tasks ADD COLUMN deleted INTEGER DEFAULT 0')
|
||||
conn.execute('ALTER TABLE tasks ADD COLUMN deleted_at INTEGER')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted)')
|
||||
# V3:projects 交付字段
|
||||
pcols = {r['name'] for r in conn.execute('PRAGMA table_info(projects)')}
|
||||
for col, ddl in (
|
||||
('deliver_email', "ALTER TABLE projects ADD COLUMN deliver_email TEXT DEFAULT ''"),
|
||||
('deliver_type', "ALTER TABLE projects ADD COLUMN deliver_type TEXT DEFAULT 'web'"),
|
||||
('deliver_note', "ALTER TABLE projects ADD COLUMN deliver_note TEXT DEFAULT ''"),
|
||||
('workspace_dir', "ALTER TABLE projects ADD COLUMN workspace_dir TEXT DEFAULT ''"),
|
||||
('demo_url', "ALTER TABLE projects ADD COLUMN demo_url TEXT DEFAULT ''"),
|
||||
('delivered_at', 'ALTER TABLE projects ADD COLUMN delivered_at INTEGER'),
|
||||
):
|
||||
if col not in pcols:
|
||||
conn.execute(ddl)
|
||||
# V3:老项目补齐工作目录名
|
||||
for r in conn.execute("SELECT id, workspace_dir FROM projects WHERE workspace_dir IS NULL OR workspace_dir=''"):
|
||||
conn.execute('UPDATE projects SET workspace_dir=? WHERE id=?',
|
||||
('project_%d' % r['id'], r['id']))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -337,6 +450,39 @@ def init_db():
|
||||
conn.close()
|
||||
_migrate()
|
||||
migrate_v2()
|
||||
seed_builtin_roles()
|
||||
|
||||
|
||||
# V3.1 内置角色权限点定义(admin 为特殊值 ALL,表示全部权限)
|
||||
BUILTIN_ROLE_PERMS = {
|
||||
'admin': ['*'],
|
||||
# 审计员:全量只读
|
||||
'auditor': ['dashboard.view', 'project.view', 'worker.view', 'agent.view', 'eval.view',
|
||||
'template.view', 'report.view', 'log.view', 'alert.view'],
|
||||
# 成员:基础功能(资源级仍受项目/Worker 授权约束)
|
||||
'member': ['dashboard.view', 'project.view', 'project.create', 'project.deliver',
|
||||
'worker.view', 'agent.view', 'agent.run', 'eval.view', 'eval.run',
|
||||
'template.view', 'report.view', 'log.view', 'alert.view'],
|
||||
}
|
||||
|
||||
|
||||
def seed_builtin_roles():
|
||||
"""幂等:仅首次创建 admin/auditor/member 内置角色(不覆盖管理员后续编辑)"""
|
||||
import json as _json
|
||||
conn = get_conn()
|
||||
try:
|
||||
for name, perms in BUILTIN_ROLE_PERMS.items():
|
||||
r = conn.execute('SELECT id FROM roles WHERE name=?', (name,)).fetchone()
|
||||
if not r:
|
||||
conn.execute(
|
||||
'INSERT INTO roles (name, description, perms, is_builtin, created_at, updated_at) '
|
||||
'VALUES (?,?,?,1,?,?)',
|
||||
(name, {'admin': '超级管理员:全部权限', 'auditor': '审计员:全量只读',
|
||||
'member': '成员:基础功能,资源级按项目/Worker 授权'}[name],
|
||||
_json.dumps(perms), now(), now()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def recover_stale_runs():
|
||||
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
V3 交付体系
|
||||
- 每个项目独立工作目录:data/workspace/project_<id>/(中间产物与交付物隔离存放)
|
||||
- 网页交付物 → data/demo/<id>/ 部署,经 /demo/<id>/ 公开访问(送达者无需登录)
|
||||
- 文件包交付物 → zip 打包到 data/packages/,随邮件附件发送
|
||||
- 送达者通知:项目完成 / 遇到无法绕开的难关时,邮件及时通知 deliver_email
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import shutil
|
||||
import zipfile
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.utils import formataddr
|
||||
|
||||
import db
|
||||
from config import DATA_DIR, EMAIL, PUBLIC_BASE_URL
|
||||
|
||||
WORKSPACE_ROOT = os.path.join(DATA_DIR, 'workspace')
|
||||
DEMO_ROOT = os.path.join(DATA_DIR, 'demo')
|
||||
PACKAGE_ROOT = os.path.join(DATA_DIR, 'packages')
|
||||
|
||||
# 项目维度 blocker 通知去重窗口(秒):同一项目短时间内不重复打扰送达者
|
||||
BLOCKER_DEDUP_SECONDS = 1800
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
for d in (WORKSPACE_ROOT, DEMO_ROOT, PACKAGE_ROOT):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
|
||||
def workspace_path(project):
|
||||
"""项目工作目录绝对路径(不存在则创建)"""
|
||||
pid = project['id'] if isinstance(project, dict) else project
|
||||
d = os.path.join(WORKSPACE_ROOT, f'project_{pid}')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def demo_path(project):
|
||||
"""Demo 部署目录绝对路径"""
|
||||
pid = project['id'] if isinstance(project, dict) else project
|
||||
return os.path.join(DEMO_ROOT, f'project_{pid}')
|
||||
|
||||
|
||||
def package_dir():
|
||||
os.makedirs(PACKAGE_ROOT, exist_ok=True)
|
||||
return PACKAGE_ROOT
|
||||
|
||||
|
||||
def _safe_relpath(relpath):
|
||||
"""路径穿越防护:仅允许工作目录内的相对路径"""
|
||||
relpath = (relpath or '').replace('\\', '/').strip('/')
|
||||
if not relpath:
|
||||
return ''
|
||||
if '..' in relpath.split('/') or relpath.startswith('/'):
|
||||
raise ValueError('非法路径')
|
||||
return relpath
|
||||
|
||||
|
||||
def list_workspace(project):
|
||||
"""递归列出工作目录文件:相对路径 + 类型 + 大小 + 修改时间"""
|
||||
root = workspace_path(project)
|
||||
out = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
# 忽略临时目录
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
||||
for fn in sorted(filenames):
|
||||
if fn.startswith('.'):
|
||||
continue
|
||||
full = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(full, root).replace(os.sep, '/')
|
||||
try:
|
||||
size = os.path.getsize(full)
|
||||
mtime = int(os.path.getmtime(full))
|
||||
except OSError:
|
||||
size, mtime = 0, 0
|
||||
out.append({'path': rel, 'name': fn, 'size': size,
|
||||
'ext': os.path.splitext(fn)[1].lstrip('.').lower(),
|
||||
'mtime': mtime})
|
||||
out.sort(key=lambda x: x['path'])
|
||||
return out
|
||||
|
||||
|
||||
def save_upload(project, file_storage, subdir=''):
|
||||
"""保存上传文件到工作目录,返回相对路径"""
|
||||
fn = os.path.basename(file_storage.filename or '')
|
||||
fn = re.sub(r'[\\/:*?"<>|]', '_', fn).strip()
|
||||
if not fn:
|
||||
raise ValueError('文件名为空')
|
||||
rel = _safe_relpath(subdir)
|
||||
target_dir = os.path.join(workspace_path(project), rel) if rel else workspace_path(project)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
target = os.path.join(target_dir, fn)
|
||||
file_storage.save(target)
|
||||
return (rel + '/' if rel else '') + fn
|
||||
|
||||
|
||||
def delete_workspace_file(project, relpath):
|
||||
rel = _safe_relpath(relpath)
|
||||
if not rel:
|
||||
raise ValueError('请指定要删除的文件')
|
||||
full = os.path.join(workspace_path(project), rel)
|
||||
if not os.path.isfile(full):
|
||||
raise ValueError('文件不存在')
|
||||
os.remove(full)
|
||||
return rel
|
||||
|
||||
|
||||
def demo_url_of(project, base_url=''):
|
||||
"""生成 Demo 访问地址"""
|
||||
base = (base_url or PUBLIC_BASE_URL).rstrip('/')
|
||||
return f'{base}/demo/{project["id"]}/'
|
||||
|
||||
|
||||
def deploy_demo(project, base_url=''):
|
||||
"""把项目工作目录部署为可公开访问的 Demo(网页交付物)
|
||||
- 将工作目录文件复制到 data/demo/project_<id>/
|
||||
- 无 index.html 时生成一个简易索引页
|
||||
- 记录 demo_url 到项目
|
||||
"""
|
||||
src = workspace_path(project)
|
||||
dst = demo_path(project)
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
# 清空旧内容,避免残留文件污染
|
||||
for item in os.listdir(dst):
|
||||
p = os.path.join(dst, item)
|
||||
if os.path.isdir(p):
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
else:
|
||||
os.remove(p)
|
||||
copied = 0
|
||||
for dirpath, dirnames, filenames in os.walk(src):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
||||
rel = os.path.relpath(dirpath, src)
|
||||
if rel == '.':
|
||||
rel = ''
|
||||
for fn in filenames:
|
||||
if fn.startswith('.') or fn.endswith('.zip'):
|
||||
continue
|
||||
sub = os.path.join(dst, rel) if rel else dst
|
||||
os.makedirs(sub, exist_ok=True)
|
||||
shutil.copy2(os.path.join(dirpath, fn), os.path.join(sub, fn))
|
||||
copied += 1
|
||||
index = os.path.join(dst, 'index.html')
|
||||
if not os.path.isfile(index):
|
||||
files = sorted(list_workspace(project), key=lambda x: x['path'])
|
||||
links = '\n'.join(
|
||||
f'<li><a href="{os.path.basename(f["path"])}">{os.path.basename(f["path"])}</a>'
|
||||
f' <small>({f["size"]} B)</small></li>'
|
||||
for f in files if f['ext'] in ('html', 'htm') or '/' not in f['path'])
|
||||
if not links:
|
||||
links = '<li>(工作目录中暂无网页文件)</li>'
|
||||
with open(index, 'w', encoding='utf-8') as fh:
|
||||
fh.write(f'''<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8">
|
||||
<title>{project['name']} · Demo</title>
|
||||
<style>body{{font-family:system-ui;max-width:720px;margin:40px auto;padding:0 16px;color:#333}}
|
||||
h1{{font-size:20px}} li{{margin:8px 0}} a{{color:#2f6fed}}</style></head>
|
||||
<body><h1>📦 {project['name']} · 交付 Demo</h1>
|
||||
<p>本页面由 AI Worker 平台自动生成,展示项目工作目录中的交付文件:</p>
|
||||
<ul>{links}</ul></body></html>''')
|
||||
url = demo_url_of(project, base_url)
|
||||
db.w('UPDATE projects SET demo_url=?, updated_at=? WHERE id=?', (url, db.now(), project['id']))
|
||||
return {'copied': copied, 'demo_url': url}
|
||||
|
||||
|
||||
def package_project(project, name=''):
|
||||
"""把项目工作目录打包为 zip,落盘到 data/packages/,返回 {path, size, relname}"""
|
||||
ensure_dirs()
|
||||
src = workspace_path(project)
|
||||
ts = time.strftime('%Y%m%d_%H%M%S')
|
||||
base = name or f'project_{project["id"]}_deliverable'
|
||||
zip_name = f'{base}_{ts}.zip'
|
||||
zip_path = os.path.join(PACKAGE_ROOT, zip_name)
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for dirpath, dirnames, filenames in os.walk(src):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
||||
for fn in filenames:
|
||||
if fn.startswith('.'):
|
||||
continue
|
||||
full = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(full, src)
|
||||
zf.write(full, os.path.join(os.path.basename(src), rel))
|
||||
size = os.path.getsize(zip_path)
|
||||
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, size, note, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(project['id'], zip_name, 'package', zip_name, size,
|
||||
'交付物打包(zip)', db.now()))
|
||||
return {'path': zip_path, 'size': size, 'name': zip_name}
|
||||
|
||||
|
||||
def record_file_deliverables(project, files):
|
||||
"""把工作目录文件登记为交付物记录"""
|
||||
for f in files:
|
||||
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, size, note, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(project['id'], f['name'], 'file', f['path'], f['size'], '工作目录交付物', db.now()))
|
||||
|
||||
|
||||
def auto_complete_if_ready(project_id, base_url=''):
|
||||
"""项目全部任务完成后自动收尾:置 done + 打包 + 通知送达者。
|
||||
返回 {'ok','msg'} 或 None(未满足条件/异常)。引擎线程与审核接口共用。"""
|
||||
try:
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
|
||||
if not proj or proj['status'] == 'done':
|
||||
return None
|
||||
total = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0', (project_id,))[0]['c']
|
||||
done = db.q('SELECT COUNT(*) c FROM tasks WHERE project_id=? AND status="done" AND deleted=0',
|
||||
(project_id,))[0]['c']
|
||||
if total == 0 or done < total:
|
||||
return None
|
||||
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), project_id))
|
||||
ok, msg = notify_project_complete(proj, base_url)
|
||||
return {'ok': ok, 'msg': msg}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def project_summary(project):
|
||||
"""项目交付摘要:任务统计 + 成本"""
|
||||
rows = db.q('SELECT status, COUNT(*) c FROM tasks WHERE project_id=? AND deleted=0 GROUP BY status',
|
||||
(project['id'],))
|
||||
by = {r['status']: r['c'] for r in rows}
|
||||
total = sum(by.values())
|
||||
done = by.get('done', 0)
|
||||
cost = db.q('SELECT COALESCE(SUM(cost),0) t FROM cost_records WHERE project_id=?',
|
||||
(project['id'],))[0]['t']
|
||||
return {'total': total, 'done': done, 'failed': by.get('failed', 0),
|
||||
'review': by.get('review', 0), 'cost': round(cost, 4)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 邮件发送(支持附件)
|
||||
# ---------------------------------------------------------------------------
|
||||
def send_mail(to_addr, subject, text, attachments=None, html=None):
|
||||
"""发送邮件到任意收件人(送达者),支持附件。返回 (ok, msg)"""
|
||||
if not EMAIL.get('host'):
|
||||
return False, '邮件服务未配置(config.EMAIL.host 为空)'
|
||||
if not to_addr:
|
||||
return False, '收件邮箱为空'
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = formataddr((EMAIL.get('from_name', 'AI Worker 平台'), EMAIL['user']))
|
||||
msg['To'] = to_addr
|
||||
msg['Subject'] = subject
|
||||
if html:
|
||||
msg.attach(MIMEText(html, 'html', 'utf-8'))
|
||||
else:
|
||||
msg.attach(MIMEText(text, 'plain', 'utf-8'))
|
||||
for f in (attachments or []):
|
||||
if not f or not os.path.isfile(f):
|
||||
continue
|
||||
with open(f, 'rb') as fh:
|
||||
subtype = os.path.splitext(f)[1].lstrip('.').lower() or 'octet-stream'
|
||||
part = MIMEApplication(fh.read(), _subtype=subtype)
|
||||
part.add_header('Content-Disposition', 'attachment',
|
||||
filename=('utf-8', '', os.path.basename(f)))
|
||||
msg.attach(part)
|
||||
try:
|
||||
s = smtplib.SMTP(EMAIL['host'], EMAIL['port'], timeout=30)
|
||||
if EMAIL.get('starttls'):
|
||||
s.starttls()
|
||||
if EMAIL.get('user'):
|
||||
s.login(EMAIL['user'], EMAIL['password'])
|
||||
s.sendmail(EMAIL['user'], [to_addr], msg.as_string())
|
||||
s.quit()
|
||||
return True, '已发送'
|
||||
except Exception as e:
|
||||
return False, f'邮件发送失败: {e}'
|
||||
|
||||
|
||||
def _email_body(project, extra=''):
|
||||
p = project
|
||||
lines = [
|
||||
f'项目名称:{p["name"]}',
|
||||
f'项目状态:{ {"planning":"规划中","active":"进行中","done":"已完成","archived":"已归档"}.get(p["status"], p["status"]) }',
|
||||
f'项目目标:{p.get("objective") or "—"}',
|
||||
]
|
||||
if p.get('demo_url'):
|
||||
lines.append(f'在线 Demo(可直接打开查看):{p["demo_url"]}')
|
||||
if extra:
|
||||
lines.append('')
|
||||
lines.append(extra)
|
||||
lines.append('')
|
||||
lines.append('—— 来自 AI Worker 项目管理平台')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def notify_deliverer(project, subject, text, attach=None, base_url=''):
|
||||
"""发邮件给送达者,写交付记录。返回 (ok, msg)"""
|
||||
email = (project.get('deliver_email') or '').strip()
|
||||
if not email:
|
||||
return False, '项目未配置送达者邮箱'
|
||||
ok, msg = send_mail(email, subject, text, attachments=[attach] if attach else None)
|
||||
if ok:
|
||||
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), project['id']))
|
||||
return ok, msg
|
||||
|
||||
|
||||
def notify_blocker(project, task_title, detail):
|
||||
"""项目遇到无法绕开的难关 → 及时邮件通知送达者(同项目限频防打扰)"""
|
||||
email = (project.get('deliver_email') or '').strip()
|
||||
if not email:
|
||||
return
|
||||
# 去重:同项目 30 分钟内只提醒一次(detail 带项目标记)
|
||||
dup = db.q("SELECT COUNT(*) c FROM alerts WHERE type='blocker' AND detail LIKE ? AND created_at>?",
|
||||
(f'[project:{project["id"]}]%', db.now() - BLOCKER_DEDUP_SECONDS))
|
||||
if dup and dup[0]['c'] > 0:
|
||||
return
|
||||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||||
"VALUES ('blocker','warn',?,?,0,?)",
|
||||
(f'项目难关:{project["name"]} · {task_title}',
|
||||
f'[project:{project["id"]}] {detail[:500]}', db.now()))
|
||||
subject = f'⚠️ 项目遇到难关:{project["name"]}'
|
||||
body = _email_body(project, extra=f'任务「{task_title}」遇到无法绕开的难关:\n{detail[:800]}')
|
||||
ok, msg = send_mail(email, subject, body)
|
||||
if ok:
|
||||
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), project['id']))
|
||||
else:
|
||||
# 邮件失败也留痕
|
||||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||||
"VALUES ('notify','warn',?,?,0,?)",
|
||||
(f'难关通知邮件发送失败:{project["name"]}', msg[:300], db.now()))
|
||||
|
||||
|
||||
def notify_project_complete(project, base_url=''):
|
||||
"""项目完成 → 打包 + 邮件送达(含 Demo 链接与附件)。返回 (ok, msg)"""
|
||||
email = (project.get('deliver_email') or '').strip()
|
||||
if not email:
|
||||
return False, '项目未配置送达者邮箱'
|
||||
# 网页交付物:确保已部署 Demo
|
||||
if project.get('deliver_type') == 'web' and not project.get('demo_url'):
|
||||
try:
|
||||
deploy_demo(project, base_url)
|
||||
project = db.q('SELECT * FROM projects WHERE id=?', (project['id'],), one=True)
|
||||
except Exception as e:
|
||||
pass
|
||||
# 打包工作目录
|
||||
attach = None
|
||||
try:
|
||||
pkg = package_project(project)
|
||||
attach = pkg['path']
|
||||
except Exception as e:
|
||||
pkg = None
|
||||
s = project_summary(project)
|
||||
extra = (f'项目已完成 ✅\n任务完成情况:{s["done"]}/{s["total"]}(失败 {s["failed"]})\n'
|
||||
f'累计成本:¥{s["cost"]:.4f}\n交付物打包:{"已生成附件(见邮件附件)" if attach else "无工作目录文件"}')
|
||||
subject = f'✅ 项目完成交付:{project["name"]}'
|
||||
body = _email_body(project, extra=extra)
|
||||
ok, msg = send_mail(email, subject, body, attachments=[attach] if attach else None)
|
||||
if ok:
|
||||
db.w('UPDATE projects SET delivered_at=? WHERE id=?', (db.now(), project['id']))
|
||||
db.w('INSERT INTO project_deliverables (project_id, name, kind, path, demo_url, size, note, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?)',
|
||||
(project['id'], f'完成交付邮件 → {email}', 'email',
|
||||
project.get('demo_url') or '', project.get('demo_url') or '',
|
||||
pkg['size'] if pkg else 0, '项目完成通知(含附件)', db.now()))
|
||||
else:
|
||||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||||
"VALUES ('notify','warn',?,?,0,?)",
|
||||
(f'完成交付邮件失败:{project["name"]}', msg[:300], db.now()))
|
||||
return ok, msg
|
||||
|
||||
|
||||
ensure_dirs()
|
||||
@@ -14,6 +14,20 @@ import llm_gateway
|
||||
import config
|
||||
import rag
|
||||
import notify
|
||||
import delivery
|
||||
|
||||
|
||||
def _notify_failed(task, message):
|
||||
"""任务失败:写告警 + 推送渠道 + 邮件通知送达者(项目难关)"""
|
||||
notify.notify('task_failed', f'任务失败:{task["title"]}',
|
||||
f'项目 #{task["project_id"]} 任务「{task["title"]}」{message}',
|
||||
save_alert=True, level='warn', atype='task_failed')
|
||||
try:
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (task['project_id'],), one=True)
|
||||
if proj and (proj.get('deliver_email') or '').strip():
|
||||
delivery.notify_blocker(proj, task['title'], message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _log(task_id, level, message):
|
||||
@@ -153,7 +167,7 @@ def _build_messages(task, worker):
|
||||
|
||||
def _trigger_downstream(task):
|
||||
"""DAG:任务完成后自动触发所有就绪的下游任务"""
|
||||
rows = db.q('SELECT * FROM tasks WHERE status IN ("todo","failed")')
|
||||
rows = db.q('SELECT * FROM tasks WHERE deleted=0 AND status IN ("todo","failed")')
|
||||
triggered = []
|
||||
for t in rows:
|
||||
deps = _deps(t)
|
||||
@@ -182,9 +196,7 @@ def run_task(task_id):
|
||||
_set_task(task_id, status='failed', error='前置任务未完成:' + '、'.join(blockers),
|
||||
finished_at=db.now())
|
||||
_log(task_id, 'error', '❌ 依赖未满足,无法执行:' + '、'.join(blockers))
|
||||
notify.notify('task_failed', f'任务失败:{task["title"]}',
|
||||
f'项目 #{task["project_id"]} 任务「{task["title"]}」因依赖未完成被拒绝执行:'
|
||||
+ '、'.join(blockers), save_alert=True, level='warn', atype='task_failed')
|
||||
_notify_failed(task, '因依赖未完成被拒绝执行:' + '、'.join(blockers))
|
||||
return
|
||||
|
||||
# 确定 Worker
|
||||
@@ -195,9 +207,7 @@ def run_task(task_id):
|
||||
_set_task(task_id, status='failed', error='指定 Worker 不存在或已停用',
|
||||
finished_at=db.now())
|
||||
_log(task_id, 'error', '指定 Worker 不存在或已停用')
|
||||
notify.notify('worker_alert', f'Worker 异常:任务「{task["title"]}」',
|
||||
f'指定 Worker #{task["worker_id"]} 不存在或已停用', save_alert=True,
|
||||
level='warn', atype='worker_alert')
|
||||
_notify_failed(task, '指定 Worker 不存在或已停用,无法执行')
|
||||
return
|
||||
else:
|
||||
worker = pick_worker_auto(task)
|
||||
@@ -205,9 +215,7 @@ def run_task(task_id):
|
||||
_set_task(task_id, status='failed', error='无可用 Worker(自动路由失败)',
|
||||
finished_at=db.now())
|
||||
_log(task_id, 'error', '自动路由失败:无可用 Worker')
|
||||
notify.notify('worker_alert', f'Worker 异常:任务「{task["title"]}」',
|
||||
'自动路由失败:没有可用的 Worker', save_alert=True,
|
||||
level='warn', atype='worker_alert')
|
||||
_notify_failed(task, '自动路由失败:没有可用的 Worker')
|
||||
return
|
||||
_set_task(task_id, worker_id=worker['id'])
|
||||
_log(task_id, 'info', f'自动路由 → Worker「{worker["name"]}」({worker["provider"]}/{worker["model"]})')
|
||||
@@ -217,15 +225,13 @@ def run_task(task_id):
|
||||
if not ok:
|
||||
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
|
||||
_log(task_id, 'error', reason)
|
||||
notify.notify('budget_alert', f'成本上限拦截:任务「{task["title"]}」', reason,
|
||||
save_alert=True, level='warn', atype='budget')
|
||||
_notify_failed(task, reason)
|
||||
return
|
||||
ok, reason = _check_project_budget(task)
|
||||
if not ok:
|
||||
_set_task(task_id, status='failed', error=reason, finished_at=db.now())
|
||||
_log(task_id, 'error', reason)
|
||||
notify.notify('budget_alert', f'预算拦截:任务「{task["title"]}」', reason,
|
||||
save_alert=True, level='warn', atype='budget')
|
||||
_notify_failed(task, reason)
|
||||
return
|
||||
|
||||
_set_task(task_id, status='running', started_at=db.now(), error='')
|
||||
@@ -239,9 +245,7 @@ def run_task(task_id):
|
||||
except Exception as e:
|
||||
_set_task(task_id, status='failed', error=str(e), finished_at=db.now())
|
||||
_log(task_id, 'error', f'执行失败: {e}')
|
||||
notify.notify('task_failed', f'任务失败:{task["title"]}',
|
||||
f'项目 #{task["project_id"]} 任务「{task["title"]}」执行出错:{str(e)[:300]}',
|
||||
save_alert=True, level='warn', atype='task_failed')
|
||||
_notify_failed(task, f'执行出错:{str(e)[:300]}')
|
||||
return
|
||||
|
||||
_cost_record(task, worker, usage)
|
||||
@@ -272,6 +276,13 @@ def run_task(task_id):
|
||||
|
||||
_budget_alert(task['project_id'])
|
||||
|
||||
if new_status == 'done':
|
||||
# V3:无需审核的任务直接完成后,检查项目是否全部完成 → 自动交付并通知送达者
|
||||
try:
|
||||
delivery.auto_complete_if_ready(task['project_id'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# DAG:触发下游就绪任务
|
||||
downstream = _trigger_downstream(task)
|
||||
for t in downstream:
|
||||
|
||||
+318
-2
@@ -293,9 +293,10 @@ def export_all():
|
||||
"""全量数据导出(JSON)"""
|
||||
tables = ['projects', 'workers', 'tasks', 'task_logs', 'cost_records', 'documents',
|
||||
'agent_runs', 'agent_steps', 'eval_datasets', 'eval_cases', 'eval_runs',
|
||||
'eval_results', 'templates', 'users', 'audit_logs']
|
||||
'eval_results', 'templates', 'users', 'audit_logs',
|
||||
'project_deliverables', 'user_projects', 'user_workers']
|
||||
out = {'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'platform': 'ai-worker-platform', 'version': 'v2.0.0'}
|
||||
'platform': 'ai-worker-platform', 'version': 'v3.1.0'}
|
||||
for t in tables:
|
||||
try:
|
||||
out[t] = db.q(f'SELECT * FROM {t}')
|
||||
@@ -337,3 +338,318 @@ def generate_consent_token():
|
||||
tok = uuid.uuid4().hex[:12]
|
||||
audit('system', 'compliance.consent', '数据使用同意', f'consent_token={tok}')
|
||||
return tok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 授权:用户 ↔ 项目 / 用户 ↔ Worker(精准权限)
|
||||
# ---------------------------------------------------------------------------
|
||||
PERM_LEVEL = {'view': 0, 'use': 1, 'manage': 2, 'admin': 3}
|
||||
PROJECT_PERMS = ('view', 'manage', 'admin')
|
||||
WORKER_PERMS = ('view', 'use', 'manage')
|
||||
|
||||
|
||||
def perm_ok(have, need):
|
||||
"""have 权限是否满足 need 权限(None 视为无权限)"""
|
||||
if not have:
|
||||
return False
|
||||
return PERM_LEVEL.get(have, -1) >= PERM_LEVEL.get(need, 99)
|
||||
|
||||
|
||||
def user_project_perm(user_id, project_id):
|
||||
"""用户在项目上的权限:None / view / manage / admin"""
|
||||
r = db.q('SELECT perm FROM user_projects WHERE user_id=? AND project_id=?',
|
||||
(user_id, project_id), one=True)
|
||||
return r['perm'] if r else None
|
||||
|
||||
|
||||
def user_worker_perm(user_id, worker_id):
|
||||
"""用户在 Worker 上的权限:None / view / use / manage(直接授权 ∪ 权限组授权,取高)"""
|
||||
r = db.q('SELECT perm FROM user_workers WHERE user_id=? AND worker_id=?',
|
||||
(user_id, worker_id), one=True)
|
||||
direct = r['perm'] if r else None
|
||||
grouped = grouped_worker_perm(user_id, worker_id)
|
||||
if direct is None:
|
||||
return grouped
|
||||
if grouped is None:
|
||||
return direct
|
||||
return direct if PERM_LEVEL.get(direct, 0) >= PERM_LEVEL.get(grouped, 0) else grouped
|
||||
|
||||
|
||||
def visible_project_ids(user_id):
|
||||
"""用户可见的项目 id 列表(admin/auditor 返回 None 表示全部)"""
|
||||
u = db.q('SELECT role FROM users WHERE id=?', (user_id,), one=True)
|
||||
if u and u['role'] in ('admin', 'auditor'):
|
||||
return None
|
||||
rows = db.q('SELECT project_id FROM user_projects WHERE user_id=?', (user_id,))
|
||||
return [r['project_id'] for r in rows]
|
||||
|
||||
|
||||
def visible_worker_ids(user_id):
|
||||
"""用户可见的 Worker id 列表 = 直接授权 ∪ 权限组授权(admin/auditor 返回 None 表示全部)"""
|
||||
u = db.q('SELECT role FROM users WHERE id=?', (user_id,), one=True)
|
||||
if u and u['role'] in ('admin', 'auditor'):
|
||||
return None
|
||||
rows = db.q('SELECT worker_id FROM user_workers WHERE user_id=?', (user_id,))
|
||||
ids = {r['worker_id'] for r in rows}
|
||||
ids |= set(grouped_worker_ids(user_id))
|
||||
return sorted(ids)
|
||||
|
||||
|
||||
def set_user_grants(user_id, projects=None, workers=None, roles=None, worker_groups=None):
|
||||
"""批量覆盖用户授权。projects=[{project_id, perm}], workers=[{worker_id, perm}],
|
||||
roles=[role_id], worker_groups=[group_id]。perm 传空/None 表示收回该授权。"""
|
||||
out = {'projects': 0, 'workers': 0, 'roles': 0, 'worker_groups': 0}
|
||||
if projects is not None:
|
||||
db.w('DELETE FROM user_projects WHERE user_id=?', (user_id,))
|
||||
for g in projects:
|
||||
perm = (g.get('perm') or '').strip()
|
||||
if perm not in PROJECT_PERMS:
|
||||
continue
|
||||
pid = int(g.get('project_id') or 0)
|
||||
if not db.q('SELECT id FROM projects WHERE id=?', (pid,), one=True):
|
||||
continue
|
||||
db.w('INSERT INTO user_projects (user_id, project_id, perm, created_at) VALUES (?,?,?,?)',
|
||||
(user_id, pid, perm, db.now()))
|
||||
out['projects'] += 1
|
||||
if workers is not None:
|
||||
db.w('DELETE FROM user_workers WHERE user_id=?', (user_id,))
|
||||
for g in workers:
|
||||
perm = (g.get('perm') or '').strip()
|
||||
if perm not in WORKER_PERMS:
|
||||
continue
|
||||
wid = int(g.get('worker_id') or 0)
|
||||
if not db.q('SELECT id FROM workers WHERE id=?', (wid,), one=True):
|
||||
continue
|
||||
db.w('INSERT INTO user_workers (user_id, worker_id, perm, created_at) VALUES (?,?,?,?)',
|
||||
(user_id, wid, perm, db.now()))
|
||||
out['workers'] += 1
|
||||
if roles is not None:
|
||||
set_user_roles(user_id, roles)
|
||||
out['roles'] = len(roles or [])
|
||||
if worker_groups is not None:
|
||||
db.w('DELETE FROM user_worker_groups WHERE user_id=?', (user_id,))
|
||||
for gid in (worker_groups or []):
|
||||
if db.q('SELECT id FROM worker_perm_groups WHERE id=?', (int(gid),), one=True):
|
||||
db.w('INSERT INTO user_worker_groups (user_id, group_id, created_at) VALUES (?,?,?)',
|
||||
(user_id, int(gid), db.now()))
|
||||
out['worker_groups'] += 1
|
||||
return out
|
||||
|
||||
|
||||
def user_grants(user_id):
|
||||
"""用户现有授权 + 全部可选项目/Worker/角色/权限组,供管理界面展示"""
|
||||
projects = db.q('SELECT p.id, p.name, p.status FROM projects p ORDER BY p.id DESC')
|
||||
workers = db.q('SELECT id, name, provider, model, status FROM workers ORDER BY id DESC')
|
||||
for p in projects:
|
||||
p['perm'] = user_project_perm(user_id, p['id'])
|
||||
for w in workers:
|
||||
w['perm'] = user_worker_perm(user_id, w['id'])
|
||||
roles = db.q('SELECT * FROM roles ORDER BY is_builtin DESC, id')
|
||||
for r in roles:
|
||||
r['perms'] = sorted(role_perms(r))
|
||||
r['bound'] = r['id'] in user_role_ids(user_id)
|
||||
groups = db.q('SELECT * FROM worker_perm_groups ORDER BY id')
|
||||
bound_gids = {g['group_id'] for g in db.q('SELECT group_id FROM user_worker_groups WHERE user_id=?',
|
||||
(user_id,))}
|
||||
for g in groups:
|
||||
g['member_count'] = db.q('SELECT COUNT(*) c FROM worker_perm_group_members WHERE group_id=?',
|
||||
(g['id'],))[0]['c']
|
||||
g['bound'] = g['id'] in bound_gids
|
||||
return {'projects': projects, 'workers': workers, 'roles': roles, 'worker_groups': groups}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3.1 自定义角色(权限功能点)
|
||||
# ---------------------------------------------------------------------------
|
||||
PERM_POINTS = [
|
||||
{'id': 'dashboard.view', 'name': '仪表盘', 'group': '总览'},
|
||||
{'id': 'project.view', 'name': '项目查看', 'group': '项目'},
|
||||
{'id': 'project.create', 'name': '创建项目', 'group': '项目'},
|
||||
{'id': 'project.deliver', 'name': '交付操作(上传/部署/打包/发送/完成)', 'group': '项目'},
|
||||
{'id': 'worker.view', 'name': 'Worker 查看', 'group': 'Worker'},
|
||||
{'id': 'worker.create', 'name': 'Worker 创建', 'group': 'Worker'},
|
||||
{'id': 'worker.manage', 'name': 'Worker 编辑/删除', 'group': 'Worker'},
|
||||
{'id': 'agent.view', 'name': '多Agent协作-查看', 'group': '协作评估'},
|
||||
{'id': 'agent.run', 'name': '多Agent协作-运行', 'group': '协作评估'},
|
||||
{'id': 'eval.view', 'name': '自动评估-查看', 'group': '协作评估'},
|
||||
{'id': 'eval.run', 'name': '自动评估-运行', 'group': '协作评估'},
|
||||
{'id': 'template.view', 'name': '模板市场-查看/应用', 'group': '模板'},
|
||||
{'id': 'template.manage', 'name': '模板市场-管理', 'group': '模板'},
|
||||
{'id': 'report.view', 'name': '成本报表', 'group': '报表日志'},
|
||||
{'id': 'log.view', 'name': '运行日志', 'group': '报表日志'},
|
||||
{'id': 'alert.view', 'name': '告警中心', 'group': '报表日志'},
|
||||
{'id': 'api.manage', 'name': '开放API Token 管理', 'group': '平台管理'},
|
||||
{'id': 'setting.manage', 'name': '通知设置', 'group': '平台管理'},
|
||||
{'id': 'enterprise.manage', 'name': '企业版管理(用户/角色/授权/SSO/审计/合规)', 'group': '平台管理'},
|
||||
]
|
||||
ALL_POINTS = {p['id'] for p in PERM_POINTS}
|
||||
|
||||
|
||||
def role_perms(role):
|
||||
"""角色权限点集合(admin 的 ['*'] 展开为全部)"""
|
||||
try:
|
||||
perms = json.loads(role.get('perms') or '[]')
|
||||
except Exception:
|
||||
perms = []
|
||||
if '*' in perms:
|
||||
return set(ALL_POINTS)
|
||||
return set(perms) & ALL_POINTS
|
||||
|
||||
|
||||
def user_role_ids(user_id):
|
||||
return [r['role_id'] for r in db.q('SELECT role_id FROM user_roles WHERE user_id=?', (user_id,))]
|
||||
|
||||
|
||||
def user_perms(user_id):
|
||||
"""用户有效权限点集合 = 内置角色基础(roles 表,可编辑)∪ 自定义角色并集;admin 返回全部"""
|
||||
u = db.q('SELECT username, role FROM users WHERE id=?', (user_id,), one=True)
|
||||
if not u:
|
||||
return set()
|
||||
# 内置角色基础:从 roles 表读取(管理员可编辑内置角色权限点)
|
||||
base_role = db.q('SELECT * FROM roles WHERE name=?', (u['role'],), one=True)
|
||||
s = role_perms(base_role) if base_role else set()
|
||||
for rid in user_role_ids(user_id):
|
||||
r = db.q('SELECT * FROM roles WHERE id=?', (rid,), one=True)
|
||||
if r:
|
||||
s |= role_perms(r)
|
||||
return s
|
||||
|
||||
|
||||
def has_perm(user_id, point):
|
||||
return point in user_perms(user_id)
|
||||
|
||||
|
||||
def list_roles():
|
||||
rows = db.q('SELECT * FROM roles ORDER BY is_builtin DESC, id')
|
||||
for r in rows:
|
||||
r['perms'] = sorted(role_perms(r))
|
||||
r['user_count'] = db.q('SELECT COUNT(*) c FROM user_roles WHERE role_id=?', (r['id'],))[0]['c']
|
||||
return rows
|
||||
|
||||
|
||||
def create_role(name, description='', perms=None):
|
||||
name = (name or '').strip()
|
||||
if not name:
|
||||
return None, '角色名不能为空'
|
||||
if db.q('SELECT id FROM roles WHERE name=?', (name,), one=True):
|
||||
return None, '角色已存在'
|
||||
rid = db.w('INSERT INTO roles (name, description, perms, is_builtin, created_at, updated_at) '
|
||||
'VALUES (?,?,?,0,?,?)',
|
||||
(name, description, json.dumps(sorted(set(perms or []) & ALL_POINTS)), db.now(), db.now()))
|
||||
return rid, None
|
||||
|
||||
|
||||
def update_role(role_id, data):
|
||||
role = db.q('SELECT * FROM roles WHERE id=?', (role_id,), one=True)
|
||||
if not role:
|
||||
return None, '角色不存在'
|
||||
if 'name' in data and (data['name'] or '').strip():
|
||||
nm = data['name'].strip()
|
||||
dup = db.q('SELECT id FROM roles WHERE name=? AND id!=?', (nm, role_id), one=True)
|
||||
if dup:
|
||||
return None, '角色名已存在'
|
||||
db.w('UPDATE roles SET name=? WHERE id=?', (nm, role_id))
|
||||
if 'description' in data:
|
||||
db.w('UPDATE roles SET description=? WHERE id=?', (data['description'], role_id))
|
||||
if 'perms' in data:
|
||||
db.w('UPDATE roles SET perms=? WHERE id=?',
|
||||
(json.dumps(sorted(set(data['perms'] or []) & ALL_POINTS)), role_id))
|
||||
db.w('UPDATE roles SET updated_at=? WHERE id=?', (db.now(), role_id))
|
||||
return role_id, None
|
||||
|
||||
|
||||
def delete_role(role_id):
|
||||
role = db.q('SELECT * FROM roles WHERE id=?', (role_id,), one=True)
|
||||
if not role:
|
||||
return None, '角色不存在'
|
||||
if role['is_builtin']:
|
||||
return None, '内置角色不可删除(可编辑权限点)'
|
||||
db.w('DELETE FROM user_roles WHERE role_id=?', (role_id,))
|
||||
db.w('DELETE FROM roles WHERE id=?', (role_id,))
|
||||
return role_id, None
|
||||
|
||||
|
||||
def set_user_roles(user_id, role_ids):
|
||||
"""覆盖设置用户绑定的自定义角色"""
|
||||
db.w('DELETE FROM user_roles WHERE user_id=?', (user_id,))
|
||||
for rid in (role_ids or []):
|
||||
if db.q('SELECT id FROM roles WHERE id=?', (rid,), one=True):
|
||||
db.w('INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?,?,?)',
|
||||
(user_id, rid, db.now()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3.1 Worker 权限组(批量授权)
|
||||
# ---------------------------------------------------------------------------
|
||||
def list_worker_groups():
|
||||
rows = db.q('SELECT * FROM worker_perm_groups ORDER BY id')
|
||||
for g in rows:
|
||||
g['members'] = db.q('SELECT w.id AS worker_id, w.name AS worker_name, m.perm '
|
||||
'FROM worker_perm_group_members m JOIN workers w ON w.id=m.worker_id '
|
||||
'WHERE m.group_id=? ORDER BY m.id', (g['id'],))
|
||||
g['users'] = db.q('SELECT u.id AS user_id, u.username, u.display_name '
|
||||
'FROM user_worker_groups ug JOIN users u ON u.id=ug.user_id '
|
||||
'WHERE ug.group_id=? ORDER BY u.id', (g['id'],))
|
||||
return rows
|
||||
|
||||
|
||||
def save_worker_group(group_id, data):
|
||||
"""新建/更新 Worker 权限组:members=[{worker_id, perm}],users=[user_id]"""
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return None, '组名不能为空'
|
||||
if group_id:
|
||||
g = db.q('SELECT * FROM worker_perm_groups WHERE id=?', (group_id,), one=True)
|
||||
if not g:
|
||||
return None, '权限组不存在'
|
||||
db.w('UPDATE worker_perm_groups SET name=?, description=?, updated_at=? WHERE id=?',
|
||||
(name, data.get('description', ''), db.now(), group_id))
|
||||
else:
|
||||
group_id = db.w('INSERT INTO worker_perm_groups (name, description, created_at, updated_at) '
|
||||
'VALUES (?,?,?,?)', (name, data.get('description', ''), db.now(), db.now()))
|
||||
# 成员
|
||||
db.w('DELETE FROM worker_perm_group_members WHERE group_id=?', (group_id,))
|
||||
for m in (data.get('members') or []):
|
||||
wid = int(m.get('worker_id') or 0)
|
||||
perm = (m.get('perm') or 'view').strip()
|
||||
if perm not in WORKER_PERMS:
|
||||
perm = 'view'
|
||||
if db.q('SELECT id FROM workers WHERE id=?', (wid,), one=True):
|
||||
db.w('INSERT INTO worker_perm_group_members (group_id, worker_id, perm, created_at) '
|
||||
'VALUES (?,?,?,?)', (group_id, wid, perm, db.now()))
|
||||
# 授权用户
|
||||
db.w('DELETE FROM user_worker_groups WHERE group_id=?', (group_id,))
|
||||
for uid in (data.get('users') or []):
|
||||
if db.q('SELECT id FROM users WHERE id=?', (int(uid),), one=True):
|
||||
db.w('INSERT INTO user_worker_groups (user_id, group_id, created_at) VALUES (?,?,?)',
|
||||
(int(uid), group_id, db.now()))
|
||||
return group_id, None
|
||||
|
||||
|
||||
def delete_worker_group(group_id):
|
||||
db.w('DELETE FROM worker_perm_group_members WHERE group_id=?', (group_id,))
|
||||
db.w('DELETE FROM user_worker_groups WHERE group_id=?', (group_id,))
|
||||
db.w('DELETE FROM worker_perm_groups WHERE id=?', (group_id,))
|
||||
return group_id, None
|
||||
|
||||
|
||||
def grouped_worker_perm(user_id, worker_id):
|
||||
"""用户通过权限组获得的 Worker 权限(取最高)"""
|
||||
rows = db.q(
|
||||
'SELECT m.perm FROM worker_perm_group_members m '
|
||||
'JOIN user_worker_groups ug ON ug.group_id=m.group_id '
|
||||
'WHERE ug.user_id=? AND m.worker_id=?', (user_id, worker_id))
|
||||
best = None
|
||||
for r in rows:
|
||||
if best is None or PERM_LEVEL.get(r['perm'], 0) > PERM_LEVEL.get(best, 0):
|
||||
best = r['perm']
|
||||
return best
|
||||
|
||||
|
||||
def grouped_worker_ids(user_id):
|
||||
"""用户通过权限组获得的 worker_id 列表(含组内权限)"""
|
||||
rows = db.q(
|
||||
'SELECT DISTINCT m.worker_id FROM worker_perm_group_members m '
|
||||
'JOIN user_worker_groups ug ON ug.group_id=m.group_id '
|
||||
'WHERE ug.user_id=?', (user_id,))
|
||||
return [r['worker_id'] for r in rows]
|
||||
+926
-119
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -11,18 +11,18 @@
|
||||
<aside id="sidebar">
|
||||
<div class="logo">🤖 <span>AI Worker</span><small>项目管理平台</small></div>
|
||||
<nav>
|
||||
<a href="#/dashboard" data-route="dashboard">📊 仪表盘</a>
|
||||
<a href="#/projects" data-route="projects">📁 项目</a>
|
||||
<a href="#/workers" data-route="workers">🧑💻 AI Worker</a>
|
||||
<a href="#/agents" data-route="agents">🤝 多 Agent 协作</a>
|
||||
<a href="#/eval" data-route="eval">🎯 自动评估</a>
|
||||
<a href="#/templates" data-route="templates">🧩 模板市场</a>
|
||||
<a href="#/reports" data-route="reports">💰 成本报表</a>
|
||||
<a href="#/logs" data-route="logs">📜 运行日志</a>
|
||||
<a href="#/alerts" data-route="alerts">🚨 告警中心 <span id="alert-badge" class="badge st-review" style="display:none;float:right">0</span></a>
|
||||
<a href="#/api" data-route="api">🔌 开放 API</a>
|
||||
<a href="#/enterprise" data-route="enterprise">🏢 企业版</a>
|
||||
<a href="#/settings" data-route="settings">⚙️ 通知设置</a>
|
||||
<a href="#/dashboard" data-route="dashboard" data-perm="dashboard.view">📊 仪表盘</a>
|
||||
<a href="#/projects" data-route="projects" data-perm="project.view">📁 项目</a>
|
||||
<a href="#/workers" data-route="workers" data-perm="worker.view">🧑💻 AI Worker</a>
|
||||
<a href="#/agents" data-route="agents" data-perm="agent.view">🤝 多 Agent 协作</a>
|
||||
<a href="#/eval" data-route="eval" data-perm="eval.view">🎯 自动评估</a>
|
||||
<a href="#/templates" data-route="templates" data-perm="template.view">🧩 模板市场</a>
|
||||
<a href="#/reports" data-route="reports" data-perm="report.view">💰 成本报表</a>
|
||||
<a href="#/logs" data-route="logs" data-perm="log.view">📜 运行日志</a>
|
||||
<a href="#/alerts" data-route="alerts" data-perm="alert.view">🚨 告警中心 <span id="alert-badge" class="badge st-review" style="display:none;float:right">0</span></a>
|
||||
<a href="#/api" data-route="api" data-perm="api.manage">🔌 开放 API</a>
|
||||
<a href="#/enterprise" data-route="enterprise" data-perm="enterprise.manage">🏢 企业版</a>
|
||||
<a href="#/settings" data-route="settings" data-perm="setting.manage">⚙️ 通知设置</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<div id="conn-state" class="conn ok">● 服务正常</div>
|
||||
|
||||
+31
-4
@@ -132,14 +132,41 @@ tr:hover td{background:var(--panel2)}
|
||||
.tabs a{padding:9px 16px;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;font-size:13px}
|
||||
.tabs a.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||||
.dag-box{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;height:520px;position:relative;touch-action:none;user-select:none;cursor:grab}
|
||||
.dag-box svg{display:block;transition:transform .05s linear}
|
||||
.dag-box svg{display:block;transition:transform .05s linear;overflow:visible}
|
||||
.dag-node{stroke:var(--border);stroke-width:1.5;rx:10;cursor:grab}
|
||||
.dag-node:hover{stroke:var(--accent)}
|
||||
.dag-node rect{stroke-width:1.5;rx:10}
|
||||
.dag-label{font-size:12px;fill:#ffffff;text-anchor:middle;pointer-events:none}
|
||||
.dag-sub{font-size:10px;fill:rgba(255,255,255,.78);text-anchor:middle;pointer-events:none}
|
||||
.dag-edge{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow)}
|
||||
.dag-label{font-size:13px;font-weight:600;fill:#ffffff;stroke:none;text-anchor:middle;pointer-events:none}
|
||||
.dag-sub{font-size:11px;fill:rgba(255,255,255,.82);stroke:none;text-anchor:middle;pointer-events:none}
|
||||
.dag-edge{stroke:var(--border);stroke-width:1.5;fill:none;marker-end:url(#arrow);pointer-events:none}
|
||||
.dag-edge.act{stroke:var(--accent)}
|
||||
.dag-edge-g{cursor:pointer}
|
||||
.dag-edge-hit{fill:none;stroke:transparent;stroke-width:14;pointer-events:stroke;cursor:pointer}
|
||||
.dag-edge-g:hover .dag-edge{stroke:var(--accent)}
|
||||
.dag-edge.sel{stroke:var(--accent);stroke-width:2.5}
|
||||
.dag-edge-x{opacity:0;fill:var(--danger);stroke:#fff;stroke-width:1.5;pointer-events:none;cursor:pointer;transition:opacity .12s}
|
||||
.dag-edge-xt{opacity:0;font-size:10px;fill:#fff;text-anchor:middle;pointer-events:none;transition:opacity .12s}
|
||||
.dag-edge-g:hover .dag-edge-x,.dag-edge-g:hover .dag-edge-xt,.dag-edge-g.sel .dag-edge-x,.dag-edge-g.sel .dag-edge-xt{opacity:1}
|
||||
.dag-edge-g:hover .dag-edge-x,.dag-edge-g.sel .dag-edge-x{pointer-events:all}
|
||||
.dag-port{fill:var(--panel2);stroke:var(--accent);stroke-width:2;opacity:0;pointer-events:all;transition:opacity .15s}
|
||||
.dag-node:hover .dag-port{opacity:1}
|
||||
.dag-connecting .dag-port{opacity:1}
|
||||
.dag-port.out{cursor:crosshair}
|
||||
.dag-port.in{cursor:crosshair}
|
||||
.dag-node.drop-ok rect{stroke:var(--accent2);stroke-width:2.5}
|
||||
.dag-node.drop-bad rect{stroke:var(--danger);stroke-width:2.5}
|
||||
.dag-ghost{stroke:var(--accent);stroke-width:2;stroke-dasharray:6 4;fill:none;marker-end:url(#arrow);pointer-events:none}
|
||||
.dag-ghost.bad{stroke:var(--danger)}
|
||||
.dag-edge-actions{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:rgba(23,30,46,.92);border:1px solid var(--border);border-radius:8px;padding:4px 8px;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;z-index:7;backdrop-filter:blur(2px);box-shadow:0 4px 14px rgba(0,0,0,.35)}
|
||||
.dag-edge-actions span{color:var(--muted);white-space:nowrap}
|
||||
.dag-edge-actions b{color:var(--accent)}
|
||||
.dag-trash{position:absolute;left:14px;bottom:14px;background:rgba(255,107,107,.1);border:1.5px dashed var(--danger);color:var(--danger);border-radius:10px;padding:8px 14px;font-size:12px;cursor:pointer;z-index:5;display:flex;align-items:center;gap:6px;backdrop-filter:blur(2px)}
|
||||
.dag-trash:hover,.dag-trash.hover{background:rgba(255,107,107,.25);border-style:solid}
|
||||
.dag-trash .cnt{background:var(--danger);color:#fff;border-radius:8px;padding:0 6px;font-size:11px}
|
||||
.dag-zoom{position:absolute;right:14px;top:14px;background:rgba(23,30,46,.88);border:1px solid var(--border);border-radius:8px;padding:4px 10px;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;z-index:6}
|
||||
.dag-zoom b{color:var(--text);font-weight:600;min-width:42px;text-align:center}
|
||||
.dag-zoom button{background:var(--panel2);border:1px solid var(--border);color:var(--text);border-radius:5px;padding:2px 8px;font-size:11px;cursor:pointer}
|
||||
.dag-zoom button:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.alert-row{border-left:3px solid var(--border);padding:12px 14px;margin-bottom:8px;background:var(--panel2);border-radius:0 10px 10px 0;cursor:pointer}
|
||||
.alert-row.unread{border-left-color:var(--warn)}
|
||||
.alert-row.critical{border-left-color:var(--danger)}
|
||||
|
||||
Reference in New Issue
Block a user