重构回忆录为 story-first / markdown-first 架构并整合图片意图与前端 UI 修复
本次 squash merge 将 codex-story-first-image-intent 的整体改动合入 development,核心内容包括: 1. 后端数据与迁移:新增 stories、story_versions、story_image_intents、chapter_cover_intents、assets 等模型与 Alembic 迁移,建立 story-first、markdown-first、asset-first 的主数据链路。 2. 生成与任务链:引入 StoryBuilderOrchestrator、ChapterComposerOrchestrator、story_image_tasks、chapter_cover_tasks,图片生成从正文占位符改为结构化 intent -> asset -> markdown 回填。 3. 并发与一致性:为 story/chapter intent 增加 claim_token、claimed_at、attempt_count,采用数据库原子 claim 为主、Redis 锁为辅,避免重复生成、锁误删和 processing 卡死。 4. Memoir 读写路径:章节 canonical_markdown 成为正文真源,列表/详情接口补齐 markdown、cover_asset、word_count 等字段,PDF 与 asset 解析链路同步升级。 5. Memory / Retrieval:扩展 transcript ingest、chunking、evidence 检索与 story 聚合基础设施,为后续 story-first RAG 与多 agent 编排提供底座。 6. App 端体验:章节页继续走 MarkdownRenderer 阅读链,同时吸收 fix3-19 的跨平台 UI glitch 修复;更新对话页、首页、文案资源与章节列表映射逻辑。 7. 测试与文档:补充 asset resolver、story image task、章节封面派发、markdown 映射等回归测试,并加入图片占位符退役设计文档。
This commit is contained in:
207
api/alembic/versions/0003_story_first_markdown_first_schema.py
Normal file
207
api/alembic/versions/0003_story_first_markdown_first_schema.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""story_first_markdown_first_schema
|
||||
|
||||
Story-First + Markdown-First 架构:新增 stories/story_versions/story_evidence_links,
|
||||
重定义 chapters(canonical_markdown),新增 chapter_versions/chapter_story_links。
|
||||
|
||||
Revision ID: 0003_story_first
|
||||
Revises: 0002_schema
|
||||
Create Date: 2026-03-19
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0003_story_first"
|
||||
down_revision: Union[str, Sequence[str], None] = "0002_schema"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. stories
|
||||
op.create_table(
|
||||
"stories",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("user_id", sa.String(), nullable=False),
|
||||
sa.Column("title", sa.String(), nullable=False),
|
||||
sa.Column("stage", sa.String(), nullable=True),
|
||||
sa.Column("story_type", sa.String(), nullable=True),
|
||||
sa.Column("summary", sa.Text(), nullable=True),
|
||||
sa.Column("canonical_markdown", sa.Text(), nullable=True),
|
||||
sa.Column("time_start", sa.String(), nullable=True),
|
||||
sa.Column("time_end", sa.String(), nullable=True),
|
||||
sa.Column("people_refs", sa.JSON(), nullable=True),
|
||||
sa.Column("place_refs", sa.JSON(), nullable=True),
|
||||
sa.Column("tag_refs", sa.JSON(), nullable=True),
|
||||
sa.Column("status", sa.String(), nullable=True),
|
||||
sa.Column("confidence", sa.Float(), nullable=True),
|
||||
sa.Column("current_version_id", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_stories_user_id"),
|
||||
"stories",
|
||||
["user_id"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
# 2. story_versions
|
||||
op.create_table(
|
||||
"story_versions",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("story_id", sa.String(), nullable=False),
|
||||
sa.Column("version_no", sa.Integer(), nullable=False),
|
||||
sa.Column("markdown_snapshot", sa.Text(), nullable=False),
|
||||
sa.Column("change_summary", sa.Text(), nullable=True),
|
||||
sa.Column("actor_type", sa.String(), nullable=True),
|
||||
sa.Column("source_type", sa.String(), nullable=True),
|
||||
sa.Column("parent_version_id", sa.String(), nullable=True),
|
||||
sa.Column("prompt_meta", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["parent_version_id"],
|
||||
["story_versions.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_story_versions_story_id"),
|
||||
"story_versions",
|
||||
["story_id"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
# 3. story_evidence_links
|
||||
op.create_table(
|
||||
"story_evidence_links",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("story_id", sa.String(), nullable=False),
|
||||
sa.Column("evidence_type", sa.String(), nullable=False),
|
||||
sa.Column("evidence_id", sa.String(), nullable=False),
|
||||
sa.Column("role", sa.String(), nullable=True),
|
||||
sa.Column("weight", sa.Float(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_story_evidence_links_story_id"),
|
||||
"story_evidence_links",
|
||||
["story_id"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
# 4. chapters 新增列(PostgreSQL ADD COLUMN IF NOT EXISTS)
|
||||
conn = op.get_bind()
|
||||
for col_sql in [
|
||||
"ALTER TABLE chapters ADD COLUMN IF NOT EXISTS book_id VARCHAR",
|
||||
"ALTER TABLE chapters ADD COLUMN IF NOT EXISTS summary TEXT",
|
||||
"ALTER TABLE chapters ADD COLUMN IF NOT EXISTS canonical_markdown TEXT",
|
||||
"ALTER TABLE chapters ADD COLUMN IF NOT EXISTS cover_asset_id VARCHAR",
|
||||
"ALTER TABLE chapters ADD COLUMN IF NOT EXISTS current_version_id VARCHAR",
|
||||
"ALTER TABLE chapters ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE",
|
||||
]:
|
||||
conn.execute(sa.text(col_sql))
|
||||
# FK 若已存在则跳过
|
||||
from sqlalchemy import inspect
|
||||
|
||||
insp = inspect(conn)
|
||||
fk_names = [fk.get("name") for fk in insp.get_foreign_keys("chapters") or []]
|
||||
if "fk_chapters_book_id" not in fk_names:
|
||||
op.create_foreign_key(
|
||||
"fk_chapters_book_id",
|
||||
"chapters",
|
||||
"books",
|
||||
["book_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
# 5. chapter_versions
|
||||
op.create_table(
|
||||
"chapter_versions",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("chapter_id", sa.String(), nullable=False),
|
||||
sa.Column("version_no", sa.Integer(), nullable=False),
|
||||
sa.Column("markdown_snapshot", sa.Text(), nullable=False),
|
||||
sa.Column("change_summary", sa.Text(), nullable=True),
|
||||
sa.Column("actor_type", sa.String(), nullable=True),
|
||||
sa.Column("source_type", sa.String(), nullable=True),
|
||||
sa.Column("parent_version_id", sa.String(), nullable=True),
|
||||
sa.Column("prompt_meta", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["chapter_id"], ["chapters.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["parent_version_id"],
|
||||
["chapter_versions.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_chapter_versions_chapter_id"),
|
||||
"chapter_versions",
|
||||
["chapter_id"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
# 6. chapter_story_links
|
||||
op.create_table(
|
||||
"chapter_story_links",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("chapter_id", sa.String(), nullable=False),
|
||||
sa.Column("story_id", sa.String(), nullable=False),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("role", sa.String(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["chapter_id"], ["chapters.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_chapter_story_links_chapter_id"),
|
||||
"chapter_story_links",
|
||||
["chapter_id"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_chapter_story_links_story_id"),
|
||||
"chapter_story_links",
|
||||
["story_id"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("chapter_story_links")
|
||||
op.drop_table("chapter_versions")
|
||||
op.drop_constraint("fk_chapters_book_id", "chapters", type_="foreignkey")
|
||||
op.drop_column("chapters", "created_at")
|
||||
op.drop_column("chapters", "current_version_id")
|
||||
op.drop_column("chapters", "cover_asset_id")
|
||||
op.drop_column("chapters", "canonical_markdown")
|
||||
op.drop_column("chapters", "summary")
|
||||
op.drop_column("chapters", "book_id")
|
||||
op.drop_table("story_evidence_links")
|
||||
op.drop_table("story_versions")
|
||||
op.drop_table("stories")
|
||||
@@ -0,0 +1,105 @@
|
||||
"""migrate sections to canonical_markdown
|
||||
|
||||
将旧章节(有 sections 但 canonical_markdown 为空)从 sections 推导并写入 canonical_markdown。
|
||||
同时创建 chapter_version 记录(source_type=migration)。
|
||||
|
||||
Revision ID: 0004_migrate_md
|
||||
Revises: 0003_story_first
|
||||
Create Date: 2026-03-19
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
revision: str = "0004_migrate_md"
|
||||
down_revision: Union[str, Sequence[str], None] = "0003_story_first"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _sections_to_markdown(chapter) -> str:
|
||||
"""从 sections 推导 markdown,与 helpers.sections_to_content_and_images 一致。"""
|
||||
sections = getattr(chapter, "sections", None) or []
|
||||
ordered = sorted(sections, key=lambda s: getattr(s, "order_index", 0))
|
||||
parts = []
|
||||
for s in ordered:
|
||||
text = (getattr(s, "content", None) or "").strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
img = _section_image_to_dict(s)
|
||||
if img:
|
||||
placeholder = (img.get("placeholder") or "").strip()
|
||||
if placeholder:
|
||||
parts.append(placeholder)
|
||||
return "\n\n".join(parts) if parts else ""
|
||||
|
||||
|
||||
def _section_image_to_dict(section) -> dict | None:
|
||||
"""与 helpers.section_image_to_dict 一致。"""
|
||||
from app.features.memoir.memoir_images.serializers import memoir_image_to_dict
|
||||
|
||||
if getattr(section, "image_record", None):
|
||||
return memoir_image_to_dict(section.image_record)
|
||||
return None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from app.features.memoir.models import Chapter, ChapterSection, ChapterVersion
|
||||
|
||||
conn = op.get_bind()
|
||||
session = Session(bind=conn)
|
||||
|
||||
chapters = (
|
||||
session.query(Chapter)
|
||||
.options(
|
||||
selectinload(Chapter.sections).selectinload(ChapterSection.image_record),
|
||||
)
|
||||
.filter(
|
||||
sa.or_(
|
||||
Chapter.canonical_markdown.is_(None),
|
||||
Chapter.canonical_markdown == "",
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
for ch in chapters:
|
||||
md = _sections_to_markdown(ch)
|
||||
if not md.strip():
|
||||
continue
|
||||
|
||||
# 创建 chapter_version(source_type=migration)
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
count_stmt = sa.select(func.count(ChapterVersion.id)).where(
|
||||
ChapterVersion.chapter_id == ch.id
|
||||
)
|
||||
version_no = (session.execute(count_stmt).scalar() or 0) + 1
|
||||
|
||||
version = ChapterVersion(
|
||||
id=str(uuid.uuid4()),
|
||||
chapter_id=ch.id,
|
||||
version_no=version_no,
|
||||
markdown_snapshot=md,
|
||||
actor_type="system",
|
||||
source_type="migration",
|
||||
)
|
||||
session.add(version)
|
||||
session.flush()
|
||||
|
||||
ch.canonical_markdown = md
|
||||
ch.current_version_id = version.id
|
||||
|
||||
# 由 alembic context 管理事务提交
|
||||
session.close()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 数据迁移不可逆,downgrade 不清理 canonical_markdown
|
||||
pass
|
||||
60
api/alembic/versions/0005_add_story_image_intents.py
Normal file
60
api/alembic/versions/0005_add_story_image_intents.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""add story_image_intents
|
||||
|
||||
Revision ID: 0005_story_image_intents
|
||||
Revises: 0004_migrate_md
|
||||
Create Date: 2026-03-19
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0005_story_image_intents"
|
||||
down_revision: Union[str, Sequence[str], None] = "0004_migrate_md"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"story_image_intents",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("story_id", sa.String(), nullable=False),
|
||||
sa.Column("story_version_id", sa.String(), nullable=True),
|
||||
sa.Column("intent_role", sa.String(), nullable=False),
|
||||
sa.Column("source_span", sa.JSON(), nullable=True),
|
||||
sa.Column("caption", sa.String(), nullable=True),
|
||||
sa.Column("prompt_brief", sa.Text(), nullable=True),
|
||||
sa.Column("style_profile", sa.String(), nullable=True),
|
||||
sa.Column("status", sa.String(), nullable=False),
|
||||
sa.Column("asset_id", sa.String(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["story_version_id"],
|
||||
["story_versions.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_story_image_intents_story_id"),
|
||||
"story_image_intents",
|
||||
["story_id"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_story_image_intents_story_id"),
|
||||
table_name="story_image_intents",
|
||||
)
|
||||
op.drop_table("story_image_intents")
|
||||
57
api/alembic/versions/0006_add_chapter_cover_intents.py
Normal file
57
api/alembic/versions/0006_add_chapter_cover_intents.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""add chapter_cover_intents
|
||||
|
||||
Revision ID: 0006_chapter_cover_intents
|
||||
Revises: 0005_story_image_intents
|
||||
Create Date: 2026-03-19
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0006_chapter_cover_intents"
|
||||
down_revision: Union[str, Sequence[str], None] = "0005_story_image_intents"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"chapter_cover_intents",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("chapter_id", sa.String(), nullable=False),
|
||||
sa.Column("chapter_version_id", sa.String(), nullable=True),
|
||||
sa.Column("story_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("prompt_brief", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(), nullable=False),
|
||||
sa.Column("asset_id", sa.String(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["chapter_id"], ["chapters.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["chapter_version_id"],
|
||||
["chapter_versions.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_chapter_cover_intents_chapter_id"),
|
||||
"chapter_cover_intents",
|
||||
["chapter_id"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_chapter_cover_intents_chapter_id"),
|
||||
table_name="chapter_cover_intents",
|
||||
)
|
||||
op.drop_table("chapter_cover_intents")
|
||||
49
api/alembic/versions/0007_add_assets_table.py
Normal file
49
api/alembic/versions/0007_add_assets_table.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""add assets table
|
||||
|
||||
Revision ID: 0007_assets
|
||||
Revises: 0006_chapter_cover_intents
|
||||
Create Date: 2026-03-19
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "0007_assets"
|
||||
down_revision: Union[str, Sequence[str], None] = "0006_chapter_cover_intents"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"assets",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("asset_type", sa.String(), nullable=False),
|
||||
sa.Column("storage_key", sa.String(), nullable=False),
|
||||
sa.Column("url", sa.String(), nullable=True),
|
||||
sa.Column("provider", sa.String(), nullable=True),
|
||||
sa.Column("style_profile", sa.String(), nullable=True),
|
||||
sa.Column("prompt_final", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(), nullable=False),
|
||||
sa.Column("width", sa.Integer(), nullable=True),
|
||||
sa.Column("height", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
if_not_exists=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_assets_asset_type"),
|
||||
"assets",
|
||||
["asset_type"],
|
||||
unique=False,
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_assets_asset_type"), table_name="assets")
|
||||
op.drop_table("assets")
|
||||
110
api/alembic/versions/0008_migrate_legacy_images_to_assets.py
Normal file
110
api/alembic/versions/0008_migrate_legacy_images_to_assets.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""migrate legacy placeholders and memoir_images to assets
|
||||
|
||||
1. 从 chapters.canonical_markdown 移除 {{IMAGE:...}} / {{{{IMAGE:...}}}} 占位符
|
||||
2. 将已完成 memoir_images(含 storage_key)写入 assets;章节封面绑定 cover_asset_id
|
||||
|
||||
Revision ID: 0008_legacy_assets
|
||||
Revises: 0007_assets
|
||||
Create Date: 2026-03-19
|
||||
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0008_legacy_assets"
|
||||
down_revision: Union[str, Sequence[str], None] = "0007_assets"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from app.features.memoir.asset_resolver import strip_legacy_image_placeholders
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
rows_ch = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, canonical_markdown FROM chapters WHERE canonical_markdown IS NOT NULL"
|
||||
)
|
||||
).fetchall()
|
||||
for cid, md in rows_ch:
|
||||
if not md or not str(md).strip():
|
||||
continue
|
||||
cleaned = strip_legacy_image_placeholders(str(md))
|
||||
if cleaned != str(md).strip():
|
||||
conn.execute(
|
||||
sa.text("UPDATE chapters SET canonical_markdown = :md WHERE id = :id"),
|
||||
{"md": cleaned, "id": cid},
|
||||
)
|
||||
|
||||
rows_mi = conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, chapter_id, section_id, storage_key, url, provider, style, status
|
||||
FROM memoir_images
|
||||
WHERE status = 'completed'
|
||||
AND storage_key IS NOT NULL
|
||||
AND TRIM(storage_key) <> ''
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
existing = {
|
||||
r[0]
|
||||
for r in conn.execute(sa.text("SELECT storage_key FROM assets")).fetchall()
|
||||
if r[0]
|
||||
}
|
||||
|
||||
for row in rows_mi:
|
||||
_mid, chapter_id, section_id, storage_key, url, provider, style, _status = row
|
||||
sk = (storage_key or "").strip()
|
||||
if not sk or sk in existing:
|
||||
continue
|
||||
aid = str(uuid.uuid4())
|
||||
asset_type = "chapter_cover" if section_id is None else "story_image"
|
||||
now = datetime.now(timezone.utc)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO assets (
|
||||
id, asset_type, storage_key, url, provider, style_profile,
|
||||
prompt_final, status, width, height, created_at
|
||||
) VALUES (
|
||||
:id, :atype, :sk, :url, :prov, :style,
|
||||
:prompt, 'completed', NULL, NULL, :created
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": aid,
|
||||
"atype": asset_type,
|
||||
"sk": sk,
|
||||
"url": url,
|
||||
"prov": provider,
|
||||
"style": style,
|
||||
"prompt": None,
|
||||
"created": now,
|
||||
},
|
||||
)
|
||||
existing.add(sk)
|
||||
if section_id is None and chapter_id:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE chapters
|
||||
SET cover_asset_id = :aid
|
||||
WHERE id = :cid
|
||||
AND (cover_asset_id IS NULL OR cover_asset_id = '')
|
||||
"""
|
||||
),
|
||||
{"aid": aid, "cid": chapter_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
72
api/alembic/versions/0009_story_image_intent_constraints.py
Normal file
72
api/alembic/versions/0009_story_image_intent_constraints.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""story_image_intents: one primary per story + optional FK to assets
|
||||
|
||||
Revision ID: 0009_si_constraints
|
||||
Revises: 0008_legacy_assets
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0009_si_constraints"
|
||||
down_revision: Union[str, Sequence[str], None] = "0008_legacy_assets"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE story_image_intents SET asset_id = NULL
|
||||
WHERE asset_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM assets a WHERE a.id = story_image_intents.asset_id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
# 去重:同一 story 多条 primary 时保留最新一条
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
DELETE FROM story_image_intents
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY story_id
|
||||
ORDER BY created_at DESC NULLS LAST, id DESC
|
||||
) AS rn
|
||||
FROM story_image_intents
|
||||
WHERE intent_role = 'primary'
|
||||
) t
|
||||
WHERE rn > 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_story_image_intents_asset_id_assets",
|
||||
"story_image_intents",
|
||||
"assets",
|
||||
["asset_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_story_primary_image_intent
|
||||
ON story_image_intents (story_id)
|
||||
WHERE intent_role = 'primary'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_story_primary_image_intent")
|
||||
op.drop_constraint(
|
||||
"fk_story_image_intents_asset_id_assets",
|
||||
"story_image_intents",
|
||||
type_="foreignkey",
|
||||
)
|
||||
50
api/alembic/versions/0010_intent_claim_fields.py
Normal file
50
api/alembic/versions/0010_intent_claim_fields.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""add claim fields to story/chapter image intents
|
||||
|
||||
Revision ID: 0010_intent_claims
|
||||
Revises: 0009_si_constraints
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0010_intent_claims"
|
||||
down_revision: Union[str, Sequence[str], None] = "0009_si_constraints"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _add_claim_columns(table_name: str) -> None:
|
||||
op.add_column(table_name, sa.Column("claim_token", sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
table_name, sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
table_name,
|
||||
sa.Column(
|
||||
"attempt_count",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.execute(f"UPDATE {table_name} SET attempt_count = 0 WHERE attempt_count IS NULL")
|
||||
op.alter_column(table_name, "attempt_count", nullable=False, server_default=None)
|
||||
|
||||
|
||||
def _drop_claim_columns(table_name: str) -> None:
|
||||
op.drop_column(table_name, "attempt_count")
|
||||
op.drop_column(table_name, "claimed_at")
|
||||
op.drop_column(table_name, "claim_token")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_add_claim_columns("story_image_intents")
|
||||
_add_claim_columns("chapter_cover_intents")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_drop_claim_columns("chapter_cover_intents")
|
||||
_drop_claim_columns("story_image_intents")
|
||||
Reference in New Issue
Block a user