init: consolidate all ephron.ren PRDs and docs
This commit is contained in:
323
prd-service-api-publish-edit.md
Normal file
323
prd-service-api-publish-edit.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# 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 API(P2,后续单独做)
|
||||
- 修改 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/Canvas(frontmatter):**
|
||||
- `draft`, `created_by`, `ownership_type`, `handoff_to_human` 存储在 markdown 文件的 YAML frontmatter 中
|
||||
- `update_post()` 函数可修改 frontmatter 字段
|
||||
|
||||
**Prompt(SQLite):**
|
||||
- `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)
|
||||
Reference in New Issue
Block a user