- 项目级验收:全部任务完成后进入「待验收」,邮件通知负责人,验收通过才算完成;公开验收链接 /review/<token> 免登录一键通过/打回(打回必填原因) - 打回自动返工:自动生成含负责人意见的返工任务,AI主管立即重做并再次提交验收;平台内也可验收 - 真实网页交付:任务HTML产出自动落盘工作目录(剥离围栏/前置叙述),Demo展示真实页面;返工产出覆盖入口页;老项目已回填 - 事件记录面板:合并AI主管动态+任务日志+交付记录,可展开/收起(记忆状态),任务事件可点击定位 - 邮件通道修复:补 Date/Message-ID 头(amavisd 拒收 invalid header section 根因),打包附件/通知恢复送达;notify.py 改标准 MIME - 规划重试:WBS 拆解失败自动重试3次,仍失败邮件通知负责人 - 项目状态新增 review(待验收),全端展示
542 lines
24 KiB
Python
542 lines
24 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)
|
||
# V3.4:done=已验收完成;review=已提交待验收(避免 engine 与 autostart 双触发重复通知)
|
||
if not proj:
|
||
return None
|
||
if proj['status'] in ('done', 'review'):
|
||
return {'ok': True, 'msg': '项目已处于待验收/完成状态'}
|
||
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
|
||
# V3.4:AI 不能替负责人拍板 → 全部任务完成后进入「待负责人验收」
|
||
if proj.get('review_required', 1):
|
||
return enter_review(proj, base_url)
|
||
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 enter_review(project, base_url=''):
|
||
"""V3.4 全部任务完成 → 项目置「待验收」:生成验收令牌 + 邮件通知负责人。
|
||
返回 {'ok','msg'}。"""
|
||
import secrets
|
||
proj = project if isinstance(project, dict) else db.q('SELECT * FROM projects WHERE id=?', (project,), one=True)
|
||
if not proj:
|
||
return {'ok': False, 'msg': '项目不存在'}
|
||
token = (proj.get('review_token') or '').strip() or secrets.token_urlsafe(16)
|
||
db.w('UPDATE projects SET status="review", review_token=?, updated_at=? WHERE id=?',
|
||
(token, db.now(), proj['id']))
|
||
base = (base_url or PUBLIC_BASE_URL).rstrip('/')
|
||
link = f'{base}/review/{token}'
|
||
s = project_summary(proj)
|
||
demo = proj.get('demo_url') or (demo_url_of(proj, base) if proj.get('deliver_type') == 'web' else '')
|
||
extra = (
|
||
f'🤖 AI 团队已全部完工({s["done"]}/{s["total"]} 个任务,失败 {s["failed"]},成本 ¥{s["cost"]:.4f})\n'
|
||
f'现在是您(项目负责人)验收的时候了!请打开下面的链接审核交付成果:\n'
|
||
f'👉 验收链接:{link}\n'
|
||
+ (f'👀 在线 Demo:{demo}\n' if demo else '')
|
||
+ '验收通过后项目才算正式完成,并自动打包交付。'
|
||
)
|
||
email = deliver_email_of(proj)
|
||
ok, msg = True, '已通知负责人验收'
|
||
if email:
|
||
ok, msg = send_mail(email, f'🔔 待您验收:{proj["name"]}(AI 团队已完工)',
|
||
_email_body(proj, extra=extra))
|
||
if not ok:
|
||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||
"VALUES ('notify','warn',?,?,0,?)",
|
||
(f'验收通知邮件失败:{proj["name"]}', msg[:300], db.now()))
|
||
else:
|
||
ok, msg = False, '项目未配置送达者邮箱,无法通知验收'
|
||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||
"VALUES ('notify','warn',?,?,0,?)",
|
||
(f'验收通知失败:{proj["name"]} 未配置送达者', '', db.now()))
|
||
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
|
||
(proj['id'], 'info', f'项目进入待验收状态,已通知负责人{("("+email+")") if email else ""}:{link}', db.now()))
|
||
return {'ok': ok, 'msg': msg}
|
||
|
||
|
||
def approve_project(project_id, base_url='', reviewer='负责人'):
|
||
"""V3.4 负责人验收通过 → 部署 Demo/打包 + 完成交付邮件 + 项目置 done"""
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
|
||
if not proj:
|
||
return {'ok': False, 'msg': '项目不存在'}
|
||
if proj['status'] != 'review':
|
||
return {'ok': False, 'msg': f'项目当前状态「{proj["status"]}」不可验收'}
|
||
db.w('UPDATE projects SET status="done", updated_at=? WHERE id=?', (db.now(), project_id))
|
||
ok, msg = notify_project_complete(proj, base_url)
|
||
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
|
||
(project_id, 'success', f'负责人({reviewer})验收通过 ✅,项目正式完成并交付', db.now()))
|
||
return {'ok': ok, 'msg': msg}
|
||
|
||
|
||
def reject_project(project_id, reason=''):
|
||
"""V3.4 负责人打回 → 项目回到进行中,记录原因,自动生成返工任务交给 AI 主管重做"""
|
||
proj = db.q('SELECT * FROM projects WHERE id=?', (project_id,), one=True)
|
||
if not proj:
|
||
return {'ok': False, 'msg': '项目不存在'}
|
||
db.w('UPDATE projects SET status="active", review_token=\'\', updated_at=? WHERE id=?',
|
||
(db.now(), project_id))
|
||
msg = f'负责人打回:{reason or "未填写原因"}'
|
||
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
|
||
(project_id, 'warn', msg, db.now()))
|
||
db.w("INSERT INTO alerts (type, level, title, detail, read, created_at) "
|
||
"VALUES ('reject','warn',?,?,0,?)",
|
||
(f'项目被打回:{proj["name"]}', msg[:500], db.now()))
|
||
# 自动生成返工任务(AI 主管按负责人意见重做),上层负责触发 autostart.launch
|
||
team = db.q('SELECT w.* FROM project_team_workers t JOIN workers w ON w.id=t.worker_id '
|
||
'WHERE t.project_id=? ORDER BY t.rowid', (project_id,))
|
||
wid = team[0]['id'] if team else None
|
||
desc = (f'负责人验收后提出修改意见,请按以下意见返工整个交付物:\n'
|
||
f'【负责人意见】{reason or "未填写"}\n\n'
|
||
f'项目目标:{proj.get("objective") or proj.get("name") or ""}\n'
|
||
f'验收标准:{proj.get("acceptance_criteria") or "—"}\n'
|
||
f'请直接输出修改后的完整成果(网页项目请输出完整 HTML 文档)。')
|
||
tid = db.w('INSERT INTO tasks (project_id, worker_id, title, description, priority, '
|
||
'review_required, deadline, depends_on, created_at, updated_at) '
|
||
'VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||
(project_id, wid, '返工:按负责人意见修改', desc, 'high', 0, '', '[]',
|
||
db.now(), db.now()))
|
||
db.w('INSERT INTO task_logs (task_id, level, message, created_at) VALUES (?,?,?,?)',
|
||
(tid, 'warn', f'负责人打回,AI 主管返工任务已创建:{reason[:120]}', db.now()))
|
||
return {'ok': True, 'msg': msg, 'rework_task_id': tid}
|
||
|
||
|
||
def _strip_fence(text):
|
||
"""去掉 LLM 输出外层 markdown 代码围栏(```html ... ```),还原纯 HTML"""
|
||
t = text.strip()
|
||
if not t.startswith('```'):
|
||
return text
|
||
lines = t.split('\n')
|
||
if lines and lines[0].startswith('```'):
|
||
lines = lines[1:]
|
||
if lines and lines[-1].strip() == '```':
|
||
lines = lines[:-1]
|
||
return '\n'.join(lines).strip()
|
||
|
||
|
||
def _extract_html(text):
|
||
"""从 LLM 输出中提取完整 HTML 文档:全文本定位 <!doctype html/<html 标签,
|
||
截取到 </html>;自动剥离 markdown 代码围栏与前置叙述。返回纯 HTML 或 None。"""
|
||
if not text or not isinstance(text, str):
|
||
return None
|
||
import re as _re
|
||
m = _re.search(r'<!doctype\s+html', text, _re.I)
|
||
if not m:
|
||
m = _re.search(r'<html[\s>]', text, _re.I)
|
||
if not m:
|
||
return None
|
||
html = text[m.start():]
|
||
end = html.lower().rfind('</html>')
|
||
if end > 0:
|
||
html = html[:end + 7]
|
||
# 剥离可能残留的围栏(如 ```html 前缀 / 结尾 ```)
|
||
html = _strip_fence(html)
|
||
return html.strip()
|
||
|
||
|
||
def save_task_output(task, text):
|
||
"""V3.4 任务产出若为完整网页文档 → 落盘到项目工作目录,供 Demo 真实展示。
|
||
返回保存的文件名列表。"""
|
||
content = _extract_html(text)
|
||
if content is None:
|
||
return []
|
||
saved = []
|
||
root = workspace_path(task['project_id'])
|
||
title = (task.get('title') or '').strip()
|
||
is_rework = '返工' in title
|
||
index = os.path.join(root, 'index.html')
|
||
index_bad = False
|
||
if os.path.isfile(index):
|
||
try:
|
||
with open(index, 'r', encoding='utf-8') as fh:
|
||
index_bad = fh.read().lstrip().startswith('```')
|
||
except Exception:
|
||
index_bad = True
|
||
if not os.path.isfile(index) or index_bad or is_rework:
|
||
fn = 'index.html'
|
||
else:
|
||
safe = re.sub(r'[\\/:*?"<>|\s]+', '_', title or f'task{task["id"]}')[:40]
|
||
fn = f'{safe}-{task["id"]}.html'
|
||
try:
|
||
with open(os.path.join(root, fn), 'w', encoding='utf-8') as fh:
|
||
fh.write(content)
|
||
saved.append(fn)
|
||
except Exception:
|
||
pass
|
||
return saved
|
||
|
||
|
||
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)
|
||
注意:邮件头必须带 Date/Message-ID,否则 mail.tphai.com 的 amavisd 内容过滤器
|
||
会以 "invalid header section / Missing required header field: Date" 退信。"""
|
||
if not EMAIL.get('host'):
|
||
return False, '邮件服务未配置(config.EMAIL.host 为空)'
|
||
if not to_addr:
|
||
return False, '收件邮箱为空'
|
||
from email.utils import formatdate, make_msgid
|
||
msg = MIMEMultipart()
|
||
msg['From'] = formataddr((EMAIL.get('from_name', 'AI Worker 平台'), EMAIL['user']))
|
||
msg['To'] = to_addr
|
||
msg['Subject'] = subject
|
||
msg['Date'] = formatdate(localtime=True)
|
||
msg['Message-ID'] = make_msgid(domain='tphai.com')
|
||
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()
|