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

324 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 检查四个条件:
```python
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:**
```json
{"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 个条件:
```python
def _is_manageable_post(meta, actor_id: str) -> bool:
return (
meta.draft # 必须是草稿
and not meta.handoff_to_human # 不能已移交人工
)
```
移除 `created_by == actor_id``ownership_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_draft``edit_any`
- DELETE 端点保持不变(仍然需要 `delete_own_draft``delete_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
```python
# === 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
```python
# 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