feat(product-suite): support temporary item drafts

This commit is contained in:
chengma
2026-07-16 11:10:51 +08:00
parent ec62e34807
commit ecd2ecb758
8 changed files with 767 additions and 27 deletions
+170 -3
View File
@@ -15,6 +15,10 @@ from .config import make_slug
PROJECT_STATUS_ACTIVE = "active"
PROJECT_BINDING_DRAFT = "draft"
PROJECT_BINDING_BOUND = "bound"
PROJECT_BINDING_STATES = {PROJECT_BINDING_DRAFT, PROJECT_BINDING_BOUND}
TEMPORARY_ITEM_PREFIX = "draft_"
ASSET_KIND_ORIGINAL = "original"
ASSET_STATUS_AVAILABLE = "available"
ASSET_STATUS_MISSING = "missing"
@@ -39,6 +43,8 @@ class ImageStudioProject:
account_slug: str
account_name: Optional[str]
item_id: str
storage_key: str
binding_state: str
target_main_count: int
target_detail_count: int
draft_prompt: Optional[str]
@@ -106,6 +112,10 @@ class ImageStudioError(RuntimeError):
"""Raised when the AI image studio service cannot complete an operation."""
class ImageStudioProjectConflictError(ImageStudioError):
"""Raised when a draft cannot be bound because the formal project already exists."""
def _now() -> str:
return datetime.now().isoformat(timespec="seconds")
@@ -168,6 +178,22 @@ def _normalize_item_id(item_id) -> str:
return text
def is_formal_item_id(item_id) -> bool:
return str(item_id or "").strip().isdigit()
def is_draft_project(project) -> bool:
return str(_get(project, "binding_state", "")).strip() == PROJECT_BINDING_DRAFT
def project_storage_key(project) -> str:
return str(_get(project, "storage_key") or _get(project, "item_id") or "").strip()
def _new_draft_item_id() -> str:
return TEMPORARY_ITEM_PREFIX + uuid.uuid4().hex
def _notify_step(callback, step, result="start", detail=None):
if callback is None:
return
@@ -314,16 +340,18 @@ def create_or_get_project(
cursor = database.execute(
"""
INSERT INTO image_studio_projects
(account_alias, account_slug, account_name, item_id,
(account_alias, account_slug, account_name, item_id, storage_key, binding_state,
target_main_count, target_detail_count, draft_prompt,
status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
alias,
slug,
name,
item,
item,
PROJECT_BINDING_BOUND,
int(target_main_count),
int(target_detail_count),
str(draft_prompt or ""),
@@ -336,6 +364,94 @@ def create_or_get_project(
return get_project(project_id, conn=database)
def create_draft_project(
account=None,
*,
account_alias=None,
account_name=None,
account_slug=None,
target_main_count=9,
target_detail_count=12,
draft_prompt="",
path=None,
conn=None,
):
"""Create one project-bound local draft without exposing its internal item key."""
alias, name, slug = _account_fields(
account,
account_alias=account_alias,
account_name=account_name,
account_slug=account_slug,
)
item = _new_draft_item_id()
now = _now()
with _connection(conn, path) as database:
with database:
cursor = database.execute(
"""
INSERT INTO image_studio_projects
(account_alias, account_slug, account_name, item_id, storage_key, binding_state,
target_main_count, target_detail_count, draft_prompt,
status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
alias,
slug,
name,
item,
item,
PROJECT_BINDING_DRAFT,
int(target_main_count),
int(target_detail_count),
str(draft_prompt or ""),
PROJECT_STATUS_ACTIVE,
now,
now,
),
)
project_id = cursor.lastrowid
return get_project(project_id, conn=database)
def bind_draft_project(project_id, item_id, path=None, conn=None):
"""Bind one active draft to a formal numeric item ID without moving its files."""
item = str(item_id or "").strip()
if not is_formal_item_id(item):
raise ImageStudioError("正式商品ID必须是数字")
with _connection(conn, path) as database:
source = get_project(project_id, conn=database, include_deleted=True)
if source is None:
raise ImageStudioError("临时草稿不存在")
if source.deleted_at is not None:
raise ImageStudioError("临时草稿已删除,无法绑定商品")
if not is_draft_project(source):
if source.item_id == item:
return source
raise ImageStudioError("当前项目不是临时草稿,不能重新绑定商品")
existing = get_project_by_account_item(
source.account_alias,
item,
conn=database,
include_deleted=True,
)
if existing is not None and existing.id != source.id:
raise ImageStudioProjectConflictError("该商品项目已存在,不能覆盖或合并")
now = _now()
with database:
database.execute(
"""
UPDATE image_studio_projects
SET item_id = ?, binding_state = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
""",
(item, PROJECT_BINDING_BOUND, now, int(source.id)),
)
return get_project(source.id, conn=database)
def list_projects(path=None, conn=None, include_deleted=False):
sql = "SELECT * FROM image_studio_projects"
if not include_deleted:
@@ -345,6 +461,57 @@ def list_projects(path=None, conn=None, include_deleted=False):
return _fetch_all(database, sql, (), ImageStudioProject)
def list_recoverable_draft_projects(path=None, conn=None):
"""Return non-empty drafts that should reappear as product-suite task tabs."""
sql = """
SELECT p.*
FROM image_studio_projects AS p
WHERE p.deleted_at IS NULL
AND p.binding_state = ?
AND (
EXISTS (
SELECT 1 FROM image_studio_assets AS a
WHERE a.project_id = p.id
)
OR EXISTS (
SELECT 1 FROM image_studio_jobs AS j
WHERE j.project_id = p.id
)
)
ORDER BY p.updated_at DESC, p.id DESC
"""
with _connection(conn, path) as database:
return _fetch_all(database, sql, (PROJECT_BINDING_DRAFT,), ImageStudioProject)
def project_has_content(project_id, path=None, conn=None) -> bool:
with _connection(conn, path) as database:
row = database.execute(
"""
SELECT EXISTS(
SELECT 1 FROM image_studio_assets WHERE project_id = ?
) OR EXISTS(
SELECT 1 FROM image_studio_jobs WHERE project_id = ?
) AS has_content
""",
(int(project_id), int(project_id)),
).fetchone()
return bool(row["has_content"] if row is not None else False)
def discard_empty_draft_project(project_id, reason="空临时草稿", path=None, conn=None):
"""Soft-delete a newly created, still empty draft and leave user files untouched."""
with _connection(conn, path) as database:
project = get_project(project_id, conn=database, include_deleted=True)
if project is None or project.deleted_at is not None or not is_draft_project(project):
return project
if project_has_content(project.id, conn=database):
return project
return soft_delete_project(project.id, reason=reason, conn=database)
def update_project_prompt(project_id, draft_prompt, path=None, conn=None):
with _connection(conn, path) as database:
with database:
@@ -406,7 +573,7 @@ def project_image_dirs(image_root, project):
str(image_root or "images"),
"pool",
_safe_component(_get(project, "account_slug"), "account"),
_safe_component(_get(project, "item_id"), "item"),
_safe_component(project_storage_key(project), "item"),
)
)
return {