From cfe6a535b09fd97f8a9df61b50d9e9131a2da1cb Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 20:17:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(templates):=20F3b=20=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=BA=93=20repo=20+=20=E7=AB=AF=E7=82=B9=EF=BC=88GET/POST/DELE?= =?UTF-8?q?TE=20/templates=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/tests/test_templates.py | 127 ++++++++++++++++++ apps/api/ww_api/main.py | 2 + apps/api/ww_api/routers/templates.py | 90 +++++++++++++ apps/api/ww_api/schemas/templates.py | 31 +++++ apps/api/ww_api/services/project_deps.py | 11 ++ packages/core/tests/test_template_repo.py | 104 ++++++++++++++ packages/core/ww_core/domain/__init__.py | 10 ++ packages/core/ww_core/domain/template_repo.py | 105 +++++++++++++++ 8 files changed, 480 insertions(+) create mode 100644 apps/api/tests/test_templates.py create mode 100644 apps/api/ww_api/routers/templates.py create mode 100644 apps/api/ww_api/schemas/templates.py create mode 100644 packages/core/tests/test_template_repo.py create mode 100644 packages/core/ww_core/domain/template_repo.py diff --git a/apps/api/tests/test_templates.py b/apps/api/tests/test_templates.py new file mode 100644 index 0000000..b96ca6f --- /dev/null +++ b/apps/api/tests/test_templates.py @@ -0,0 +1,127 @@ +"""F3b GET/POST/DELETE /templates 端点(内存替身,无 DB/无网络)。 + +覆盖: +- POST 201 + 回显 + 端点 commit;GET 列出;DELETE 204 + commit; +- 空 title → 422、空 body → 422(schema `min_length=1`,FastAPI 422); +- 删不存在 → 404(repo.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 diff --git a/apps/api/ww_api/main.py b/apps/api/ww_api/main.py index e64b5f6..e2e78c1 100644 --- a/apps/api/ww_api/main.py +++ b/apps/api/ww_api/main.py @@ -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) diff --git a/apps/api/ww_api/routers/templates.py b/apps/api/ww_api/routers/templates.py new file mode 100644 index 0000000..f3c9a2a --- /dev/null +++ b/apps/api/ww_api/routers/templates.py @@ -0,0 +1,90 @@ +"""模板库端点(F3 / 契约 §F3)。 + +单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。 +**不做分享/市场**(需多租户)。owner_id 全程 stub(单用户原型)。 + +- GET /templates 列出当前用户的模板。 +- POST /templates 新建模板(201;title/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 422(schema `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) diff --git a/apps/api/ww_api/schemas/templates.py b/apps/api/ww_api/schemas/templates.py new file mode 100644 index 0000000..24dc604 --- /dev/null +++ b/apps/api/ww_api/schemas/templates.py @@ -0,0 +1,31 @@ +"""模板库端点的请求/响应 schema(F3 / 契约 §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 diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index 3c8af1e..41bec52 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -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: + """模板库 repo(GET/POST/DELETE /templates;create/delete 只 flush,端点提交)。 + + 测试经 `app.dependency_overrides` 注入内存 fake。 + """ + return SqlTemplateRepo(session) + + async def get_skill_registry( session: Annotated[AsyncSession, Depends(get_session)], ) -> SkillRegistry: diff --git a/packages/core/tests/test_template_repo.py b/packages/core/tests/test_template_repo.py new file mode 100644 index 0000000..57be064 --- /dev/null +++ b/packages/core/tests/test_template_repo.py @@ -0,0 +1,104 @@ +"""F3b 模板库 repo 单测(契约 §F3)。 + +`TemplateRepo` = 单用户提示词模板的 list/create/delete,统一按 `owner_id` 过滤 +(单用户原型 stub)。`create`/`delete` 只 `flush()` 不 `commit()`——提交交端点事务 +(与项目其它写侧 repo 一致,见 memory/gotchas)。`delete` 返回是否删到行(端点据此 404)。 +纯内存 fake,无 DB。 +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + +import pytest +from pydantic import ValidationError +from ww_core.domain.template_repo import TemplateCreate, TemplateRepo, TemplateView + +OWNER = uuid.UUID(int=1) +OTHER_OWNER = uuid.UUID(int=2) + + +@dataclass +class _FakeTemplateRepo: + rows: dict[uuid.UUID, TemplateView] = field(default_factory=dict) + owners: dict[uuid.UUID, uuid.UUID] = field(default_factory=dict) + flushed: int = 0 + + 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 + self.owners[view.id] = owner_id + self.flushed += 1 + return view + + async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: + return [v for tid, v in self.rows.items() if self.owners[tid] == owner_id] + + async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: + if self.rows.get(template_id) is None or self.owners.get(template_id) != owner_id: + return False + del self.rows[template_id] + del self.owners[template_id] + self.flushed += 1 + return True + + +@pytest.mark.asyncio +async def test_create_then_list_returns_view() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + + view = await repo.create( + OWNER, TemplateCreate(title="爽文开局", body="主角穿越...", category=None, tool_key=None) + ) + listed = await repo.list_for_owner(OWNER) + + assert view.title == "爽文开局" + assert view.body == "主角穿越..." + assert [v.id for v in listed] == [view.id] + + +@pytest.mark.asyncio +async def test_list_filters_by_owner() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + await repo.create(OWNER, TemplateCreate(title="a", body="b")) + + assert await repo.list_for_owner(OTHER_OWNER) == [] + + +@pytest.mark.asyncio +async def test_delete_existing_returns_true() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + view = await repo.create(OWNER, TemplateCreate(title="a", body="b")) + + deleted = await repo.delete(OWNER, view.id) + + assert deleted is True + assert await repo.list_for_owner(OWNER) == [] + + +@pytest.mark.asyncio +async def test_delete_missing_returns_false() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + + assert await repo.delete(OWNER, uuid.uuid4()) is False + + +@pytest.mark.asyncio +async def test_delete_other_owner_returns_false() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + view = await repo.create(OWNER, TemplateCreate(title="a", body="b")) + + assert await repo.delete(OTHER_OWNER, view.id) is False + + +def test_template_view_is_frozen() -> None: + view = TemplateView(id=uuid.uuid4(), title="a", body="b", category=None, tool_key=None) + with pytest.raises(ValidationError): + view.title = "x" diff --git a/packages/core/ww_core/domain/__init__.py b/packages/core/ww_core/domain/__init__.py index 906c1c9..6a9f4a5 100644 --- a/packages/core/ww_core/domain/__init__.py +++ b/packages/core/ww_core/domain/__init__.py @@ -60,6 +60,12 @@ from ww_core.domain.style_repo import ( StyleFingerprintView, StyleFingerprintWriteRepo, ) +from ww_core.domain.template_repo import ( + SqlTemplateRepo, + TemplateCreate, + TemplateRepo, + TemplateView, +) from ww_core.domain.world_entity_repo import ( SqlWorldEntityWriteRepo, WorldEntityWriteRepo, @@ -116,4 +122,8 @@ __all__ = [ "StyleFingerprintWriteRepo", "StyleFingerprintView", "SqlStyleFingerprintWriteRepo", + "TemplateCreate", + "TemplateRepo", + "TemplateView", + "SqlTemplateRepo", ] diff --git a/packages/core/ww_core/domain/template_repo.py b/packages/core/ww_core/domain/template_repo.py new file mode 100644 index 0000000..72b6833 --- /dev/null +++ b/packages/core/ww_core/domain/template_repo.py @@ -0,0 +1,105 @@ +"""提示词/模板库 Repository(F3 / 契约 §F3)。 + +单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。**不做分享/市场** +(需多租户)。统一按 `owner_id` 过滤(单用户原型 stub,多租户化时由认证主体替换)。 + +提交边界:`create`/`delete` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它写侧 +repo 一致,见 memory/gotchas)。`delete` 返回是否删到行(端点据此映射 404)。 +视图是与 ORM 解耦的只读 Pydantic 快照(frozen)——路由不碰 SQLAlchemy 行。 +""" + +from __future__ import annotations + +import uuid +from typing import Protocol + +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from ww_db.models import PromptTemplate + + +class TemplateView(BaseModel): + """模板只读快照(snake_case,frozen)。""" + + model_config = {"frozen": True} + + id: uuid.UUID + title: str + body: str + category: str | None = None + tool_key: str | None = None + + +class TemplateCreate(BaseModel): + """新建模板写入字段(owner_id 由服务层补 stub)。""" + + title: str + body: str + category: str | None = None + tool_key: str | None = None + + +class TemplateRepo(Protocol): + """模板读写接口(按 owner_id 隔离;create/delete 只 flush)。""" + + async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView: ... + + async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: ... + + async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: ... + + +def _to_view(row: PromptTemplate) -> TemplateView: + return TemplateView( + id=row.id, + title=row.title, + body=row.body, + category=row.category, + tool_key=row.tool_key, + ) + + +class SqlTemplateRepo: + """SQLAlchemy 实现:写/读/删 `prompt_templates`,按 owner_id 过滤(只 flush 不 commit)。""" + + def __init__(self, session: AsyncSession) -> None: + self._s = session + + async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView: + row = PromptTemplate( + owner_id=owner_id, + title=data.title, + body=data.body, + category=data.category, + tool_key=data.tool_key, + ) + self._s.add(row) + await self._s.flush() + await self._s.refresh(row) + return _to_view(row) + + async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: + rows = ( + await self._s.execute( + select(PromptTemplate) + .where(PromptTemplate.owner_id == owner_id) + .order_by(PromptTemplate.created_at) + ) + ).scalars() + return [_to_view(r) for r in rows] + + async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: + row = ( + await self._s.execute( + select(PromptTemplate).where( + PromptTemplate.owner_id == owner_id, + PromptTemplate.id == template_id, + ) + ) + ).scalar_one_or_none() + if row is None: + return False + await self._s.delete(row) + await self._s.flush() + return True