Files
ai-worker-platform/delivery.py
T
hz4th_coder 8be01b5af7 V3.2 送达者=用户 + 用户邮箱必填
- users 表新增 email 列:所有用户(含 admin)必须有邮箱,新建/编辑用户强制校验
- 项目送达者从'邮箱字符串'改为'绑定用户(deliver_user_id)':创建项目必须选择平台用户
- 送达通知实时读取用户最新邮箱:用户改邮箱 → 其送达的所有项目自动跟随,无需改项目
- 项目进行中可随时更换送达者用户(编辑项目 → 送达者下拉)
- 迁移:存量用户自动补 username@tphai.com 默认邮箱;旧项目按 deliver_email 匹配回填 deliver_user_id
- 新增 /api/users 轻量用户列表接口(登录即可,供选送达者);deliver_email 降级为快照缓存
- 前端:项目表单送达者改为用户下拉选择;用户管理增加邮箱列;交付页显示送达者用户名+邮箱
2026-08-13 16:21:35 +08:00

380 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()