积分冻结释放

This commit is contained in:
2026-07-24 09:18:05 +08:00
parent 920d884e92
commit 68e902b4a4
38 changed files with 4743 additions and 391 deletions
@@ -0,0 +1,772 @@
"""repair generation record frozen config
Revision ID: 6a3ea8d0b4c8
Revises: 7cf645f7c418
Create Date: 2026-07-23 14:01:54.109369
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "6a3ea8d0b4c8"
down_revision: Union[str, None] = "7cf645f7c418"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_KEY = "repair_generation_record_frozen_config_20260723"
BACKUP_TABLE = "generation_records_repair_backup_20260723"
_TARGET_CONDITION = """
gr.deleted_at IS NULL
AND p.deleted_at IS NULL
AND gr.status = 'prompt_optimized'
AND gr.gen_type IN ('video', 'image')
AND (
gr.engine_id IS NULL
OR gr.engine_id = ''
OR gr.engine_snapshot_json IS NULL
OR gr.engine_snapshot_json = ''
OR gr.engine_snapshot_json IS NOT JSON
OR (
gr.gen_type = 'video'
AND (
gr.duration IS NULL
OR gr.aspect_ratio IS NULL
OR gr.aspect_ratio = ''
OR gr.resolution IS NULL
OR gr.resolution = ''
)
)
OR (
gr.gen_type = 'image'
AND (
gr.image_size IS NULL
OR gr.image_size = ''
OR gr.image_proportion IS NULL
OR gr.image_proportion = ''
OR gr.image_px IS NULL
OR gr.image_px = ''
)
)
)
"""
_CREATE_BACKUP_TABLE_SQL = f"""
CREATE TABLE IF NOT EXISTS {BACKUP_TABLE} (
repair_key VARCHAR(96) NOT NULL,
record_id VARCHAR(32) NOT NULL,
gen_type VARCHAR(16),
old_engine_id VARCHAR(32),
old_engine_snapshot_json TEXT,
old_duration INTEGER,
old_aspect_ratio VARCHAR(8),
old_resolution VARCHAR(8),
old_provider_generation_resolution VARCHAR(16),
old_video_upscale_enabled_snapshot BOOLEAN,
old_video_upscale_snapshot_json TEXT,
old_image_size VARCHAR(8),
old_image_proportion VARCHAR(8),
old_image_px VARCHAR(10),
old_include_media_references BOOLEAN,
old_updated_at TIMESTAMP WITH TIME ZONE,
backed_up_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (repair_key, record_id)
)
"""
_INSERT_BACKUP_SQL = f"""
INSERT INTO {BACKUP_TABLE} (
repair_key,
record_id,
gen_type,
old_engine_id,
old_engine_snapshot_json,
old_duration,
old_aspect_ratio,
old_resolution,
old_provider_generation_resolution,
old_video_upscale_enabled_snapshot,
old_video_upscale_snapshot_json,
old_image_size,
old_image_proportion,
old_image_px,
old_include_media_references,
old_updated_at
)
SELECT
:repair_key,
gr.id,
gr.gen_type,
gr.engine_id,
gr.engine_snapshot_json,
gr.duration,
gr.aspect_ratio,
gr.resolution,
gr.provider_generation_resolution,
gr.video_upscale_enabled_snapshot,
gr.video_upscale_snapshot_json,
gr.image_size,
gr.image_proportion,
gr.image_px,
gr.include_media_references,
gr.updated_at
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
ON CONFLICT (repair_key, record_id) DO NOTHING
"""
# PostgreSQL JSON helper snippets used inside SQL expressions. These avoid calling
# jsonb_array_elements_text/jsonb_object_keys on malformed JSON or the wrong JSON type.
_VIDEO_RATIOS_JSON = """
CASE
WHEN ve.supported_ratios IS JSON THEN
CASE
WHEN jsonb_typeof(ve.supported_ratios::jsonb) = 'array'
THEN ve.supported_ratios::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_VIDEO_RESOLUTIONS_JSON = """
CASE
WHEN ve.supported_resolutions IS JSON THEN
CASE
WHEN jsonb_typeof(ve.supported_resolutions::jsonb) = 'array'
THEN ve.supported_resolutions::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_VIDEO_DURATIONS_JSON = """
CASE
WHEN ve.supported_durations IS JSON THEN
CASE
WHEN jsonb_typeof(ve.supported_durations::jsonb) = 'array'
THEN ve.supported_durations::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_IMAGE_SUPPORTED_MODELS_JSON = """
CASE
WHEN px_resolved.supported_models IS JSON THEN
CASE
WHEN jsonb_typeof(px_resolved.supported_models::jsonb) = 'array'
THEN px_resolved.supported_models::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_IMAGE_SUPPORTED_SIZES_JSON_IE = """
CASE
WHEN ie.supported_sizes IS JSON THEN
CASE
WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
THEN ie.supported_sizes::jsonb
ELSE '{}'::jsonb
END
ELSE '{}'::jsonb
END
"""
_IMAGE_SUPPORTED_SIZES_JSON_ES = """
CASE
WHEN ie.supported_sizes IS JSON THEN
CASE
WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
THEN ie.supported_sizes::jsonb
ELSE '{}'::jsonb
END
ELSE '{}'::jsonb
END
"""
_REPAIR_VIDEO_SQL = f"""
WITH target_records AS (
SELECT gr.*
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'video'
),
resolved AS (
SELECT
tr.id AS record_id,
ve.id AS selected_engine_id,
ve.name AS engine_name,
ve.provider,
ve.api_base,
ve.api_key,
ve.model_name,
ve.generate_url,
ve.query_url,
ve.supported_ratios,
ve.supported_resolutions,
ve.supported_durations,
ve.max_duration,
ve.max_image_count,
ve.max_video_count,
ve.max_audio_count,
ve.supports_universal_reference,
ve.supports_first_last_frame,
ve.multi_generation_enabled,
ve.max_generation_count,
COALESCE(
CASE
WHEN tr.duration IS NOT NULL
AND tr.duration > 0
AND (
jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value)
WHERE d.value ~ '^[0-9]+$'
AND d.value::int = tr.duration
)
)
AND (
ve.max_duration IS NULL
OR ve.max_duration <= 0
OR tr.duration <= ve.max_duration
)
THEN tr.duration
END,
(
SELECT d.value::int
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) WITH ORDINALITY d(value, ord)
WHERE d.value ~ '^[0-9]+$'
AND d.value::int > 0
AND (
ve.max_duration IS NULL
OR ve.max_duration <= 0
OR d.value::int <= ve.max_duration
)
ORDER BY d.ord
LIMIT 1
),
CASE
WHEN ve.max_duration IS NOT NULL AND ve.max_duration > 0 THEN LEAST(4, ve.max_duration)
ELSE 4
END
) AS final_duration,
COALESCE(
CASE
WHEN tr.aspect_ratio IS NOT NULL
AND tr.aspect_ratio <> ''
AND (
jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
WHERE r.value = tr.aspect_ratio
)
)
THEN tr.aspect_ratio
END,
CASE
WHEN EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
WHERE r.value = '16:9'
)
THEN '16:9'
END,
(
SELECT r.value
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) WITH ORDINALITY r(value, ord)
WHERE r.value <> ''
ORDER BY r.ord
LIMIT 1
),
'16:9'
) AS final_aspect_ratio,
COALESCE(
CASE
WHEN tr.resolution IS NOT NULL
AND tr.resolution <> ''
AND (
jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
WHERE r.value = tr.resolution
)
)
THEN tr.resolution
END,
CASE
WHEN EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
WHERE r.value = '480p'
)
THEN '480p'
END,
(
SELECT r.value
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) WITH ORDINALITY r(value, ord)
WHERE r.value <> ''
ORDER BY r.ord
LIMIT 1
),
'480p'
) AS final_resolution
FROM target_records tr
JOIN LATERAL (
SELECT ve.*
FROM video_engines ve
WHERE ve.is_active IS TRUE
AND ve.deleted_at IS NULL
ORDER BY
CASE
WHEN (
(
tr.duration IS NULL
OR tr.duration <= 0
OR jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value)
WHERE d.value ~ '^[0-9]+$'
AND d.value::int = tr.duration
)
)
AND (
tr.aspect_ratio IS NULL
OR tr.aspect_ratio = ''
OR jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
WHERE r.value = tr.aspect_ratio
)
)
AND (
tr.resolution IS NULL
OR tr.resolution = ''
OR jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
WHERE r.value = tr.resolution
)
)
)
THEN 0
ELSE 1
END,
ve.priority DESC,
ve.created_at ASC,
ve.id ASC
LIMIT 1
) ve ON TRUE
)
UPDATE generation_records gr
SET
engine_id = resolved.selected_engine_id,
duration = resolved.final_duration,
aspect_ratio = resolved.final_aspect_ratio,
resolution = resolved.final_resolution,
provider_generation_resolution = resolved.final_resolution,
video_upscale_enabled_snapshot = FALSE,
video_upscale_snapshot_json = NULL,
engine_snapshot_json = jsonb_build_object(
'engine_type', 'video',
'id', resolved.selected_engine_id,
'name', resolved.engine_name,
'provider', resolved.provider,
'api_base', resolved.api_base,
'api_key_masked', CASE WHEN COALESCE(resolved.api_key, '') <> '' THEN '****' ELSE '' END,
'model_name', resolved.model_name,
'generate_url', resolved.generate_url,
'query_url', resolved.query_url,
'supported_ratios', CASE
WHEN resolved.supported_ratios IS JSON THEN
CASE
WHEN jsonb_typeof(resolved.supported_ratios::jsonb) = 'array'
THEN resolved.supported_ratios::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END,
'supported_resolutions', CASE
WHEN resolved.supported_resolutions IS JSON THEN
CASE
WHEN jsonb_typeof(resolved.supported_resolutions::jsonb) = 'array'
THEN resolved.supported_resolutions::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END,
'supported_durations', CASE
WHEN resolved.supported_durations IS JSON THEN
CASE
WHEN jsonb_typeof(resolved.supported_durations::jsonb) = 'array'
THEN resolved.supported_durations::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END,
'max_duration', resolved.max_duration,
'max_image_count', COALESCE(resolved.max_image_count, 0),
'max_video_count', COALESCE(resolved.max_video_count, 0),
'max_audio_count', COALESCE(resolved.max_audio_count, 0),
'supports_universal_reference', COALESCE(resolved.supports_universal_reference, FALSE),
'supports_first_last_frame', COALESCE(resolved.supports_first_last_frame, FALSE),
'multi_generation_enabled', COALESCE(resolved.multi_generation_enabled, FALSE),
'max_generation_count', LEAST(5, GREATEST(1, COALESCE(resolved.max_generation_count, 1))),
'selected_ratio', resolved.final_aspect_ratio,
'selected_resolution', resolved.final_resolution,
'selected_duration', resolved.final_duration
)::text,
include_media_references = COALESCE(gr.include_media_references, FALSE),
updated_at = NOW()
FROM resolved
WHERE gr.id = resolved.record_id
"""
_REPAIR_IMAGE_SQL = f"""
WITH target_records AS (
SELECT gr.*
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'image'
),
engine_selected AS (
SELECT
tr.*,
ie.id AS selected_engine_id,
ie.name AS engine_name,
ie.provider,
ie.api_base,
ie.api_key,
ie.model_name,
ie.generate_url,
ie.supported_models,
ie.supported_sizes,
ie.default_size,
ie.multi_generation_enabled,
ie.max_generation_count,
ie.multi_image_max_images,
ie.max_reference_image_count,
ie.output_format,
CASE
WHEN ie.supported_sizes IS JSON AND jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
THEN ie.supported_sizes::jsonb
ELSE '{{}}'::jsonb
END AS sizes_json
FROM target_records tr
JOIN LATERAL (
SELECT ie.*
FROM image_engines ie
WHERE ie.is_active IS TRUE
AND ie.deleted_at IS NULL
ORDER BY
CASE
WHEN (
ie.supported_sizes IS NULL
OR ie.supported_sizes = ''
OR ie.supported_sizes IS NOT JSON
OR jsonb_typeof(ie.supported_sizes::jsonb) <> 'object'
OR ie.supported_sizes::jsonb = '{{}}'::jsonb
OR (
(
tr.image_size IS NULL
OR tr.image_size = ''
OR ie.supported_sizes::jsonb ? tr.image_size
)
AND (
tr.image_size IS NULL
OR tr.image_size = ''
OR tr.image_proportion IS NULL
OR tr.image_proportion = ''
OR (
jsonb_typeof(ie.supported_sizes::jsonb -> tr.image_size) = 'object'
AND (ie.supported_sizes::jsonb -> tr.image_size) ? tr.image_proportion
)
)
)
)
THEN 0
ELSE 1
END,
ie.priority DESC,
ie.created_at ASC,
ie.id ASC
LIMIT 1
) ie ON TRUE
),
size_selected AS (
SELECT
es.*,
COALESCE(
CASE
WHEN es.image_size IS NOT NULL
AND es.image_size <> ''
AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.image_size)
THEN es.image_size
END,
CASE
WHEN es.default_size IS NOT NULL
AND es.default_size <> ''
AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.default_size)
THEN es.default_size
END,
CASE WHEN es.sizes_json ? '2K' THEN '2K' END,
(
SELECT key
FROM jsonb_object_keys(es.sizes_json) AS key
ORDER BY key
LIMIT 1
),
'2K'
) AS final_image_size
FROM engine_selected es
),
ratio_selected AS (
SELECT
ss.*,
CASE
WHEN jsonb_typeof(ss.sizes_json -> ss.final_image_size) = 'object'
THEN ss.sizes_json -> ss.final_image_size
ELSE '{{}}'::jsonb
END AS ratio_json
FROM size_selected ss
),
final_resolved AS (
SELECT
rs.*,
COALESCE(
CASE
WHEN rs.image_proportion IS NOT NULL
AND rs.image_proportion <> ''
AND (rs.ratio_json = '{{}}'::jsonb OR rs.ratio_json ? rs.image_proportion)
THEN rs.image_proportion
END,
CASE WHEN rs.ratio_json ? '1:1' THEN '1:1' END,
(
SELECT key
FROM jsonb_object_keys(rs.ratio_json) AS key
ORDER BY key
LIMIT 1
),
'1:1'
) AS final_image_proportion
FROM ratio_selected rs
),
px_resolved AS (
SELECT
fr.*,
regexp_replace(
lower(
replace(
COALESCE(
NULLIF(fr.ratio_json ->> fr.final_image_proportion, ''),
NULLIF(fr.image_px, ''),
'2048x2048'
),
'×',
'x'
)
),
'x+',
'x',
'g'
) AS final_image_px
FROM final_resolved fr
)
UPDATE generation_records gr
SET
engine_id = px_resolved.selected_engine_id,
image_size = px_resolved.final_image_size,
image_proportion = px_resolved.final_image_proportion,
image_px = LEFT(px_resolved.final_image_px, 10),
provider_generation_resolution = NULL,
video_upscale_enabled_snapshot = FALSE,
video_upscale_snapshot_json = NULL,
engine_snapshot_json = jsonb_build_object(
'engine_type', 'image',
'id', px_resolved.selected_engine_id,
'name', px_resolved.engine_name,
'provider', px_resolved.provider,
'api_base', px_resolved.api_base,
'api_key_masked', CASE WHEN COALESCE(px_resolved.api_key, '') <> '' THEN '****' ELSE '' END,
'model_name', px_resolved.model_name,
'generate_url', px_resolved.generate_url,
'supported_models', {_IMAGE_SUPPORTED_MODELS_JSON},
'default_size', px_resolved.default_size,
'multi_generation_enabled', COALESCE(px_resolved.multi_generation_enabled, FALSE),
'max_generation_count', LEAST(5, GREATEST(1, COALESCE(px_resolved.max_generation_count, 1))),
'multi_image_max_images', COALESCE(px_resolved.multi_image_max_images, 15),
'max_reference_image_count', COALESCE(px_resolved.max_reference_image_count, 0),
'output_format', lower(trim(COALESCE(px_resolved.output_format, ''))),
'selected_size', px_resolved.final_image_size,
'selected_proportion', px_resolved.final_image_proportion,
'selected_px', LEFT(px_resolved.final_image_px, 10)
)::text,
include_media_references = COALESCE(gr.include_media_references, FALSE),
updated_at = NOW()
FROM px_resolved
WHERE gr.id = px_resolved.id
"""
_DOWNGRADE_SQL = f"""
UPDATE generation_records gr
SET
engine_id = b.old_engine_id,
engine_snapshot_json = b.old_engine_snapshot_json,
duration = b.old_duration,
aspect_ratio = b.old_aspect_ratio,
resolution = b.old_resolution,
provider_generation_resolution = b.old_provider_generation_resolution,
video_upscale_enabled_snapshot = COALESCE(b.old_video_upscale_enabled_snapshot, FALSE),
video_upscale_snapshot_json = b.old_video_upscale_snapshot_json,
image_size = b.old_image_size,
image_proportion = b.old_image_proportion,
image_px = b.old_image_px,
include_media_references = COALESCE(b.old_include_media_references, FALSE),
updated_at = b.old_updated_at
FROM {BACKUP_TABLE} b
WHERE b.repair_key = :repair_key
AND b.record_id = gr.id
AND gr.status = 'prompt_optimized'
AND gr.deleted_at IS NULL
"""
_DROP_BACKUP_TABLE_SQL = f"DROP TABLE IF EXISTS {BACKUP_TABLE}"
def _scalar_int(sql: str, **params: object) -> int:
bind = op.get_bind()
value = bind.execute(sa.text(sql), params).scalar()
return int(value or 0)
def _ensure_required_engines() -> None:
video_targets = _scalar_int(
f"""
SELECT COUNT(*)
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'video'
"""
)
image_targets = _scalar_int(
f"""
SELECT COUNT(*)
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'image'
"""
)
video_engines = _scalar_int(
"""
SELECT COUNT(*)
FROM video_engines
WHERE is_active IS TRUE
AND deleted_at IS NULL
"""
)
image_engines = _scalar_int(
"""
SELECT COUNT(*)
FROM image_engines
WHERE is_active IS TRUE
AND deleted_at IS NULL
"""
)
if video_targets > 0 and video_engines <= 0:
raise RuntimeError("存在待修复的视频生成记录,但没有可用的视频引擎")
if image_targets > 0 and image_engines <= 0:
raise RuntimeError("存在待修复的图片生成记录,但没有可用的图片引擎")
def _ensure_postgresql() -> None:
bind = op.get_bind()
dialect_name = getattr(bind.dialect, "name", "")
if dialect_name != "postgresql":
raise RuntimeError("本迁移只支持 PostgreSQL,当前数据库类型不支持此数据修复")
def _execute(sql: str, **params: object) -> None:
bind = op.get_bind()
bind.execute(sa.text(sql), params)
def _backup_table_exists() -> bool:
return (
_scalar_int(
"""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = :table_name
""",
table_name=BACKUP_TABLE,
)
> 0
)
def upgrade() -> None:
"""Repair historical prompt_optimized GenerationRecord frozen config.
This migration only fills missing frozen generation configuration for old
records. It does not change status, charge credits, create tasks, enqueue
Celery jobs, or call application services.
"""
_ensure_postgresql()
_execute(_CREATE_BACKUP_TABLE_SQL)
_ensure_required_engines()
# Keep the original values for a guarded downgrade. The ON CONFLICT clause
# makes this migration safe to re-run inside a partially repaired database.
_execute(_INSERT_BACKUP_SQL, repair_key=REPAIR_KEY)
# Repair by generation type. The update SQL only targets records that are
# still prompt_optimized and still incomplete, so already repaired records
# are skipped.
_execute(_REPAIR_VIDEO_SQL)
_execute(_REPAIR_IMAGE_SQL)
def downgrade() -> None:
"""Restore backed-up values for records that are still not generated.
Records that moved past prompt_optimized are intentionally not restored;
reverting those after users have generated media would corrupt production
state.
"""
_ensure_postgresql()
if not _backup_table_exists():
return
_execute(_DOWNGRADE_SQL, repair_key=REPAIR_KEY)
_execute(_DROP_BACKUP_TABLE_SQL)