feat(templates): F3b 模板库 repo + 端点(GET/POST/DELETE /templates)

- TemplateRepo/SqlTemplateRepo(list/create/delete,owner stub 过滤,只 flush 端点 commit)
- schemas/templates.py(title/body min_length=1→422)+ routers/templates.py(删不存在→404)+ main 注册
- 单测:repo CRUD/owner 隔离/frozen + 端点 201/列出/204/422×2/404
This commit is contained in:
Yaojia Wang
2026-06-23 20:17:32 +02:00
parent 9bb453acc8
commit cfe6a535b0
8 changed files with 480 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
"""F3b GET/POST/DELETE /templates 端点(内存替身,无 DB/无网络)。
覆盖:
- POST 201 + 回显 + 端点 commitGET 列出DELETE 204 + commit
- 空 title → 422、空 body → 422schema `min_length=1`FastAPI 422
- 删不存在 → 404repo.delete 返 False → AppError NOT_FOUND
"""
from __future__ import annotations
import os
import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from ww_core.domain.template_repo import TemplateCreate, TemplateView
class _FakeTemplateRepo:
def __init__(self) -> None:
self.rows: dict[uuid.UUID, TemplateView] = {}
async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView:
view = TemplateView(
id=uuid.uuid4(),
title=data.title,
body=data.body,
category=data.category,
tool_key=data.tool_key,
)
self.rows[view.id] = view
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
return list(self.rows.values())
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
if template_id in self.rows:
del self.rows[template_id]
return True
return False
def _make_client() -> tuple[httpx.AsyncClient, _FakeTemplateRepo, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_template_repo
from ww_db import get_session
repo = _FakeTemplateRepo()
session = FakeSession()
app = create_app()
app.dependency_overrides[get_template_repo] = lambda: repo
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, repo, session
@pytest.mark.asyncio
async def test_create_template_returns_201_and_commits() -> None:
client, repo, session = _make_client()
async with client:
resp = await client.post(
"/templates",
json={"title": "爽文开局", "body": "主角穿越...", "tool_key": "expand"},
)
assert resp.status_code == 201
body = resp.json()
assert body["title"] == "爽文开局"
assert body["body"] == "主角穿越..."
assert body["tool_key"] == "expand"
assert session.commits == 1
assert len(repo.rows) == 1
@pytest.mark.asyncio
async def test_list_templates_returns_created() -> None:
client, repo, _session = _make_client()
async with client:
await client.post("/templates", json={"title": "a", "body": "b"})
resp = await client.get("/templates")
assert resp.status_code == 200
items = resp.json()
assert len(items) == 1
assert items[0]["title"] == "a"
@pytest.mark.asyncio
async def test_create_template_empty_title_returns_422() -> None:
client, _repo, _session = _make_client()
async with client:
resp = await client.post("/templates", json={"title": "", "body": "b"})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_template_empty_body_returns_422() -> None:
client, _repo, _session = _make_client()
async with client:
resp = await client.post("/templates", json={"title": "a", "body": ""})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_delete_template_returns_204_and_commits() -> None:
client, repo, session = _make_client()
async with client:
created = await client.post("/templates", json={"title": "a", "body": "b"})
tid = created.json()["id"]
resp = await client.delete(f"/templates/{tid}")
assert resp.status_code == 204
assert session.commits == 2
assert repo.rows == {}
@pytest.mark.asyncio
async def test_delete_missing_template_returns_404() -> None:
client, _repo, session = _make_client()
async with client:
resp = await client.delete(f"/templates/{uuid.uuid4()}")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
assert session.commits == 0

View File

@@ -27,6 +27,7 @@ from ww_api.routers import (
rules,
settings_providers,
style,
templates,
toolbox,
)
from ww_api.security.credentials import _fernet
@@ -115,6 +116,7 @@ def create_app() -> FastAPI:
app.include_router(outline.router)
app.include_router(rules.router)
app.include_router(style.router)
app.include_router(templates.router)
app.include_router(generation.router)
app.include_router(generation.skills_router)
app.include_router(toolbox.router)

View File

@@ -0,0 +1,90 @@
"""模板库端点F3 / 契约 §F3
单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。
**不做分享/市场**需多租户。owner_id 全程 stub单用户原型
- GET /templates 列出当前用户的模板。
- POST /templates 新建模板201title/body 空 → 422
- DELETE /templates/:id 删除模板204不存在 → 404
提交边界:`TemplateRepo.create`/`delete` 只 `flush()`,端点写后 `await session.commit()`
(仿 rules/foreshadow 写侧,见 memory/gotchas
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import TemplateCreate, TemplateRepo
from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.templates import TemplateCreateRequest, TemplateResponse
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import get_template_repo
log = get_logger("ww.api.templates")
router = APIRouter(prefix="/templates", tags=["templates"])
TemplateRepoDep = Annotated[TemplateRepo, Depends(get_template_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.get("")
async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]:
"""列出当前用户stub的模板。"""
views = await repo.list_for_owner(STUB_OWNER_ID)
return [
TemplateResponse(
id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key
)
for v in views
]
@router.post("", status_code=201)
async def create_template(
body: TemplateCreateRequest,
request: Request,
repo: TemplateRepoDep,
session: SessionDep,
) -> TemplateResponse:
"""新建模板201。title/body 空 → FastAPI 422schema `min_length=1`)。"""
request_id = getattr(request.state, "request_id", None)
view = await repo.create(
STUB_OWNER_ID,
TemplateCreate(
title=body.title, body=body.body, category=body.category, tool_key=body.tool_key
),
)
await session.commit()
log.info("template_created", template_id=str(view.id), request_id=request_id)
return TemplateResponse(
id=view.id,
title=view.title,
body=view.body,
category=view.category,
tool_key=view.tool_key,
)
@router.delete("/{template_id}", status_code=204)
async def delete_template(
template_id: uuid.UUID,
request: Request,
repo: TemplateRepoDep,
session: SessionDep,
) -> Response:
"""删除模板204。不存在 → 404 NOT_FOUND。"""
request_id = getattr(request.state, "request_id", None)
deleted = await repo.delete(STUB_OWNER_ID, template_id)
if not deleted:
raise AppError(ErrorCode.NOT_FOUND, f"template not found: {template_id}")
await session.commit()
log.info("template_deleted", template_id=str(template_id), request_id=request_id)
return Response(status_code=204)

View File

@@ -0,0 +1,31 @@
"""模板库端点的请求/响应 schemaF3 / 契约 §F3
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。
`title`/`body` 非空(`min_length=1`,空 → FastAPI 422。`category`/`tool_key` 可选。
"""
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
class TemplateCreateRequest(BaseModel):
"""POST /templates新建一条提示词模板。"""
title: str = Field(min_length=1, description="模板标题")
body: str = Field(min_length=1, description="模板正文(一键填入生成器的 brief/text")
category: str | None = Field(default=None, description="可选分类")
tool_key: str | None = Field(default=None, description="可选关联生成器NULL=通用)")
class TemplateResponse(BaseModel):
"""模板视图(列出/创建后回显snake_case"""
id: uuid.UUID
title: str
body: str
category: str | None = None
tool_key: str | None = None

View File

@@ -29,6 +29,7 @@ from ww_core.domain.repositories import MemoryRepos, OutlineRepo, RulesRepo
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo, StyleFingerprintWriteRepo
from ww_core.domain.template_repo import SqlTemplateRepo, TemplateRepo
from ww_core.domain.world_entity_repo import SqlWorldEntityWriteRepo, WorldEntityWriteRepo
from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos
from ww_db import get_session, get_sessionmaker
@@ -159,6 +160,16 @@ def get_rule_write_repo(
return SqlRuleWriteRepo(session)
async def get_template_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> TemplateRepo:
"""模板库 repoGET/POST/DELETE /templatescreate/delete 只 flush端点提交
测试经 `app.dependency_overrides` 注入内存 fake。
"""
return SqlTemplateRepo(session)
async def get_skill_registry(
session: Annotated[AsyncSession, Depends(get_session)],
) -> SkillRegistry: