- users 表新增 email 列:所有用户(含 admin)必须有邮箱,新建/编辑用户强制校验 - 项目送达者从'邮箱字符串'改为'绑定用户(deliver_user_id)':创建项目必须选择平台用户 - 送达通知实时读取用户最新邮箱:用户改邮箱 → 其送达的所有项目自动跟随,无需改项目 - 项目进行中可随时更换送达者用户(编辑项目 → 送达者下拉) - 迁移:存量用户自动补 username@tphai.com 默认邮箱;旧项目按 deliver_email 匹配回填 deliver_user_id - 新增 /api/users 轻量用户列表接口(登录即可,供选送达者);deliver_email 降级为快照缓存 - 前端:项目表单送达者改为用户下拉选择;用户管理增加邮箱列;交付页显示送达者用户名+邮箱
380 lines
16 KiB
Python
380 lines
16 KiB
Python
# -*- 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 deliver_email_of(project):
|
||
"""送达者邮箱:优先取绑定的用户(deliver_user_id)最新邮箱;无绑定则回退旧快照"""
|
||
uid = project.get('deliver_user_id')
|
||
if uid:
|
||
u = db.q('SELECT email FROM users WHERE id=?', (uid,), one=True)
|
||
if u and (u.get('email') or '').strip():
|
||
return u['email'].strip()
|
||
return (project.get('deliver_email') or '').strip()
|
||
|
||
|
||
def notify_deliverer(project, subject, text, attach=None, base_url=''):
|
||
"""发邮件给送达者,写交付记录。返回 (ok, msg)"""
|
||
email = deliver_email_of(project)
|
||
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 = deliver_email_of(project)
|
||
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 = deliver_email_of(project)
|
||
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()
|