-
Notifications
You must be signed in to change notification settings - Fork 5
feat(style-preset): 增加画风预设目录表与接口 #614
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
xiaocheny214
wants to merge
3
commits into
1024XEngineer:main
Choose a base branch
from
xiaocheny214:feat/style-preset-catalog
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
5 changes: 5 additions & 0 deletions
5
backend/packages/app/src/windup_app/server/style_preset/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
44
backend/packages/app/src/windup_app/server/style_preset/model.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| 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
36
backend/packages/app/src/windup_app/server/style_preset/service.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
115
backend/packages/app/src/windup_app/web/api/style_preset.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
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]) | ||
|
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="更新成功") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.