2026-09-20-oa-instance-comment.md 37 KB

OA 审批实例评论 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在 OA 审批详情提供独立评论区:文字评论、引用表单附件、本人撤回;Web 管理端先交付,API 可供小程序复用。

Architecture: 新建 oa_instance_comment 表与服务模块 oa_instance_comment.py(附件枚举/校验复用 oa_related_approval 的 upload 遍历能力);API 挂在 /web/admin/oa-approval;详情 get_instance_detail 注入 comments;前端抽 OaCommentPanel.vue 挂到详情页与只读详情弹窗。不改动审批意见 / 进度链。

Tech Stack: FastAPI · SQLAlchemy · pytest/unittest · Vue 3 · Element Plus · MinIO 既有 URL(仅引用)

Spec: docs/superpowers/specs/2026-09-20-oa-instance-comment-design.md


File map

Path Responsibility
Create: docs/sql/migrations/2026-09-20-oa-instance-comment.sql 建表 DDL
Modify: backend/app/models/models.py 在 OaInstanceEvent 后新增 OaInstanceComment
Create: backend/app/services/oa_instance_comment.py 附件枚举、校验、列表/发表/撤回、_comment_to_dict
Create: backend/tests/test_oa_instance_comment.py 纯函数 + SQLite 集成测
Modify: backend/app/services/oa_engine.py get_instance_detail 注入 comments
Modify: backend/app/api/oa_approval.py Body + 4 个路由
Modify: web-admin/src/api/oaApproval.ts 类型 + fetch 方法
Create: web-admin/src/components/oa-approval/OaCommentPanel.vue 评论 UI
Modify: web-admin/src/views/oa-approval/instances/detail.vue 挂载评论区
Modify: web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue 挂载评论区(只读关联详情也要能看;running 时可评)

Locked rules (from spec):

  • 评论 ≠ 审批意见;禁止写 OaTask.comment_text。
  • 仅 status=running 可发/可撤;结束后只读。
  • 撤回:status=revoked,保留 content(审计);UI 对 revoked 不展示正文与附件。
  • 撤回幂等:已撤回再撤 → 成功返回当前行。
  • 无通知、无 @、无评论新上传。
  • 可见性与现网详情一致:同租户 + 实例存在(_get_instance)。

Task 1: SQL + Model

Files:

  • Create: docs/sql/migrations/2026-09-20-oa-instance-comment.sql
  • Modify: backend/app/models/models.py(在 OaInstanceEvent 类之后、OaRelation 之前插入)

  • [ ] Step 1: 写迁移 SQL

-- OA 审批实例评论
CREATE TABLE IF NOT EXISTS `oa_instance_comment` (
  `id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
  `instance_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属实例ID',
  `content` varchar(1024) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '评论正文',
  `attachment_refs_json` text COLLATE utf8mb4_unicode_ci COMMENT '引用表单附件快照 JSON',
  `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'normal' COMMENT 'normal/revoked',
  `revoked_time` datetime DEFAULT NULL COMMENT '撤回时间',
  `tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
  `remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
  `create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id(评论作者)',
  `create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记',
  PRIMARY KEY (`id`),
  KEY `idx_oa_comment_tenant_inst_time` (`tenant_no`,`instance_id`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OA审批实例评论';
  • Step 2: 新增 SQLAlchemy 模型

在 OaInstanceEvent 与 OaRelation 之间插入:

class OaInstanceComment(Base):
    """OA审批实例评论 oa_instance_comment"""

    __tablename__ = "oa_instance_comment"

    id = Column(String(64), primary_key=True, comment="主键ID")
    instance_id = Column(String(64), nullable=False, comment="所属实例ID")
    content = Column(String(1024), nullable=False, comment="评论正文")
    attachment_refs_json = Column(Text, nullable=True, comment="引用表单附件快照 JSON")
    status = Column(String(16), nullable=False, default="normal", comment="normal/revoked")
    revoked_time = Column(DateTime, nullable=True, comment="撤回时间")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id(评论作者)")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")

确认文件顶部已 import Text(OaInstance 已用,应已有)。

  • Step 3: Commit
git add docs/sql/migrations/2026-09-20-oa-instance-comment.sql backend/app/models/models.py
git commit -m "$(cat <<'EOF'
feat(oa): add oa_instance_comment table and model

EOF
)"

Task 2: 附件枚举与引用校验(纯函数 TDD)

Files:

  • Create: backend/app/services/oa_instance_comment.py
  • Create: backend/tests/test_oa_instance_comment.py

  • [ ] Step 1: 写失败/行为测试(先写测)

"""OA 实例评论:附件枚举与引用校验"""
import unittest

from app.services.oa_instance_comment import (
    enumerate_form_attachments,
    normalize_attachment_refs,
)


class TestEnumerateFormAttachments(unittest.TestCase):
    def test_groups_upload_fields(self):
        schema = {
            "rule": [
                {
                    "type": "upload",
                    "field": "files",
                    "title": "附件",
                },
                {"type": "input", "field": "name", "title": "名称"},
            ]
        }
        form = {
            "files": [
                {"url": "https://x/a.pdf", "name": "a.pdf"},
                {"url": "https://x/b.pdf", "name": "b.pdf"},
            ],
            "name": "demo",
        }
        items = enumerate_form_attachments(schema, form)
        self.assertEqual(len(items), 1)
        self.assertEqual(items[0]["fieldKey"], "files")
        self.assertEqual(items[0]["fieldLabel"], "附件")
        self.assertEqual(len(items[0]["files"]), 2)
        self.assertEqual(items[0]["files"][0]["fileName"], "a.pdf")

    def test_skips_empty_upload(self):
        schema = {"rule": [{"type": "fcUpload", "field": "f", "title": "F"}]}
        self.assertEqual(enumerate_form_attachments(schema, {"f": []}), [])


class TestNormalizeAttachmentRefs(unittest.TestCase):
    def test_accepts_valid_ref(self):
        schema = {"rule": [{"type": "upload", "field": "files", "title": "附件"}]}
        form = {"files": [{"url": "https://x/a.pdf", "name": "a.pdf"}]}
        out = normalize_attachment_refs(
            schema,
            form,
            [{"fieldKey": "files", "url": "https://x/a.pdf", "fileName": "wrong"}],
        )
        self.assertEqual(out[0]["fileName"], "a.pdf")
        self.assertEqual(out[0]["fieldLabel"], "附件")

    def test_rejects_foreign_url(self):
        from app.services.oa_engine import OaEngineError

        schema = {"rule": [{"type": "upload", "field": "files", "title": "附件"}]}
        form = {"files": [{"url": "https://x/a.pdf", "name": "a.pdf"}]}
        with self.assertRaises(OaEngineError):
            normalize_attachment_refs(
                schema,
                form,
                [{"fieldKey": "files", "url": "https://evil/x.pdf"}],
            )
  • Step 2: Run 确认失败
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py -v

Expected: FAIL(模块不存在)

  • Step 3: 实现纯函数

创建 backend/app/services/oa_instance_comment.py:

"""OA 审批实例评论:附件枚举、校验、CRUD"""
from __future__ import annotations

import json
from datetime import datetime
from typing import Any, Dict, List, Optional

from sqlalchemy.orm import Session

from app.core.utils import generate_uuid
from app.models.models import OaAppVersion, OaInstance, OaInstanceComment
from app.services.oa_engine import OaEngineError, _get_instance, _loads, _user_name_map
from app.services.oa_related_approval import (
    UPLOAD_TYPES,
    _extract_upload_urls,
    _schema_rules,
    _upload_display_name,
)

CONTENT_MAX = 1024


def enumerate_form_attachments(
    schema: Any, form_data: Optional[Dict[str, Any]]
) -> List[Dict[str, Any]]:
    data = form_data or {}
    items: List[Dict[str, Any]] = []
    for rule in _schema_rules(schema):
        t = str(rule.get("type") or "")
        if t not in UPLOAD_TYPES:
            continue
        field = str(rule.get("field") or rule.get("name") or "").strip()
        if not field:
            continue
        label = str(rule.get("title") or rule.get("label") or field).strip() or field
        raw = data.get(field)
        files: List[Dict[str, str]] = []
        if isinstance(raw, list):
            for it in raw:
                urls = _extract_upload_urls(it if not isinstance(it, list) else it)
                # prefer per-item
                if isinstance(it, dict):
                    u = (_extract_upload_urls(it) or [""])[0]
                    if not u:
                        continue
                    files.append(
                        {
                            "url": u,
                            "fileName": _upload_display_name(it, u),
                        }
                    )
                else:
                    for u in _extract_upload_urls(it):
                        files.append({"url": u, "fileName": _upload_display_name(it, u)})
        else:
            for u in _extract_upload_urls(raw):
                files.append({"url": u, "fileName": _upload_display_name(raw, u)})
        if not files:
            continue
        items.append({"fieldKey": field, "fieldLabel": label, "files": files})
    return items


def _allowed_url_index(
    schema: Any, form_data: Optional[Dict[str, Any]]
) -> Dict[str, Dict[str, str]]:
    """url -> {fieldKey, fieldLabel, fileName}"""
    index: Dict[str, Dict[str, str]] = {}
    for group in enumerate_form_attachments(schema, form_data):
        for f in group["files"]:
            u = str(f.get("url") or "").strip()
            if not u:
                continue
            index[u] = {
                "fieldKey": group["fieldKey"],
                "fieldLabel": group["fieldLabel"],
                "fileName": str(f.get("fileName") or "").strip() or u,
            }
    return index


def normalize_attachment_refs(
    schema: Any,
    form_data: Optional[Dict[str, Any]],
    refs: Optional[List[Dict[str, Any]]],
) -> List[Dict[str, str]]:
    if not refs:
        return []
    if not isinstance(refs, list):
        raise OaEngineError("attachmentRefs 格式无效")
    index = _allowed_url_index(schema, form_data)
    out: List[Dict[str, str]] = []
    seen = set()
    for raw in refs:
        if not isinstance(raw, dict):
            raise OaEngineError("引用的附件不存在或不属于本表单")
        url = str(raw.get("url") or "").strip()
        if not url or url not in index:
            raise OaEngineError("引用的附件不存在或不属于本表单")
        if url in seen:
            continue
        seen.add(url)
        meta = index[url]
        out.append(
            {
                "fieldKey": meta["fieldKey"],
                "fieldLabel": meta["fieldLabel"],
                "url": url,
                "fileName": meta["fileName"],
            }
        )
    return out

(CRUD 函数在 Task 3 追加;本任务先保证上述纯函数可测。)

  • Step 4: Run 确认通过
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py::TestEnumerateFormAttachments tests/test_oa_instance_comment.py::TestNormalizeAttachmentRefs -v

Expected: PASS

  • Step 5: Commit
git add backend/app/services/oa_instance_comment.py backend/tests/test_oa_instance_comment.py
git commit -m "$(cat <<'EOF'
feat(oa): enumerate and validate comment form attachment refs

EOF
)"

Task 3: 评论 CRUD 服务 + 集成测

Files:

  • Modify: backend/app/services/oa_instance_comment.py
  • Modify: backend/tests/test_oa_instance_comment.py

  • [ ] Step 1: 追加集成测试

在同测试文件追加(对齐 TestTransferTask 的内存 SQLite 风格):

class TestCommentCrud(unittest.TestCase):
    def setUp(self):
        from sqlalchemy import create_engine
        from sqlalchemy.orm import sessionmaker
        from app.models.models import OaAppVersion, OaInstance, OaInstanceComment, SysUser

        self.engine = create_engine("sqlite:///:memory:")
        OaAppVersion.__table__.create(self.engine)
        OaInstance.__table__.create(self.engine)
        OaInstanceComment.__table__.create(self.engine)
        SysUser.__table__.create(self.engine)
        Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
        self.db = Session()
        now = datetime.now()
        schema = {
            "rule": [
                {"type": "upload", "field": "files", "title": "附件"},
            ]
        }
        self.db.add(
            OaAppVersion(
                id="v1",
                app_id="app1",
                version_no=1,
                status="published",
                is_latest_published="1",
                form_schema_json=json.dumps(schema, ensure_ascii=False),
                flow_tree_json="{}",
                advanced_settings_json="{}",
                tenant_no="zkxw",
                create_dept_id="",
                create_time=now,
                update_time=now,
                del_flag=0,
            )
        )
        self.db.add(
            OaInstance(
                id="i1",
                app_id="app1",
                app_version_id="v1",
                title="t",
                status="running",
                initiator_user_id="u0",
                initiator_dept_id="d1",
                form_data_json=json.dumps(
                    {"files": [{"url": "https://x/a.pdf", "name": "a.pdf"}]},
                    ensure_ascii=False,
                ),
                tenant_no="zkxw",
                create_dept_id="d1",
                create_time=now,
                update_time=now,
                del_flag=0,
                current_round=1,
            )
        )
        self.db.add(
            SysUser(
                id="u1",
                tenant_no="zkxw",
                user_no="u1",
                nick_name="张三",
                status="1",
                del_flag=0,
                create_time=now,
                update_time=now,
            )
        )
        self.db.commit()

    def test_create_list_revoke(self):
        from app.services import oa_instance_comment as svc

        row = svc.create_comment(
            self.db,
            instance_id="i1",
            tenant_no="zkxw",
            user_id="u1",
            dept_id="d1",
            content="请看附件",
            attachment_refs=[{"fieldKey": "files", "url": "https://x/a.pdf"}],
        )
        self.assertEqual(row["status"], "normal")
        self.assertTrue(row["canRevoke"])
        self.assertEqual(len(row["attachmentRefs"]), 1)

        listed = svc.list_comments(
            self.db, instance_id="i1", tenant_no="zkxw", viewer_user_id="u1"
        )
        self.assertEqual(len(listed), 1)

        revoked = svc.revoke_comment(
            self.db,
            instance_id="i1",
            comment_id=row["id"],
            tenant_no="zkxw",
            user_id="u1",
        )
        self.assertEqual(revoked["status"], "revoked")
        self.assertFalse(revoked["canRevoke"])

        # 幂等
        again = svc.revoke_comment(
            self.db,
            instance_id="i1",
            comment_id=row["id"],
            tenant_no="zkxw",
            user_id="u1",
        )
        self.assertEqual(again["status"], "revoked")

    def test_cannot_comment_when_not_running(self):
        from app.services import oa_instance_comment as svc
        from app.services.oa_engine import OaEngineError
        from app.models.models import OaInstance

        inst = self.db.query(OaInstance).filter(OaInstance.id == "i1").one()
        inst.status = "completed"
        self.db.commit()
        with self.assertRaises(OaEngineError):
            svc.create_comment(
                self.db,
                instance_id="i1",
                tenant_no="zkxw",
                user_id="u1",
                dept_id="d1",
                content="x",
                attachment_refs=None,
            )

文件顶部补:import json、from datetime import datetime。

  • Step 2: Run 确认失败
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py::TestCommentCrud -v

Expected: FAIL(缺 create_comment 等)

  • Step 3: 实现 CRUD

在 oa_instance_comment.py 追加:

def _fmt_time(dt: Optional[datetime]) -> str:
    return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else ""


def _comment_to_dict(
    row: OaInstanceComment,
    *,
    name_map: Dict[str, str],
    viewer_user_id: str,
    instance_status: str,
) -> Dict[str, Any]:
    refs = _loads(row.attachment_refs_json, [])
    if not isinstance(refs, list):
        refs = []
    status = (row.status or "normal").strip() or "normal"
    can_revoke = (
        status == "normal"
        and instance_status == "running"
        and (row.create_by or "") == (viewer_user_id or "")
    )
    return {
        "id": row.id,
        "instanceId": row.instance_id,
        "content": row.content or "",
        "attachmentRefs": refs,
        "status": status,
        "revokedTime": _fmt_time(row.revoked_time),
        "createBy": row.create_by or "",
        "createByName": name_map.get(row.create_by or "", row.create_by or ""),
        "createTime": _fmt_time(row.create_time),
        "canRevoke": can_revoke,
    }


def _load_version_schema(db: Session, inst: OaInstance) -> Any:
    ver = (
        db.query(OaAppVersion)
        .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
        .first()
    )
    if not ver:
        return {}
    return _loads(ver.form_schema_json, {})


def list_comments(
    db: Session,
    *,
    instance_id: str,
    tenant_no: str,
    viewer_user_id: str,
) -> List[Dict[str, Any]]:
    inst = _get_instance(db, instance_id, tenant_no)
    rows = (
        db.query(OaInstanceComment)
        .filter(
            OaInstanceComment.instance_id == instance_id,
            OaInstanceComment.tenant_no == tenant_no,
            OaInstanceComment.del_flag == 0,
        )
        .order_by(OaInstanceComment.create_time.asc())
        .all()
    )
    name_map = _user_name_map(db, [r.create_by for r in rows if r.create_by])
    return [
        _comment_to_dict(
            r,
            name_map=name_map,
            viewer_user_id=viewer_user_id,
            instance_status=inst.status or "",
        )
        for r in rows
    ]


def list_form_attachments(
    db: Session, *, instance_id: str, tenant_no: str
) -> List[Dict[str, Any]]:
    inst = _get_instance(db, instance_id, tenant_no)
    schema = _load_version_schema(db, inst)
    form = _loads(inst.form_data_json, {})
    if not isinstance(form, dict):
        form = {}
    return enumerate_form_attachments(schema, form)


def create_comment(
    db: Session,
    *,
    instance_id: str,
    tenant_no: str,
    user_id: str,
    dept_id: str,
    content: str,
    attachment_refs: Optional[List[Dict[str, Any]]],
) -> Dict[str, Any]:
    inst = _get_instance(db, instance_id, tenant_no)
    if (inst.status or "") != "running":
        raise OaEngineError("仅审批进行中可评论")
    text = (content or "").strip()
    if not text:
        raise OaEngineError("评论内容不能为空")
    if len(text) > CONTENT_MAX:
        raise OaEngineError(f"评论内容不能超过{CONTENT_MAX}字")
    schema = _load_version_schema(db, inst)
    form = _loads(inst.form_data_json, {})
    if not isinstance(form, dict):
        form = {}
    refs = normalize_attachment_refs(schema, form, attachment_refs)
    now = datetime.now()
    row = OaInstanceComment(
        id=generate_uuid(),
        instance_id=instance_id,
        content=text,
        attachment_refs_json=json.dumps(refs, ensure_ascii=False) if refs else None,
        status="normal",
        revoked_time=None,
        tenant_no=tenant_no,
        create_by=user_id,
        create_dept_id=dept_id or "",
        create_time=now,
        update_by=user_id,
        update_time=now,
        del_flag=0,
    )
    db.add(row)
    db.commit()
    db.refresh(row)
    name_map = _user_name_map(db, [user_id])
    return _comment_to_dict(
        row,
        name_map=name_map,
        viewer_user_id=user_id,
        instance_status=inst.status or "",
    )


def revoke_comment(
    db: Session,
    *,
    instance_id: str,
    comment_id: str,
    tenant_no: str,
    user_id: str,
) -> Dict[str, Any]:
    inst = _get_instance(db, instance_id, tenant_no)
    if (inst.status or "") != "running":
        raise OaEngineError("仅审批进行中可撤回评论")
    row = (
        db.query(OaInstanceComment)
        .filter(
            OaInstanceComment.id == comment_id,
            OaInstanceComment.instance_id == instance_id,
            OaInstanceComment.tenant_no == tenant_no,
            OaInstanceComment.del_flag == 0,
        )
        .first()
    )
    if not row:
        raise OaEngineError("评论不存在")
    if (row.create_by or "") != (user_id or ""):
        raise OaEngineError("只能撤回自己的评论")
    now = datetime.now()
    if (row.status or "") != "revoked":
        row.status = "revoked"
        row.revoked_time = now
        row.update_by = user_id
        row.update_time = now
        db.add(row)
        db.commit()
        db.refresh(row)
    name_map = _user_name_map(db, [row.create_by] if row.create_by else [])
    return _comment_to_dict(
        row,
        name_map=name_map,
        viewer_user_id=user_id,
        instance_status=inst.status or "",
    )

注意:_loads / _get_instance / _user_name_map 若为 oa_engine 私有导出,现网其它服务已在用(见 oa_related_approval),保持一致即可。

  • Step 4: Run 全文件测试
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py -v

Expected: PASS(若 SysUser 必填列不足,按报错补全 setUp,对齐 test_oa_engine.py 中 SysUser 构造)

  • Step 5: Commit
git add backend/app/services/oa_instance_comment.py backend/tests/test_oa_instance_comment.py
git commit -m "$(cat <<'EOF'
feat(oa): add instance comment create list and revoke service

EOF
)"

Task 4: 详情注入 + HTTP API

Files:

  • Modify: backend/app/services/oa_engine.py(get_instance_detail 返回前)
  • Modify: backend/app/api/oa_approval.py

  • [ ] Step 1: 详情注入 comments

在 get_instance_detail 末尾 return data 前:

    from app.services import oa_instance_comment as oa_comment

    # viewer 在 API 层注入更准;此处无 user_id 时 canRevoke 一律 false
    data["comments"] = oa_comment.list_comments(
        db,
        instance_id=instance_id,
        tenant_no=tenant_no,
        viewer_user_id="",
    )

更好做法:给 get_instance_detail 增加可选参数 viewer_user_id: str = "",并在 get_oa_instance 传入 user.get("user_id") or "",以便 canRevoke 正确。

def get_instance_detail(
    db: Session,
    instance_id: str,
    *,
    tenant_no: str,
    viewer_user_id: str = "",
) -> Dict[str, Any]:
    ...
    from app.services import oa_instance_comment as oa_comment

    data["comments"] = oa_comment.list_comments(
        db,
        instance_id=instance_id,
        tenant_no=tenant_no,
        viewer_user_id=viewer_user_id or "",
    )
    return data

API:

return success_response(
    get_instance_detail(
        db,
        instance_id,
        tenant_no=tenant_no,
        viewer_user_id=user.get("user_id") or "",
    )
)

检查其它调用 get_instance_detail 的地方:新增参数有默认值,无需全改。

  • Step 2: 增加 Body 与路由

在 oa_approval.py 靠近 OaTransferBody 处:

class OaCommentCreateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    content: str
    attachment_refs: Optional[List[Dict[str, Any]]] = Field(
        None, alias="attachmentRefs"
    )

在 get_oa_instance 附近增加:

@router.get("/instances/{instance_id}/form-attachments")
async def list_oa_form_attachments(
    instance_id: str,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    from app.services import oa_instance_comment as oa_comment
    from app.services.oa_engine import OaEngineError

    try:
        tenant_no = _get_tenant_no(db, user)
        items = oa_comment.list_form_attachments(
            db, instance_id=instance_id, tenant_no=tenant_no
        )
        return success_response({"items": items})
    except OaEngineError as e:
        return error_response(400, str(e))


@router.get("/instances/{instance_id}/comments")
async def list_oa_comments(
    instance_id: str,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    from app.services import oa_instance_comment as oa_comment
    from app.services.oa_engine import OaEngineError

    try:
        tenant_no = _get_tenant_no(db, user)
        items = oa_comment.list_comments(
            db,
            instance_id=instance_id,
            tenant_no=tenant_no,
            viewer_user_id=user.get("user_id") or "",
        )
        return success_response({"list": items})
    except OaEngineError as e:
        return error_response(400, str(e))


@router.post("/instances/{instance_id}/comments")
async def create_oa_comment(
    instance_id: str,
    body: OaCommentCreateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    from app.services import oa_instance_comment as oa_comment
    from app.services.oa_engine import OaEngineError

    try:
        tenant_no = _get_tenant_no(db, user)
        row = oa_comment.create_comment(
            db,
            instance_id=instance_id,
            tenant_no=tenant_no,
            user_id=user.get("user_id") or "",
            dept_id=user.get("dept_id") or "",
            content=body.content,
            attachment_refs=body.attachment_refs,
        )
        return success_response(row)
    except OaEngineError as e:
        return error_response(400, str(e))


@router.post("/instances/{instance_id}/comments/{comment_id}/revoke")
async def revoke_oa_comment(
    instance_id: str,
    comment_id: str,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    from app.services import oa_instance_comment as oa_comment
    from app.services.oa_engine import OaEngineError

    try:
        tenant_no = _get_tenant_no(db, user)
        row = oa_comment.revoke_comment(
            db,
            instance_id=instance_id,
            comment_id=comment_id,
            tenant_no=tenant_no,
            user_id=user.get("user_id") or "",
        )
        return success_response(row)
    except OaEngineError as e:
        return error_response(400, str(e))

确认 user dict 是否含 dept_id:若无,用 "" 或现有 _get_dept_id 辅助(搜同文件其它 create 用法)。

  • Step 3: 跑引擎相关测确保未破
cd backend && source venv/bin/activate && pytest tests/test_oa_instance_comment.py tests/test_oa_engine.py -v --tb=line -q

Expected: PASS(或仅既有无关失败)

  • Step 4: Commit
git add backend/app/services/oa_engine.py backend/app/api/oa_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): expose instance comment APIs and embed comments in detail

EOF
)"

Task 5: 前端 API

Files:

  • Modify: web-admin/src/api/oaApproval.ts

  • [ ] Step 1: 增加类型与方法

在 fetchOaInstanceDetail 附近追加:

export type OaCommentAttachmentRef = {
  fieldKey: string
  fieldLabel?: string
  url: string
  fileName: string
}

export type OaInstanceComment = {
  id: string
  instanceId?: string
  content: string
  attachmentRefs: OaCommentAttachmentRef[]
  status: 'normal' | 'revoked' | string
  revokedTime?: string
  createBy: string
  createByName?: string
  createTime: string
  canRevoke?: boolean
}

export function fetchOaFormAttachments(instanceId: string) {
  return request.get<{ items: Array<{ fieldKey: string; fieldLabel: string; files: OaCommentAttachmentRef[] }> }>({
    url: `${BASE}/instances/${instanceId}/form-attachments`
  })
}

export function fetchOaInstanceComments(instanceId: string) {
  return request.get<{ list: OaInstanceComment[] }>({
    url: `${BASE}/instances/${instanceId}/comments`
  })
}

export function fetchCreateOaComment(
  instanceId: string,
  data: { content: string; attachmentRefs?: OaCommentAttachmentRef[] }
) {
  return request.post<OaInstanceComment>({
    url: `${BASE}/instances/${instanceId}/comments`,
    data
  })
}

export function fetchRevokeOaComment(instanceId: string, commentId: string) {
  return request.post<OaInstanceComment>({
    url: `${BASE}/instances/${instanceId}/comments/${commentId}/revoke`
  })
}
  • Step 2: Commit
git add web-admin/src/api/oaApproval.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add OA instance comment API clients

EOF
)"

Task 6: OaCommentPanel.vue

Files:

  • Create: web-admin/src/components/oa-approval/OaCommentPanel.vue

  • [ ] Step 1: 实现组件

Props:

  • instanceId: string
  • instanceStatus: string
  • comments: OaInstanceComment[](可由父传入;发送/撤回后 emit('refresh'))

行为要点:

  • running 才显示输入区与撤回。
  • 列表升序展示;revoked 显示「{createByName} 撤回了一条评论」。
  • 「引用附件」:el-dialog + el-checkbox-group,数据来自 fetchOaFormAttachments。
  • 引用芯片点击:window.open(url)(对齐 OaFcUpload preview)。
  • 撤回:ElMessageBox.confirm → fetchRevokeOaComment → emit refresh。
  • 发送:trim 非空 → fetchCreateOaComment → 清空输入与已选引用 → emit refresh。
  • 按钮带 el-icon(ChatDotRound / Paperclip / Promotion / RefreshLeft 等)。

骨架:

<template>
  <section class="oa-comment-panel">
    <h3 class="oa-comment-panel__title">评论</h3>
    <div v-if="!comments.length" class="oa-comment-panel__empty">暂无评论</div>
    <ul class="oa-comment-panel__list">
      <li v-for="c in comments" :key="c.id" class="oa-comment-panel__item">
        <template v-if="c.status === 'revoked'">
          <span class="oa-comment-panel__revoked">
            {{ c.createByName || c.createBy }} 撤回了一条评论
            <span v-if="c.revokedTime" class="oa-comment-panel__time">{{ c.revokedTime }}</span>
          </span>
        </template>
        <template v-else>
          <div class="oa-comment-panel__meta">
            <strong>{{ c.createByName || c.createBy }}</strong>
            <span class="oa-comment-panel__time">{{ c.createTime }}</span>
            <el-button
              v-if="c.canRevoke"
              link
              type="danger"
              @click="onRevoke(c)"
            >
              <el-icon><RefreshLeft /></el-icon>
              撤回
            </el-button>
          </div>
          <div class="oa-comment-panel__content">{{ c.content }}</div>
          <div v-if="c.attachmentRefs?.length" class="oa-comment-panel__refs">
            <el-tag
              v-for="(f, idx) in c.attachmentRefs"
              :key="idx"
              class="oa-comment-panel__ref"
              size="small"
              @click="openUrl(f.url)"
            >
              {{ f.fileName || f.url }}
            </el-tag>
          </div>
        </template>
      </li>
    </ul>
    <div v-if="instanceStatus === 'running'" class="oa-comment-panel__composer">
      <el-input
        v-model="draft"
        type="textarea"
        :rows="3"
        maxlength="1024"
        show-word-limit
        placeholder="填写评论"
      />
      <div v-if="picked.length" class="oa-comment-panel__picked">
        <el-tag
          v-for="(f, idx) in picked"
          :key="idx"
          closable
          size="small"
          @close="picked.splice(idx, 1)"
        >
          {{ f.fileName }}
        </el-tag>
      </div>
      <div class="oa-comment-panel__actions">
        <el-button class="btn-ghost" @click="openAttach">
          <el-icon><Paperclip /></el-icon>
          引用附件
        </el-button>
        <el-button type="primary" class="btn-main" :loading="sending" @click="onSend">
          <el-icon><Promotion /></el-icon>
          发送
        </el-button>
      </div>
    </div>
    <!-- 引用附件弹窗:多选 field 下 files -->
  </section>
</template>

样式:Fresh Mint;section 间距与 oa-detail__section 一致;不要改 OaProgressChain。

  • Step 2: Commit
git add web-admin/src/components/oa-approval/OaCommentPanel.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add OaCommentPanel for instance discussion comments

EOF
)"

Task 7: 挂到详情页与只读弹窗

Files:

  • Modify: web-admin/src/views/oa-approval/instances/detail.vue
  • Modify: web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue

  • [ ] Step 1: detail.vue

在表单 section 与 progress section 之间插入:

<section class="oa-detail__section">
  <OaCommentPanel
    v-if="detail?.id"
    :instance-id="detail.id"
    :instance-status="detail.status || ''"
    :comments="detail.comments || []"
    @refresh="load"
  />
</section>

import OaCommentPanel from '@/components/oa-approval/OaCommentPanel.vue'。确认 load 会刷新详情(含 comments)。

  • Step 2: OaInstanceDetailDialog.vue

同样在表单与进度之间挂载 OaCommentPanel,@refresh 重新 fetchOaInstanceDetail。

  • Step 3: 手工验收清单
  1. 本地执行迁移 SQL 后启动 backend + pnpm dev。
  2. 打开一张 running 审批详情 → 可见评论区。
  3. 发纯文字评论 → 列表出现。
  4. 引用表单附件 → 评论下可点开。
  5. 本人撤回 → 变灰条;他人无撤回按钮。
  6. 完成后的单 → 无输入区、不可撤,历史仍可见。
  7. 同意/拒绝意见与进度链无回归。
  • Step 4: 更新图谱(改了 backend + web-admin)
./scripts/update-graph.sh all
  • Step 5: Commit
git add web-admin/src/views/oa-approval/instances/detail.vue \
  web-admin/src/components/oa-approval/OaInstanceDetailDialog.vue \
  backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
feat(web-admin): mount instance comment panel on OA detail views

EOF
)"

Spec coverage check

Spec 要求 Task
独立表 + 模型 T1
文字评论 ≤1024 T3 create_comment
引用表单附件 + 服务端校验 T2–T3
本人撤回 + 留痕 + 幂等 T3
仅 running 可发/撤 T3
GET form-attachments / comments / POST create / revoke T4
详情注入 comments T4
Web CommentPanel + 详情挂载 T6–T7
不混入进度链 / 不改审批意见 全任务未触碰
无通知/@/新上传 未实现
小程序预留 API T4–T5 无 Web 专用字段

Placeholder scan

无 TBD /「类似 Task N」悬空步骤。


Execution handoff

Plan complete and saved to docs/superpowers/plans/2026-09-20-oa-instance-comment.md.

Two execution options:

  1. Subagent-Driven(推荐) — 每个 Task 开新子代理,任务间复审,迭代快
  2. Inline Execution — 本会话按 executing-plans 连续执行,设检查点

Which approach?