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:
127
apps/api/tests/test_templates.py
Normal file
127
apps/api/tests/test_templates.py
Normal file
@@ -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
|
||||||
@@ -27,6 +27,7 @@ from ww_api.routers import (
|
|||||||
rules,
|
rules,
|
||||||
settings_providers,
|
settings_providers,
|
||||||
style,
|
style,
|
||||||
|
templates,
|
||||||
toolbox,
|
toolbox,
|
||||||
)
|
)
|
||||||
from ww_api.security.credentials import _fernet
|
from ww_api.security.credentials import _fernet
|
||||||
@@ -115,6 +116,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(outline.router)
|
app.include_router(outline.router)
|
||||||
app.include_router(rules.router)
|
app.include_router(rules.router)
|
||||||
app.include_router(style.router)
|
app.include_router(style.router)
|
||||||
|
app.include_router(templates.router)
|
||||||
app.include_router(generation.router)
|
app.include_router(generation.router)
|
||||||
app.include_router(generation.skills_router)
|
app.include_router(generation.skills_router)
|
||||||
app.include_router(toolbox.router)
|
app.include_router(toolbox.router)
|
||||||
|
|||||||
90
apps/api/ww_api/routers/templates.py
Normal file
90
apps/api/ww_api/routers/templates.py
Normal file
@@ -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)
|
||||||
31
apps/api/ww_api/schemas/templates.py
Normal file
31
apps/api/ww_api/schemas/templates.py
Normal file
@@ -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
|
||||||
@@ -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.review_repo import ReviewRepo, SqlReviewRepo
|
||||||
from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo
|
from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo
|
||||||
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo, StyleFingerprintWriteRepo
|
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.domain.world_entity_repo import SqlWorldEntityWriteRepo, WorldEntityWriteRepo
|
||||||
from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos
|
from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos
|
||||||
from ww_db import get_session, get_sessionmaker
|
from ww_db import get_session, get_sessionmaker
|
||||||
@@ -159,6 +160,16 @@ def get_rule_write_repo(
|
|||||||
return SqlRuleWriteRepo(session)
|
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(
|
async def get_skill_registry(
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
) -> SkillRegistry:
|
) -> SkillRegistry:
|
||||||
|
|||||||
104
packages/core/tests/test_template_repo.py
Normal file
104
packages/core/tests/test_template_repo.py
Normal file
@@ -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"
|
||||||
@@ -60,6 +60,12 @@ from ww_core.domain.style_repo import (
|
|||||||
StyleFingerprintView,
|
StyleFingerprintView,
|
||||||
StyleFingerprintWriteRepo,
|
StyleFingerprintWriteRepo,
|
||||||
)
|
)
|
||||||
|
from ww_core.domain.template_repo import (
|
||||||
|
SqlTemplateRepo,
|
||||||
|
TemplateCreate,
|
||||||
|
TemplateRepo,
|
||||||
|
TemplateView,
|
||||||
|
)
|
||||||
from ww_core.domain.world_entity_repo import (
|
from ww_core.domain.world_entity_repo import (
|
||||||
SqlWorldEntityWriteRepo,
|
SqlWorldEntityWriteRepo,
|
||||||
WorldEntityWriteRepo,
|
WorldEntityWriteRepo,
|
||||||
@@ -116,4 +122,8 @@ __all__ = [
|
|||||||
"StyleFingerprintWriteRepo",
|
"StyleFingerprintWriteRepo",
|
||||||
"StyleFingerprintView",
|
"StyleFingerprintView",
|
||||||
"SqlStyleFingerprintWriteRepo",
|
"SqlStyleFingerprintWriteRepo",
|
||||||
|
"TemplateCreate",
|
||||||
|
"TemplateRepo",
|
||||||
|
"TemplateView",
|
||||||
|
"SqlTemplateRepo",
|
||||||
]
|
]
|
||||||
|
|||||||
105
packages/core/ww_core/domain/template_repo.py
Normal file
105
packages/core/ww_core/domain/template_repo.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user