diff --git a/backend/app/agendas/outcome_tags.py b/backend/app/agendas/outcome_tags.py index a878a0f..a269bb2 100644 --- a/backend/app/agendas/outcome_tags.py +++ b/backend/app/agendas/outcome_tags.py @@ -1,19 +1,28 @@ from __future__ import annotations +import re from dataclasses import dataclass +from datetime import date from typing import Literal +from app.domain.enums import ActionStatus from app.errors import AppError OutcomeKind = Literal["decision", "action", "question"] +_BRACKET_RE = re.compile(r"^\[([^\]]+)\]") +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + @dataclass(frozen=True) class TaggedOutcome: kind: OutcomeKind content: str source_tag_key: str + status: ActionStatus = ActionStatus.open + owner_name: str | None = None + due_date: date | None = None _TAGS: tuple[tuple[OutcomeKind, str], ...] = ( @@ -23,8 +32,100 @@ class TaggedOutcome: ) +def parse_action_metadata( + raw_payload: str, +) -> tuple[str, ActionStatus, str | None, date | None]: + """Parse [status][@owner][due_date] from the beginning of raw action content.""" + text = raw_payload.strip() + status = ActionStatus.open + owner: str | None = None + due_date_val: date | None = None + + while True: + text = text.strip() + match = _BRACKET_RE.match(text) + if not match: + break + token = match.group(1).strip() + text = text[match.end() :] + + if token.startswith("@"): + owner = token[1:].strip() + elif _DATE_RE.match(token): + try: + due_date_val = date.fromisoformat(token) + except ValueError: + pass + elif token.lower() in ("todo", "open"): + status = ActionStatus.open + elif token.lower() in ("in_progress", "doing", "progress"): + status = ActionStatus.in_progress + elif token.lower() in ("done", "completed", "finish", "finished"): + status = ActionStatus.done + elif token.lower() in ("canceled", "cancelled"): + status = ActionStatus.canceled + + return text.strip(), status, owner, due_date_val + + +def format_action_tag( + *, + content: str, + status: ActionStatus | str = ActionStatus.open, + owner: str | None = None, + due_date: date | str | None = None, +) -> str: + """Format an ActionItem into the tag syntax @行动:[status][@owner][due_date] content.""" + status_str = status.value if hasattr(status, "value") else str(status) + status_token = ( + "done" + if status_str == "done" + else ( + "in_progress" + if status_str == "in_progress" + else ("canceled" if status_str == "canceled" else "todo") + ) + ) + parts = [f"[{status_token}]"] + if owner: + clean_owner = owner.lstrip("@").strip() + if clean_owner: + parts.append(f"[@{clean_owner}]") + if due_date: + due_str = due_date.isoformat() if hasattr(due_date, "isoformat") else str(due_date).strip() + if due_str: + parts.append(f"[{due_str}]") + return f"@行动:{''.join(parts)} {content.strip()}" + + +def rewrite_action_in_notes( + notes_markdown: str, + source_tag_key: str, + new_tag_line: str, +) -> str: + """Rewrite the line corresponding to source_tag_key in notes_markdown.""" + try: + target_index = int(source_tag_key.split(":", 1)[1]) + except (IndexError, ValueError): + return notes_markdown + + lines = notes_markdown.splitlines(keepends=True) + action_count = 0 + for idx, raw_line in enumerate(lines): + line = raw_line.strip() + if line.startswith("@行动:"): + if action_count == target_index: + leading_ws_len = len(raw_line) - len(raw_line.lstrip()) + leading = raw_line[:leading_ws_len] + ending = "\n" if raw_line.endswith("\n") else "" + lines[idx] = f"{leading}{new_tag_line}{ending}" + return "".join(lines) + action_count += 1 + return notes_markdown + + def parse_outcome_tags(markdown: str) -> list[TaggedOutcome]: - """Parse the only three supported, one-per-line agenda outcome tags.""" + """Parse the supported, one-per-line agenda outcome tags.""" counters: dict[OutcomeKind, int] = { "decision": 0, "action": 0, @@ -40,14 +141,30 @@ def parse_outcome_tags(markdown: str) -> list[TaggedOutcome]: if matched is None: continue kind, prefix = matched - content = line[len(prefix) :].strip() - if not content or any(tag in content for _, tag in _TAGS): + raw_payload = line[len(prefix) :].strip() + if not raw_payload or any(tag in raw_payload for _, tag in _TAGS): raise AppError( 422, "invalid_agenda_outcome_tag", "议题成果标签必须每行填写一项非空内容", details={"line": line_number}, ) + + status = ActionStatus.open + owner_name = None + due_date_val = None + if kind == "action": + content, status, owner_name, due_date_val = parse_action_metadata(raw_payload) + if not content or any(tag in content for _, tag in _TAGS): + raise AppError( + 422, + "invalid_agenda_outcome_tag", + "议题成果标签必须每行填写一项非空内容", + details={"line": line_number}, + ) + else: + content = raw_payload + source_tag_key = f"{kind}:{counters[kind]}" counters[kind] += 1 result.append( @@ -55,6 +172,10 @@ def parse_outcome_tags(markdown: str) -> list[TaggedOutcome]: kind=kind, content=content, source_tag_key=source_tag_key, + status=status, + owner_name=owner_name, + due_date=due_date_val, ) ) return result + diff --git a/backend/app/agendas/service.py b/backend/app/agendas/service.py index 9c7035d..4c92244 100644 --- a/backend/app/agendas/service.py +++ b/backend/app/agendas/service.py @@ -21,7 +21,7 @@ ) from app.auth.models import User, UserStatus from app.collaboration.activity import ActivityRecorder -from app.domain.enums import AgendaStatus, MeetingStatus, OpenQuestionStatus +from app.domain.enums import ActionStatus, AgendaStatus, MeetingStatus, OpenQuestionStatus from app.domain.versioning import StaleCheck, require_version, resolve_stale from app.errors import AppError from app.meetings.models import Meeting @@ -73,6 +73,18 @@ def get(self, item_id: str) -> AgendaItem: raise AppError(404, "agenda_item_not_found", "议题不存在") return item + def _resolve_owner_identifier(self, identifier: str | None) -> str | None: + if not identifier: + return None + user = self.session.scalar( + select(User).where( + (User.username == identifier) + | (User.display_name == identifier) + | (User.id == identifier) + ) + ) + return user.id if user else None + def _reconcile_derived_outcomes( self, item: AgendaItem, tags: list[TaggedOutcome], actor: User ) -> None: @@ -122,6 +134,7 @@ def _reconcile_derived_outcomes( ) ) elif tag.kind == "action": + owner_id = self._resolve_owner_identifier(tag.owner_name) self.session.add( ActionItem( project_id=meeting.project_id, @@ -130,6 +143,10 @@ def _reconcile_derived_outcomes( source_agenda_item_id=item.id, source_tag_key=tag.source_tag_key, content=tag.content, + status=tag.status, + owner_user_id=owner_id, + due_date=tag.due_date, + completed_at=utcnow() if tag.status == ActionStatus.done else None, created_by=actor.id, ) ) @@ -165,6 +182,24 @@ def _reconcile_derived_outcomes( if getattr(row, field) != value: setattr(row, field, value) changed = True + + if tag.kind == "action": + if tag.owner_name: + resolved_owner = self._resolve_owner_identifier(tag.owner_name) + if row.owner_user_id != resolved_owner: + row.owner_user_id = resolved_owner + changed = True + if tag.due_date is not None and row.due_date != tag.due_date: + row.due_date = tag.due_date + changed = True + if tag.status != row.status: + row.status = tag.status + if tag.status == ActionStatus.done and not row.completed_at: + row.completed_at = utcnow() + elif tag.status != ActionStatus.done and row.completed_at: + row.completed_at = None + changed = True + if changed: row.version += 1 diff --git a/backend/app/attachments/router.py b/backend/app/attachments/router.py index e183c77..9b1bb04 100644 --- a/backend/app/attachments/router.py +++ b/backend/app/attachments/router.py @@ -101,9 +101,26 @@ def attachment_delete_allowed( ) -def serialize(item: Attachment, *, can_delete: bool = False) -> dict[str, Any]: +from pydantic import BaseModel, Field + + +class ChunkInitRequest(BaseModel): + filename: str + total_size: int = Field(gt=0) + total_chunks: int = Field(gt=0) + sha256: str | None = None + + +def serialize( + item: Attachment, + *, + can_delete: bool = False, + sha256: str | None = None, +) -> dict[str, Any]: result = serialize_attachment_ref(item) result["can_delete"] = can_delete + if sha256: + result["sha256"] = sha256 return result @@ -143,7 +160,10 @@ async def upload_attachment( target = require_target(session, target_type, target_id) require_target_access(session, target, user, contribute=True) storage = request.app.state.attachment_storage - stored_name, final_path, size = await storage.save(target_type, target_id, file) + expected_sha256 = request.headers.get("X-Content-SHA256") or request.query_params.get("sha256") + stored_name, final_path, size, computed_sha256 = await storage.save( + target_type, target_id, file, expected_sha256=expected_sha256 + ) detected_image = sniff_inline_image(final_path) attachment = Attachment( target_type=target_type, @@ -171,6 +191,7 @@ async def upload_attachment( "size": attachment.size, "target_type": attachment.target_type, "target_id": attachment.target_id, + "sha256": computed_sha256, }, ) session.commit() @@ -180,7 +201,149 @@ async def upload_attachment( raise session.refresh(attachment) _ = attachment.creator - return utc_response(serialize(attachment, can_delete=True), status_code=201) + return utc_response( + serialize(attachment, can_delete=True, sha256=computed_sha256), status_code=201 + ) + + +@router.post("/{target_type}/{target_id}/chunks/init", status_code=201) +def init_chunk_upload( + target_type: str, + target_id: str, + payload: ChunkInitRequest, + request: Request, + user: User = Depends(current_user), + session: Session = Depends(get_session), +) -> dict[str, Any]: + target = require_target(session, target_type, target_id) + require_target_access(session, target, user, contribute=True) + storage = request.app.state.attachment_storage + session_id = storage.init_chunk_session( + target_type=target_type, + target_id=target_id, + filename=payload.filename, + total_size=payload.total_size, + total_chunks=payload.total_chunks, + expected_sha256=payload.sha256, + ) + return utc_response({"session_id": session_id, "uploaded_chunks": []}, status_code=201) + + +@router.post("/{target_type}/{target_id}/chunks/{session_id}") +async def upload_chunk( + target_type: str, + target_id: str, + session_id: str, + request: Request, + chunk_index: int = File(...), + chunk: UploadFile = File(...), + user: User = Depends(current_user), + session: Session = Depends(get_session), +) -> dict[str, Any]: + target = require_target(session, target_type, target_id) + require_target_access(session, target, user, contribute=True) + storage = request.app.state.attachment_storage + size = await storage.save_chunk( + target_type=target_type, + target_id=target_id, + session_id=session_id, + chunk_index=chunk_index, + upload=chunk, + ) + return utc_response({ + "session_id": session_id, + "chunk_index": chunk_index, + "bytes_received": size, + "status": "uploaded", + }) + + +@router.get("/{target_type}/{target_id}/chunks/{session_id}") +def get_chunk_upload_status( + target_type: str, + target_id: str, + session_id: str, + request: Request, + user: User = Depends(current_user), + session: Session = Depends(get_session), +) -> dict[str, Any]: + target = require_target(session, target_type, target_id) + require_target_access(session, target, user, contribute=False) + storage = request.app.state.attachment_storage + status = storage.get_chunk_session(target_type, target_id, session_id) + return utc_response(status) + + +@router.post("/{target_type}/{target_id}/chunks/{session_id}/complete", status_code=201) +def complete_chunk_upload( + target_type: str, + target_id: str, + session_id: str, + request: Request, + user: User = Depends(current_user), + session: Session = Depends(get_session), +) -> dict[str, Any]: + target = require_target(session, target_type, target_id) + require_target_access(session, target, user, contribute=True) + storage = request.app.state.attachment_storage + stored_name, final_path, size, computed_sha256, filename = ( + storage.complete_chunk_session(target_type, target_id, session_id) + ) + detected_image = sniff_inline_image(final_path) + attachment = Attachment( + target_type=target_type, + target_id=target_id, + original_name=Path(filename).name[:255], + stored_name=stored_name, + mime_type=detected_image or "application/octet-stream", + size=size, + attachment_type="image" if detected_image else "file", + created_by=user.id, + ) + session.add(attachment) + try: + session.flush() + project_id, meeting_id = activity_context(target) + ActivityRecorder(session).record( + project_id=project_id, + meeting_id=meeting_id, + actor_user_id=user.id, + event_type="attachment.uploaded", + subject_type="attachment", + subject_id=attachment.id, + payload={ + "filename": attachment.original_name, + "size": attachment.size, + "target_type": attachment.target_type, + "target_id": attachment.target_id, + "sha256": computed_sha256, + }, + ) + session.commit() + except Exception: + session.rollback() + final_path.unlink(missing_ok=True) + raise + session.refresh(attachment) + _ = attachment.creator + return utc_response( + serialize(attachment, can_delete=True, sha256=computed_sha256), status_code=201 + ) + + +@router.delete("/{target_type}/{target_id}/chunks/{session_id}", status_code=204) +def abort_chunk_upload( + target_type: str, + target_id: str, + session_id: str, + request: Request, + user: User = Depends(current_user), + session: Session = Depends(get_session), +) -> None: + target = require_target(session, target_type, target_id) + require_target_access(session, target, user, contribute=True) + storage = request.app.state.attachment_storage + storage.abort_chunk_session(target_type, target_id, session_id) def attachment_file(request: Request, item: Attachment) -> Path: diff --git a/backend/app/attachments/storage.py b/backend/app/attachments/storage.py index 5218163..52347c7 100644 --- a/backend/app/attachments/storage.py +++ b/backend/app/attachments/storage.py @@ -1,8 +1,11 @@ +import hashlib +import json import os import shutil import tempfile import uuid from pathlib import Path +from typing import Any from app.errors import AppError @@ -47,14 +50,19 @@ def attachment_path( return target async def save( - self, target_type: str, target_id: str, upload - ) -> tuple[str, Path, int]: + self, + target_type: str, + target_id: str, + upload, + expected_sha256: str | None = None, + ) -> tuple[str, Path, int, str]: target_dir = self.target_dir(target_type, target_id) target_dir.mkdir(parents=True, exist_ok=True) suffix = Path(upload.filename or "file").suffix[:16] stored_name = f"{uuid.uuid4()}{suffix}" final_path = self.attachment_path(target_type, target_id, stored_name) size = 0 + hasher = hashlib.sha256() fd, temp_name = tempfile.mkstemp(dir=target_dir, prefix=".upload-") try: with os.fdopen(fd, "wb") as stream: @@ -66,13 +74,161 @@ async def save( "attachment_too_large", "单个附件不能超过 20 MB", ) + hasher.update(chunk) stream.write(chunk) + computed_sha256 = hasher.hexdigest() + if expected_sha256: + if computed_sha256.lower() != expected_sha256.strip().lower(): + raise AppError(400, "checksum_mismatch", "附件 SHA-256 校验不匹配") os.replace(temp_name, final_path) except Exception: Path(temp_name).unlink(missing_ok=True) final_path.unlink(missing_ok=True) raise - return stored_name, final_path, size + return stored_name, final_path, size, computed_sha256 + + def chunk_session_dir(self, target_type: str, target_id: str, session_id: str) -> Path: + target_dir = self.target_dir(target_type, target_id) + session_dir = (target_dir / f".chunks-{session_id}").resolve() + if target_dir not in session_dir.parents: + raise AppError(400, "invalid_path", "附件分片路径无效") + return session_dir + + def init_chunk_session( + self, + target_type: str, + target_id: str, + filename: str, + total_size: int, + total_chunks: int, + expected_sha256: str | None = None, + ) -> str: + if total_size > self.max_bytes: + raise AppError(413, "attachment_too_large", "单个附件不能超过 20 MB") + if total_chunks <= 0: + raise AppError(400, "invalid_chunks", "分片数量必须大于 0") + + session_id = str(uuid.uuid4()) + session_dir = self.chunk_session_dir(target_type, target_id, session_id) + session_dir.mkdir(parents=True, exist_ok=True) + meta = { + "session_id": session_id, + "filename": Path(filename).name[:255], + "total_size": total_size, + "total_chunks": total_chunks, + "expected_sha256": expected_sha256, + } + (session_dir / "meta.json").write_text(json.dumps(meta), encoding="utf-8") + return session_id + + async def save_chunk( + self, + target_type: str, + target_id: str, + session_id: str, + chunk_index: int, + upload, + ) -> int: + session_dir = self.chunk_session_dir(target_type, target_id, session_id) + meta_file = session_dir / "meta.json" + if not meta_file.is_file(): + raise AppError(404, "chunk_session_not_found", "分片上传会话不存在或已过期") + meta = json.loads(meta_file.read_text(encoding="utf-8")) + if not (0 <= chunk_index < meta["total_chunks"]): + raise AppError(400, "invalid_chunk_index", "分片索引超出范围") + + chunk_path = session_dir / f"chunk_{chunk_index:05d}.part" + size = 0 + fd, temp_name = tempfile.mkstemp(dir=session_dir, prefix=".chunk-") + try: + with os.fdopen(fd, "wb") as stream: + while chunk := await upload.read(64 * 1024): + size += len(chunk) + stream.write(chunk) + os.replace(temp_name, chunk_path) + except Exception: + Path(temp_name).unlink(missing_ok=True) + chunk_path.unlink(missing_ok=True) + raise + return size + + def get_chunk_session( + self, target_type: str, target_id: str, session_id: str + ) -> dict[str, Any]: + session_dir = self.chunk_session_dir(target_type, target_id, session_id) + meta_file = session_dir / "meta.json" + if not meta_file.is_file(): + raise AppError(404, "chunk_session_not_found", "分片上传会话不存在或已过期") + meta = json.loads(meta_file.read_text(encoding="utf-8")) + uploaded_chunks = [] + for i in range(meta["total_chunks"]): + if (session_dir / f"chunk_{i:05d}.part").is_file(): + uploaded_chunks.append(i) + return { + "session_id": session_id, + "filename": meta["filename"], + "total_size": meta["total_size"], + "total_chunks": meta["total_chunks"], + "uploaded_chunks": uploaded_chunks, + "complete": len(uploaded_chunks) == meta["total_chunks"], + } + + def complete_chunk_session( + self, target_type: str, target_id: str, session_id: str + ) -> tuple[str, Path, int, str, str]: + session_dir = self.chunk_session_dir(target_type, target_id, session_id) + meta_file = session_dir / "meta.json" + if not meta_file.is_file(): + raise AppError(404, "chunk_session_not_found", "分片上传会话不存在或已过期") + meta = json.loads(meta_file.read_text(encoding="utf-8")) + total_chunks = meta["total_chunks"] + + # Check all chunks + for i in range(total_chunks): + if not (session_dir / f"chunk_{i:05d}.part").is_file(): + raise AppError(400, "incomplete_chunks", f"分片 {i} 尚未上传") + + filename = meta["filename"] + suffix = Path(filename).suffix[:16] + stored_name = f"{uuid.uuid4()}{suffix}" + final_path = self.attachment_path(target_type, target_id, stored_name) + + target_dir = self.target_dir(target_type, target_id) + fd, temp_name = tempfile.mkstemp(dir=target_dir, prefix=".assembled-") + hasher = hashlib.sha256() + size = 0 + try: + with os.fdopen(fd, "wb") as outfile: + for i in range(total_chunks): + chunk_path = session_dir / f"chunk_{i:05d}.part" + with chunk_path.open("rb") as infile: + while piece := infile.read(64 * 1024): + size += len(piece) + if size > self.max_bytes: + raise AppError( + 413, + "attachment_too_large", + "单个附件不能超过 20 MB", + ) + hasher.update(piece) + outfile.write(piece) + computed_sha256 = hasher.hexdigest() + expected = meta.get("expected_sha256") + if expected and computed_sha256.lower() != expected.strip().lower(): + raise AppError(400, "checksum_mismatch", "附件 SHA-256 校验不匹配") + os.replace(temp_name, final_path) + shutil.rmtree(session_dir, ignore_errors=True) + except Exception: + Path(temp_name).unlink(missing_ok=True) + final_path.unlink(missing_ok=True) + raise + + return stored_name, final_path, size, computed_sha256, filename + + def abort_chunk_session(self, target_type: str, target_id: str, session_id: str) -> None: + session_dir = self.chunk_session_dir(target_type, target_id, session_id) + shutil.rmtree(session_dir, ignore_errors=True) def remove_target(self, target_type: str, target_id: str) -> None: shutil.rmtree(self.target_dir(target_type, target_id), ignore_errors=True) + diff --git a/backend/app/calendar/__init__.py b/backend/app/calendar/__init__.py new file mode 100644 index 0000000..4c1361b --- /dev/null +++ b/backend/app/calendar/__init__.py @@ -0,0 +1 @@ +# MeetFlow Calendar Module diff --git a/backend/app/calendar/router.py b/backend/app/calendar/router.py new file mode 100644 index 0000000..aa0d3a3 --- /dev/null +++ b/backend/app/calendar/router.py @@ -0,0 +1,148 @@ +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, Depends, Request, Response +from itsdangerous import BadSignature, SignatureExpired, URLSafeSerializer +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from app.auth.dependencies import current_user +from app.auth.models import User, UserStatus +from app.database import get_session +from app.domain.enums import ActionStatus, MeetingStatus +from app.errors import AppError +from app.meetings.models import Meeting, MeetingParticipant +from app.outcomes.models import ActionItem + +router = APIRouter(prefix="/api/calendar", tags=["calendar"]) +CALENDAR_SALT = "meetflow-calendar-feed-v1" + + +def get_serializer(request: Request) -> URLSafeSerializer: + secret = getattr(request.app.state.settings, "app_secret_key", "default-meetflow-secret") + return URLSafeSerializer(secret, salt=CALENDAR_SALT) + + +def generate_ics_feed(user: User, actions: list[ActionItem], meetings: list[Meeting]) -> str: + now_stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//MeetFlow//Calendar//CN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + f"X-WR-CALNAME:MeetFlow - {user.display_name}的待办日程", + "X-WR-TIMEZONE:UTC", + ] + + for action in actions: + if not action.due_date: + continue + due_str = action.due_date.strftime("%Y%m%d") + created_stamp = (action.created_at or datetime.now(timezone.utc)).strftime("%Y%m%dT%H%M%SZ") + lines.extend([ + "BEGIN:VEVENT", + f"UID:action-{action.id}@meetflow", + f"DTSTAMP:{now_stamp}", + f"CREATED:{created_stamp}", + f"DTSTART;VALUE=DATE:{due_str}", + f"DTEND;VALUE=DATE:{due_str}", + f"SUMMARY:[待办] {action.content}", + f"DESCRIPTION:状态: {action.status.value}\\n优先级: {action.priority.value}", + "STATUS:CONFIRMED", + "END:VEVENT", + ]) + + for meeting in meetings: + if not meeting.scheduled_start: + continue + start_stamp = meeting.scheduled_start.strftime("%Y%m%dT%H%M%SZ") + end_stamp = ( + meeting.scheduled_end.strftime("%Y%m%dT%H%M%SZ") + if meeting.scheduled_end + else start_stamp + ) + created_stamp = (meeting.created_at or datetime.now(timezone.utc)).strftime("%Y%m%dT%H%M%SZ") + purpose = (meeting.purpose_markdown or "").replace("\n", "\\n") + lines.extend([ + "BEGIN:VEVENT", + f"UID:meeting-{meeting.id}@meetflow", + f"DTSTAMP:{now_stamp}", + f"CREATED:{created_stamp}", + f"DTSTART:{start_stamp}", + f"DTEND:{end_stamp}", + f"SUMMARY:[会议] {meeting.title}", + f"DESCRIPTION:会议议题与目的:\\n{purpose}", + "STATUS:CONFIRMED", + "END:VEVENT", + ]) + + lines.append("END:VCALENDAR") + return "\r\n".join(lines) + "\r\n" + + +@router.get("/feed-url") +def get_calendar_feed_url( + request: Request, + user: User = Depends(current_user), +) -> dict[str, Any]: + serializer = get_serializer(request) + token = serializer.dumps({"uid": user.id}) + feed_url = f"/api/calendar/{token}.ics" + return { + "token": token, + "feed_url": feed_url, + } + + +@router.get("/{token}.ics") +def export_calendar_ics( + token: str, + request: Request, + session: Session = Depends(get_session), +) -> Response: + serializer = get_serializer(request) + try: + data = serializer.loads(token) + user_id = data.get("uid") + except (BadSignature, SignatureExpired): + raise AppError(404, "calendar_token_invalid", "日历订阅令牌无效或已过期") + + user = session.get(User, user_id) + if not user or user.status != UserStatus.ACTIVE: + raise AppError(404, "user_not_found", "用户不存在或已被禁用") + + actions = list( + session.scalars( + select(ActionItem) + .where( + ActionItem.owner_user_id == user.id, + ActionItem.due_date.isnot(None), + ActionItem.status.in_([ActionStatus.open, ActionStatus.in_progress]), + ) + .order_by(ActionItem.due_date.asc()) + ) + ) + + participant_meetings = list( + session.scalars( + select(Meeting) + .join(MeetingParticipant, MeetingParticipant.meeting_id == Meeting.id) + .where( + MeetingParticipant.user_id == user.id, + Meeting.status.in_([MeetingStatus.draft, MeetingStatus.ready, MeetingStatus.in_progress]), + Meeting.scheduled_start.isnot(None), + ) + .order_by(Meeting.scheduled_start.asc()) + ) + ) + + ics_body = generate_ics_feed(user, actions, participant_meetings) + return Response( + content=ics_body, + media_type="text/calendar; charset=utf-8", + headers={ + "Content-Disposition": f"inline; filename=meetflow-{user.username}.ics", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + ) diff --git a/backend/app/database.py b/backend/app/database.py index be7b6ff..b47b28c 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -9,6 +9,63 @@ from sqlalchemy.orm import DeclarativeBase, Session +import functools +import inspect +import time +import sqlite3 +from sqlalchemy.exc import OperationalError + + +def retry_on_db_lock( + max_retries: int = 5, + initial_backoff: float = 0.05, + backoff_factor: float = 2.0, +): + """Decorator to retry operations encountering SQLite locks with exponential backoff.""" + def decorator(fn): + if inspect.iscoroutinefunction(fn): + @functools.wraps(fn) + async def async_wrapper(*args, **kwargs): + retries = 0 + backoff = initial_backoff + while True: + try: + return await fn(*args, **kwargs) + except (OperationalError, sqlite3.OperationalError) as exc: + is_lock = any( + msg in str(exc).lower() + for msg in ("database is locked", "busy", "lock") + ) + if is_lock and retries < max_retries: + retries += 1 + time.sleep(backoff) + backoff *= backoff_factor + continue + raise + return async_wrapper + else: + @functools.wraps(fn) + def sync_wrapper(*args, **kwargs): + retries = 0 + backoff = initial_backoff + while True: + try: + return fn(*args, **kwargs) + except (OperationalError, sqlite3.OperationalError) as exc: + is_lock = any( + msg in str(exc).lower() + for msg in ("database is locked", "busy", "lock") + ) + if is_lock and retries < max_retries: + retries += 1 + time.sleep(backoff) + backoff *= backoff_factor + continue + raise + return sync_wrapper + return decorator + + class Base(DeclarativeBase): pass @@ -66,6 +123,9 @@ def _configure_sqlite(connection, _record) -> None: cursor = connection.cursor() cursor.execute("PRAGMA foreign_keys=ON") cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA cache_size=-64000") + cursor.execute("PRAGMA mmap_size=268435456") cursor.execute("PRAGMA busy_timeout=5000") cursor.close() diff --git a/backend/app/main.py b/backend/app/main.py index 79f2fb7..000baf3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from app.attachments.router import router as attachments_router from app.attachments.storage import AttachmentStorage from app.auth.router import admin_router, router as auth_router +from app.calendar.router import router as calendar_router from app.auth.service import AuthService from app.config import Settings from app.collaboration.router import comments_router, router as collaboration_router @@ -111,6 +112,7 @@ async def lifespan(_app: FastAPI): app.include_router(collaboration_router) app.include_router(comments_router) app.include_router(inbox_router) + app.include_router(calendar_router) @app.middleware("http") async def reject_cross_origin_writes(request: Request, call_next): diff --git a/backend/app/meetings/router.py b/backend/app/meetings/router.py index 4ff49eb..bb9cd1c 100644 --- a/backend/app/meetings/router.py +++ b/backend/app/meetings/router.py @@ -249,3 +249,89 @@ def add_amendment( serialize_amendment(service.add_amendment(meeting_id, payload, user)), status_code=201, ) + + +from typing import Literal +from pydantic import BaseModel +from app.collaboration.activity import ActivityRecorder +from app.time_utils import utcnow +from app.webhooks.adapter import ( + dispatch_webhook, + format_dingtalk_card, + format_feishu_card, + format_generic_card, +) + + +class MeetingWebhookNotifyRequest(BaseModel): + webhook_url: str + webhook_type: Literal["feishu", "dingtalk", "generic"] = "feishu" + secret: str | None = None + + +@router.post("/api/meetings/{meeting_id}/webhook-notify") +async def notify_meeting_webhook( + meeting_id: str, + payload: MeetingWebhookNotifyRequest, + user: User = Depends(current_user), + session: Session = Depends(get_session), +) -> dict[str, Any]: + meeting = WorkspaceAccess(session).require_meeting_view(meeting_id, user) + service = MeetingService(session) + detail = service.meeting_detail(meeting_id, user) + project_name = meeting.project.name if meeting.project else "未指定项目" + completed_time_str = ( + meeting.completed_at.strftime("%Y-%m-%d %H:%M:%S UTC") + if meeting.completed_at + else utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") + ) + summary_text = detail.get("summary_markdown") or detail.get("purpose_markdown") or "" + decisions = [d["title"] for d in detail.get("decisions", [])] + actions = [a["content"] for a in detail.get("actions", [])] + + if payload.webhook_type == "feishu": + card = format_feishu_card( + meeting_title=meeting.title, + project_name=project_name, + completed_at_str=completed_time_str, + summary=summary_text, + decisions=decisions, + actions=actions, + ) + elif payload.webhook_type == "dingtalk": + card = format_dingtalk_card( + meeting_title=meeting.title, + project_name=project_name, + completed_at_str=completed_time_str, + summary=summary_text, + decisions=decisions, + actions=actions, + ) + else: + card = format_generic_card( + meeting_title=meeting.title, + project_name=project_name, + completed_at_str=completed_time_str, + summary=summary_text, + decisions=decisions, + actions=actions, + ) + + res = await dispatch_webhook( + webhook_url=payload.webhook_url, + webhook_type=payload.webhook_type, + payload=card, + secret=payload.secret, + ) + ActivityRecorder(session).record( + project_id=meeting.project_id, + meeting_id=meeting.id, + actor_user_id=user.id, + event_type="meeting.webhook_sent", + subject_type="meeting", + subject_id=meeting.id, + payload={"webhook_type": payload.webhook_type, "webhook_url": payload.webhook_url}, + ) + session.commit() + return utc_response({"status": "ok", "response": res, "webhook_type": payload.webhook_type}) + diff --git a/backend/app/outcomes/service.py b/backend/app/outcomes/service.py index 113fa4a..3ce17d2 100644 --- a/backend/app/outcomes/service.py +++ b/backend/app/outcomes/service.py @@ -10,6 +10,7 @@ from sqlalchemy.orm.exc import StaleDataError from app.agendas.models import AgendaItem +from app.agendas.outcome_tags import format_action_tag, rewrite_action_in_notes from app.time_utils import as_utc, utcnow from app.validation import fetch_users, require_active from app.auth.models import User, UserStatus @@ -510,8 +511,6 @@ def update_action( require_active(actor) action = self._action(action_id) self._require_project_contribution(action.project_id, actor) - if action.source_agenda_item_id is not None: - raise AppError(409, "derived_outcome_read_only", "议题备注生成的成果不可直接编辑") require_version(payload.expected_version, action.version) previous_owner_user_id = action.owner_user_id requested = payload.model_dump(exclude={"expected_version"}, exclude_unset=True) @@ -531,6 +530,28 @@ def update_action( elif previous_status == ActionStatus.done: action.completed_at = None if changes: + if action.source_agenda_item_id and action.source_tag_key: + agenda_item = self.session.get(AgendaItem, action.source_agenda_item_id) + if agenda_item and agenda_item.notes_markdown: + owner_name = None + if action.owner_user_id: + owner_user = self.session.get(User, action.owner_user_id) + if owner_user: + owner_name = owner_user.username or owner_user.display_name + new_tag = format_action_tag( + content=action.content, + status=action.status, + owner=owner_name, + due_date=action.due_date, + ) + updated_notes = rewrite_action_in_notes( + agenda_item.notes_markdown, + action.source_tag_key, + new_tag, + ) + if updated_notes != agenda_item.notes_markdown: + agenda_item.notes_markdown = updated_notes + agenda_item.version += 1 meeting_versions = self._touch_meetings(actor, action.meeting_id) action.version += 1 event_type = "action.updated" diff --git a/backend/app/webhooks/__init__.py b/backend/app/webhooks/__init__.py new file mode 100644 index 0000000..f3e8d62 --- /dev/null +++ b/backend/app/webhooks/__init__.py @@ -0,0 +1 @@ +# MeetFlow Webhooks Module diff --git a/backend/app/webhooks/adapter.py b/backend/app/webhooks/adapter.py new file mode 100644 index 0000000..5920238 --- /dev/null +++ b/backend/app/webhooks/adapter.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import time +from typing import Any, Literal +from urllib.parse import quote_plus + +import httpx + +from app.errors import AppError + +WebhookType = Literal["feishu", "dingtalk", "generic"] + + +def format_feishu_card( + meeting_title: str, + project_name: str, + completed_at_str: str, + summary: str, + decisions: list[str], + actions: list[str], +) -> dict[str, Any]: + decision_text = "\n".join(f"• {d}" for d in decisions) if decisions else "无" + action_text = "\n".join(f"• {a}" for a in actions) if actions else "无" + + elements: list[dict[str, Any]] = [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"**所属项目**:{project_name}\n**定稿时间**:{completed_at_str}", + }, + }, + {"tag": "hr"}, + ] + + if summary.strip(): + elements.extend([ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"**📝 会议纪要**:\n{summary.strip()[:1000]}", + }, + }, + {"tag": "hr"}, + ]) + + elements.extend([ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"**🎯 决议清单 ({len(decisions)})**:\n{decision_text}", + }, + }, + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"**⚡ 待办事项 ({len(actions)})**:\n{action_text}", + }, + }, + ]) + + return { + "msg_type": "interactive", + "card": { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"tag": "plain_text", "content": f"📋 会议已定稿:{meeting_title}"}, + "template": "blue", + }, + "elements": elements, + }, + } + + +def format_dingtalk_card( + meeting_title: str, + project_name: str, + completed_at_str: str, + summary: str, + decisions: list[str], + actions: list[str], +) -> dict[str, Any]: + decision_text = "\n".join(f"- {d}" for d in decisions) if decisions else "无" + action_text = "\n".join(f"- {a}" for a in actions) if actions else "无" + + md_lines = [ + f"### 📋 会议已定稿:{meeting_title}", + f"- **项目**:{project_name}", + f"- **定稿时间**:{completed_at_str}", + ] + if summary.strip(): + md_lines.extend(["\n#### 📝 会议纪要", summary.strip()[:1000]]) + md_lines.extend([ + f"\n#### 🎯 决议清单 ({len(decisions)})", + decision_text, + f"\n#### ⚡ 待办事项 ({len(actions)})", + action_text, + ]) + + return { + "msgtype": "markdown", + "markdown": { + "title": f"📋 会议已定稿:{meeting_title}", + "text": "\n\n".join(md_lines), + }, + } + + +def format_generic_card( + meeting_title: str, + project_name: str, + completed_at_str: str, + summary: str, + decisions: list[str], + actions: list[str], +) -> dict[str, Any]: + return { + "event": "meeting.finalized", + "title": meeting_title, + "project": project_name, + "completed_at": completed_at_str, + "summary": summary, + "decisions": decisions, + "actions": actions, + } + + +async def dispatch_webhook( + webhook_url: str, + webhook_type: WebhookType, + payload: dict[str, Any], + secret: str | None = None, +) -> dict[str, Any]: + url = webhook_url + headers = {"Content-Type": "application/json"} + body = payload + + if webhook_type == "dingtalk" and secret: + timestamp = str(round(time.time() * 1000)) + secret_enc = secret.encode("utf-8") + string_to_sign = f"{timestamp}\n{secret}".encode("utf-8") + hmac_code = hmac.new(secret_enc, string_to_sign, digestmod=hashlib.sha256).digest() + sign = quote_plus(base64.b64encode(hmac_code).decode("utf-8")) + url = f"{webhook_url}×tamp={timestamp}&sign={sign}" if "?" in webhook_url else f"{webhook_url}?timestamp={timestamp}&sign={sign}" + + elif webhook_type == "feishu" and secret: + timestamp = str(int(time.time())) + string_to_sign = f"{timestamp}\n{secret}".encode("utf-8") + hmac_code = hmac.new(string_to_sign, b"", digestmod=hashlib.sha256).digest() + sign = base64.b64encode(hmac_code).decode("utf-8") + body["timestamp"] = timestamp + body["sign"] = sign + + async with httpx.AsyncClient(timeout=10.0) as client: + try: + response = await client.post(url, json=body, headers=headers) + if response.status_code >= 400: + raise AppError( + 502, + "webhook_dispatch_failed", + f"Webhook返回错误状态码: {response.status_code}", + ) + try: + return response.json() + except Exception: + return {"status": "ok", "http_status": response.status_code} + except httpx.HTTPError as exc: + raise AppError(502, "webhook_dispatch_failed", f"Webhook发送失败: {str(exc)}") diff --git a/backend/tests/api/test_calendar_and_webhooks.py b/backend/tests/api/test_calendar_and_webhooks.py new file mode 100644 index 0000000..b14753b --- /dev/null +++ b/backend/tests/api/test_calendar_and_webhooks.py @@ -0,0 +1,93 @@ +from datetime import date, datetime, timezone +import pytest +from unittest.mock import AsyncMock, patch + +from app.domain.enums import ActionStatus +from app.meetings.models import Meeting +from app.outcomes.models import ActionItem + + +def test_calendar_feed_url_and_ics_export(authenticated_client, meeting_id): + # 1. Get user's private feed url + res = authenticated_client.get("/api/calendar/feed-url") + assert res.status_code == 200 + data = res.json() + token = data["token"] + feed_url = data["feed_url"] + assert feed_url.endswith(".ics") + + # 2. Add an action with due_date + with authenticated_client.app.state.database.session() as session: + meeting = session.get(Meeting, meeting_id) + action = ActionItem( + project_id=meeting.project_id, + meeting_id=meeting.id, + content="准备季度财务总结", + due_date=date(2026, 10, 20), + status=ActionStatus.open, + owner_user_id=meeting.created_by, + created_by=meeting.created_by, + ) + session.add(action) + session.commit() + + # 3. Access the feed without cookies (external calendar client) + ics_res = authenticated_client.get(f"/api/calendar/{token}.ics", cookies={}) + assert ics_res.status_code == 200 + assert "text/calendar" in ics_res.headers["content-type"] + text = ics_res.text + assert "BEGIN:VCALENDAR" in text + assert "BEGIN:VEVENT" in text + assert "[待办] 准备季度财务总结" in text + assert "VALUE=DATE:20261020" in text + assert "END:VCALENDAR" in text + + # 4. Invalid token test + bad_res = authenticated_client.get("/api/calendar/invalid-token-123.ics", cookies={}) + assert bad_res.status_code == 404 + + +import httpx + +@pytest.mark.anyio +async def test_meeting_webhook_notification_feishu_and_dingtalk(authenticated_client, meeting_id): + mock_post = AsyncMock() + mock_post.return_value = httpx.Response(200, json={"StatusCode": 0, "StatusMessage": "success"}) + + # Test Feishu webhook notify + with patch("httpx.AsyncClient.post", mock_post): + res = authenticated_client.post( + f"/api/meetings/{meeting_id}/webhook-notify", + json={ + "webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/mock", + "webhook_type": "feishu", + }, + ) + assert res.status_code == 200 + assert res.json()["status"] == "ok" + assert res.json()["webhook_type"] == "feishu" + assert mock_post.called + call_args, call_kwargs = mock_post.call_args + assert call_args[0] == "https://open.feishu.cn/open-apis/bot/v2/hook/mock" + assert call_kwargs["json"]["msg_type"] == "interactive" + + # Test DingTalk webhook notify + mock_post.reset_mock() + mock_post.return_value = httpx.Response(200, json={"errcode": 0, "errmsg": "ok"}) + with patch("httpx.AsyncClient.post", mock_post): + res2 = authenticated_client.post( + f"/api/meetings/{meeting_id}/webhook-notify", + json={ + "webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=mock", + "webhook_type": "dingtalk", + "secret": "SECmock123", + }, + ) + assert res2.status_code == 200 + assert res2.json()["status"] == "ok" + assert res2.json()["webhook_type"] == "dingtalk" + assert mock_post.called + call_args2, call_kwargs2 = mock_post.call_args + assert "timestamp=" in call_args2[0] + assert "sign=" in call_args2[0] + assert call_kwargs2["json"]["msgtype"] == "markdown" diff --git a/backend/tests/domain/test_agendas.py b/backend/tests/domain/test_agendas.py index 83ede3d..5b26e79 100644 --- a/backend/tests/domain/test_agendas.py +++ b/backend/tests/domain/test_agendas.py @@ -370,13 +370,15 @@ def test_saving_agenda_reconciles_tagged_outcomes_and_preserves_manual_rows( assert manual.id not in {row.id for row in actions} assert manual.id in {row.id for row in saved.actions} - with pytest.raises(AppError) as readonly: - OutcomeService(session).update_action( - actions[0].id, - ActionEdit(expected_version=actions[0].version, content="Rewrite"), - admin, - ) - assert readonly.value.code == "derived_outcome_read_only" + updated_action = OutcomeService(session).update_action( + actions[0].id, + ActionEdit(expected_version=actions[0].version, content="Rewrite"), + admin, + ) + assert updated_action.content == "Rewrite" + refreshed_agenda = session.get(AgendaItem, saved.id) + assert "@行动:[todo] Rewrite" in refreshed_agenda.notes_markdown + with pytest.raises(AppError, match="derived_outcome_read_only"): OutcomeService(session).update_decision( decisions[0].id, diff --git a/backend/tests/domain/test_bidirectional_actions.py b/backend/tests/domain/test_bidirectional_actions.py new file mode 100644 index 0000000..22a2aae --- /dev/null +++ b/backend/tests/domain/test_bidirectional_actions.py @@ -0,0 +1,168 @@ +from datetime import date, datetime, timezone +import pytest +from sqlalchemy import select + +from app.agendas.models import AgendaItem +from app.agendas.outcome_tags import format_action_tag, parse_outcome_tags, rewrite_action_in_notes +from app.agendas.schemas import AgendaEdit, AgendaWrite +from app.agendas.service import AgendaService +from app.auth.models import User, UserRole, UserStatus +from app.domain.enums import ActionStatus +from app.meetings.models import Meeting +from app.meetings.schemas import MeetingWrite +from app.meetings.service import MeetingService +from app.outcomes.schemas import ActionEdit +from app.outcomes.service import OutcomeService +from app.projects.schemas import ProjectWrite +from app.projects.service import ProjectService + + +@pytest.fixture +def agenda_context(client): + with client.app.state.database.session() as session: + admin = session.scalar(select(User).where(User.username == "admin")) + member = User( + username="test-member", + display_name="Test Member", + password_hash="unused", + role=UserRole.MEMBER, + status=UserStatus.ACTIVE, + ) + session.add(member) + session.commit() + project = ProjectService(session).create( + ProjectWrite( + name="Bidirectional Action Project", + slug="bidirectional-action-project", + status="active", + lead_user_id=admin.id, + member_ids=[admin.id, member.id], + ), + admin, + ) + meeting = MeetingService(session).create_meeting( + project.id, + MeetingWrite( + title="Bidirectional Meeting", + scheduled_start=datetime(2026, 9, 20, 10, tzinfo=timezone.utc), + scheduled_end=datetime(2026, 9, 20, 11, tzinfo=timezone.utc), + ), + admin, + ) + return admin, member, meeting.id + + +def test_parse_outcome_tags_extended_syntax(): + md = """ +# Agenda Notes +@行动:[done][@alice][2026-10-01] 部署生产环境 +@行动:[in_progress][@bob] 联调第三方接口 +@行动:[todo] 撰写用户手册 +@行动: 传统无括号待办 +@决策: 采用方案 A +@开放问题: 是否需要备份? +""" + tags = parse_outcome_tags(md) + actions = [t for t in tags if t.kind == "action"] + assert len(actions) == 4 + + assert actions[0].status == ActionStatus.done + assert actions[0].owner_name == "alice" + assert actions[0].due_date == date(2026, 10, 1) + assert actions[0].content == "部署生产环境" + + assert actions[1].status == ActionStatus.in_progress + assert actions[1].owner_name == "bob" + assert actions[1].content == "联调第三方接口" + + assert actions[2].status == ActionStatus.open + assert actions[2].owner_name is None + assert actions[2].content == "撰写用户手册" + + assert actions[3].status == ActionStatus.open + assert actions[3].owner_name is None + assert actions[3].content == "传统无括号待办" + + +def test_rewrite_action_in_notes(): + notes = """# Notes +- List item +@行动: 第一个待办 +@决策: 决定采用方案 +@行动:[todo][@bob] 第二个待办 +""" + new_tag = format_action_tag( + content="第一个待办 (已完成)", + status=ActionStatus.done, + owner="alice", + due_date=date(2026, 10, 5), + ) + rewritten = rewrite_action_in_notes(notes, "action:0", new_tag) + assert "@行动:[done][@alice][2026-10-05] 第一个待办 (已完成)" in rewritten + assert "@行动:[todo][@bob] 第二个待办" in rewritten + + # Now rewrite action:1 + new_tag_1 = format_action_tag( + content="第二个待办 (进行中)", + status=ActionStatus.in_progress, + owner="charlie", + ) + rewritten_2 = rewrite_action_in_notes(rewritten, "action:1", new_tag_1) + assert "@行动:[in_progress][@charlie] 第二个待办 (进行中)" in rewritten_2 + + +def test_bidirectional_action_sync_end_to_end(client, agenda_context): + admin, member, meeting_id = agenda_context + with client.app.state.database.session() as session: + agenda_service = AgendaService(session) + outcome_service = OutcomeService(session) + meeting = session.get(Meeting, meeting_id) + + agenda = agenda_service.create( + meeting.id, + AgendaWrite(title="Architecture Review", agenda_type="discussion"), + admin, + expected_meeting_version=meeting.version, + ) + + # 1. Edit notes markdown with extended action syntax + notes_input = f""" +## 会议研讨记录 +@行动:[todo][@{admin.username}][2026-10-15] 实施双向状态机 +@行动:[in_progress] 优化SQLite WAL参数 +""" + saved_agenda = agenda_service.update( + agenda.id, + AgendaEdit(expected_version=agenda.version, notes_markdown=notes_input), + admin, + ) + + actions = outcome_service.list_actions(meeting.project_id) + derived_actions = [a for a in actions if a.agenda_item_id == saved_agenda.id] + assert len(derived_actions) == 2 + + action_0 = next(a for a in derived_actions if a.source_tag_key == "action:0") + assert action_0.content == "实施双向状态机" + assert action_0.status == ActionStatus.open + assert action_0.owner_user_id == admin.id + assert action_0.due_date == date(2026, 10, 15) + + action_1 = next(a for a in derived_actions if a.source_tag_key == "action:1") + assert action_1.content == "优化SQLite WAL参数" + assert action_1.status == ActionStatus.in_progress + + # 2. Update action_0 via OutcomeService (simulating one-click toggle in outcome list) + updated_action_0 = outcome_service.update_action( + action_0.id, + ActionEdit( + expected_version=action_0.version, + status=ActionStatus.done, + ), + admin, + ) + assert updated_action_0.status == ActionStatus.done + assert updated_action_0.completed_at is not None + + # 3. Verify that the agenda item notes_markdown AST has been rewritten! + refreshed_agenda = session.get(AgendaItem, saved_agenda.id) + assert f"@行动:[done][@{admin.username}][2026-10-15] 实施双向状态机" in refreshed_agenda.notes_markdown diff --git a/backend/tests/meetings/test_chunked_attachments.py b/backend/tests/meetings/test_chunked_attachments.py new file mode 100644 index 0000000..033c10e --- /dev/null +++ b/backend/tests/meetings/test_chunked_attachments.py @@ -0,0 +1,139 @@ +import hashlib +import io +import pytest + +from app.database import retry_on_db_lock +from sqlalchemy.exc import OperationalError + + +def test_streaming_sha256_verification(authenticated_client, meeting_id): + content = b"Streamed content with sha256 verification" + expected_sha256 = hashlib.sha256(content).hexdigest() + + # Success case with correct SHA256 header + res = authenticated_client.post( + f"/api/attachments/meeting/{meeting_id}", + files={"file": ("verified.txt", content, "text/plain")}, + headers={"X-Content-SHA256": expected_sha256}, + ) + assert res.status_code == 201 + attachment = res.json() + assert attachment["sha256"] == expected_sha256 + + # Failure case with mismatched SHA256 header + bad_res = authenticated_client.post( + f"/api/attachments/meeting/{meeting_id}", + files={"file": ("bad.txt", content, "text/plain")}, + headers={"X-Content-SHA256": "0" * 64}, + ) + assert bad_res.status_code == 400 + assert bad_res.json()["error"]["code"] == "checksum_mismatch" + + +def test_chunked_resumable_upload_lifecycle(authenticated_client, meeting_id): + full_data = b"Part1-data-AAAA" + b"Part2-data-BBBB" + b"Part3-data-CCCC" + total_size = len(full_data) + expected_sha256 = hashlib.sha256(full_data).hexdigest() + + chunk1 = b"Part1-data-AAAA" + chunk2 = b"Part2-data-BBBB" + chunk3 = b"Part3-data-CCCC" + chunks = [chunk1, chunk2, chunk3] + + # 1. Initialize session + init_res = authenticated_client.post( + f"/api/attachments/meeting/{meeting_id}/chunks/init", + json={ + "filename": "big_archive.bin", + "total_size": total_size, + "total_chunks": 3, + "sha256": expected_sha256, + }, + ) + assert init_res.status_code == 201 + session_id = init_res.json()["session_id"] + + # 2. Upload chunk 0 + res_c0 = authenticated_client.post( + f"/api/attachments/meeting/{meeting_id}/chunks/{session_id}", + data={"chunk_index": 0}, + files={"chunk": ("chunk0.part", chunk1, "application/octet-stream")}, + ) + assert res_c0.status_code == 200 + assert res_c0.json()["status"] == "uploaded" + + # 3. Check status (resume check) + status_1 = authenticated_client.get( + f"/api/attachments/meeting/{meeting_id}/chunks/{session_id}" + ).json() + assert status_1["uploaded_chunks"] == [0] + assert status_1["complete"] is False + + # 4. Upload remaining chunks + for idx, chunk_bytes in [(1, chunk2), (2, chunk3)]: + res = authenticated_client.post( + f"/api/attachments/meeting/{meeting_id}/chunks/{session_id}", + data={"chunk_index": idx}, + files={"chunk": (f"chunk{idx}.part", chunk_bytes, "application/octet-stream")}, + ) + assert res.status_code == 200 + + # 5. Check status: complete + status_2 = authenticated_client.get( + f"/api/attachments/meeting/{meeting_id}/chunks/{session_id}" + ).json() + assert status_2["uploaded_chunks"] == [0, 1, 2] + assert status_2["complete"] is True + + # 6. Complete upload + complete_res = authenticated_client.post( + f"/api/attachments/meeting/{meeting_id}/chunks/{session_id}/complete" + ) + assert complete_res.status_code == 201 + attachment = complete_res.json() + assert attachment["original_name"] == "big_archive.bin" + assert attachment["size"] == total_size + assert attachment["sha256"] == expected_sha256 + + # 7. Download and verify content + download = authenticated_client.get(attachment["download_url"]) + assert download.status_code == 200 + assert download.content == full_data + + +def test_chunked_upload_abort(authenticated_client, meeting_id): + init_res = authenticated_client.post( + f"/api/attachments/meeting/{meeting_id}/chunks/init", + json={ + "filename": "abandoned.bin", + "total_size": 100, + "total_chunks": 2, + }, + ) + session_id = init_res.json()["session_id"] + + abort_res = authenticated_client.delete( + f"/api/attachments/meeting/{meeting_id}/chunks/{session_id}" + ) + assert abort_res.status_code == 204 + + # Getting status after abort should fail with 404 + get_res = authenticated_client.get( + f"/api/attachments/meeting/{meeting_id}/chunks/{session_id}" + ) + assert get_res.status_code == 404 + + +def test_retry_on_db_lock_decorator(): + call_count = 0 + + @retry_on_db_lock(max_retries=3, initial_backoff=0.01, backoff_factor=1.5) + def flaky_db_op(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise OperationalError("database is locked", {}, None) + return "success" + + assert flaky_db_op() == "success" + assert call_count == 3 diff --git a/backend/tests/plugins/test_actions.py b/backend/tests/plugins/test_actions.py index cc557e9..c121e30 100644 --- a/backend/tests/plugins/test_actions.py +++ b/backend/tests/plugins/test_actions.py @@ -67,6 +67,48 @@ async def post(self, _url, **kwargs): assert result == {"markdown": "# AI 草稿", "model": "test-model"} +def test_ai_work_assistant_supports_ollama_without_api_key( + ai_work_assistant_backend, monkeypatch +): + captured: dict = {} + + class Response: + def raise_for_status(self): + return None + + def json(self): + return {"choices": [{"message": {"content": "- [ ] 本地提炼行动项"}}]} + + class Client: + def __init__(self, **_kwargs): + pass + + async def post(self, url, **kwargs): + captured["url"] = url + captured.update(kwargs) + return Response() + + monkeypatch.setattr(ai_work_assistant_backend.httpx, "AsyncClient", Client) + + result = asyncio.run( + ai_work_assistant_backend.action_suggestions( + {"title": "内网离线评审"}, + {"current_markdown": ""}, + { + "base_url": "http://127.0.0.1:11434/v1", + "api_key": "", + "model": "qwen2.5:7b", + "timeout_seconds": 30, + }, + ) + ) + + assert captured["url"] == "http://127.0.0.1:11434/v1/chat/completions" + assert "Authorization" not in captured["headers"] + assert result == {"markdown": "- [ ] 本地提炼行动项", "model": "qwen2.5:7b"} + + + def test_action_suggestions_return_editable_markdown( ai_work_assistant_backend, monkeypatch ): diff --git a/docs/README.md b/docs/README.md index 3a0a3e9..4ce33a7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ | 修改 CI、容器发布或创建版本 tag | [release.md](release.md) | | 修改插件运行时契约 | [后端插件契约](../backend/app/plugins/README.md) | | 修改 AI 工作助手插件 | [AI 工作助手说明](../plugins/ai-work-assistant/README.md) | +| 查阅产品深度调研与演进白皮书 | [产品调研与演进白皮书](product_research_and_plan.md) | ## 文档归属 diff --git a/docs/product_research_and_plan.md b/docs/product_research_and_plan.md new file mode 100644 index 0000000..52c28ea --- /dev/null +++ b/docs/product_research_and_plan.md @@ -0,0 +1,528 @@ +--- +title: MeetFlow 产品调研与演进白皮书 +version: "1.0" +keywords: [MeetFlow, 会议协作, 产品架构, 状态机, 自托管, SQLite优化, 离线AI] +description: MeetFlow 深度产品调研、架构加固与未来演进路线白皮书 +--- + +# MeetFlow 深度产品调研与架构演进白皮书 + +> **文档定位**:面向技术决策者、协同办公系统架构师及开源社区核心维护者,系统性阐述 MeetFlow 的行业价值定位、架构技术审查、交互重塑策略及未来分期演进路线。 + +--- + +## 目录 + +1. [背景调查与行业趋势](#1-背景调查与行业趋势) +2. [开源同类产品深度对比与断层分析](#2-开源同类产品深度对比与断层分析) +3. [产品定位与核心杀手级特点](#3-产品定位与核心杀手级特点) +4. [目标用户画像与核心应用场景](#4-目标用户画像与核心应用场景) +5. [当前代码与已实现功能深度盘点](#5-当前代码与已实现功能深度盘点) +6. [现有功能强化与架构加固方案](#6-现有功能强化与架构加固方案) +7. [UI 与交互逻辑重塑(第一印象优化)](#7-ui-与交互逻辑重塑第一印象优化) +8. [缺失关键功能补充与痛点攻坚](#8-缺失关键功能补充与痛点攻坚) +9. [未来分期演进计划(P0 / P1 / P2 路线图)](#9-未来分期演进计划p0--p1--p2-路线图) +10. [架构师总结与落地展望](#10-架构师总结与落地展望) + +--- + +## 1. 背景调查与行业趋势 + +### 1.1 现代团队的“会议资产流失危机” + +随着远程分布式协作与混合办公(Hybrid Work)成为全球软件开发与知识密集型企业的常态,会议的频次呈指数级上升。然而,组织协同效率并没有因会议增多而线性提升,反而陷入了深重的“会议资产流失危机”: + +``` + ┌─────────────────────────────────────────────────────────────┐ + │ 传统会议流失闭环 (The Leak Loop) │ + └─────────────────────────────────────────────────────────────┘ + 会前准备缺失 会中记录混乱 会后跟踪断层 + ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ + │ • 议程目标不明晰 │ ───► │ • 原始笔记碎片化 │ ───► │ • 决议无法查证 │ + │ • 资料零散未对齐 │ │ • 打字与思考冲突 │ │ • 待办未责任到人 │ + │ • 缺乏时间预期 │ │ • 产出无结构化沉淀 │ │ • 进度脱节被遗忘 │ + └──────────────────┘ └──────────────────┘ └──────────────────┘ + ▲ │ + └───────────────── 重新开会核对对齐 ◄────────────────────────┘ +``` + +1. **会前脱节**:缺乏统一且具有时序约定的周期性议程,参会者常在“零背景输入”状态下参会,会议前期大量时间被消耗在基础信息同步上。 +2. **会中记录混乱与摩擦**:速记人员与核心参会者在“自由文本快速录入”与“结构化表单规范填写”之间左右为难。纯文本 Markdown 最契合人类打字心流,但无法提供强约束的实体;而打开重型项目管理系统录入表单又会严重打断会议进程。 +3. **会后流失与决策沉睡**:会议结束后,纪要往往被封存在个人的本地记事本或分散在各 IM 群聊的历史记录中。关键决策(Decisions)缺乏版本化追溯链条,行动项(Action Items)缺乏清晰的分配、排期与闭环状态,导致团队陷入“开会讨论 → 形成口头共识 → 无人跟进 → 发生事故 → 再次开会”的无限低效循环。 + +### 1.2 商业 SaaS 的天花板与隐忧 + +市面上主流的商业 SaaS 产品(如飞书会议/妙记、Notion、Otter.ai、Fireflies.ai、腾讯会议/企微)提供了高度封装的智能转写和云端协同能力,但在企业深水区应用中暴露出愈发严重的结构性缺陷: + +| 评估维度 | 商业 SaaS 解决方案现状 | 深度隐患与核心痛点 | +| :--- | :--- | :--- | +| **数据主权与隐私** | 核心录音、逐字稿、未公开财报、核心架构决策全部上传公有云多租户环境 | 金融、国防、高端制造、出海法务合规(GDPR/等保三级)无法过审;存在敏感商业机密被用于第三方公有大模型训练的巨大风险 | +| **数据孤岛与供应商锁定** | 依赖厂商私有存储格式与专有云 API,导出为无状态只读 PDF/HTML | 历史资产难以低成本完整迁移;跨系统自动化对接困难;无法与内网自建代码库、工单系统深度集成 | +| **席位税与成本激增** | 普遍采用基于“人头/月”(Seat-based)的阶梯收费(每人每月 $10 ~ $30 不等) | 团队规模扩展时 IT 预算激增;临时或轻度参会人员同样需要付费席位,投入产出比极低 | +| **系统复杂度与臃肿度** | 功能大包大揽(融合了即时通讯、邮箱、复杂多维表格、云文档) | 学习曲线陡峭;团队往往只需要一个专注的会议资产闭环工具,却被迫承担笨重系统的运维与认知负荷 | + +### 1.3 自托管轻量级会议流的“巨大市场空白” + +在全球开源生态与数字化主权回归的浪潮下,**自托管(Self-Hosted)**、**轻量级(Lightweight)**、**专注会议流与待办闭环(Meeting Flow & Outcome-Driven)** 的工具存在显著的市场断层: +- 重型开源协同套件(如 Nextcloud、GitLab + Mattermost 矩阵)部署动辄需要 Kubernetes、PostgreSQL、Redis、ElasticSearch 等复杂组件,运维成本居高不下; +- 个人知识库工具(如 Obsidian、Logseq)专注于个人本地双链,缺乏多用户工作区隔离、周期性会议排期引擎与组织级待办权限边界; +- **行业呼唤一款真正单容器启动、零外部外部依赖、天然支持 Markdown 心流且具备企业级决策/待办实体双向联动的协同中枢**。这正是 MeetFlow 具备核心竞争力的战略生态位。 + +--- + +## 2. 开源同类产品深度对比与断层分析 + +### 2.1 开源生态标杆全景对比 + +为准确定位 MeetFlow 的独特性,我们将其与开源协同领域的四大标杆系统进行横向架构级对比: + +| 评估维度 | Focalboard | Outline | AppFlowy | Joplin | **MeetFlow** | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **核心定位** | 看板式项目管理 (Trello/Notion Boards 替代) | 团队企业级知识库 Wiki (Notion/Confluence 替代) | 客户端优先个人/小团队笔记 (Notion 替代) | 个人端到端加密笔记 | **轻量多人共享会议档案与决策待办闭环系统** | +| **部署架构** | Go + PostgreSQL/MySQL 单体或插件 | Node.js + PostgreSQL + Redis + S3 对象存储 | Rust + Flutter + Go,支持云端/自建多组件 | 纯客户端 + WebDAV/Nextcloud/Dropbox 同步 | **单一 Docker 容器,Python 3.12 + FastAPI + SQLite WAL** | +| **外部依赖** | 需要关系型数据库 | 强依赖 Postgres, Redis, S3/MinIO, 外部 OIDC | 依赖复杂后端多服务及本地跨平台客户端 | 依赖三方存储同步后端 | **零外部依赖,内置数据持久化与插件 Worker** | +| **会议生命周期** | ❌ 无会议概念 | ❌ 仅作为普通文档模板 | ❌ 仅作为普通页面卡片 | ❌ 无会议流状态概念 | **✅ 原生支持:草稿/准备 → 进行中(议程耗时) → 完成快照/归档** | +| **会议周期系列** | ❌ 无 | ❌ 无 | ❌ 无 | ❌ 无 | **✅ 内置 MeetingSeries 周期规则引擎与自动创建槽位** | +| **手记与实体联动** | ❌ 纯属性卡片驱动 | ❌ 纯富文本,无结构化闭环 | ❌ 块编辑器,无法直接映射全局待办实体 | ❌ 仅文本复选框 `- [ ]` | **✅ 核心创新:Markdown 原始手记与结构化产出双向映射状态机** | +| **决策审计追溯** | ❌ 仅卡片评论 | ❌ 仅文档历史版本 | ❌ 仅页面历史 | ❌ 仅笔记历史 | **✅ 独立 Decision 实体:提议、会签评审、定稿与替代关系(Supersede)** | +| **安全沙箱插件** | ⚠️ Mattermost 插件生态,体系庞大 | ⚠️ 依赖 Webhook 与外部 App,无轻量内嵌插件 | ⚠️ 处于早期,Flutter 插件较繁琐 | ❌ 仅本地 JS 插件 | **✅ 内置安全沙箱架构,Manifest 能力声明与受限上下文** | + +### 2.2 核心断层剖析:Markdown 原始手记与结构化决议/待办分离双向联动的行业缺失 + +在协同办公领域,长期存在着**“自由录入心流”**与**“严谨数据流转”**的不可调和的矛盾: + +``` + ┌─────────────────────────────────────────────────────────────┐ + │ 行业典型妥协方案对比 (Compromise vs MeetFlow) │ + └─────────────────────────────────────────────────────────────┘ + 方案 A:纯文本标记法 (Obsidian / Outline) + Markdown: "- [ ] 张三下周完成接口" ──► 仅为纯文本符号,无法全局检索、无法排期、无法通知催办。 + + 方案 B:重型表单微件 (Jira / Linear / ClickUp) + 每次录入: 弹出弹窗 ──► 选项目 ──► 填标题 ──► 选经办人 ──► 选排期 ──► 严重阻断会议实时发言。 + + 方案 C:当前 MeetFlow 代码初版 (单向抽取锁死) + Markdown: "@行动: 完成接口联调" ──► 解析生成 ActionItem ──► 锁死为 derived_outcome_read_only + 缺陷: 用户在行动项看台无法打勾完成,无法改派人员,无法设置截止期,形成数据死锁。 + + ★ 终极解法:MeetFlow 双向映射状态机 (Bi-directional State Machine) + ┌────────────────────────┐ ┌────────────────────────┐ + │ Markdown 原始文本 │ ◄─── 双向同步 ───► │ 结构化数据库实体 │ + │ @行动:[todo][@张三] 接口 │ 状态机 │ ActionItem (id, state,│ + │ │ │ owner=张三, due_date) │ + └────────────────────────┘ └────────────────────────┘ +``` + +- **现状之痛**:绝大多数工程师与产品经理极其依赖 Markdown 进行速记。但在现有工具中,一旦会议结束,这些笔记就像“死文字”一样躺在文档里;若想进行任务跟踪,又必须人肉将内容一条条复制到 Jira、Teambition 或 Trello 中。 +- **双向割裂**:即使有部分系统尝试从 Markdown 中提取任务,通常也只是**单向生成**。一旦在待办看板中将任务标记为“已完成”,原始 Markdown 文档并不会得到更新;反之,若在文档中删改文字,又可能导致数据库中的关联任务被意外级联删除或状态混乱。 +- **MeetFlow 的技术攻坚点**:构建一套能够在单机事务保证下,精准识别行级语法扩展、支持 AST 增量 Diff、双向事件派发与防冲突回写的**双向映射状态机**。 + +--- + +## 3. 产品定位与核心杀手级特点 + +### 3.1 产品核心定位 + +**MeetFlow** 是一款专为敏捷产研团队、极客组织及注重数据隐私与自主权的企业打造的**“轻量级多人共享会议档案、结构化行动项追踪与团队知识沉淀系统”**。它摒弃了冗余复杂的巨石架构,将注意力彻底聚焦在会议这一组织最高密度的沟通场景上,实现从“会前议程规划 → 会中沉浸速记 → 会后决策定稿与待办流转”的全生命周期闭环。 + +### 3.2 四大核心杀手级亮点 + +``` + ┌──────────────────────────────────┐ + │ MeetFlow 核心杀手级架构体系 │ + └──────────────────────────────────┘ + │ + ┌──────────────────┬─────────┴────────┬──────────────────┐ + ▼ ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ 轻量单容器 │ │ 零外部依赖 │ │ 双向映射状态机│ │ 内置安全沙箱 │ + │ Single Docker│ │ Zero Ext-Dep │ │ Bi-Sync Engine│ │ Plugin Sandbox│ + │ • Python 3.12│ │ • SQLite WAL │ │ • 行级语法扩展│ │ • Manifest v2│ + │ • Vue 3 前端 │ │ • 内置 Worker│ │ • 增量 AST Diff│ │ • 边界上下文 │ + │ • 内存 <100MB│ │ • 内存状态机 │ │ • 看板/手记共振│ │ • 凭证密文隔离│ + └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +1. **轻量自托管单容器(Single Docker Container)**: + - 生产环境仅需单一容器(`ghcr.io/mcocdaa/meetflow:latest`),单条 `docker run` 命令挂载 `./data` 目录即可投入生产,内存常驻开销低于 100MB,支持快速冷启动与秒级灾备迁移。 +2. **零复杂外部依赖(Zero External Overhead)**: + - 彻底摒弃 Postgres、Redis、RabbitMQ 等多组件运维负担。利用现代嵌入式 SQLite 的高并发 WAL 模式作为数据主引擎,应用内协同驱动单进程 Worker 与轻量轮询/SSE 流,实现工业级稳定性的同时提供极致简单的运维体验。 +3. **Markdown 原始手记与结构化决议/待办任务双向映射状态机**: + - 原创设计行级语义标记(如 `@行动:[done][@alex][2026-10-01]: 重构支付网关` 与 `@决策: 采用方案B`)。 + - 用户既可以在编辑器中以极致打字心流完成记录,也可以在待办列表/看台中一键打勾、拖拽指派;系统底层通过 AST 解析器与事件总线保持两端实时一致,彻底终结自由笔记与结构化流转的割裂。 +4. **内置安全沙箱插件机制(Security Sandbox Plugin Platform v2)**: + - 插件采用严密的 Manifest 声明规范,提供精确的权限能力(`actions`, `exporters`, `event_subscriptions`, `ui_slots`, `context_scopes`)白名单; + - 插件仅能访问服务端注入的受限上下文(Bounded Context),敏感凭据经过 AES-256 密文存储,事件通过带指数退让的 Outbox 事务保障最终一致性。 + +--- + +## 4. 目标用户画像与核心应用场景 + +### 4.1 目标用户画像 (Personas) + +``` + ┌────────────────────────────────────────────────────────────────────────┐ + │ 👤 画像 1:产研技术负责人 / Tech Lead │ + │ • 特征:追求极简与效率,极度依赖 Markdown,排斥层级冗长的传统工单系统。 │ + │ • 痛点:每周架构会、评审会产出一堆结论,隔周再看没人执行,追溯无门。 │ + │ • 诉求:开会时键盘全盲打速记,散会时一键自动提炼生成责任待办,随时查阅历史决策。 │ + └────────────────────────────────────────────────────────────────────────┘ + + ┌────────────────────────────────────────────────────────────────────────┐ + │ 👤 画像 2:跨职能项目经理 / PMO │ + │ • 特征:强目标导向,关注任务交付率、阻塞项(Blockers)与跨团队对齐。 │ + │ • 痛点:会议超时严重,讨论发散;跨部门待办分散在微信、飞书、邮件,催办困难。 │ + │ • 诉求:严格的议题计时推进流,会议结束即锁定不可篡改快照,待办能联动外部推送。 │ + └────────────────────────────────────────────────────────────────────────┘ + + ┌────────────────────────────────────────────────────────────────────────┐ + │ 👤 画像 3:数据安全与合规主管(金融科技 / 涉密科研 / 跨国合规团队) │ + │ • 特征:对公有云 SaaS 极度敏感,严禁公司核心战略和技术讨论流出内网。 │ + │ • 痛点:商业会议工具均带有云端 AI 录音分析,存在合规死穴;自建开源方案又太重。 │ + │ • 诉求:数据全链路落盘在内网本地磁盘,支持局域网离线 AI 萃取与全量离线备份。 │ + └────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 核心应用场景与标准工作流 + +#### 场景一:产研团队敏捷周会与架构评审(Focus & Live Sync) +1. **会前自动铺路**:由 `MeetingSeries` 周期规则自动创建本周固定槽位会议,预填固定站会站位议题(Standing Items),参会成员提前在待办看台中提报讨论议题。 +2. **会中时间推进与沉浸记录**: + - 负责人点击“开始会议”,进入专注工作台; + - 议题自带倒计时时钟,当前议题耗时实时计算; + - 记录员在 Markdown 记录区键入讨论细节,随时使用 `@行动:` 与 `@决策:` 语法捕获共识; + - 点击“完成议题并进入下一项”,系统自动推进议程队列。 +3. **会后快照封存与待办分发**: + - 点击“结束会议”,系统在单事务中收敛未完议题为“已跳过”,持久化计算总耗时,创建只读 `MeetingSnapshot` 快照; + - 派生出的待办自动出现在责任人的个人待办清单与工作区收件箱(Inbox)中。 + +#### 场景二:跨部门重大业务决策评审会(Architecture Decision Record / ADR) +1. **决策提议与评审指派**:在议题中创建 `Decision` 实体,状态初始化为 `proposed`,并指派跨部门负责人为必须参与会签的评审人(Reviewers)。 +2. **异步会签评审**:各评审人登录 MeetFlow,在决策详情抽屉中提交审批意见(`approved` / `rejected`)与反馈备注。 +3. **定稿与历史替代机制**:当所有人达成共识,组织者执行 `finalize_decision`,决策进入不可变更的 `final` 状态;若未来业务架构升级,可通过 `supersede_decision` 创建新决策并精确关联旧决策形成替代拓扑链条。 + +--- + +## 5. 当前代码与已实现功能深度盘点 + +经过对 `backend/app/`, `backend/migrations/`, `frontend/src/`, `plugins/` 的全面审查,MeetFlow 已经具备了非常扎实的领域模型与严谨的工程底座。 + +### 5.1 后端领域架构与模型审查 (`backend/app/`) + +``` +backend/app/ +├── database.py # SQLite 引擎配置与连接生命周期 +├── schema_guard.py # 历史遗留表结构防护卫士 +├── domain/ # 领域共享:枚举、乐观锁版本校验工具 (versioning.py) +├── meetings/ # 会议生命周期、周期系列调度 (scheduler.py)、快照投影 +├── agendas/ # 议程推进生命周期、耗时计算、Tag 解析器 (outcome_tags.py) +├── outcomes/ # 核心产出:ActionItem, Decision, OpenQuestion 状态机 +├── projects/ # 工作区隔离边界、成员角色与访问控制 (access.py) +├── collaboration/ # 协同记录:活动事件流 (activity.py)、划词与实体评论 +├── attachments/ # 附件流式存取与目录安全边界 (storage.py) +├── auth/ # 鉴权系统:基于 itsdangerous 的加签 Cookie 与密码哈希 +├── inbox/ # 个人通知中心与 Attention 模型 +└── plugins/ # 插件运行时:Manager, Worker, Outbox 事件流与契约 +``` + +#### 领域服务与核心抽象亮点 +- **基于版本的乐观锁控制(Optimistic Concurrency Control)**: + 在 `domain/versioning.py` 中实现了 `resolve_stale`,所有写操作(PUT/POST)均强制校验 `expected_version`。当多人并发修改同一实体时,严格抛出 `409 version_conflict` 并返回服务端最新内容,杜绝静默覆盖。 +- **不可篡改快照机制(Meeting Snapshot)**: + 会议结束(`finish`)时,系统不仅将会议状态流转为 `completed`,更在单事务中执行投影(`projectors.py`),将所有议程、耗时、讨论手记及直接产出序列化为 JSON 固化于 `meeting_snapshots` 表,保证会后归档真实客观。 +- **插件 Outbox 事务可靠性**: + 插件事件写入 `plugin_events` 表,与业务数据在同一 SQLite 事务中提交。内置的单进程 `PluginJobWorker` 负责轮询认领、执行与失败重试,具备 5 次指数退让熔断机制。 + +### 5.2 数据库层与 Alembic 迁移流水线 (`backend/migrations/`) + +系统严格遵守企业级数据库版本控制规范,具备完整的迁移演进轨迹: +- `0001_meetflow_1.py`: 建立基线表结构(包括用户、项目、成员、会议、议程、决策、行动项、问题、评论、附件、活动流与快照等); +- `0002 ~ 0006`: 细化评论解决状态、插件任务上下文、任务忽略机制、错误详情脱敏与用户工作简报; +- `0007_series_recurrence_and_agenda_outcomes.py`: 核心架构升级,引入会议周期规则(`recurrence_frequency`, `recurrence_timezone`)以及议题成果标签(`source_tag_key`, `source_agenda_item_id`); +- `0008_plugin_events.py`: 建立插件 Outbox 事件表; +- `0009_workspace_access_versions.py`: 为项目访问权限引入实体版本与 `project_members.user_id` 高性能复合索引。 + +### 5.3 前端架构与交互审查 (`frontend/src/`) + +- **技术选型**:Vue 3 + TypeScript + Vite + Naive UI。 +- **编辑器集成**:深度集成了现代化富文本/Markdown 编辑器 **Milkdown Crepe** 与 marked + DOMPurify 安全管道。 +- **轻量状态设计**:避免了引入 Pinia 带来的全局状态冗余,采用基于 Vue 3 Reactive 的单例 Composable 模式(`useMeetingWorkspace`, `session.ts`, `useUserNameMap`),通信链路极短且可预测。 +- **交互一致性约定**:抽屉式(Drawer)详情查阅、破坏性操作 Popconfirm 确认、行级数据表格与卡片自适应混排。 + +### 5.4 发现的关键设计缺陷与技术债务清单 + +通过深度静态审查与测试运行,识别出当前实现中的四项关键短板: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 当前代码库核心技术缺陷与瓶颈 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ ⚠️ 1. 双向映射断层 (The Derived Outcome Lock) │ +│ 在 agendas/outcome_tags.py 与 outcomes/service.py 中: │ +│ 通过 @行动: 标签解析生成的 ActionItem 会被打上 source_agenda_item_id 标记。 │ +│ 在 update_action() 中强制拦截: │ +│ if action.source_agenda_item_id is not None: │ +│ raise AppError(409, "derived_outcome_read_only", "成果不可直接编辑") │ +│ 导致前端看板中这些任务完全无法打勾完成,无法分配负责人,严重伤害用户体验!│ +├─────────────────────────────────────────────────────────────────────────────┤ +│ ⚠️ 2. SQLite 高并发参数调优不足 │ +│ database.py 仅设置了 PRAGMA foreign_keys=ON, journal_mode=WAL 和 │ +│ busy_timeout=5000。未设置 cache_size, mmap_size, synchronous=NORMAL, │ +│ 在高频并发写入与大快照查询下存在瞬时锁冲突隐患。 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ ⚠️ 3. 附件上传完整性与容量局限 │ +│ AttachmentStorage 限制 20MB 单请求,使用临时文件流式写入,但未在数据库中 │ +│ 记录文件 SHA-256 散列值,无法应对大容量会议音视频录像与分片续传。 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ ⚠️ 4. 缺乏安全防暴破与审计流 │ +│ /api/auth/login 缺乏基于 IP 和账号的滑动窗口频率限制,存在暴力字典破解风险;│ +│ 敏感操作(重置密码、删除项目)缺少不可篡改的系统审计日志。 │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. 现有功能强化与架构加固方案 + +### 6.1 SQLite WAL 并发写入极致调优 + +SQLite 在 WAL(Write-Ahead Logging)模式下支持多读单写并发。为消除并发阻塞、减少磁盘 I/O 放大,必须对底层 PRAGMA 参数进行全方位深度调优: + +```python +# 建议加固方案:backend/app/database.py 中的 SQLite 调优参数 +@staticmethod +def _configure_sqlite(connection, _record) -> None: + cursor = connection.cursor() + # 1. 基础完整性与并发模式 + cursor.execute("PRAGMA foreign_keys = ON") + cursor.execute("PRAGMA journal_mode = WAL") + cursor.execute("PRAGMA busy_timeout = 10000") # 提升至 10 秒超时退让 + + # 2. I/O 与性能调优 + cursor.execute("PRAGMA synchronous = NORMAL") # WAL 模式下保障掉电安全,同时减少 fsync 瓶颈 + cursor.execute("PRAGMA cache_size = -64000") # 保持 64MB 页面内存缓存 + cursor.execute("PRAGMA temp_store = MEMORY") # 临时表与中间排序全部置于内存 + cursor.execute("PRAGMA mmap_size = 268435456") # 启用 256MB 内存映射读取,加速快照查询 + + # 3. 自动 Checkpoint 优化 + cursor.execute("PRAGMA wal_autocheckpoint = 1000") # 达到 1000 页时自动转储 + cursor.close() +``` + +此外,针对多协程或后台 Worker 触发的 `sqlite3.OperationalError: database is locked`,需在持久层引入带抖动(Jitter)的指数退让装饰器(Exponential Backoff Retry),彻底消除瞬时冲突。 + +### 6.2 大附件流式上传与完整性校验机制 + +当前系统附件上限为 20MB,且无哈希校验。针对团队日常需要上传会议录音、高分辨率架构全景图或演示视频的场景,加固方案如下: + +``` + 大附件流式校验与分片存储流水线 + 客户端 (Client) 服务端 (FastAPI Storage) + │ │ + ├──── 1. POST /api/attachments/init (预检文件名/容量) ──►│ 创建元数据记录 + │◄─── 返回 attachment_id, upload_token, chunk_size ───┤ 状态: pending + │ │ + ├──── 2. PUT /api/attachments/{id}/chunks/{part} ────►│ 流式分片边写边算 + │ (带 Content-MD5 / 分块索引) │ 计算 Part SHA-256 + │ │ + ├──── 3. POST /api/attachments/{id}/complete ────────►│ 组装分片 + │ (提交 Client-Side 总文件 SHA-256) │ 服务端重算总 SHA-256 + │ │ 验证一致性 ──► 状态: ready + │◄─── 返回 201 Created (附带 ETag 与安全校验码) ────────┤ +``` + +- **数据层扩展**:在 `attachments` 表新增 `sha256 VARCHAR(64)`、`etag VARCHAR(64)`、`status VARCHAR(16)` 字段(通过 Alembic 迁移交付)。 +- **完整性双向校验**:服务端流式接收时,使用 Python `hashlib.sha256()` 边读取边递增更新散列值;落盘后与客户端声明的散列值严格比对,不匹配立即中断并清理脏数据。 +- **文件安全嗅探增强**:集成基于文件头特征码的严格 MIME 校验,严禁上传带有可执行属性的文件(`.sh`, `.exe`, `.bat`, `.py`, `.js` 等),防范文件上传穿透漏洞。 + +### 6.3 鉴权与会话管理安全性升级 + +- **滑动窗口防暴力破解(Rate Limiting)**: + 在 `/api/auth/login` 入口实现内存级滑动窗口算法(基于 `time.monotonic()` 队列),限制同一 IP 1 分钟内最多尝试 5 次,同一用户名 15 分钟内最多失败 5 次。超限返回 `429 Too Many Requests` 并锁定 15 分钟。 +- **会话滑动过期(Sliding Expiration)**: + 现有会话为固定 7 天生命周期。升级为“活跃期滑动延长”机制:当用户在会话后半段发生正常请求时,由后端在响应头刷新 Set-Cookie,延长会话周期;同时保留最大绝对生命周期(如 30 天强制重登)。 +- **统一审计日志(Audit Logging)**: + 创建专用的 `security_audit_logs` 表,不可篡改地记录:管理员创建用户、用户密码重置、项目归属转让、插件凭证变更、会议永久删除等敏感动作,记录项包含时间戳、操作人 ID、客户端 IP、操作类型及变更前后指纹。 + +--- + +## 7. UI 与交互逻辑重塑(第一印象优化) + +### 7.1 会议进行时快速记录模式(Focus Meeting Mode) + +**痛点**:当前界面存在过多菜单栏、外围元数据及折叠控制,在快节奏的开会场景下视觉干扰过重,打字区域狭窄。 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 🎯 MeetFlow 专注会议工作台 (Focus Meeting Mode) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ [🟢 架构评审会 #3] 议程进度: 2/5 ⏱ 本议题进行: 04:25 / 15:00 (主讲: 张三) │ +├──────────────────────────────────────┬──────────────────────────────────────┤ +│ 📝 原始手记区 (Markdown 心流输入) │ 📋 实时提炼看台 (Live Outcomes) │ +│ │ │ +│ ### 议题:支付网关高可用重构方案 │ 💡 本议题决策 (Decisions) │ +│ 经过与基础架构组讨论,决定废弃传统主从│ ┌──────────────────────────────────┐ │ +│ 方案,采用分布式多活节点部署。 │ │ 采用分布式多活节点部署方案 │ │ +│ │ └──────────────────────────────────┘ │ +│ @决策: 采用分布式多活节点部署方案 │ │ +│ @行动:[todo][@李四][2026-10-15]: │ ⚡ 行动待办 (Action Items) │ +│ 搭建测试集群并跑通压测基准 │ ┌──────────────────────────────────┐ │ +│ │ │ [ ] 搭建测试集群并跑通压测基准 │ │ +│ 键盘提示: │ │ 👤 李四 📅 2026-10-15 │ │ +│ • [/] 呼出插入微件 │ └──────────────────────────────────┘ │ +│ • [Cmd+Enter] 完成议题进入下一项 │ │ +│ • [Cmd+K] 全局动作面板 │ ❓ 开放问题 (Open Questions) │ +└──────────────────────────────────────┴──────────────────────────────────────┘ +``` + +#### 核心交互优化点 +1. **沉浸式双栏分屏(Split Comparison View)**: + - 左栏为 Markdown 实时编辑区,右栏为结构化成果(Decisions, Actions, Questions)卡片流。 + - **交互联动高亮**:鼠标滑过右侧行动项卡片时,左侧 Markdown 对应行高亮呼吸闪烁;左侧打字时光标行如果有 Tag,右侧卡片实时以微动效出现。 +2. **斜杠命令(Slash Command)与快捷插入**: + - 在手记中输入 `/`,弹出快速插入选择器: + - `/action`:自动生成 `@行动:[todo][@未指派]: ` 并弹出成员选择框; + - `/decision`:自动生成 `@决策: `; + - `/question`:自动生成 `@开放问题: `。 +3. **全局热键驱动(Keyboard-First Architecture)**: + - `Cmd/Ctrl + Enter`:保存当前手记草稿并自动完成本议题,无缝流转至下一项; + - `Cmd/Ctrl + J`:快捷聚焦手记输入框; + - `Cmd/Ctrl + K`:呼出万能指令面板(切换议题、上传附件、邀请成员)。 + +### 7.2 待办状态一键切换交互(彻底打破 Read-Only 约束) + +**重构状态流转架构**: +必须废除原有的“派生产物只读(`derived_outcome_read_only`)”限制,引入**“双向映射状态机”**。 + +``` + 双向状态同步与回写状态机 + [ 用户在待办看台点击 Checkbox ] [ 用户在 Markdown 编辑器修改文本 ] + │ │ + ▼ ▼ + 触发 PUT /api/actions/{id} 触发 PUT /api/agenda-items/{id} + payload: { status: "done" } payload: { notes_markdown: "..." } + │ │ + ├───────────────────────┬────────────────────────┤ + ▼ ▼ ▼ + 更新数据库 ActionItem 通过 AST 定位行 更新数据库 ActionItem + status = done 替换状态标记为 [done] 内容与状态同步 + │ │ │ + └───────────────────────┴────────────────────────┘ + │ + 广播 SSE / 前端响应更新 +``` + +1. **三态交互 Checkbox**: + 待办卡片上的复选框直接支持交互:点击即可在 `待办 (todo)` ➔ `进行中 (in_progress)` ➔ `已完成 (done)` 之间循环流转。 +2. **行内智能回写**: + 当用户在右侧看板勾选“完成”,后端或前端协同层自动检索左侧手记中的对应标记行(通过 `source_tag_key` 或稳定锚点 UUID),将 `@行动:[todo]` 替换为 `@行动:[done]`,并无损保存。 + +--- + +## 8. 缺失关键功能补充与痛点攻坚 + +### 8.1 离线 / 自建 AI 智能提炼决议与待办(一键从纯手记萃取) + +#### 痛点剖析 +目前系统的 `ai-work-assistant` 插件仅支持配置外部公有云 OpenAI 兼容接口,很多涉密或内网隔离企业无法使用,必须接入完全私有化的离线小模型。 + +#### 技术方案与工作流设计 +- **适配自建推理引擎**:原生兼容局域网内由 **Ollama**、**LocalAI** 或 **vLLM** 运行的轻量开源模型(如 `Qwen2.5-7B-Instruct` 或 `Llama-3.1-8B-Instruct`)。 +- **零外泄的确定性提炼 Pipeline**: + +``` + 离线 AI 决议待办萃取流水线 (Offline AI Extraction) +┌────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ +│ 会议/议题原始手记 │ ──► │ 上下文清洗与组装 │ ──► │ 内网 Ollama 推理 │ ──► │ JSON 严格校验器 │ +│ (纯文本 Markdown)│ │ 注入会议背景/成员│ │ (本地 Qwen2.5) │ │ Pydantic 校验 │ +└────────────────┘ └────────────────┘ └────────────────┘ └────────────────┘ + │ + ▼ +┌────────────────┐ ┌────────────────┐ ┌────────────────┐ +│ 会议工作台呈现 │ ◄───────────────────────── │ 人工确认弹窗 │ ◄───│ 结构化输出注入 │ +│ (双栏同步高亮) │ 一键合并入手记与数据库 │ (Diff Review) │ │ 候选决策与行动项│ +└────────────────┘ └────────────────┘ └────────────────┘ +``` + +- **差异审查确认机制(Diff & Merge Modal)**: + AI 的提炼结果不得直接静默覆盖数据库。系统弹出对比抽屉: + 左侧显示识别到的待办与决策候选项,用户可以通过 Checkbox 勾选采纳哪些项、微调指派人后,点击“确认注入”,系统自动在手记末尾追加对应规范标签并创建实体。 + +### 8.2 会议待办与第三方生态联动(日历订阅与消息推送) + +#### 1. iCal 动态日历源订阅(Outbound Calendar Feed) +- **功能特性**:为每位系统成员生成唯一的带安全 Token 的 Webcal / iCal 链接(如 `/api/calendar/feed/{user_id}?token=...`)。 +- **同步能力**:用户将其添加到 Apple 日历、Google Calendar、Outlook 后,所有由该用户主持的会议、参会的议程以及分配给该用户的**待办截止日(Due Date)**均会自动以日程事件或全天待办形式展现,实现“工作日历无缝统一”。 + +#### 2. 事件驱动出站 Webhook(Outgoing Webhooks Engine) +- **触发机制**:基于 `plugin_events` 机制,在会议生命周期流转(如会议开始、会议结束生成快照、决策定稿)时对外派发。 +- **模板化卡片推送**: + 内置飞书互动卡片(Feishu Card)、钉钉群机器人、企业微信应用机器人以及 Slack Block Kit 模板。 + - **飞书/钉钉卡片效果**:会议结束时,自动在项目绑定的大群推送卡片,标题展现《[项目名] 架构周会纪要已归档》,正文包含核心决策清单、未完待办 @各责任人,并附带直达内网 MeetFlow 会议详情的快捷按钮。 + +### 8.3 会议音频实时转写与导入(Local Speech-to-Text Pipeline) + +- **技术实现**: + 设计 `audio-transcribe` 扩展模块,后端可选集成高性能嵌入式语音引擎 **Faster-Whisper**(基于 CTranslate2,支持纯 CPU 运行与 NVIDIA GPU 加速)。 +- **处理链路**: + 1. 用户上传会议录音(支持 `.mp3`, `.m4a`, `.wav` 等主流格式); + 2. 后台异步任务执行 VAD(Voice Activity Detection,静音切分)与分段转写; + 3. 转写结果按时间轴分段列出,并支持说话人聚类对齐; + 4. 用户在前端可“一键将转写文本导入当前议题手记”,并交由离线 AI 助手提炼。 + +--- + +## 9. 未来分期演进计划(P0 / P1 / P2 路线图) + +为确保方案具备高度的可实施性与工程落地确定性,制定清晰的三阶段演进里程碑: + +``` +MeetFlow 演进时间线规划 +======================================================================================== +阶段 核心主题 重点交付目标 +---------------------------------------------------------------------------------------- +P0 阶段 架构加固与双向状态机破局 • 彻底解绑 derived_outcome_read_only 约束 +(0-1个月) (Foundation & Bi-Sync) • 实现待办看台点击一键打勾并回写 Markdown + • SQLite WAL 深度调优与 Busy 退让重试机制 + • 会议专注模式 (Focus Mode) 前端初版交付 +---------------------------------------------------------------------------------------- +P1 阶段 生态联动与离线 AI 萃取 • 大附件分片断点续传与 SHA-256 完整性校验 +(1-3个月) (Ecosystem & Local AI) • 飞书/钉钉/企微/Slack 出站 Webhook 模板推送 + • iCal 个人动态日历订阅源 (/api/calendar/feed) + • 离线 Ollama/LocalAI 一键萃取插件交付 +---------------------------------------------------------------------------------------- +P2 阶段 智能化与企业级治理 • Faster-Whisper 本地离线语音转写导入流水线 +(3-6个月) (Enterprise & Intelligence) • 企业级单点登录 (OIDC / OAuth2 / LDAP) + • 会议安全审计日志 (Security Audit Log) + • 基于 CRDT/Yjs 的多人实时光标协同速记实验 +======================================================================================== +``` + +### P0 阶段:架构加固与双向状态机破局(0 ~ 1 个月) +- **目标**:彻底修复最严重的体验断层,让系统在会议记录与待办流转中真正可用。 +- **关键交付成果**: + 1. **数据库与领域服务升级**:重构 `agendas/service.py` 与 `outcomes/service.py`,支持可逆的 Tag 解析与状态映射,在待办更新时触发手记 AST 局部替换。 + 2. **SQLite 生产加固**:在 `database.py` 中注入 `PRAGMA synchronous = NORMAL`、`cache_size`、`mmap_size` 等调优配置,并编写并发读写压力测试。 + 3. **UI 沉浸式改造**:重塑 `MeetingWorkspaceView.vue` 与 `AgendaWorkbench.vue`,实现双栏对照与全键盘快捷键。 + +### P1 阶段:生态联动与离线 AI 萃取(1 ~ 3 个月) +- **目标**:打通企业办公外围生态,引入纯内网隐私安全的大模型提炼能力。 +- **关键交付成果**: + 1. **分片上传与哈希体系**:交付带有 SHA-256 完整性校验的大文件分块上传流水线,满足 500MB 音视频附件存储。 + 2. **外部推送与日历源**:实现 iCal Webcal 订阅与群机器人卡片推送。 + 3. **Ollama 本地插件**:升级 `ai-work-assistant`,支持无外网依赖的内网大模型直连,增加“差异审查弹窗”。 + +### P2 阶段:智能化与企业级治理(3 ~ 6 个月) +- **目标**:满足高安全、大规模研发组织的企业级合规与多媒体会议资产化诉求。 +- **关键交付成果**: + 1. **语音转写模块**:引入 Faster-Whisper 本地转写容器组件。 + 2. **企业 SSO 认证体系**:支持对接企业自建 Keycloak、GitLab、Active Directory 等身份源。 + 3. **多人实时协同协作**:探索基于 Yjs / CRDT 的轻量级多人光标同步方案,实现同场会议多人分工速记。 + +--- + +## 10. 架构师总结与落地展望 + +MeetFlow 绝非又一个平庸的 Todo-List 或通用 Markdown 笔记工具。它通过极具前瞻性的**“单容器自托管”**、**“零外部依赖”**设计,直击混合办公时代企业在会议资产沉淀中的核心痛点;通过创新的**“Markdown 原始手记与结构化决议/待办任务双向映射状态机”**,巧妙化解了自由书写心流与严谨数据流转之间的百年矛盾。 + +本白皮书提出的技术加固、交互重塑与生态扩展路线,在最大化保留 MeetFlow 轻量、优雅基因的前提下,为其注入了应对高并发企业场景的工业级韧性与面向未来的智能化潜能。随着各阶段里程碑的稳步推进,MeetFlow 必将成为开源协同与数字化主权领域极具标杆意义的卓越作品。 diff --git a/frontend/src/components/AgendaDetail.vue b/frontend/src/components/AgendaDetail.vue index 83f5b37..9589227 100644 --- a/frontend/src/components/AgendaDetail.vue +++ b/frontend/src/components/AgendaDetail.vue @@ -7,8 +7,10 @@ import { errorMessage } from '../utils/errors' import { api, ApiError } from '../api/client' import type { Page, UserRef } from '../api/contracts' import type { AgendaDraft, AgendaItem, Attachment, Meeting } from '../domain/meetings' +import type { ActionItem, ActionStatus } from '../domain/outcomes' import type { Project } from '../domain/projects' import { assistantsForSlot } from '../plugins/registry' +import { Check } from '@lucide/vue' import AttachmentPanel from './AttachmentPanel.vue' import MarkdownEditor from './MarkdownEditor.vue' import type { MarkdownEditorHandle } from './MarkdownEditor.vue' @@ -333,6 +335,58 @@ async function submitCopy() { copySaving.value = false } } + +const hoveredOutcomeId = ref(null) + +async function toggleActionStatus(action: ActionItem, newStatus?: ActionStatus) { + if (!props.canContribute) return + const nextStatus: ActionStatus = newStatus ?? (action.status === 'done' ? 'open' : 'done') + try { + const updated = await api(`/api/actions/${action.id}`, { + method: 'PUT', + body: JSON.stringify({ + status: nextStatus, + expected_version: action.version, + }), + }) + action.status = updated.status + action.version = updated.version + emit('changed') + } catch (caught) { + error.value = errorMessage(caught, '更新行动项失败') + } +} + +function cycleActionStatus(action: ActionItem) { + if (!props.canContribute) return + const cycleMap: Record = { + open: 'in_progress', + in_progress: 'done', + done: 'open', + canceled: 'open', + } + void toggleActionStatus(action, cycleMap[action.status] || 'open') +} + +function actionStatusLabel(status: ActionStatus): string { + const map: Record = { + open: '待办', + in_progress: '进行中', + done: '已完成', + canceled: '已取消', + } + return map[status] || status +} + +const noteActionTags = computed(() => { + const regex = /@行动:(\[[^\]]*\])*\s*([^\n\r]+)/g + const matches: string[] = [] + let match: RegExpExecArray | null + while ((match = regex.exec(draft.notes_markdown)) !== null) { + matches.push(match[2].trim()) + } + return matches +})