diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 74aec2f1..63148d46 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -30,11 +30,13 @@ SensitiveWordReloadSubscriber, service as sensitive_word_service, ) +from windup_app.server.style_preset.model import StylePreset # noqa: F401 from windup_app.server.user.model import User # noqa: F401 from windup_app.server.workflow_run.model import WorkflowRun # noqa: F401 from windup_app.server.action_preset import ACTION_PRESETS from windup_app.web.api.action_preset import router as action_preset_router from windup_app.web.api.admin_quota import router as admin_quota_router +from windup_app.web.api.style_preset import router as style_preset_router from windup_app.web.api.agent import router as agent_router from windup_framework.mq.model import MqMessage # noqa: F401 from windup_app.web.api.auth import router as auth_router @@ -181,6 +183,7 @@ def health() -> dict[str, str]: app.include_router(agent_router) app.include_router(action_preset_router) app.include_router(pixel_perfect_router) + app.include_router(style_preset_router) # 母版预检与建 3D 资产:web 层不能静态依赖 ai_engine,由 state 注入。 app.state.precheck_master = precheck_master app.state.render3d_operations = default_operations() diff --git a/backend/packages/app/src/windup_app/bootstrap/worker.py b/backend/packages/app/src/windup_app/bootstrap/worker.py index 6e4024e6..e63d7e5a 100644 --- a/backend/packages/app/src/windup_app/bootstrap/worker.py +++ b/backend/packages/app/src/windup_app/bootstrap/worker.py @@ -23,6 +23,7 @@ from windup_app.worker.pending_timeout import release_stale_pending_tasks from windup_framework.db import Base, SessionLocal, engine from windup_framework.mq.model import MqMessage # noqa: F401 — register metadata +from windup_app.server.style_preset.model import StylePreset # noqa: F401 — register metadata from windup_framework.mq.publisher import MqPublisher from windup_framework.mq.relay import relay_pending_messages from windup_framework.sse.bridge import RedisTaskEventBridge diff --git a/backend/packages/app/src/windup_app/server/style_preset/__init__.py b/backend/packages/app/src/windup_app/server/style_preset/__init__.py new file mode 100644 index 00000000..cc20e1f7 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/style_preset/__init__.py @@ -0,0 +1,5 @@ +"""画风预设目录。""" + +from windup_app.server.style_preset.model import StylePreset + +__all__ = ["StylePreset"] diff --git a/backend/packages/app/src/windup_app/server/style_preset/model.py b/backend/packages/app/src/windup_app/server/style_preset/model.py new file mode 100644 index 00000000..20bcc2c6 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/style_preset/model.py @@ -0,0 +1,44 @@ +"""画风预设 ORM。全局目录,运营增删行即可扩展风格,不改表。""" + +from datetime import datetime, timezone + +from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from windup_framework.db import Base + + +class StylePreset(Base): + """画风预设表。 + + 一行 = 一种可选画风。前端选出后把 ``prompt`` / ``sample_url`` / ``sprite_width`` + / ``sprite_height`` 填进 Project 已有字段;``stylize`` 给生成管线(含三渲二出口)。 + """ + + __tablename__ = "windup_style_preset" + __table_args__ = (UniqueConstraint("code", name="uq_windup_style_preset_code"),) + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + code: Mapped[str] = mapped_column(String(32), nullable=False) + name: Mapped[str] = mapped_column(String(40), nullable=False) + kind: Mapped[str] = mapped_column(String(32), nullable=False) + prompt: Mapped[str] = mapped_column(Text, nullable=False) + sample_url: Mapped[str] = mapped_column(Text, nullable=False) + stylize: Mapped[str] = mapped_column(String(16), nullable=False) + sprite_width: Mapped[int] = mapped_column(SmallInteger, nullable=False) + sprite_height: Mapped[int] = mapped_column(SmallInteger, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + enabled: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1) + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/packages/app/src/windup_app/server/style_preset/service.py b/backend/packages/app/src/windup_app/server/style_preset/service.py new file mode 100644 index 00000000..04ea8dbc --- /dev/null +++ b/backend/packages/app/src/windup_app/server/style_preset/service.py @@ -0,0 +1,36 @@ +"""画风预设读写。""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from windup_app.server.style_preset.model import StylePreset + +service: "StylePresetService" + + +class StylePresetService: + def list_enabled(self, session: Session) -> list[StylePreset]: + stmt = ( + select(StylePreset) + .where(StylePreset.enabled == 1) + .order_by(StylePreset.sort_order, StylePreset.id) + ) + return list(session.scalars(stmt)) + + def get(self, session: Session, preset_id: int) -> StylePreset | None: + return session.get(StylePreset, preset_id) + + def create(self, session: Session, **fields) -> StylePreset: + preset = StylePreset(**fields) + session.add(preset) + session.flush() + return preset + + def update(self, session: Session, preset: StylePreset, **fields) -> StylePreset: + for key, value in fields.items(): + setattr(preset, key, value) + session.flush() + return preset + + +service = StylePresetService() diff --git a/backend/packages/app/src/windup_app/web/api/style_preset.py b/backend/packages/app/src/windup_app/web/api/style_preset.py new file mode 100644 index 00000000..fa303ade --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/style_preset.py @@ -0,0 +1,115 @@ +"""画风预设 API。 + +GET 给前端选档;POST/PATCH 给管理端维护目录。无独立 admin 角色,与其余业务接口同一登录门。 +""" + +from datetime import datetime +from typing import Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ConfigDict, Field, model_validator +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.db import get_session + +from windup_app.server.style_preset.service import service + +router = APIRouter(prefix="/style-presets", tags=["style-presets"]) + +_STYLIZE = Literal["pixel", "none"] + + +class StylePresetCreate(BaseModel): + code: str = Field(min_length=1, max_length=32) + name: str = Field(min_length=1, max_length=40) + kind: str = Field(min_length=1, max_length=32) + prompt: str = Field(min_length=1) + sample_url: str = Field(min_length=1) + stylize: _STYLIZE + sprite_width: int = Field(ge=32, le=2048) + sprite_height: int = Field(ge=32, le=2048) + sort_order: int = 0 + enabled: int = Field(default=1, ge=0, le=1) + + +class StylePresetUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=40) + kind: str | None = Field(default=None, min_length=1, max_length=32) + prompt: str | None = Field(default=None, min_length=1) + sample_url: str | None = Field(default=None, min_length=1) + stylize: _STYLIZE | None = None + sprite_width: int | None = Field(default=None, ge=32, le=2048) + sprite_height: int | None = Field(default=None, ge=32, le=2048) + sort_order: int | None = None + enabled: int | None = Field(default=None, ge=0, le=1) + + @model_validator(mode="before") + @classmethod + def reject_explicit_null(cls, data: object) -> object: + if isinstance(data, dict): + for key, value in data.items(): + if value is None: + raise ValueError(f"{key} 不能为 null") + return data + + +class StylePresetOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + name: str + kind: str + prompt: str + sample_url: str + stylize: str + sprite_width: int + sprite_height: int + sort_order: int + enabled: int + create_at: datetime + update_at: datetime + + +@router.get("", response_model=ListResponse[StylePresetOut]) +def list_style_presets(session: Session = Depends(get_session)) -> ListResponse[StylePresetOut]: + rows = service.list_enabled(session) + return ListResponse.success( + [StylePresetOut.model_validate(row) for row in rows], + total=len(rows), + page=1, + page_size=0, + ) + + +@router.post("", response_model=Response[StylePresetOut]) +def create_style_preset( + body: StylePresetCreate, + session: Session = Depends(get_session), +) -> Response[StylePresetOut]: + try: + preset = service.create(session, **body.model_dump()) + except IntegrityError: + session.rollback() + raise BizException("画风编码已存在", code=BizCode.BAD_REQUEST) from None + return Response.success(StylePresetOut.model_validate(preset), message="创建成功") + + +@router.patch("/{preset_id}", response_model=Response[StylePresetOut]) +def update_style_preset( + preset_id: int, + body: StylePresetUpdate, + session: Session = Depends(get_session), +) -> Response[StylePresetOut]: + preset = service.get(session, preset_id) + if preset is None: + raise BizException("画风预设不存在", code=BizCode.NOT_FOUND) + fields = body.model_dump(exclude_unset=True) + if not fields: + return Response.success(StylePresetOut.model_validate(preset), message="更新成功") + preset = service.update(session, preset, **fields) + return Response.success(StylePresetOut.model_validate(preset), message="更新成功") diff --git a/backend/scripts/schema_sync.py b/backend/scripts/schema_sync.py index f7395eab..75450181 100644 --- a/backend/scripts/schema_sync.py +++ b/backend/scripts/schema_sync.py @@ -38,6 +38,7 @@ def _load_models() -> None: import windup_app.server.project.model # noqa: F401 import windup_app.server.quota.model # noqa: F401 import windup_app.server.sensitive_word.model # noqa: F401 + import windup_app.server.style_preset.model # noqa: F401 import windup_app.server.user.model # noqa: F401 import windup_framework.gateway.models # noqa: F401 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d9193d0f..fa57de09 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,6 +23,7 @@ from windup_app.bootstrap.app import create_app from windup_app.server.character.model import Character from windup_app.server.project.model import Project +from windup_app.server.style_preset.model import StylePreset from windup_app.server.quota.model import ( CreditAccount, CreditRedemptionCode, @@ -123,6 +124,7 @@ def engine(): engine, tables=[ Project.__table__, + StylePreset.__table__, User.__table__, Character.__table__, WorkflowRun.__table__, diff --git a/backend/tests/test_style_preset.py b/backend/tests/test_style_preset.py new file mode 100644 index 00000000..51465f58 --- /dev/null +++ b/backend/tests/test_style_preset.py @@ -0,0 +1,92 @@ +"""画风预设目录:建表字段 + 列表/写入接口。""" + +from windup_app.server.style_preset.model import StylePreset + + +def _payload(**overrides): + body = { + "code": "pixel_mid", + "name": "中像素", + "kind": "pixel", + "prompt": "pixel art", + "sample_url": "https://cdn.windup.test/pixel-mid.png", + "stylize": "pixel", + "sprite_width": 64, + "sprite_height": 64, + "sort_order": 20, + } + body.update(overrides) + return body + + +def test_create_and_list_enabled_presets_in_sort_order(auth_client): + first = auth_client.post( + "/style-presets", + json=_payload(code="cartoon", name="卡通", kind="cartoon", stylize="none", sort_order=30), + ).json() + second = auth_client.post("/style-presets", json=_payload()).json() + hidden = auth_client.post( + "/style-presets", + json=_payload(code="pixel_low", name="低像素", sort_order=10, enabled=0), + ).json() + + assert first["code"] == 200 + assert second["code"] == 200 + assert hidden["code"] == 200 + assert second["data"]["sprite_width"] == 64 + assert second["data"]["sprite_height"] == 64 + assert second["data"]["sample_url"] == "https://cdn.windup.test/pixel-mid.png" + + listed = auth_client.get("/style-presets").json() + assert listed["code"] == 200 + assert [item["code"] for item in listed["data"]] == ["pixel_mid", "cartoon"] + assert listed["total"] == 2 + + +def test_list_style_presets_requires_login(client): + body = client.get("/style-presets").json() + assert body["code"] == 401 + + +def test_duplicate_code_returns_400(auth_client): + auth_client.post("/style-presets", json=_payload()) + body = auth_client.post("/style-presets", json=_payload(name="另一个中像素")).json() + assert body["code"] == 400 + assert body["message"] == "画风编码已存在" + + +def test_update_can_disable_and_drop_from_list(auth_client): + created = auth_client.post("/style-presets", json=_payload()).json()["data"] + patched = auth_client.patch( + f"/style-presets/{created['id']}", + json={"enabled": 0, "sprite_width": 128, "sprite_height": 128}, + ).json() + assert patched["code"] == 200 + assert patched["data"]["enabled"] == 0 + assert patched["data"]["sprite_width"] == 128 + + listed = auth_client.get("/style-presets").json() + assert listed["data"] == [] + + +def test_update_missing_preset_returns_404(auth_client): + body = auth_client.patch("/style-presets/99999", json={"name": "不存在"}).json() + assert body["code"] == 404 + + +def test_update_rejects_explicit_null(auth_client): + created = auth_client.post("/style-presets", json=_payload()).json()["data"] + body = auth_client.patch( + f"/style-presets/{created['id']}", + json={"name": None}, + ).json() + assert body["code"] == 400 + assert "不能为 null" in body["message"] + persisted = auth_client.get("/style-presets").json()["data"][0] + assert persisted["name"] == "中像素" + + +def test_style_preset_table_has_project_aligned_sprite_columns(): + assert StylePreset.__table__.c.sprite_width.name == "sprite_width" + assert StylePreset.__table__.c.sprite_height.name == "sprite_height" + assert StylePreset.__table__.c.sample_url.nullable is False diff --git a/openapi.json b/openapi.json index 479a9391..568d4843 100644 --- a/openapi.json +++ b/openapi.json @@ -2095,6 +2095,65 @@ "title": "ListResponse[ProjectListOut]", "type": "object" }, + "ListResponse_StylePresetOut_": { + "properties": { + "code": { + "default": 200, + "description": "业务状态码:成功 200,失败非 200", + "title": "Code", + "type": "integer" + }, + "data": { + "description": "业务数据列表", + "items": { + "$ref": "#/components/schemas/StylePresetOut" + }, + "title": "Data", + "type": "array" + }, + "message": { + "default": "success", + "description": "提示信息", + "title": "Message", + "type": "string" + }, + "page": { + "default": 1, + "description": "当前页码,从 1 开始", + "minimum": 1.0, + "title": "Page", + "type": "integer" + }, + "page_size": { + "default": 0, + "description": "每页条数;0 表示不分页(全量)", + "minimum": 0.0, + "title": "Page Size", + "type": "integer" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "响应时间;默认不携带,不携带时省略", + "title": "Timestamp" + }, + "total": { + "default": 0, + "description": "数据总数;分页时为查询条件总数,不分页时为 len(data)", + "title": "Total", + "type": "integer" + } + }, + "title": "ListResponse[StylePresetOut]", + "type": "object" + }, "ListResponse_WorkflowRunOut_": { "properties": { "code": { @@ -3207,6 +3266,48 @@ "title": "Response[RedeemCodeOut]", "type": "object" }, + "Response_StylePresetOut_": { + "properties": { + "code": { + "default": 200, + "description": "业务状态码:成功 200,失败非 200", + "title": "Code", + "type": "integer" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/StylePresetOut" + }, + { + "type": "null" + } + ], + "description": "业务数据" + }, + "message": { + "default": "success", + "description": "提示信息", + "title": "Message", + "type": "string" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "响应时间;默认不携带,不携带时省略", + "title": "Timestamp" + } + }, + "title": "Response[StylePresetOut]", + "type": "object" + }, "Response_TokenResponse_": { "properties": { "code": { @@ -3456,6 +3557,278 @@ "title": "SetPasswordRequest", "type": "object" }, + "StylePresetCreate": { + "properties": { + "code": { + "maxLength": 32, + "minLength": 1, + "title": "Code", + "type": "string" + }, + "enabled": { + "default": 1, + "maximum": 1.0, + "minimum": 0.0, + "title": "Enabled", + "type": "integer" + }, + "kind": { + "maxLength": 32, + "minLength": 1, + "title": "Kind", + "type": "string" + }, + "name": { + "maxLength": 40, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "prompt": { + "minLength": 1, + "title": "Prompt", + "type": "string" + }, + "sample_url": { + "minLength": 1, + "title": "Sample Url", + "type": "string" + }, + "sort_order": { + "default": 0, + "title": "Sort Order", + "type": "integer" + }, + "sprite_height": { + "maximum": 2048.0, + "minimum": 32.0, + "title": "Sprite Height", + "type": "integer" + }, + "sprite_width": { + "maximum": 2048.0, + "minimum": 32.0, + "title": "Sprite Width", + "type": "integer" + }, + "stylize": { + "enum": [ + "pixel", + "none" + ], + "title": "Stylize", + "type": "string" + } + }, + "required": [ + "code", + "name", + "kind", + "prompt", + "sample_url", + "stylize", + "sprite_width", + "sprite_height" + ], + "title": "StylePresetCreate", + "type": "object" + }, + "StylePresetOut": { + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "create_at": { + "format": "date-time", + "title": "Create At", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "integer" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "prompt": { + "title": "Prompt", + "type": "string" + }, + "sample_url": { + "title": "Sample Url", + "type": "string" + }, + "sort_order": { + "title": "Sort Order", + "type": "integer" + }, + "sprite_height": { + "title": "Sprite Height", + "type": "integer" + }, + "sprite_width": { + "title": "Sprite Width", + "type": "integer" + }, + "stylize": { + "title": "Stylize", + "type": "string" + }, + "update_at": { + "format": "date-time", + "title": "Update At", + "type": "string" + } + }, + "required": [ + "id", + "code", + "name", + "kind", + "prompt", + "sample_url", + "stylize", + "sprite_width", + "sprite_height", + "sort_order", + "enabled", + "create_at", + "update_at" + ], + "title": "StylePresetOut", + "type": "object" + }, + "StylePresetUpdate": { + "properties": { + "enabled": { + "anyOf": [ + { + "maximum": 1.0, + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Enabled" + }, + "kind": { + "anyOf": [ + { + "maxLength": 32, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Kind" + }, + "name": { + "anyOf": [ + { + "maxLength": 40, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "prompt": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prompt" + }, + "sample_url": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sample Url" + }, + "sort_order": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sort Order" + }, + "sprite_height": { + "anyOf": [ + { + "maximum": 2048.0, + "minimum": 32.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sprite Height" + }, + "sprite_width": { + "anyOf": [ + { + "maximum": 2048.0, + "minimum": 32.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sprite Width" + }, + "stylize": { + "anyOf": [ + { + "enum": [ + "pixel", + "none" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stylize" + } + }, + "title": "StylePresetUpdate", + "type": "object" + }, "TokenResponse": { "description": "登录/注册/刷新成功响应。", "properties": { @@ -6259,6 +6632,118 @@ ] } }, + "/style-presets": { + "get": { + "operationId": "list_style_presets_style_presets_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse_StylePresetOut_" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Style Presets", + "tags": [ + "style-presets" + ] + }, + "post": { + "operationId": "create_style_preset_style_presets_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StylePresetCreate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_StylePresetOut_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Style Preset", + "tags": [ + "style-presets" + ] + } + }, + "/style-presets/{preset_id}": { + "patch": { + "operationId": "update_style_preset_style_presets__preset_id__patch", + "parameters": [ + { + "in": "path", + "name": "preset_id", + "required": true, + "schema": { + "title": "Preset Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StylePresetUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_StylePresetOut_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Style Preset", + "tags": [ + "style-presets" + ] + } + }, "/tools/pixel-perfect/reconstruct": { "post": { "description": "按项目声明的精灵网格重建 PNG/JPEG,不执行网格猜测或资产写入。",