Files
ephron-ren-prd/prd-service-api-publish-edit.md

11 KiB
Raw Blame History

PRD: Service API Publish & Edit Enhancement

Background

Service API/api/service/)是 ephron.ren 各服务的程序化管理接口,通过 Bearer Token 认证。当前 Service API 只能创建和编辑「由自己创建、未发布、未被人工修改过的草稿」,发布操作必须通过浏览器登录 Admin 面板完成。

这导致自动化工作流(如 AI Agent 内容发布)效率极低:每次创建草稿后都要浏览器登录→找到文章→点击发布按钮。

Goals

  1. Service API 支持发布/取消发布操作,消除浏览器依赖
  2. Service API 可编辑任意草稿(不限 created_by 和 ownership_type
  3. 保持已发布内容的安全性:已发布内容不可通过 Service API 直接修改或删除

Non-Goals

  • Service API 直接删除已发布内容(先取消发布再删除)
  • Service API 直接编辑已发布内容(先取消发布再编辑再发布)
  • 新增 Collection CRUD APIP2后续单独做
  • 修改 Admin 面板功能

Affected Services

Service Storage Service API File Admin File
Blog Markdown files + frontmatter blog/src/routes/service_api.py blog/src/routes/admin.py
Prompt SQLite prompt/src/routes/service_api.py prompt/src/routes/admin.py
Canvas Markdown files + frontmatter canvas/src/routes/service_api.py canvas/src/routes/admin.py

Current State Analysis

Permission System

权限已在 auth/src/services/db.py 的 seed data 中定义:

blog.post.publish        # 发布博客
blog.post.edit_own_draft # 编辑自己的草稿
blog.post.edit_any       # 编辑任意博客
blog.post.delete_own_draft
prompt.entry.publish
prompt.entry.edit_own_draft
prompt.entry.edit_any
canvas.item.publish
canvas.item.edit_own_draft
canvas.item.edit_any

Admin 面板的 toggle-draft 已在使用 *.publish 权限。Service API 未接入。

Current Guard Functions

Blog/Canvas 的 _is_manageable_post / _is_manageable_canvas 检查四个条件:

def _is_manageable_post(meta, actor_id: str) -> bool:
    return (
        meta.created_by == actor_id           # 必须是自己创建的
        and meta.ownership_type == "service"  # 必须是 service 创建的
        and meta.draft                        # 必须是草稿
        and not meta.handoff_to_human         # 不能已移交人工
    )

Prompt 的 _can_manage_own_draft 逻辑相同。

Storage Details

Blog/Canvasfrontmatter

  • draft, created_by, ownership_type, handoff_to_human 存储在 markdown 文件的 YAML frontmatter 中
  • update_post() 函数可修改 frontmatter 字段

PromptSQLite

  • draft, created_by, ownership_type, handoff_to_human 存储在 prompts 表的列中
  • update_prompt() 函数可修改这些字段

Requirements

R1: Publish Endpoint

Endpoint: POST /api/service/{type}/{id}/publish

Field Blog Prompt Canvas
Path param {id} = slug {id} = key {id} = slug
Permission blog.post.publish prompt.entry.publish canvas.item.publish

Behavior:

  1. 验证 Bearer Token → 获取 actor
  2. 检查权限actor 必须拥有 {type}.post.publish{type}.entry.publish
  3. 查找资源(包含草稿)
  4. 如果资源不存在 → 404
  5. 如果 handoff_to_human == true → 403提示「已移交人工无法操作」
  6. 如果 draft == false(已经是发布状态)→ 返回成功(幂等)
  7. 设置 draft = false,记录 updated_by = actor_id
  8. 记录审计日志
  9. 返回 {"success": true, "slug": "...", "draft": false}

Request: 无 body

Response:

{"success": true, "slug": "my-post", "draft": false}

R2: Unpublish Endpoint

Endpoint: POST /api/service/{type}/{id}/unpublish

与 Publish 对称,将 draft 设为 true

Behavior:

  1. 验证 Bearer Token → 获取 actor
  2. 检查权限:{type}.post.publish / {type}.entry.publish
  3. 查找资源(包含草稿)
  4. 如果资源不存在 → 404
  5. 如果 handoff_to_human == true → 403
  6. 如果 draft == true(已经是草稿状态)→ 返回成功(幂等)
  7. 设置 draft = true,记录 updated_by = actor_id
  8. 记录审计日志
  9. 返回 {"success": true, "slug": "...", "draft": true}

R3: Relax PATCH Guard

Current: _is_manageable_post / _can_manage_own_draft 检查 4 个条件

New: 放宽为 2 个条件:

def _is_manageable_post(meta, actor_id: str) -> bool:
    return (
        meta.draft                        # 必须是草稿
        and not meta.handoff_to_human     # 不能已移交人工
    )

移除 created_by == actor_idownership_type == "service" 限制。

影响范围:

  • Blog service_api.py_is_manageable_post, _can_manage_own_draft
  • Canvas service_api.py_is_manageable_canvas, _can_manage_own_draft
  • Prompt service_api.py_can_manage_own_draft

权限检查调整:

  • PATCH 端点当前使用 edit_own_draft 权限 → 改为同时接受 edit_own_draftedit_any
  • DELETE 端点保持不变(仍然需要 delete_own_draftdelete_any

R4: GET Endpoint Enhancement (P1)

Current: GET /api/service/{type} 只返回 created_by == actor_id && ownership_type == "service" 的草稿

New: 增加可选查询参数 status

  • status=draft(默认):只返回自己的草稿(现有行为)
  • status=all:返回所有内容(含已发布),但只有草稿可编辑

Implementation Details

Blog Service API Changes

# === R3: Relax guard ===

# BEFORE
def _is_manageable_post(meta, actor_id: str) -> bool:
    return (
        meta.created_by == actor_id
        and meta.ownership_type == "service"
        and meta.draft
        and not meta.handoff_to_human
    )

# AFTER
def _is_manageable_post(meta, actor_id: str) -> bool:
    return meta.draft and not meta.handoff_to_human


# === R1/R2: New endpoints ===

@router.post("/posts/{slug}/publish")
async def publish_service_post(
    slug: str,
    actor: dict = Depends(_require_service_actor_dep),
):
    if not _has_any_service_permission(actor, ("blog.post.publish",)):
        raise HTTPException(status_code=403, detail="Missing permission")

    post = get_post_by_slug(slug, include_drafts=True)
    if post is None:
        raise HTTPException(status_code=404, detail="Post not found")

    if post.meta.handoff_to_human:
        raise HTTPException(status_code=403, detail="Post handed off to human")

    if not post.meta.draft:
        return {"success": True, "slug": slug, "draft": False}

    success = update_post(slug=slug, draft=False, updated_by=actor["actor_id"])
    if not success:
        raise HTTPException(status_code=500, detail="Failed to publish")

    _record_service_audit(
        actor_id=actor["actor_id"],
        action="publish",
        result="success",
        resource_id=slug,
    )
    return {"success": True, "slug": slug, "draft": False}


@router.post("/posts/{slug}/unpublish")
async def unpublish_service_post(
    slug: str,
    actor: dict = Depends(_require_service_actor_dep),
):
    if not _has_any_service_permission(actor, ("blog.post.publish",)):
        raise HTTPException(status_code=403, detail="Missing permission")

    post = get_post_by_slug(slug, include_drafts=True)
    if post is None:
        raise HTTPException(status_code=404, detail="Post not found")

    if post.meta.handoff_to_human:
        raise HTTPException(status_code=403, detail="Post handed off to human")

    if post.meta.draft:
        return {"success": True, "slug": slug, "draft": True}

    success = update_post(slug=slug, draft=True, updated_by=actor["actor_id"])
    if not success:
        raise HTTPException(status_code=500, detail="Failed to unpublish")

    _record_service_audit(
        actor_id=actor["actor_id"],
        action="unpublish",
        result="success",
        resource_id=slug,
    )
    return {"success": True, "slug": slug, "draft": True}

Prompt 和 Canvas 同理,路径参数和函数名对应替换。

PATCH Endpoint Changes

# BEFORE
@router.patch("/posts/{slug}")
async def update_service_post(...):
    if not _can_manage_own_draft(actor["actor_id"], slug):
        raise HTTPException(status_code=403, detail="Cannot edit this draft")

# AFTER
@router.patch("/posts/{slug}")
async def update_service_post(...):
    post = get_post_by_slug(slug, include_drafts=True)
    if post is None:
        raise HTTPException(status_code=404, detail="Post not found")
    if not _is_manageable_post(post.meta, actor["actor_id"]):
        raise HTTPException(status_code=403, detail="Cannot edit this post")
    # check edit permission
    if not _has_any_service_permission(actor, ("blog.post.edit_own_draft", "blog.post.edit_any")):
        raise HTTPException(status_code=403, detail="Missing permission")

Audit Events

新增审计事件类型:

  • service.publish — 通过 Service API 发布
  • service.unpublish — 通过 Service API 取消发布

复用现有 record_audit_event 函数,action 字段使用新值。

Testing

Unit Tests

每个服务的 Service API 测试文件需要新增:

  1. Publish 测试:

    • 正常发布草稿 → 成功draft=false
    • 发布已发布的内容 → 幂等,返回 success
    • 无 publish 权限 → 403
    • 不存在的 slug → 404
    • handoff_to_human=true → 403
  2. Unpublish 测试:

    • 正常取消发布 → 成功draft=true
    • 取消已取消的内容 → 幂等,返回 success
    • 无 publish 权限 → 403
  3. PATCH 放宽测试:

    • 编辑由其他 service account 创建的草稿 → 成功
    • 编辑由 human 创建的草稿 → 成功(如果有 edit_any 权限)
    • 编辑已发布内容 → 403
    • 编辑 handoff_to_human=true 的内容 → 403

Integration Test

完整工作流:

1. POST /api/service/posts → 创建草稿 (draft=true)
2. PATCH /api/service/posts/{slug} → 编辑内容
3. POST /api/service/posts/{slug}/publish → 发布 (draft=false)
4. PATCH /api/service/posts/{slug} → 403 (已发布不可编辑)
5. POST /api/service/posts/{slug}/unpublish → 取消发布 (draft=true)
6. PATCH /api/service/posts/{slug} → 成功 (草稿可编辑)
7. POST /api/service/posts/{slug}/publish → 重新发布
8. POST /api/service/posts/{slug}/unpublish → 取消发布
9. DELETE /api/service/posts/{slug} → 删除草稿

Migration

无需数据库迁移。权限已存在于 seed data 中。

需要确认 Service Account 的 Role 是否已绑定 *.publish*.edit_any 权限。如果没有,需要通过 Admin 面板手动绑定。

Rollout

  1. 先在 Blog 服务实现并测试
  2. 复制到 Prompt 和 Canvas 服务
  3. 验证 Service Account 权限配置
  4. 更新文档content-ops-agent skill