# -*- coding: utf-8 -*- """ V3 交付体系 - 每个项目独立工作目录:data/workspace/project_/(中间产物与交付物隔离存放) - 网页交付物 → data/demo// 部署,经 /demo// 公开访问(送达者无需登录) - 文件包交付物 → 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_/ - 无 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'
  • {os.path.basename(f["path"])}' f' ({f["size"]} B)
  • ' for f in files if f['ext'] in ('html', 'htm') or '/' not in f['path']) if not links: links = '
  • (工作目录中暂无网页文件)
  • ' with open(index, 'w', encoding='utf-8') as fh: fh.write(f''' {project['name']} · Demo

    📦 {project['name']} · 交付 Demo

    本页面由 AI Worker 平台自动生成,展示项目工作目录中的交付文件:

      {links}
    ''') 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 文档:全文本定位 ;自动剥离 markdown 代码围栏与前置叙述。返回纯 HTML 或 None。""" if not text or not isinstance(text, str): return None import re as _re m = _re.search(r']', text, _re.I) if not m: return None html = text[m.start():] end = html.lower().rfind('') 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()