feat(product-suite): support temporary item drafts
This commit is contained in:
+170
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user