Files
ai-worker-platform/delivery.py
T
hz4th_coder 7c4cbf8994 V3.4 负责人验收制+邮件修复+事件面板+真实网页交付
- 项目级验收:全部任务完成后进入「待验收」,邮件通知负责人,验收通过才算完成;公开验收链接 /review/<token> 免登录一键通过/打回(打回必填原因)
- 打回自动返工:自动生成含负责人意见的返工任务,AI主管立即重做并再次提交验收;平台内也可验收
- 真实网页交付:任务HTML产出自动落盘工作目录(剥离围栏/前置叙述),Demo展示真实页面;返工产出覆盖入口页;老项目已回填
- 事件记录面板:合并AI主管动态+任务日志+交付记录,可展开/收起(记忆状态),任务事件可点击定位
- 邮件通道修复:补 Date/Message-ID 头(amavisd 拒收 invalid header section 根因),打包附件/通知恢复送达;notify.py 改标准 MIME
- 规划重试:WBS 拆解失败自动重试3次,仍失败邮件通知负责人
- 项目状态新增 review(待验收),全端展示
2026-08-14 13:33:59 +08:00

542 lines
24 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)
# V3.4done=已验收完成;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()