Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/packages/app/src/windup_app/bootstrap/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions backend/packages/app/src/windup_app/bootstrap/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""画风预设目录。"""

from windup_app.server.style_preset.model import StylePreset

__all__ = ["StylePreset"]
44 changes: 44 additions & 0 deletions backend/packages/app/src/windup_app/server/style_preset/model.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
xiaocheny214 marked this conversation as resolved.
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),
)
36 changes: 36 additions & 0 deletions backend/packages/app/src/windup_app/server/style_preset/service.py
Original file line number Diff line number Diff line change
@@ -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()
115 changes: 115 additions & 0 deletions backend/packages/app/src/windup_app/web/api/style_preset.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
xiaocheny214 marked this conversation as resolved.
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])
Comment thread
xiaocheny214 marked this conversation as resolved.
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="更新成功")
1 change: 1 addition & 0 deletions backend/scripts/schema_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -123,6 +124,7 @@ def engine():
engine,
tables=[
Project.__table__,
StylePreset.__table__,
User.__table__,
Character.__table__,
WorkflowRun.__table__,
Expand Down
92 changes: 92 additions & 0 deletions backend/tests/test_style_preset.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading