修复冻结积分BUG | 拆镜状态异常BUG
This commit is contained in:
+364
@@ -0,0 +1,364 @@
|
|||||||
|
"""repair llm billing optimize idempotency
|
||||||
|
|
||||||
|
Revision ID: 20da1d353914
|
||||||
|
Revises: 6a3ea8d0b4c8
|
||||||
|
Create Date: 2026-07-24 13:35:48.513710
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20da1d353914"
|
||||||
|
down_revision: Union[str, None] = "6a3ea8d0b4c8"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
_PROMPT_USAGE_COLUMN = "prompt_usage_snapshot_json"
|
||||||
|
|
||||||
|
_INDEX_SPECS: tuple[dict[str, Any], ...] = (
|
||||||
|
{
|
||||||
|
"name": "uq_credit_records_user_refund_target",
|
||||||
|
"table": "credit_records",
|
||||||
|
"columns": ("user_id", "refund_for_biz_key"),
|
||||||
|
"where_sql": "type = 'refund' AND refund_for_biz_key IS NOT NULL",
|
||||||
|
"definition_fragments": (
|
||||||
|
"(user_id, refund_for_biz_key)",
|
||||||
|
"type",
|
||||||
|
"refund",
|
||||||
|
"refund_for_biz_key is not null",
|
||||||
|
),
|
||||||
|
"duplicate_sql": """
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
refund_for_biz_key,
|
||||||
|
COUNT(*) AS duplicate_count
|
||||||
|
FROM credit_records
|
||||||
|
WHERE type = 'refund'
|
||||||
|
AND refund_for_biz_key IS NOT NULL
|
||||||
|
GROUP BY user_id, refund_for_biz_key
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY duplicate_count DESC, user_id, refund_for_biz_key
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uq_genrec_user_idempotency_active",
|
||||||
|
"table": "generation_records",
|
||||||
|
"columns": ("user_id", "idempotency_key"),
|
||||||
|
"where_sql": "idempotency_key IS NOT NULL AND deleted_at IS NULL",
|
||||||
|
"definition_fragments": (
|
||||||
|
"(user_id, idempotency_key)",
|
||||||
|
"idempotency_key is not null",
|
||||||
|
"deleted_at is null",
|
||||||
|
),
|
||||||
|
"duplicate_sql": """
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
idempotency_key,
|
||||||
|
COUNT(*) AS duplicate_count
|
||||||
|
FROM generation_records
|
||||||
|
WHERE idempotency_key IS NOT NULL
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
GROUP BY user_id, idempotency_key
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY duplicate_count DESC, user_id, idempotency_key
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uq_token_usage_user_biz_key",
|
||||||
|
"table": "token_usage",
|
||||||
|
"columns": ("user_id", "biz_key"),
|
||||||
|
"where_sql": "biz_key IS NOT NULL",
|
||||||
|
"definition_fragments": (
|
||||||
|
"(user_id, biz_key)",
|
||||||
|
"biz_key is not null",
|
||||||
|
),
|
||||||
|
"duplicate_sql": """
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
biz_key,
|
||||||
|
COUNT(*) AS duplicate_count
|
||||||
|
FROM token_usage
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
AND biz_key IS NOT NULL
|
||||||
|
GROUP BY user_id, biz_key
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
ORDER BY duplicate_count DESC, user_id, biz_key
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bind() -> Connection:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.dialect.name != "postgresql":
|
||||||
|
raise RuntimeError(
|
||||||
|
"Migration 20da1d353914 requires PostgreSQL; "
|
||||||
|
f"current dialect is {bind.dialect.name!r}."
|
||||||
|
)
|
||||||
|
return bind
|
||||||
|
|
||||||
|
|
||||||
|
def _current_schema(bind: Connection) -> str:
|
||||||
|
schema = bind.execute(sa.text("SELECT current_schema()")).scalar_one_or_none()
|
||||||
|
if not schema:
|
||||||
|
raise RuntimeError("Unable to resolve PostgreSQL current_schema().")
|
||||||
|
return str(schema)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_table(bind: Connection, schema: str, table_name: str) -> None:
|
||||||
|
exists = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = :schema
|
||||||
|
AND table_name = :table_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"schema": schema, "table_name": table_name},
|
||||||
|
).scalar_one()
|
||||||
|
if not bool(exists):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Required table {schema}.{table_name} does not exist; "
|
||||||
|
"refusing to apply migration on an unexpected schema baseline."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(
|
||||||
|
bind: Connection,
|
||||||
|
schema: str,
|
||||||
|
table_name: str,
|
||||||
|
column_name: str,
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = :schema
|
||||||
|
AND table_name = :table_name
|
||||||
|
AND column_name = :column_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"schema": schema,
|
||||||
|
"table_name": table_name,
|
||||||
|
"column_name": column_name,
|
||||||
|
},
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _index_info(
|
||||||
|
bind: Connection,
|
||||||
|
schema: str,
|
||||||
|
index_name: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
row = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
table_rel.relname AS table_name,
|
||||||
|
index_meta.indisvalid AS is_valid,
|
||||||
|
index_meta.indisunique AS is_unique,
|
||||||
|
pg_get_indexdef(index_rel.oid) AS index_definition
|
||||||
|
FROM pg_class AS index_rel
|
||||||
|
JOIN pg_namespace AS namespace_rel
|
||||||
|
ON namespace_rel.oid = index_rel.relnamespace
|
||||||
|
JOIN pg_index AS index_meta
|
||||||
|
ON index_meta.indexrelid = index_rel.oid
|
||||||
|
JOIN pg_class AS table_rel
|
||||||
|
ON table_rel.oid = index_meta.indrelid
|
||||||
|
WHERE namespace_rel.nspname = :schema
|
||||||
|
AND index_rel.relname = :index_name
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"schema": schema, "index_name": index_name},
|
||||||
|
).mappings().one_or_none()
|
||||||
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_index_definition(value: str) -> str:
|
||||||
|
normalized = value.lower().replace('"', "")
|
||||||
|
normalized = re.sub(r"::[a-z_ ]+(?:\[\])?", "", normalized)
|
||||||
|
normalized = re.sub(r"[()]", lambda match: match.group(0), normalized)
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized)
|
||||||
|
return normalized.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_existing_index_matches(spec: dict[str, Any], info: dict[str, Any]) -> None:
|
||||||
|
if str(info["table_name"]) != str(spec["table"]):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Index {spec['name']} already exists on table {info['table_name']}, "
|
||||||
|
f"expected table {spec['table']}."
|
||||||
|
)
|
||||||
|
if not bool(info["is_unique"]):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Index {spec['name']} already exists but is not UNIQUE."
|
||||||
|
)
|
||||||
|
|
||||||
|
definition = _normalize_index_definition(str(info["index_definition"] or ""))
|
||||||
|
missing = [
|
||||||
|
fragment
|
||||||
|
for fragment in spec["definition_fragments"]
|
||||||
|
if fragment not in definition
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Index {spec['name']} already exists with an unexpected definition; "
|
||||||
|
f"missing expected fragments: {missing}. Actual definition: "
|
||||||
|
f"{info['index_definition']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_duplicates(bind: Connection, spec: dict[str, Any]) -> None:
|
||||||
|
rows = bind.execute(sa.text(spec["duplicate_sql"])).mappings().all()
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
samples = "; ".join(
|
||||||
|
", ".join(f"{key}={value!r}" for key, value in row.items())
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot create unique index {spec['name']}: duplicate historical data "
|
||||||
|
f"exists. Resolve the conflicts first. Samples: {samples}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_identifier(bind: Connection, value: str) -> str:
|
||||||
|
return bind.dialect.identifier_preparer.quote(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_missing_indexes_concurrently(
|
||||||
|
bind: Connection,
|
||||||
|
schema: str,
|
||||||
|
) -> None:
|
||||||
|
missing_specs: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for spec in _INDEX_SPECS:
|
||||||
|
_require_table(bind, schema, str(spec["table"]))
|
||||||
|
info = _index_info(bind, schema, str(spec["name"]))
|
||||||
|
if info is None:
|
||||||
|
_assert_no_duplicates(bind, spec)
|
||||||
|
missing_specs.append(spec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if bool(info["is_valid"]):
|
||||||
|
_assert_existing_index_matches(spec, info)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# A failed CREATE INDEX CONCURRENTLY may leave an invalid index behind.
|
||||||
|
# Remove only the invalid index with this migration-owned name, then retry.
|
||||||
|
missing_specs.append(spec)
|
||||||
|
|
||||||
|
if not missing_specs:
|
||||||
|
return
|
||||||
|
|
||||||
|
context = op.get_context()
|
||||||
|
with context.autocommit_block():
|
||||||
|
for spec in missing_specs:
|
||||||
|
current = _index_info(bind, schema, str(spec["name"]))
|
||||||
|
quoted_index = _quote_identifier(bind, str(spec["name"]))
|
||||||
|
quoted_schema = _quote_identifier(bind, schema)
|
||||||
|
if current is not None:
|
||||||
|
if bool(current["is_valid"]):
|
||||||
|
_assert_existing_index_matches(spec, current)
|
||||||
|
continue
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
f"DROP INDEX CONCURRENTLY IF EXISTS "
|
||||||
|
f"{quoted_schema}.{quoted_index}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
quoted_table = _quote_identifier(bind, str(spec["table"]))
|
||||||
|
quoted_columns = ", ".join(
|
||||||
|
_quote_identifier(bind, str(column))
|
||||||
|
for column in spec["columns"]
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
f"CREATE UNIQUE INDEX CONCURRENTLY {quoted_index} "
|
||||||
|
f"ON {quoted_schema}.{quoted_table} ({quoted_columns}) "
|
||||||
|
f"WHERE {spec['where_sql']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_indexes_concurrently(bind: Connection, schema: str) -> None:
|
||||||
|
existing_names = [
|
||||||
|
str(spec["name"])
|
||||||
|
for spec in _INDEX_SPECS
|
||||||
|
if _index_info(bind, schema, str(spec["name"])) is not None
|
||||||
|
]
|
||||||
|
if not existing_names:
|
||||||
|
return
|
||||||
|
|
||||||
|
quoted_schema = _quote_identifier(bind, schema)
|
||||||
|
context = op.get_context()
|
||||||
|
with context.autocommit_block():
|
||||||
|
for index_name in existing_names:
|
||||||
|
quoted_index = _quote_identifier(bind, index_name)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
f"DROP INDEX CONCURRENTLY IF EXISTS "
|
||||||
|
f"{quoted_schema}.{quoted_index}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = _bind()
|
||||||
|
schema = _current_schema(bind)
|
||||||
|
|
||||||
|
_require_table(bind, schema, "generation_records")
|
||||||
|
if not _column_exists(
|
||||||
|
bind,
|
||||||
|
schema,
|
||||||
|
"generation_records",
|
||||||
|
_PROMPT_USAGE_COLUMN,
|
||||||
|
):
|
||||||
|
op.add_column(
|
||||||
|
"generation_records",
|
||||||
|
sa.Column(_PROMPT_USAGE_COLUMN, sa.Text(), nullable=True),
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
_create_missing_indexes_concurrently(bind, schema)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = _bind()
|
||||||
|
schema = _current_schema(bind)
|
||||||
|
|
||||||
|
_drop_indexes_concurrently(bind, schema)
|
||||||
|
|
||||||
|
_require_table(bind, schema, "generation_records")
|
||||||
|
if _column_exists(
|
||||||
|
bind,
|
||||||
|
schema,
|
||||||
|
"generation_records",
|
||||||
|
_PROMPT_USAGE_COLUMN,
|
||||||
|
):
|
||||||
|
op.drop_column(
|
||||||
|
"generation_records",
|
||||||
|
_PROMPT_USAGE_COLUMN,
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
@@ -26,7 +26,6 @@ from app.services.generation.pipeline.db_lock_service import (
|
|||||||
DatabaseRowLockBusy,
|
DatabaseRowLockBusy,
|
||||||
execute_with_lock_timeout,
|
execute_with_lock_timeout,
|
||||||
)
|
)
|
||||||
from app.services.llm import optimize_prompt
|
|
||||||
from app.services.video_url import validate_and_get_record_id, get_video_stream_url
|
from app.services.video_url import validate_and_get_record_id, get_video_stream_url
|
||||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||||
@@ -43,21 +42,6 @@ from app.enums.generation_status import (
|
|||||||
GenerationType,
|
GenerationType,
|
||||||
)
|
)
|
||||||
from app.enums.common import LogEventStatusEnum
|
from app.enums.common import LogEventStatusEnum
|
||||||
from app.enums.credit_record import (
|
|
||||||
CreditRecordBillingScene,
|
|
||||||
CreditRecordChargeKind,
|
|
||||||
CreditRecordSourceModule,
|
|
||||||
)
|
|
||||||
from app.enums.llm_billing import LlmBillingConfigKey
|
|
||||||
from app.services.llm_billing import (
|
|
||||||
LlmBillingContext,
|
|
||||||
log_provider_failure,
|
|
||||||
log_provider_start,
|
|
||||||
log_provider_success,
|
|
||||||
release_on_failure,
|
|
||||||
settle_success,
|
|
||||||
start_hold,
|
|
||||||
)
|
|
||||||
from app.enums.generation_record import (
|
from app.enums.generation_record import (
|
||||||
GenerationRecordConfigSourceEnum,
|
GenerationRecordConfigSourceEnum,
|
||||||
GenerationRecordEventTypeEnum,
|
GenerationRecordEventTypeEnum,
|
||||||
@@ -70,12 +54,9 @@ from app.services.generation.billing_service import (
|
|||||||
from app.services.generation.ai.engine_service import (
|
from app.services.generation.ai.engine_service import (
|
||||||
get_image_engine,
|
get_image_engine,
|
||||||
get_video_engine,
|
get_video_engine,
|
||||||
image_supported_sizes,
|
|
||||||
parse_json_list,
|
|
||||||
)
|
)
|
||||||
from app.services.generation.pipeline.generation_record_config_service import (
|
from app.services.generation.pipeline.generation_record_config_service import (
|
||||||
ensure_generation_record_config_frozen,
|
ensure_generation_record_config_frozen,
|
||||||
freeze_generation_record_config_with_log,
|
|
||||||
frozen_generation_record_engine_view,
|
frozen_generation_record_engine_view,
|
||||||
generation_record_config_fallback_hint,
|
generation_record_config_fallback_hint,
|
||||||
generation_record_engine_snapshot,
|
generation_record_engine_snapshot,
|
||||||
@@ -87,12 +68,12 @@ from app.services.generation.media_reference_service import (
|
|||||||
calculate_media_reference_usage,
|
calculate_media_reference_usage,
|
||||||
validate_media_reference_usage_for_engine,
|
validate_media_reference_usage_for_engine,
|
||||||
)
|
)
|
||||||
|
from app.services.generation.prompt_optimize_service import optimize_generation_prompt
|
||||||
from app.enums.audio_reference import (
|
from app.enums.audio_reference import (
|
||||||
AUDIO_ALLOWED_EXTENSIONS,
|
AUDIO_ALLOWED_EXTENSIONS,
|
||||||
AUDIO_ALLOWED_MIME_TYPES,
|
AUDIO_ALLOWED_MIME_TYPES,
|
||||||
AUDIO_MAX_FILE_SIZE_MB,
|
AUDIO_MAX_FILE_SIZE_MB,
|
||||||
)
|
)
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
from app.utils.exceptions import RecordNotFoundError, InvalidStatusError
|
from app.utils.exceptions import RecordNotFoundError, InvalidStatusError
|
||||||
|
|
||||||
router = APIRouter(prefix="/generation-records", tags=["generation"])
|
router = APIRouter(prefix="/generation-records", tags=["generation"])
|
||||||
@@ -107,43 +88,12 @@ def _record_config_complete(record: GenerationRecord) -> bool:
|
|||||||
return is_generation_record_config_complete(record)
|
return is_generation_record_config_complete(record)
|
||||||
|
|
||||||
|
|
||||||
def _canonical_references(value: object) -> str:
|
|
||||||
return json.dumps(value or [], ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
|
|
||||||
|
|
||||||
def _idempotency_config_matches(record: GenerationRecord, req: OptimizeParams) -> bool:
|
|
||||||
try:
|
|
||||||
existing_references = json.loads(record.media_references) if record.media_references else []
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
return False
|
|
||||||
if (
|
|
||||||
str(record.project_id) != str(req.project_id)
|
|
||||||
or record.original_prompt != req.prompt
|
|
||||||
or record.gen_type != req.gen_type.value
|
|
||||||
or str(record.engine_id or "") != str(req.engine_id)
|
|
||||||
or bool(record.include_media_references) != bool(req.include_media_references)
|
|
||||||
or _canonical_references(existing_references) != _canonical_references(req.references)
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
if req.gen_type == GenerationType.video:
|
|
||||||
return (
|
|
||||||
record.duration == req.duration
|
|
||||||
and record.aspect_ratio == req.aspect_ratio
|
|
||||||
and record.resolution == req.resolution
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
record.image_size == req.image_size
|
|
||||||
and record.image_proportion == req.image_proportion
|
|
||||||
and record.image_px == req.image_px
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_status_view(record: GenerationRecord) -> dict[str, object]:
|
def _record_status_view(record: GenerationRecord) -> dict[str, object]:
|
||||||
config_complete = _record_config_complete(record)
|
config_complete = _record_config_complete(record)
|
||||||
config_recoverable = is_generation_record_config_recoverable(record)
|
config_recoverable = is_generation_record_config_recoverable(record)
|
||||||
prompt_failure = record.status == "failed" and record.resource_generation_started_at is None
|
prompt_failure = record.status == "failed" and record.resource_generation_started_at is None
|
||||||
resource_failure = record.status == "failed" and record.resource_generation_started_at is not None
|
resource_failure = record.status == "failed" and record.resource_generation_started_at is not None
|
||||||
if record.status in {"pending", "optimizing"}:
|
if record.status in {"pending", "optimizing", "settlement_pending"}:
|
||||||
client_status = "prompt_processing"
|
client_status = "prompt_processing"
|
||||||
operation_phase = "prompt"
|
operation_phase = "prompt"
|
||||||
elif record.status == "prompt_optimized":
|
elif record.status == "prompt_optimized":
|
||||||
@@ -164,7 +114,7 @@ def _record_status_view(record: GenerationRecord) -> dict[str, object]:
|
|||||||
"config_fallback_hint": generation_record_config_fallback_hint(record),
|
"config_fallback_hint": generation_record_config_fallback_hint(record),
|
||||||
"can_generate": record.status == "prompt_optimized" and (config_complete or config_recoverable),
|
"can_generate": record.status == "prompt_optimized" and (config_complete or config_recoverable),
|
||||||
"can_retry": resource_failure and config_complete and record.pipeline_stage != GenerationRecordPipelineStage.UPSCALE_FAILED.value,
|
"can_retry": resource_failure and config_complete and record.pipeline_stage != GenerationRecordPipelineStage.UPSCALE_FAILED.value,
|
||||||
"should_poll": record.status in {"optimizing", "generating"},
|
"should_poll": record.status in {"optimizing", "settlement_pending", "generating"},
|
||||||
"client_status": client_status,
|
"client_status": client_status,
|
||||||
"operation_phase": operation_phase,
|
"operation_phase": operation_phase,
|
||||||
}
|
}
|
||||||
@@ -174,26 +124,6 @@ def _frozen_engine_view(record: GenerationRecord) -> SimpleNamespace:
|
|||||||
return frozen_generation_record_engine_view(record)
|
return frozen_generation_record_engine_view(record)
|
||||||
|
|
||||||
|
|
||||||
def _validate_video_engine_selection(engine, *, aspect_ratio: str, resolution: str, duration: int) -> None:
|
|
||||||
ratios = [str(item) for item in parse_json_list(engine.supported_ratios, [])]
|
|
||||||
resolutions = [str(item) for item in parse_json_list(engine.supported_resolutions, [])]
|
|
||||||
durations = [int(item) for item in parse_json_list(engine.supported_durations, []) if str(item).isdigit()]
|
|
||||||
if ratios and aspect_ratio not in ratios:
|
|
||||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选画面比例")
|
|
||||||
if resolutions and resolution not in resolutions:
|
|
||||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选分辨率")
|
|
||||||
if durations and duration not in durations:
|
|
||||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选时长")
|
|
||||||
if int(engine.max_duration or 0) > 0 and duration > int(engine.max_duration):
|
|
||||||
raise HTTPException(status_code=400, detail="生成时长超过当前视频引擎上限")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_image_engine_selection(engine, *, image_size: str) -> None:
|
|
||||||
sizes = image_supported_sizes(engine)
|
|
||||||
if sizes and image_size not in sizes:
|
|
||||||
raise HTTPException(status_code=400, detail="当前图片引擎不支持所选画面分辨率")
|
|
||||||
|
|
||||||
|
|
||||||
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
|
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
|
||||||
refs = refs_override
|
refs = refs_override
|
||||||
if refs is None and record.media_references:
|
if refs is None and record.media_references:
|
||||||
@@ -286,6 +216,8 @@ async def list_records(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
allowed_statuses = {
|
allowed_statuses = {
|
||||||
|
"optimizing",
|
||||||
|
"settlement_pending",
|
||||||
"prompt_optimized",
|
"prompt_optimized",
|
||||||
"generating",
|
"generating",
|
||||||
"failed",
|
"failed",
|
||||||
@@ -295,7 +227,7 @@ async def list_records(
|
|||||||
if status and status not in allowed_statuses:
|
if status and status not in allowed_statuses:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="状态参数错误,仅支持:prompt_optimized、generating、failed、completed",
|
detail="状态参数错误,仅支持:optimizing、settlement_pending、prompt_optimized、generating、failed、completed",
|
||||||
)
|
)
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
@@ -356,324 +288,20 @@ async def optimize(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
user_id_snapshot = str(current_user.id)
|
user_id_snapshot = str(current_user.id)
|
||||||
# Validate project, engine and the complete generation configuration before
|
service_result = await optimize_generation_prompt(
|
||||||
# charging prompt credits or invoking the LLM.
|
db,
|
||||||
proj_result = await db.execute(
|
req=req,
|
||||||
select(Project).where(
|
|
||||||
Project.id == req.project_id,
|
|
||||||
Project.user_id == user_id_snapshot,
|
|
||||||
Project.deleted_at.is_(None),
|
|
||||||
).limit(1)
|
|
||||||
)
|
|
||||||
project = proj_result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(status_code=404, detail="项目不存在")
|
|
||||||
|
|
||||||
log_generation_record_config_event(
|
|
||||||
event_type=GenerationRecordEventTypeEnum.PROMPT_CONFIG_VALIDATE_START,
|
|
||||||
event_status=LogEventStatusEnum.STARTED,
|
|
||||||
source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE,
|
|
||||||
record=GenerationRecord(
|
|
||||||
id=req.idempotency_key or "pending",
|
|
||||||
user_id=user_id_snapshot,
|
|
||||||
project_id=req.project_id,
|
|
||||||
original_prompt=req.prompt,
|
|
||||||
gen_type=req.gen_type.value,
|
|
||||||
duration=req.duration if req.gen_type == GenerationType.video else None,
|
|
||||||
aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None,
|
|
||||||
resolution=req.resolution if req.gen_type == GenerationType.video else None,
|
|
||||||
image_size=req.image_size if req.gen_type == GenerationType.image else None,
|
|
||||||
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
|
|
||||||
image_px=req.image_px if req.gen_type == GenerationType.image else None,
|
|
||||||
include_media_references=bool(req.include_media_references),
|
|
||||||
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
|
|
||||||
engine_id=req.engine_id,
|
|
||||||
),
|
|
||||||
detail={
|
|
||||||
"project_id": req.project_id,
|
|
||||||
"gen_type": req.gen_type.value,
|
|
||||||
"engine_id": req.engine_id,
|
|
||||||
"include_media_references": bool(req.include_media_references),
|
|
||||||
"reference_count": len(req.references or []),
|
|
||||||
"source": GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE.value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Idempotency must be checked before the temporary prompt-credit hold. The
|
|
||||||
# key is bound to one immutable generation configuration; reusing it with a
|
|
||||||
# different engine, parameter set or attachment selection is rejected.
|
|
||||||
if req.idempotency_key:
|
|
||||||
existing = await db.execute(
|
|
||||||
select(GenerationRecord, Project.name)
|
|
||||||
.join(Project, GenerationRecord.project_id == Project.id)
|
|
||||||
.where(
|
|
||||||
GenerationRecord.user_id == user_id_snapshot,
|
|
||||||
GenerationRecord.deleted_at.is_(None),
|
|
||||||
Project.deleted_at.is_(None),
|
|
||||||
GenerationRecord.idempotency_key == req.idempotency_key,
|
|
||||||
)
|
|
||||||
.order_by(GenerationRecord.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
row = existing.first()
|
|
||||||
if row:
|
|
||||||
existing_record, project_name = row
|
|
||||||
if not _idempotency_config_matches(existing_record, req):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="幂等键已绑定其他生成配置,请重新提交",
|
|
||||||
)
|
|
||||||
if not existing_record.optimized_prompt or not _record_config_complete(existing_record):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="幂等记录配置不完整,请使用新的幂等键重新提交",
|
|
||||||
)
|
|
||||||
refs = await resolve_private_portrait_reference_display_urls(
|
|
||||||
db,
|
|
||||||
json.loads(existing_record.media_references) if existing_record.media_references else None,
|
|
||||||
user_id=user_id_snapshot,
|
|
||||||
)
|
|
||||||
return OptimizeResult(
|
|
||||||
optimized_prompt=existing_record.optimized_prompt,
|
|
||||||
text_credits_cost=existing_record.text_credits_cost or 0.0,
|
|
||||||
text_tokens_used=existing_record.text_tokens_used or 0,
|
|
||||||
record=_record_to_out(existing_record, project_name, refs_override=refs),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if req.gen_type == GenerationType.video:
|
|
||||||
if req.duration not in DURATIONS:
|
|
||||||
raise HTTPException(status_code=400, detail=f"视频时长必须为{DURATIONS}秒之一")
|
|
||||||
if req.aspect_ratio not in ASPECT_RATIOS:
|
|
||||||
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
|
||||||
if req.resolution not in RESOLUTIONS:
|
|
||||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
|
||||||
engine = await get_video_engine(db, req.engine_id)
|
|
||||||
_validate_video_engine_selection(
|
|
||||||
engine,
|
|
||||||
aspect_ratio=req.aspect_ratio,
|
|
||||||
resolution=req.resolution,
|
|
||||||
duration=int(req.duration),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if req.image_size not in IMAGE_SIZES:
|
|
||||||
raise HTTPException(status_code=400, detail=f"图片分辨率必须为{IMAGE_SIZES}之一")
|
|
||||||
if not req.image_proportion or not req.image_px:
|
|
||||||
raise HTTPException(status_code=400, detail="图片生成需要指定比例和像素尺寸")
|
|
||||||
engine = await get_image_engine(db, req.engine_id)
|
|
||||||
_validate_image_engine_selection(engine, image_size=req.image_size)
|
|
||||||
|
|
||||||
project_name_snapshot = str(project.name)
|
|
||||||
project_industry_snapshot = str(project.industry or "")
|
|
||||||
engine_snapshot_source = SimpleNamespace(
|
|
||||||
**{
|
|
||||||
key: value
|
|
||||||
for key, value in vars(engine).items()
|
|
||||||
if key != "_sa_instance_state"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
reference_usage = calculate_media_reference_usage(
|
|
||||||
json.dumps(req.references, ensure_ascii=False) if req.references else None,
|
|
||||||
include=bool(req.include_media_references),
|
|
||||||
)
|
|
||||||
validate_media_reference_usage_for_engine(
|
|
||||||
reference_usage,
|
|
||||||
gen_type=req.gen_type.value,
|
|
||||||
engine=engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
log_generation_record_config_event(
|
|
||||||
event_type=GenerationRecordEventTypeEnum.PROMPT_CONFIG_VALIDATE_SUCCESS,
|
|
||||||
event_status=LogEventStatusEnum.SUCCESS,
|
|
||||||
source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE,
|
|
||||||
record=GenerationRecord(
|
|
||||||
id=req.idempotency_key or "pending",
|
|
||||||
user_id=user_id_snapshot,
|
|
||||||
project_id=req.project_id,
|
|
||||||
original_prompt=req.prompt,
|
|
||||||
gen_type=req.gen_type.value,
|
|
||||||
duration=req.duration if req.gen_type == GenerationType.video else None,
|
|
||||||
aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None,
|
|
||||||
resolution=req.resolution if req.gen_type == GenerationType.video else None,
|
|
||||||
image_size=req.image_size if req.gen_type == GenerationType.image else None,
|
|
||||||
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
|
|
||||||
image_px=req.image_px if req.gen_type == GenerationType.image else None,
|
|
||||||
include_media_references=bool(req.include_media_references),
|
|
||||||
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
|
|
||||||
engine_id=req.engine_id,
|
|
||||||
),
|
|
||||||
detail={
|
|
||||||
"project_id": req.project_id,
|
|
||||||
"gen_type": req.gen_type.value,
|
|
||||||
"engine_id": req.engine_id,
|
|
||||||
"include_media_references": bool(req.include_media_references),
|
|
||||||
"reference_count": len(req.references or []),
|
|
||||||
"media_reference_usage": getattr(reference_usage, "__dict__", None) or str(reference_usage),
|
|
||||||
"source": GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE.value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
record_id_value = generate_id()
|
|
||||||
prompt_attempt_no = 1
|
|
||||||
llm_billing_context = LlmBillingContext(
|
|
||||||
user_id=user_id_snapshot,
|
user_id=user_id_snapshot,
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
owner_id=record_id_value,
|
|
||||||
attempt_no=prompt_attempt_no,
|
|
||||||
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
|
|
||||||
billing_scene=CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
|
|
||||||
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
|
|
||||||
related_id=record_id_value,
|
|
||||||
hold_config_key=LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
|
|
||||||
description_prefix="AI创作提示词优化",
|
|
||||||
trace_id=f"generation-optimize:{record_id_value}",
|
|
||||||
request_id=req.idempotency_key,
|
|
||||||
)
|
)
|
||||||
await start_hold(db, llm_billing_context)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
log_provider_start(llm_billing_context, detail={"gen_type": req.gen_type.value})
|
|
||||||
try:
|
|
||||||
optimized, token_usage = await optimize_prompt(
|
|
||||||
db,
|
|
||||||
req.prompt,
|
|
||||||
user_id=user_id_snapshot,
|
|
||||||
industry_key=project_industry_snapshot,
|
|
||||||
duration=req.duration if req.gen_type == GenerationType.video else None,
|
|
||||||
image_size=req.image_size if req.gen_type == GenerationType.image else None,
|
|
||||||
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
|
|
||||||
image_px=req.image_px if req.gen_type == GenerationType.image else None,
|
|
||||||
references=req.references,
|
|
||||||
gen_type=req.gen_type.value,
|
|
||||||
log_module="generation_record",
|
|
||||||
log_step="prompt_optimize",
|
|
||||||
log_project_id=req.project_id,
|
|
||||||
log_owner_type=OWNER_GENERATION_RECORD,
|
|
||||||
log_owner_id=record_id_value,
|
|
||||||
generation_attempt_no=prompt_attempt_no,
|
|
||||||
)
|
|
||||||
log_provider_success(llm_billing_context, usage=token_usage)
|
|
||||||
except Exception as exc:
|
|
||||||
from app.services.error_codes import extract_error_message
|
|
||||||
|
|
||||||
await db.rollback()
|
|
||||||
log_provider_failure(llm_billing_context, error=str(exc))
|
|
||||||
await release_on_failure(db, llm_billing_context, error=str(exc))
|
|
||||||
await db.commit()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail=f"AI模型调用失败: {extract_error_message(exc, '提示词')}",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
async def _persist_optimized_result() -> str:
|
|
||||||
existing_result = await db.execute(
|
|
||||||
select(GenerationRecord)
|
|
||||||
.where(GenerationRecord.id == record_id_value)
|
|
||||||
.with_for_update()
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
record = existing_result.scalar_one_or_none()
|
|
||||||
if record is None:
|
|
||||||
record = GenerationRecord(
|
|
||||||
id=record_id_value,
|
|
||||||
user_id=user_id_snapshot,
|
|
||||||
project_id=req.project_id,
|
|
||||||
original_prompt=req.prompt,
|
|
||||||
optimized_prompt=optimized,
|
|
||||||
gen_type=req.gen_type.value,
|
|
||||||
duration=req.duration if req.gen_type == GenerationType.video else None,
|
|
||||||
aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None,
|
|
||||||
resolution=req.resolution if req.gen_type == GenerationType.video else None,
|
|
||||||
image_size=req.image_size if req.gen_type == GenerationType.image else None,
|
|
||||||
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
|
|
||||||
image_px=req.image_px if req.gen_type == GenerationType.image else None,
|
|
||||||
status="prompt_optimized",
|
|
||||||
pipeline_stage=None,
|
|
||||||
credits_cost=0,
|
|
||||||
text_credits_cost=0,
|
|
||||||
text_tokens_used=int(token_usage.get("total_tokens", 0) or 0),
|
|
||||||
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
|
|
||||||
include_media_references=bool(req.include_media_references),
|
|
||||||
idempotency_key=req.idempotency_key,
|
|
||||||
)
|
|
||||||
db.add(record)
|
|
||||||
else:
|
|
||||||
# commit 结果不确定或本地持久化重试时,复用同一主键和同一账务 attempt。
|
|
||||||
record.optimized_prompt = optimized
|
|
||||||
record.status = "prompt_optimized"
|
|
||||||
record.pipeline_stage = None
|
|
||||||
record.error_message = None
|
|
||||||
record.text_credits_cost = 0
|
|
||||||
record.text_tokens_used = int(token_usage.get("total_tokens", 0) or 0)
|
|
||||||
|
|
||||||
if req.gen_type == GenerationType.video:
|
|
||||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
|
||||||
|
|
||||||
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
|
||||||
db,
|
|
||||||
target_resolution=req.resolution,
|
|
||||||
aspect_ratio=req.aspect_ratio,
|
|
||||||
supported_provider_resolutions=parse_json_list(
|
|
||||||
engine_snapshot_source.supported_resolutions,
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
record.provider_generation_resolution = provider_resolution
|
|
||||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
|
||||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
|
||||||
else:
|
|
||||||
record.provider_generation_resolution = None
|
|
||||||
record.video_upscale_enabled_snapshot = False
|
|
||||||
record.video_upscale_snapshot_json = None
|
|
||||||
|
|
||||||
freeze_generation_record_config_with_log(
|
|
||||||
record,
|
|
||||||
engine=engine_snapshot_source,
|
|
||||||
source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE,
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
billing = await settle_success(
|
|
||||||
db,
|
|
||||||
llm_billing_context,
|
|
||||||
usage=token_usage,
|
|
||||||
description=f"提示词优化 - {project_name_snapshot}",
|
|
||||||
)
|
|
||||||
charge_item = next(
|
|
||||||
(item for item in billing.items if item.biz_key == llm_billing_context.charge_biz_key),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if charge_item:
|
|
||||||
record.text_credits_cost = round(charge_item.amount, 2)
|
|
||||||
record_id_snapshot = str(record.id)
|
|
||||||
await db.commit()
|
|
||||||
return record_id_snapshot
|
|
||||||
|
|
||||||
try:
|
|
||||||
record_id_snapshot = await _persist_optimized_result()
|
|
||||||
except Exception as first_exc:
|
|
||||||
await db.rollback()
|
|
||||||
logger.exception(
|
|
||||||
"prompt optimize local persistence/settlement failed after provider success; retry once: record_id=%s",
|
|
||||||
record_id_value,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
record_id_snapshot = await _persist_optimized_result()
|
|
||||||
except Exception:
|
|
||||||
await db.rollback()
|
|
||||||
logger.exception(
|
|
||||||
"prompt optimize idempotent persistence retry failed; active HOLD retained for repair: record_id=%s",
|
|
||||||
record_id_value,
|
|
||||||
)
|
|
||||||
raise first_exc
|
|
||||||
|
|
||||||
|
|
||||||
refreshed = await db.execute(
|
refreshed = await db.execute(
|
||||||
select(GenerationRecord, Project.name)
|
select(GenerationRecord, Project.name)
|
||||||
.join(Project, GenerationRecord.project_id == Project.id)
|
.join(Project, GenerationRecord.project_id == Project.id)
|
||||||
.where(GenerationRecord.id == record_id_snapshot)
|
.where(
|
||||||
|
GenerationRecord.id == service_result.record_id,
|
||||||
|
GenerationRecord.user_id == user_id_snapshot,
|
||||||
|
GenerationRecord.deleted_at.is_(None),
|
||||||
|
Project.deleted_at.is_(None),
|
||||||
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
refreshed_row = refreshed.first()
|
refreshed_row = refreshed.first()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.enums.credit_record import (
|
|||||||
CreditRecordBillingScene,
|
CreditRecordBillingScene,
|
||||||
CreditRecordChargeKind,
|
CreditRecordChargeKind,
|
||||||
CreditRecordOwnerType,
|
CreditRecordOwnerType,
|
||||||
|
CreditRecordSourceStepCode,
|
||||||
)
|
)
|
||||||
from app.enums.llm_billing import LlmBillingConfigKey
|
from app.enums.llm_billing import LlmBillingConfigKey
|
||||||
from app.enums.shot_replicate import (
|
from app.enums.shot_replicate import (
|
||||||
@@ -274,7 +275,7 @@ def _analysis_dispatch_billing_context(
|
|||||||
source_module=MODULE,
|
source_module=MODULE,
|
||||||
source_project_id=task_set_id,
|
source_project_id=task_set_id,
|
||||||
source_step_id=owner_id,
|
source_step_id=owner_id,
|
||||||
source_step_code=ShotReplicateStepCodeEnum.VIDEO_ANALYSIS.value,
|
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
|
||||||
related_id=owner_id,
|
related_id=owner_id,
|
||||||
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
|
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
|
||||||
description_prefix=(
|
description_prefix=(
|
||||||
@@ -569,7 +570,7 @@ async def create_shot_task_set(
|
|||||||
from app.tasks.shot_replicate_tasks import analyze_original_video
|
from app.tasks.shot_replicate_tasks import analyze_original_video
|
||||||
|
|
||||||
analyze_original_video.apply_async(
|
analyze_original_video.apply_async(
|
||||||
args=[task_set_id],
|
args=[task_set_id, analysis_attempt_no],
|
||||||
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||||
countdown=0,
|
countdown=0,
|
||||||
task_id=celery_task_id,
|
task_id=celery_task_id,
|
||||||
@@ -590,6 +591,7 @@ async def create_shot_task_set(
|
|||||||
db,
|
db,
|
||||||
current_user=_user_context(current_user),
|
current_user=_user_context(current_user),
|
||||||
task_set_id=task_set_id,
|
task_set_id=task_set_id,
|
||||||
|
expected_attempt_no=analysis_attempt_no,
|
||||||
error_message=f"拆镜分析任务投递失败: {exc}",
|
error_message=f"拆镜分析任务投递失败: {exc}",
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -672,7 +674,7 @@ async def get_shot_task_set(
|
|||||||
"/task-sets/{task_set_id}/reanalyze",
|
"/task-sets/{task_set_id}/reanalyze",
|
||||||
response_model=ShotReanalyzeOut,
|
response_model=ShotReanalyzeOut,
|
||||||
summary="重新投递原视频 AI 分析任务",
|
summary="重新投递原视频 AI 分析任务",
|
||||||
description="用于处理原视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_original_video。",
|
description="仅用于重新处理原视频分析失败的数据;处理中、待处理或已完成状态均拒绝重复投递。",
|
||||||
)
|
)
|
||||||
async def reanalyze_task_set(
|
async def reanalyze_task_set(
|
||||||
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
|
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
|
||||||
@@ -686,7 +688,6 @@ async def reanalyze_task_set(
|
|||||||
db,
|
db,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
task_set_id=task_set_id,
|
task_set_id=task_set_id,
|
||||||
force=req.force,
|
|
||||||
reason=req.reason,
|
reason=req.reason,
|
||||||
)
|
)
|
||||||
analysis_attempt_no = int(out.analysis_attempt_no)
|
analysis_attempt_no = int(out.analysis_attempt_no)
|
||||||
@@ -729,7 +730,7 @@ async def reanalyze_task_set(
|
|||||||
from app.tasks.shot_replicate_tasks import analyze_original_video
|
from app.tasks.shot_replicate_tasks import analyze_original_video
|
||||||
|
|
||||||
analyze_original_video.apply_async(
|
analyze_original_video.apply_async(
|
||||||
args=[task_set_id],
|
args=[task_set_id, analysis_attempt_no],
|
||||||
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||||
countdown=0,
|
countdown=0,
|
||||||
task_id=celery_task_id,
|
task_id=celery_task_id,
|
||||||
@@ -758,6 +759,7 @@ async def reanalyze_task_set(
|
|||||||
db,
|
db,
|
||||||
current_user=_user_context(current_user),
|
current_user=_user_context(current_user),
|
||||||
task_set_id=task_set_id,
|
task_set_id=task_set_id,
|
||||||
|
expected_attempt_no=analysis_attempt_no,
|
||||||
error_message=f"原视频再次分析任务投递失败: {exc}",
|
error_message=f"原视频再次分析任务投递失败: {exc}",
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -923,7 +925,7 @@ async def get_segment(
|
|||||||
"/segments/{segment_id}/reanalyze",
|
"/segments/{segment_id}/reanalyze",
|
||||||
response_model=ShotReanalyzeOut,
|
response_model=ShotReanalyzeOut,
|
||||||
summary="重新投递切片视频 AI 分析任务",
|
summary="重新投递切片视频 AI 分析任务",
|
||||||
description="用于处理自定义切片视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_custom_segment_video。",
|
description="仅用于重新处理自定义切片视频分析失败的数据;处理中、待处理或已完成状态均拒绝重复投递。",
|
||||||
)
|
)
|
||||||
async def reanalyze_segment(
|
async def reanalyze_segment(
|
||||||
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
|
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
|
||||||
@@ -937,7 +939,6 @@ async def reanalyze_segment(
|
|||||||
db,
|
db,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
segment_id=segment_id,
|
segment_id=segment_id,
|
||||||
force=req.force,
|
|
||||||
reason=req.reason,
|
reason=req.reason,
|
||||||
)
|
)
|
||||||
task_set_id = str(out.task_set_id)
|
task_set_id = str(out.task_set_id)
|
||||||
@@ -981,7 +982,7 @@ async def reanalyze_segment(
|
|||||||
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video
|
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video
|
||||||
|
|
||||||
analyze_custom_segment_video.apply_async(
|
analyze_custom_segment_video.apply_async(
|
||||||
args=[segment_id],
|
args=[segment_id, analysis_attempt_no],
|
||||||
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||||
countdown=0,
|
countdown=0,
|
||||||
task_id=celery_task_id,
|
task_id=celery_task_id,
|
||||||
@@ -1012,6 +1013,7 @@ async def reanalyze_segment(
|
|||||||
db,
|
db,
|
||||||
current_user=_user_context(current_user),
|
current_user=_user_context(current_user),
|
||||||
segment_id=segment_id,
|
segment_id=segment_id,
|
||||||
|
expected_attempt_no=analysis_attempt_no,
|
||||||
error_message=f"切片视频再次分析任务投递失败: {exc}",
|
error_message=f"切片视频再次分析任务投递失败: {exc}",
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class LogSourceEnum(StrEnum):
|
|||||||
CELERY = "celery"
|
CELERY = "celery"
|
||||||
RECOVERY = "recovery"
|
RECOVERY = "recovery"
|
||||||
REMOTE_API = "remote_api"
|
REMOTE_API = "remote_api"
|
||||||
|
CLI = "cli"
|
||||||
|
|
||||||
class ModuleGenerationFlowVersionEnum(StrEnum):
|
class ModuleGenerationFlowVersionEnum(StrEnum):
|
||||||
"""模块生成项目流程版本。"""
|
"""模块生成项目流程版本。"""
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ class GenerationRecordEventTypeEnum(StrEnum):
|
|||||||
PROMPT_CONFIG_FREEZE_START = "PROMPT_CONFIG_FREEZE_START"
|
PROMPT_CONFIG_FREEZE_START = "PROMPT_CONFIG_FREEZE_START"
|
||||||
PROMPT_CONFIG_FREEZE_SUCCESS = "PROMPT_CONFIG_FREEZE_SUCCESS"
|
PROMPT_CONFIG_FREEZE_SUCCESS = "PROMPT_CONFIG_FREEZE_SUCCESS"
|
||||||
PROMPT_CONFIG_FREEZE_FAILED = "PROMPT_CONFIG_FREEZE_FAILED"
|
PROMPT_CONFIG_FREEZE_FAILED = "PROMPT_CONFIG_FREEZE_FAILED"
|
||||||
|
PROMPT_OPTIMIZE_PLACEHOLDER_CREATED = "PROMPT_OPTIMIZE_PLACEHOLDER_CREATED"
|
||||||
|
PROMPT_OPTIMIZE_IDEMPOTENCY_HIT = "PROMPT_OPTIMIZE_IDEMPOTENCY_HIT"
|
||||||
|
PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED = "PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED"
|
||||||
|
PROMPT_OPTIMIZE_SETTLEMENT_PENDING = "PROMPT_OPTIMIZE_SETTLEMENT_PENDING"
|
||||||
|
PROMPT_OPTIMIZE_SETTLEMENT_SUCCESS = "PROMPT_OPTIMIZE_SETTLEMENT_SUCCESS"
|
||||||
|
PROMPT_OPTIMIZE_FAILED_RELEASED = "PROMPT_OPTIMIZE_FAILED_RELEASED"
|
||||||
LEGACY_CONFIG_FALLBACK_START = "LEGACY_CONFIG_FALLBACK_START"
|
LEGACY_CONFIG_FALLBACK_START = "LEGACY_CONFIG_FALLBACK_START"
|
||||||
LEGACY_CONFIG_FALLBACK_SUCCESS = "LEGACY_CONFIG_FALLBACK_SUCCESS"
|
LEGACY_CONFIG_FALLBACK_SUCCESS = "LEGACY_CONFIG_FALLBACK_SUCCESS"
|
||||||
LEGACY_CONFIG_FALLBACK_FAILED = "LEGACY_CONFIG_FALLBACK_FAILED"
|
LEGACY_CONFIG_FALLBACK_FAILED = "LEGACY_CONFIG_FALLBACK_FAILED"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from enum import Enum
|
|||||||
|
|
||||||
class GenerationStatus(str, Enum):
|
class GenerationStatus(str, Enum):
|
||||||
"""生成状态。"""
|
"""生成状态。"""
|
||||||
|
optimizing = "optimizing"
|
||||||
|
settlement_pending = "settlement_pending"
|
||||||
prompt_optimized = "prompt_optimized"
|
prompt_optimized = "prompt_optimized"
|
||||||
generating = "generating"
|
generating = "generating"
|
||||||
completed = "completed"
|
completed = "completed"
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class LlmBillingEvent(StrEnum):
|
|||||||
CELERY_DISPATCH_SUCCESS = "LLM_CELERY_DISPATCH_SUCCESS"
|
CELERY_DISPATCH_SUCCESS = "LLM_CELERY_DISPATCH_SUCCESS"
|
||||||
CELERY_DISPATCH_FAILURE = "LLM_CELERY_DISPATCH_FAILURE"
|
CELERY_DISPATCH_FAILURE = "LLM_CELERY_DISPATCH_FAILURE"
|
||||||
CELERY_DISPATCH_COMPENSATED = "LLM_CELERY_DISPATCH_COMPENSATED"
|
CELERY_DISPATCH_COMPENSATED = "LLM_CELERY_DISPATCH_COMPENSATED"
|
||||||
|
RETRY_PREVIOUS_ATTEMPT_VALIDATE_START = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_START"
|
||||||
|
RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS"
|
||||||
|
RETRY_PREVIOUS_ATTEMPT_BLOCKED = "LLM_RETRY_PREVIOUS_ATTEMPT_BLOCKED"
|
||||||
|
USAGE_INVALID = "LLM_USAGE_INVALID"
|
||||||
|
TOKEN_USAGE_CREATED = "LLM_TOKEN_USAGE_CREATED"
|
||||||
|
TOKEN_USAGE_REUSED = "LLM_TOKEN_USAGE_REUSED"
|
||||||
|
|
||||||
|
|
||||||
class LlmBillingDomain(StrEnum):
|
class LlmBillingDomain(StrEnum):
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ class ShotReplicateLogEventEnum(StrEnum):
|
|||||||
ANALYSIS_REMOTE_API_FAILED = "SHOT_ANALYSIS_REMOTE_API_FAILED"
|
ANALYSIS_REMOTE_API_FAILED = "SHOT_ANALYSIS_REMOTE_API_FAILED"
|
||||||
ANALYSIS_RESPONSE_PARSE_FAILED = "SHOT_ANALYSIS_RESPONSE_PARSE_FAILED"
|
ANALYSIS_RESPONSE_PARSE_FAILED = "SHOT_ANALYSIS_RESPONSE_PARSE_FAILED"
|
||||||
ANALYSIS_RESPONSE_EMPTY = "SHOT_ANALYSIS_RESPONSE_EMPTY"
|
ANALYSIS_RESPONSE_EMPTY = "SHOT_ANALYSIS_RESPONSE_EMPTY"
|
||||||
|
ANALYSIS_STALE_ATTEMPT_SKIPPED = "SHOT_ANALYSIS_STALE_ATTEMPT_SKIPPED"
|
||||||
|
|
||||||
SEGMENT_REANALYZE_RECEIVED = "SHOT_SEGMENT_REANALYZE_RECEIVED"
|
SEGMENT_REANALYZE_RECEIVED = "SHOT_SEGMENT_REANALYZE_RECEIVED"
|
||||||
SEGMENT_REANALYZE_SUBMITTED = "SHOT_SEGMENT_REANALYZE_SUBMITTED"
|
SEGMENT_REANALYZE_SUBMITTED = "SHOT_SEGMENT_REANALYZE_SUBMITTED"
|
||||||
@@ -131,6 +132,7 @@ class ShotReplicateLogEventEnum(StrEnum):
|
|||||||
SEGMENT_ANALYSIS_REMOTE_API_FAILED = "SHOT_SEGMENT_ANALYSIS_REMOTE_API_FAILED"
|
SEGMENT_ANALYSIS_REMOTE_API_FAILED = "SHOT_SEGMENT_ANALYSIS_REMOTE_API_FAILED"
|
||||||
|
|
||||||
SPLIT_STATUS_CHANGED = "SHOT_SPLIT_STATUS_CHANGED"
|
SPLIT_STATUS_CHANGED = "SHOT_SPLIT_STATUS_CHANGED"
|
||||||
|
SPLIT_SUMMARY_REPAIRED = "SHOT_SPLIT_SUMMARY_REPAIRED"
|
||||||
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
|
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
|
||||||
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
|
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
|
||||||
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED"
|
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Float, ForeignKey, Index, Integer, String
|
from sqlalchemy import Float, ForeignKey, Index, Integer, String, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
@@ -11,6 +11,13 @@ class CreditRecord(Base, TimestampMixin):
|
|||||||
# PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。
|
# PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。
|
||||||
Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True),
|
Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True),
|
||||||
Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"),
|
Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"),
|
||||||
|
Index(
|
||||||
|
"uq_credit_records_user_refund_target",
|
||||||
|
"user_id",
|
||||||
|
"refund_for_biz_key",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
|
||||||
|
),
|
||||||
Index("ix_credit_records_related_type", "related_id", "type"),
|
Index("ix_credit_records_related_type", "related_id", "type"),
|
||||||
Index("ix_credit_records_owner", "owner_type", "owner_id"),
|
Index("ix_credit_records_owner", "owner_type", "owner_id"),
|
||||||
Index("ix_credit_records_subject_media", "credit_subject", "media_type"),
|
Index("ix_credit_records_subject_media", "credit_subject", "media_type"),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
)
|
)
|
||||||
original_prompt: Mapped[str] = mapped_column(Text)
|
original_prompt: Mapped[str] = mapped_column(Text)
|
||||||
optimized_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
optimized_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
prompt_usage_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
gen_type: Mapped[str] = mapped_column(String(16), default="video")
|
gen_type: Mapped[str] = mapped_column(String(16), default="video")
|
||||||
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||||
@@ -95,6 +96,13 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_genrec_user_status_created', 'user_id', 'status', 'created_at'),
|
Index('idx_genrec_user_status_created', 'user_id', 'status', 'created_at'),
|
||||||
Index('idx_genrec_project_status', 'project_id', 'status'),
|
Index('idx_genrec_project_status', 'project_id', 'status'),
|
||||||
|
Index(
|
||||||
|
'uq_genrec_user_idempotency_active',
|
||||||
|
'user_id',
|
||||||
|
'idempotency_key',
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("idempotency_key IS NOT NULL AND deleted_at IS NULL"),
|
||||||
|
),
|
||||||
Index(
|
Index(
|
||||||
'idx_genrec_next_poll_at',
|
'idx_genrec_next_poll_at',
|
||||||
'next_poll_at',
|
'next_poll_at',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import ForeignKey, Index, Integer, String
|
from sqlalchemy import ForeignKey, Index, Integer, String, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
@@ -9,6 +9,13 @@ class TokenUsage(Base, TimestampMixin):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_token_usage_owner", "owner_type", "owner_id"),
|
Index("ix_token_usage_owner", "owner_type", "owner_id"),
|
||||||
Index("ix_token_usage_biz_key", "biz_key"),
|
Index("ix_token_usage_biz_key", "biz_key"),
|
||||||
|
Index(
|
||||||
|
"uq_token_usage_user_biz_key",
|
||||||
|
"user_id",
|
||||||
|
"biz_key",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("biz_key IS NOT NULL"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ class ShotSegmentListOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ShotReanalyzeRequest(BaseModel):
|
class ShotReanalyzeRequest(BaseModel):
|
||||||
force: bool = Field(False, description="是否强制重跑;默认 false。当前仅允许失败/待处理数据重试,已完成数据不建议强制覆盖")
|
force: bool = Field(False, description="兼容旧客户端保留字段;后端不再支持强制重跑,仅分析失败状态允许重新分析")
|
||||||
reason: str | None = Field(None, max_length=200, description="再次分析原因,会写入模块日志")
|
reason: str | None = Field(None, max_length=200, description="再次分析原因,会写入模块日志")
|
||||||
|
|
||||||
@field_validator("reason", mode="before")
|
@field_validator("reason", mode="before")
|
||||||
|
|||||||
@@ -0,0 +1,647 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.enums.common import LogEventStatusEnum
|
||||||
|
from app.enums.credit_record import (
|
||||||
|
CreditRecordBillingScene,
|
||||||
|
CreditRecordChargeKind,
|
||||||
|
CreditRecordSourceModule,
|
||||||
|
)
|
||||||
|
from app.enums.generation_record import (
|
||||||
|
GenerationRecordConfigSourceEnum,
|
||||||
|
GenerationRecordEventTypeEnum,
|
||||||
|
)
|
||||||
|
from app.enums.generation_status import (
|
||||||
|
ASPECT_RATIOS,
|
||||||
|
DURATIONS,
|
||||||
|
IMAGE_SIZES,
|
||||||
|
RESOLUTIONS,
|
||||||
|
GenerationStatus,
|
||||||
|
GenerationType,
|
||||||
|
)
|
||||||
|
from app.enums.llm_billing import LlmBillingConfigKey
|
||||||
|
from app.models.generation_record import GenerationRecord
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.schemas.generation import OptimizeParams
|
||||||
|
from app.services.error_codes import extract_error_message
|
||||||
|
from app.services.generation.ai.engine_service import (
|
||||||
|
get_image_engine,
|
||||||
|
get_video_engine,
|
||||||
|
image_supported_sizes,
|
||||||
|
parse_json_list,
|
||||||
|
)
|
||||||
|
from app.services.generation.billing_service import OWNER_GENERATION_RECORD
|
||||||
|
from app.services.generation.media_reference_service import (
|
||||||
|
calculate_media_reference_usage,
|
||||||
|
validate_media_reference_usage_for_engine,
|
||||||
|
)
|
||||||
|
from app.services.generation.pipeline.generation_record_config_service import (
|
||||||
|
freeze_generation_record_config_with_log,
|
||||||
|
is_generation_record_config_complete,
|
||||||
|
)
|
||||||
|
from app.services.llm import optimize_prompt
|
||||||
|
from app.services.llm_billing import (
|
||||||
|
LlmBillingContext,
|
||||||
|
log_provider_failure,
|
||||||
|
log_provider_start,
|
||||||
|
log_provider_success,
|
||||||
|
release_on_failure,
|
||||||
|
settle_success,
|
||||||
|
start_hold,
|
||||||
|
)
|
||||||
|
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||||
|
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
logger = logging.getLogger("videogen")
|
||||||
|
|
||||||
|
_PROMPT_ATTEMPT_NO = 1
|
||||||
|
_LOG_DOMAIN = "generation_record"
|
||||||
|
_LOG_MODULE = "generation_record"
|
||||||
|
_LOG_SOURCE = GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE.value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class PromptOptimizeServiceResult:
|
||||||
|
record_id: str
|
||||||
|
idempotent: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _log_event(
|
||||||
|
event: GenerationRecordEventTypeEnum,
|
||||||
|
*,
|
||||||
|
status: LogEventStatusEnum = LogEventStatusEnum.SUCCESS,
|
||||||
|
user_id: str,
|
||||||
|
project_id: str | None,
|
||||||
|
record_id: str | None,
|
||||||
|
detail: dict[str, Any] | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
log_operation_event(
|
||||||
|
domain=_LOG_DOMAIN,
|
||||||
|
module=_LOG_MODULE,
|
||||||
|
event_type=event.value,
|
||||||
|
event_status=status.value,
|
||||||
|
source=_LOG_SOURCE,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
task_id=record_id,
|
||||||
|
detail=detail,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _billing_context(*, user_id: str, record_id: str, request_id: str | None) -> LlmBillingContext:
|
||||||
|
return LlmBillingContext(
|
||||||
|
user_id=user_id,
|
||||||
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
|
owner_id=record_id,
|
||||||
|
attempt_no=_PROMPT_ATTEMPT_NO,
|
||||||
|
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
|
||||||
|
billing_scene=CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
|
||||||
|
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
|
||||||
|
related_id=record_id,
|
||||||
|
hold_config_key=LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
|
||||||
|
description_prefix="AI创作提示词优化",
|
||||||
|
trace_id=f"generation-optimize:{record_id}",
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_references(value: object) -> str:
|
||||||
|
return json.dumps(value or [], ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _idempotency_config_matches(record: GenerationRecord, req: OptimizeParams) -> bool:
|
||||||
|
try:
|
||||||
|
existing_references = json.loads(record.media_references) if record.media_references else []
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
str(record.project_id) != str(req.project_id)
|
||||||
|
or record.original_prompt != req.prompt
|
||||||
|
or record.gen_type != req.gen_type.value
|
||||||
|
or str(record.engine_id or "") != str(req.engine_id)
|
||||||
|
or bool(record.include_media_references) != bool(req.include_media_references)
|
||||||
|
or _canonical_references(existing_references) != _canonical_references(req.references)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if req.gen_type == GenerationType.video:
|
||||||
|
return (
|
||||||
|
record.duration == req.duration
|
||||||
|
and record.aspect_ratio == req.aspect_ratio
|
||||||
|
and record.resolution == req.resolution
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
record.image_size == req.image_size
|
||||||
|
and record.image_proportion == req.image_proportion
|
||||||
|
and record.image_px == req.image_px
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_video_engine_selection(engine: Any, *, aspect_ratio: str, resolution: str, duration: int) -> None:
|
||||||
|
ratios = [str(item) for item in parse_json_list(engine.supported_ratios, [])]
|
||||||
|
resolutions = [str(item) for item in parse_json_list(engine.supported_resolutions, [])]
|
||||||
|
durations = [int(item) for item in parse_json_list(engine.supported_durations, []) if str(item).isdigit()]
|
||||||
|
if ratios and aspect_ratio not in ratios:
|
||||||
|
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选画面比例")
|
||||||
|
if resolutions and resolution not in resolutions:
|
||||||
|
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选分辨率")
|
||||||
|
if durations and duration not in durations:
|
||||||
|
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选时长")
|
||||||
|
if int(engine.max_duration or 0) > 0 and duration > int(engine.max_duration):
|
||||||
|
raise HTTPException(status_code=400, detail="生成时长超过当前视频引擎上限")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_image_engine_selection(engine: Any, *, image_size: str) -> None:
|
||||||
|
sizes = image_supported_sizes(engine)
|
||||||
|
if sizes and image_size not in sizes:
|
||||||
|
raise HTTPException(status_code=400, detail="当前图片引擎不支持所选画面分辨率")
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_idempotency_record(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
idempotency_key: str | None,
|
||||||
|
) -> GenerationRecord | None:
|
||||||
|
if not idempotency_key:
|
||||||
|
return None
|
||||||
|
stmt = (
|
||||||
|
select(GenerationRecord)
|
||||||
|
.where(
|
||||||
|
GenerationRecord.user_id == user_id,
|
||||||
|
GenerationRecord.idempotency_key == idempotency_key,
|
||||||
|
GenerationRecord.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(GenerationRecord.created_at.desc(), GenerationRecord.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _settle_staged_result(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
record_id: str,
|
||||||
|
user_id: str,
|
||||||
|
project_name: str,
|
||||||
|
request_id: str | None,
|
||||||
|
) -> PromptOptimizeServiceResult:
|
||||||
|
result = await db.execute(
|
||||||
|
select(GenerationRecord)
|
||||||
|
.where(
|
||||||
|
GenerationRecord.id == record_id,
|
||||||
|
GenerationRecord.user_id == user_id,
|
||||||
|
GenerationRecord.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail="生成记录不存在")
|
||||||
|
if record.status in {
|
||||||
|
GenerationStatus.prompt_optimized.value,
|
||||||
|
GenerationStatus.generating.value,
|
||||||
|
GenerationStatus.completed.value,
|
||||||
|
}:
|
||||||
|
await db.rollback()
|
||||||
|
return PromptOptimizeServiceResult(record_id=record_id, idempotent=True)
|
||||||
|
if record.status != GenerationStatus.settlement_pending.value:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail=f"当前提词状态不可结算:{record.status}")
|
||||||
|
if not record.optimized_prompt or not record.prompt_usage_snapshot_json:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="提词结果或计费快照缺失,需人工排查")
|
||||||
|
|
||||||
|
try:
|
||||||
|
usage = json.loads(record.prompt_usage_snapshot_json)
|
||||||
|
except (TypeError, json.JSONDecodeError) as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="提词计费快照损坏,需人工排查") from exc
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="提词计费快照格式错误,需人工排查")
|
||||||
|
|
||||||
|
project_id_snapshot = str(record.project_id)
|
||||||
|
status_snapshot = str(record.status)
|
||||||
|
ctx = _billing_context(user_id=user_id, record_id=record_id, request_id=request_id)
|
||||||
|
_log_event(
|
||||||
|
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_SETTLEMENT_PENDING,
|
||||||
|
status=LogEventStatusEnum.STARTED,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id_snapshot,
|
||||||
|
record_id=record_id,
|
||||||
|
detail={"status": status_snapshot, "attempt_no": _PROMPT_ATTEMPT_NO},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
billing = await settle_success(
|
||||||
|
db,
|
||||||
|
ctx,
|
||||||
|
usage=usage,
|
||||||
|
description=f"提示词优化 - {project_name}",
|
||||||
|
)
|
||||||
|
charge_item = next(
|
||||||
|
(item for item in billing.items if item.biz_key == ctx.charge_biz_key),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
record.text_credits_cost = round(float(charge_item.amount if charge_item else 0.0), 2)
|
||||||
|
record.text_tokens_used = int(usage.get("total_tokens", 0) or 0)
|
||||||
|
record.status = GenerationStatus.prompt_optimized.value
|
||||||
|
record.pipeline_stage = None
|
||||||
|
record.error_message = None
|
||||||
|
credits_snapshot = float(record.text_credits_cost or 0.0)
|
||||||
|
tokens_snapshot = int(record.text_tokens_used or 0)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
_log_event(
|
||||||
|
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_SETTLEMENT_PENDING,
|
||||||
|
status=LogEventStatusEnum.FAILED,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id_snapshot,
|
||||||
|
record_id=record_id,
|
||||||
|
detail={"attempt_no": _PROMPT_ATTEMPT_NO, "error_type": type(exc).__name__},
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=503, detail="提词已生成,积分结算暂未完成,请使用相同幂等键重试") from exc
|
||||||
|
|
||||||
|
_log_event(
|
||||||
|
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_SETTLEMENT_SUCCESS,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id_snapshot,
|
||||||
|
record_id=record_id,
|
||||||
|
detail={
|
||||||
|
"attempt_no": _PROMPT_ATTEMPT_NO,
|
||||||
|
"text_credits_cost": credits_snapshot,
|
||||||
|
"text_tokens_used": tokens_snapshot,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return PromptOptimizeServiceResult(record_id=record_id, idempotent=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_existing_record(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
record: GenerationRecord,
|
||||||
|
req: OptimizeParams,
|
||||||
|
user_id: str,
|
||||||
|
project_name: str,
|
||||||
|
) -> PromptOptimizeServiceResult:
|
||||||
|
record_id = str(record.id)
|
||||||
|
project_id = str(record.project_id)
|
||||||
|
status = str(record.status)
|
||||||
|
if not _idempotency_config_matches(record, req):
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="幂等键已绑定其他生成配置,请重新提交")
|
||||||
|
|
||||||
|
_log_event(
|
||||||
|
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_IDEMPOTENCY_HIT,
|
||||||
|
status=LogEventStatusEnum.SKIPPED,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
record_id=record_id,
|
||||||
|
detail={"record_status": status, "idempotency_key": req.idempotency_key},
|
||||||
|
)
|
||||||
|
if status == GenerationStatus.settlement_pending.value:
|
||||||
|
await db.rollback()
|
||||||
|
return await _settle_staged_result(
|
||||||
|
db,
|
||||||
|
record_id=record_id,
|
||||||
|
user_id=user_id,
|
||||||
|
project_name=project_name,
|
||||||
|
request_id=req.idempotency_key,
|
||||||
|
)
|
||||||
|
if status == GenerationStatus.optimizing.value:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="相同幂等请求正在处理,请勿重复提交")
|
||||||
|
if status == GenerationStatus.failed.value:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="该幂等请求已失败,请使用新的幂等键重新提交")
|
||||||
|
if record.optimized_prompt and is_generation_record_config_complete(record):
|
||||||
|
await db.rollback()
|
||||||
|
return PromptOptimizeServiceResult(record_id=record_id, idempotent=True)
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="幂等记录配置不完整,需人工排查或使用新的幂等键")
|
||||||
|
|
||||||
|
|
||||||
|
async def optimize_generation_prompt(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
req: OptimizeParams,
|
||||||
|
user_id: str,
|
||||||
|
) -> PromptOptimizeServiceResult:
|
||||||
|
project_result = await db.execute(
|
||||||
|
select(Project).where(
|
||||||
|
Project.id == req.project_id,
|
||||||
|
Project.user_id == user_id,
|
||||||
|
Project.deleted_at.is_(None),
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
project_name = str(project.name)
|
||||||
|
project_industry = str(project.industry or "")
|
||||||
|
project_id = str(project.id)
|
||||||
|
|
||||||
|
existing = await _find_idempotency_record(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
idempotency_key=req.idempotency_key,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return await _handle_existing_record(
|
||||||
|
db,
|
||||||
|
record=existing,
|
||||||
|
req=req,
|
||||||
|
user_id=user_id,
|
||||||
|
project_name=project_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if req.gen_type == GenerationType.video:
|
||||||
|
if req.duration not in DURATIONS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"视频时长必须为{DURATIONS}秒之一")
|
||||||
|
if req.aspect_ratio not in ASPECT_RATIOS:
|
||||||
|
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
||||||
|
if req.resolution not in RESOLUTIONS:
|
||||||
|
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
||||||
|
engine = await get_video_engine(db, req.engine_id)
|
||||||
|
_validate_video_engine_selection(
|
||||||
|
engine,
|
||||||
|
aspect_ratio=str(req.aspect_ratio),
|
||||||
|
resolution=str(req.resolution),
|
||||||
|
duration=int(req.duration),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if req.image_size not in IMAGE_SIZES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"图片分辨率必须为{IMAGE_SIZES}之一")
|
||||||
|
if not req.image_proportion or not req.image_px:
|
||||||
|
raise HTTPException(status_code=400, detail="图片生成需要指定比例和像素尺寸")
|
||||||
|
engine = await get_image_engine(db, req.engine_id)
|
||||||
|
_validate_image_engine_selection(engine, image_size=str(req.image_size))
|
||||||
|
|
||||||
|
reference_usage = calculate_media_reference_usage(
|
||||||
|
json.dumps(req.references, ensure_ascii=False) if req.references else None,
|
||||||
|
include=bool(req.include_media_references),
|
||||||
|
)
|
||||||
|
validate_media_reference_usage_for_engine(
|
||||||
|
reference_usage,
|
||||||
|
gen_type=req.gen_type.value,
|
||||||
|
engine=engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
record_id = generate_id()
|
||||||
|
record = GenerationRecord(
|
||||||
|
id=record_id,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
original_prompt=req.prompt,
|
||||||
|
optimized_prompt=None,
|
||||||
|
prompt_usage_snapshot_json=None,
|
||||||
|
gen_type=req.gen_type.value,
|
||||||
|
duration=req.duration if req.gen_type == GenerationType.video else None,
|
||||||
|
aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None,
|
||||||
|
resolution=req.resolution if req.gen_type == GenerationType.video else None,
|
||||||
|
image_size=req.image_size if req.gen_type == GenerationType.image else None,
|
||||||
|
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
|
||||||
|
image_px=req.image_px if req.gen_type == GenerationType.image else None,
|
||||||
|
status=GenerationStatus.optimizing.value,
|
||||||
|
pipeline_stage=None,
|
||||||
|
credits_cost=0,
|
||||||
|
text_credits_cost=0,
|
||||||
|
text_tokens_used=0,
|
||||||
|
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
|
||||||
|
include_media_references=bool(req.include_media_references),
|
||||||
|
idempotency_key=req.idempotency_key,
|
||||||
|
engine_id=req.engine_id,
|
||||||
|
)
|
||||||
|
if req.gen_type == GenerationType.video:
|
||||||
|
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
||||||
|
db,
|
||||||
|
target_resolution=str(req.resolution),
|
||||||
|
aspect_ratio=str(req.aspect_ratio),
|
||||||
|
supported_provider_resolutions=parse_json_list(engine.supported_resolutions, []),
|
||||||
|
)
|
||||||
|
record.provider_generation_resolution = provider_resolution
|
||||||
|
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||||
|
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||||
|
else:
|
||||||
|
record.provider_generation_resolution = None
|
||||||
|
record.video_upscale_enabled_snapshot = False
|
||||||
|
record.video_upscale_snapshot_json = None
|
||||||
|
freeze_generation_record_config_with_log(
|
||||||
|
record,
|
||||||
|
engine=engine,
|
||||||
|
source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
conflicting = await _find_idempotency_record(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
idempotency_key=req.idempotency_key,
|
||||||
|
)
|
||||||
|
if conflicting is None:
|
||||||
|
raise
|
||||||
|
return await _handle_existing_record(
|
||||||
|
db,
|
||||||
|
record=conflicting,
|
||||||
|
req=req,
|
||||||
|
user_id=user_id,
|
||||||
|
project_name=project_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = _billing_context(user_id=user_id, record_id=record_id, request_id=req.idempotency_key)
|
||||||
|
try:
|
||||||
|
await start_hold(db, ctx)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
_log_event(
|
||||||
|
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PLACEHOLDER_CREATED,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
record_id=record_id,
|
||||||
|
detail={
|
||||||
|
"idempotency_key": req.idempotency_key,
|
||||||
|
"gen_type": req.gen_type.value,
|
||||||
|
"engine_id": req.engine_id,
|
||||||
|
"attempt_no": _PROMPT_ATTEMPT_NO,
|
||||||
|
"reference_count": len(req.references or []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
log_provider_start(ctx, detail={"gen_type": req.gen_type.value})
|
||||||
|
try:
|
||||||
|
optimized_prompt, usage = await optimize_prompt(
|
||||||
|
db,
|
||||||
|
req.prompt,
|
||||||
|
user_id=user_id,
|
||||||
|
industry_key=project_industry,
|
||||||
|
duration=req.duration if req.gen_type == GenerationType.video else None,
|
||||||
|
image_size=req.image_size if req.gen_type == GenerationType.image else None,
|
||||||
|
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
|
||||||
|
image_px=req.image_px if req.gen_type == GenerationType.image else None,
|
||||||
|
references=req.references,
|
||||||
|
gen_type=req.gen_type.value,
|
||||||
|
log_module="generation_record",
|
||||||
|
log_step="prompt_optimize",
|
||||||
|
log_project_id=project_id,
|
||||||
|
log_owner_type=OWNER_GENERATION_RECORD,
|
||||||
|
log_owner_id=record_id,
|
||||||
|
generation_attempt_no=_PROMPT_ATTEMPT_NO,
|
||||||
|
)
|
||||||
|
log_provider_success(ctx, usage=usage)
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
log_provider_failure(ctx, error=str(exc))
|
||||||
|
compensated = False
|
||||||
|
try:
|
||||||
|
failed_result = await db.execute(
|
||||||
|
select(GenerationRecord)
|
||||||
|
.where(GenerationRecord.id == record_id, GenerationRecord.deleted_at.is_(None))
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
failed_record = failed_result.scalar_one_or_none()
|
||||||
|
if failed_record is not None and failed_record.status == GenerationStatus.optimizing.value:
|
||||||
|
failed_record.status = GenerationStatus.failed.value
|
||||||
|
failed_record.error_message = extract_error_message(exc, "提示词")
|
||||||
|
await release_on_failure(db, ctx, error=str(exc))
|
||||||
|
compensated = True
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception("prompt optimize failure compensation failed: record_id=%s", record_id)
|
||||||
|
raise
|
||||||
|
_log_event(
|
||||||
|
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_FAILED_RELEASED,
|
||||||
|
status=(LogEventStatusEnum.FAILED if compensated else LogEventStatusEnum.SKIPPED),
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
record_id=record_id,
|
||||||
|
detail={
|
||||||
|
"attempt_no": _PROMPT_ATTEMPT_NO,
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"compensated": compensated,
|
||||||
|
},
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"AI模型调用失败: {extract_error_message(exc, '提示词')}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
usage_snapshot = dict(usage or {})
|
||||||
|
try:
|
||||||
|
input_tokens = int(usage_snapshot.get("input_tokens", 0) or 0)
|
||||||
|
output_tokens = int(usage_snapshot.get("output_tokens", 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# 保留原始 usage 交给统一账务校验拒绝;这里只避免展示字段写入异常。
|
||||||
|
input_tokens = 0
|
||||||
|
output_tokens = 0
|
||||||
|
if input_tokens >= 0 and output_tokens >= 0:
|
||||||
|
reported_total = usage_snapshot.get("total_tokens")
|
||||||
|
normalized_total = input_tokens + output_tokens
|
||||||
|
if reported_total not in (None, ""):
|
||||||
|
try:
|
||||||
|
if int(reported_total) != normalized_total:
|
||||||
|
usage_snapshot["reported_total_tokens"] = int(reported_total)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
usage_snapshot["reported_total_tokens"] = reported_total
|
||||||
|
usage_snapshot["total_tokens"] = normalized_total
|
||||||
|
usage_snapshot.setdefault("source_module", "generation_record")
|
||||||
|
usage_snapshot.setdefault("source_step_code", "prompt_optimize")
|
||||||
|
staged = False
|
||||||
|
last_stage_error: Exception | None = None
|
||||||
|
for _ in range(2):
|
||||||
|
try:
|
||||||
|
await db.rollback()
|
||||||
|
stage_result = await db.execute(
|
||||||
|
select(GenerationRecord)
|
||||||
|
.where(
|
||||||
|
GenerationRecord.id == record_id,
|
||||||
|
GenerationRecord.user_id == user_id,
|
||||||
|
GenerationRecord.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
staged_record = stage_result.scalar_one_or_none()
|
||||||
|
if staged_record is None:
|
||||||
|
raise RuntimeError("prompt optimize owner record missing")
|
||||||
|
if staged_record.status in {
|
||||||
|
GenerationStatus.prompt_optimized.value,
|
||||||
|
GenerationStatus.generating.value,
|
||||||
|
GenerationStatus.completed.value,
|
||||||
|
}:
|
||||||
|
await db.rollback()
|
||||||
|
return PromptOptimizeServiceResult(record_id=record_id, idempotent=True)
|
||||||
|
staged_record.optimized_prompt = optimized_prompt
|
||||||
|
staged_record.prompt_usage_snapshot_json = json.dumps(
|
||||||
|
usage_snapshot,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
staged_record.text_tokens_used = int(usage_snapshot.get("total_tokens", 0) or 0)
|
||||||
|
staged_record.status = GenerationStatus.settlement_pending.value
|
||||||
|
staged_record.pipeline_stage = None
|
||||||
|
staged_record.error_message = None
|
||||||
|
await db.commit()
|
||||||
|
staged = True
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
last_stage_error = exc
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception("prompt optimize provider result staging failed: record_id=%s", record_id)
|
||||||
|
if not staged:
|
||||||
|
log_operation_error(
|
||||||
|
domain=_LOG_DOMAIN,
|
||||||
|
event_type=GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED.value,
|
||||||
|
module=_LOG_MODULE,
|
||||||
|
source=_LOG_SOURCE,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
task_id=record_id,
|
||||||
|
detail={"attempt_no": _PROMPT_ATTEMPT_NO, "stage": "provider_result_persistence"},
|
||||||
|
exc=last_stage_error or RuntimeError("unknown staging failure"),
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,请联系管理员根据模型日志处理")
|
||||||
|
|
||||||
|
_log_event(
|
||||||
|
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
record_id=record_id,
|
||||||
|
detail={
|
||||||
|
"attempt_no": _PROMPT_ATTEMPT_NO,
|
||||||
|
"input_tokens": usage_snapshot.get("input_tokens"),
|
||||||
|
"output_tokens": usage_snapshot.get("output_tokens"),
|
||||||
|
"total_tokens": usage_snapshot.get("total_tokens"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await _settle_staged_result(
|
||||||
|
db,
|
||||||
|
record_id=record_id,
|
||||||
|
user_id=user_id,
|
||||||
|
project_name=project_name,
|
||||||
|
request_id=req.idempotency_key,
|
||||||
|
)
|
||||||
@@ -8,7 +8,7 @@ from app.models.chat_generation_task import ChatGenerationTask
|
|||||||
from app.models.credit_record import CreditRecord
|
from app.models.credit_record import CreditRecord
|
||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.services.generation.pipeline.db_lock_service import execute_with_lock_timeout
|
from app.services.generation.pipeline.db_lock_service import execute_with_lock_timeout
|
||||||
from app.services.credits import refund_credits
|
from app.services.credits import add_credits_result
|
||||||
from app.services.credit_record_meta_service import build_refund_meta_from_charge
|
from app.services.credit_record_meta_service import build_refund_meta_from_charge
|
||||||
from app.services.generation.billing_service import (
|
from app.services.generation.billing_service import (
|
||||||
CHARGE_MEDIA,
|
CHARGE_MEDIA,
|
||||||
@@ -113,17 +113,19 @@ async def refund_unrefunded_media_charges(
|
|||||||
amount = abs(_round2(charge.amount))
|
amount = abs(_round2(charge.amount))
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
continue
|
continue
|
||||||
await refund_credits(
|
mutation = await add_credits_result(
|
||||||
db,
|
db,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
description=f"{description_prefix}失败积分回退",
|
description=f"{description_prefix}失败积分回退",
|
||||||
related_id=owner_id,
|
related_id=owner_id,
|
||||||
|
record_type="refund",
|
||||||
biz_key=refund_biz_key,
|
biz_key=refund_biz_key,
|
||||||
refund_for_biz_key=charge.biz_key,
|
refund_for_biz_key=charge.biz_key,
|
||||||
record_meta=build_refund_meta_from_charge(charge, attempt_no=attempt_no),
|
record_meta=build_refund_meta_from_charge(charge, attempt_no=attempt_no),
|
||||||
)
|
)
|
||||||
total_refunded = round(total_refunded + amount, 2)
|
if mutation.created:
|
||||||
|
total_refunded = round(total_refunded + mutation.amount, 2)
|
||||||
return total_refunded
|
return total_refunded
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ from app.enums.common import (
|
|||||||
)
|
)
|
||||||
from app.enums.video_prompt_schema import PromptSchemaVersionEnum, VideoPromptSchemaUsageEnum
|
from app.enums.video_prompt_schema import PromptSchemaVersionEnum, VideoPromptSchemaUsageEnum
|
||||||
from app.models.model_config import ModelConfig
|
from app.models.model_config import ModelConfig
|
||||||
from app.models.token_usage import TokenUsage
|
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
DEFAULT_FRAME_RATE = "30fps"
|
DEFAULT_FRAME_RATE = "30fps"
|
||||||
DEFAULT_REFERENCE_VIDEO_FPS = 1
|
DEFAULT_REFERENCE_VIDEO_FPS = 1
|
||||||
@@ -1599,7 +1598,13 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
await db.rollback()
|
await db.rollback()
|
||||||
if not config:
|
if not config:
|
||||||
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
|
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
|
||||||
return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
return result, build_final_video_prompt(result), {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
|
"usage_reported": True,
|
||||||
|
"billing_free": True,
|
||||||
|
}
|
||||||
|
|
||||||
if use_base64:
|
if use_base64:
|
||||||
video_url_final = await media_to_base64(material_video_url, "video/mp4")
|
video_url_final = await media_to_base64(material_video_url, "video/mp4")
|
||||||
@@ -1739,27 +1744,20 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
usage = data.get("usage", {}) or {}
|
raw_usage = data.get("usage")
|
||||||
|
usage_reported = bool(
|
||||||
|
isinstance(raw_usage, dict)
|
||||||
|
and any(key in raw_usage for key in ("prompt_tokens", "completion_tokens", "total_tokens"))
|
||||||
|
)
|
||||||
|
usage = raw_usage if isinstance(raw_usage, dict) else {}
|
||||||
token_usage = {
|
token_usage = {
|
||||||
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
||||||
"output_tokens": int(usage.get("completion_tokens") or 0),
|
"output_tokens": int(usage.get("completion_tokens") or 0),
|
||||||
"total_tokens": int(usage.get("total_tokens") or 0),
|
"total_tokens": int(usage.get("total_tokens") or 0),
|
||||||
|
"usage_reported": usage_reported,
|
||||||
# "log_user_message": log_user_message,
|
# "log_user_message": log_user_message,
|
||||||
}
|
}
|
||||||
token_usage_id = generate_id()
|
|
||||||
db.add(
|
|
||||||
TokenUsage(
|
|
||||||
id=token_usage_id,
|
|
||||||
model_config_id=config.id,
|
|
||||||
user_id=user_id,
|
|
||||||
input_tokens=token_usage["input_tokens"],
|
|
||||||
output_tokens=token_usage["output_tokens"],
|
|
||||||
total_tokens=token_usage["total_tokens"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
token_usage.update({
|
token_usage.update({
|
||||||
"token_usage_id": token_usage_id,
|
|
||||||
"model_config_id": config.id,
|
"model_config_id": config.id,
|
||||||
"model_config_name": config.name,
|
"model_config_name": config.name,
|
||||||
"model_provider": config.provider,
|
"model_provider": config.provider,
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.model_config import ModelConfig
|
from app.models.model_config import ModelConfig
|
||||||
from app.models.token_usage import TokenUsage
|
|
||||||
from app.utils.id_gen import generate_id
|
|
||||||
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
|
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +46,13 @@ def _get_default_prompt(prompt: str, gen_type: str = "video") -> tuple[str, dict
|
|||||||
f"smooth camera movements. Theme: {prompt}. Cinematic shooting techniques with "
|
f"smooth camera movements. Theme: {prompt}. Cinematic shooting techniques with "
|
||||||
f"rich lighting layers and strong visual impact, suitable for commercial distribution."
|
f"rich lighting layers and strong visual impact, suitable for commercial distribution."
|
||||||
)
|
)
|
||||||
return optimized, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
return optimized, {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
|
"usage_reported": True,
|
||||||
|
"billing_free": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def optimize_prompt(
|
async def optimize_prompt(
|
||||||
@@ -135,7 +140,13 @@ def _mock_optimize(prompt: str, gen_type: str = "video") -> tuple[str, dict]:
|
|||||||
"""Return a keyword-matched mock optimized prompt."""
|
"""Return a keyword-matched mock optimized prompt."""
|
||||||
for keyword, optimized in MOCK_OPTIMIZED_PROMPTS.items():
|
for keyword, optimized in MOCK_OPTIMIZED_PROMPTS.items():
|
||||||
if keyword in prompt:
|
if keyword in prompt:
|
||||||
return optimized, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
return optimized, {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
|
"usage_reported": True,
|
||||||
|
"billing_free": True,
|
||||||
|
}
|
||||||
return _get_default_prompt(prompt, gen_type)
|
return _get_default_prompt(prompt, gen_type)
|
||||||
|
|
||||||
|
|
||||||
@@ -425,7 +436,15 @@ async def _call_openai_compatible(
|
|||||||
raise LLMProviderCallError(f"{type(exc).__name__}: {exc}") from exc
|
raise LLMProviderCallError(f"{type(exc).__name__}: {exc}") from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
usage = data.get("usage", {})
|
raw_usage = data.get("usage")
|
||||||
|
usage_reported = bool(
|
||||||
|
isinstance(raw_usage, dict)
|
||||||
|
and any(
|
||||||
|
key in raw_usage
|
||||||
|
for key in ("prompt_tokens", "completion_tokens", "total_tokens")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
usage = raw_usage if isinstance(raw_usage, dict) else {}
|
||||||
input_tokens = int(usage.get("prompt_tokens", 0) or 0)
|
input_tokens = int(usage.get("prompt_tokens", 0) or 0)
|
||||||
output_tokens = int(usage.get("completion_tokens", 0) or 0)
|
output_tokens = int(usage.get("completion_tokens", 0) or 0)
|
||||||
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
|
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
|
||||||
@@ -444,40 +463,7 @@ async def _call_openai_compatible(
|
|||||||
)
|
)
|
||||||
raise LLMProviderCallError(f"模型响应解析失败: {exc}") from exc
|
raise LLMProviderCallError(f"模型响应解析失败: {exc}") from exc
|
||||||
|
|
||||||
token_usage_id = None
|
|
||||||
if db is not None:
|
|
||||||
try:
|
|
||||||
token_usage_id = generate_id()
|
|
||||||
record = TokenUsage(
|
|
||||||
id=token_usage_id,
|
|
||||||
model_config_id=config.id,
|
|
||||||
user_id=user_id,
|
|
||||||
input_tokens=input_tokens,
|
|
||||||
output_tokens=output_tokens,
|
|
||||||
total_tokens=total_tokens,
|
|
||||||
source_module=log_module,
|
|
||||||
source_step_code=log_step,
|
|
||||||
owner_type=log_owner_type,
|
|
||||||
owner_id=log_owner_id,
|
|
||||||
)
|
|
||||||
db.add(record)
|
|
||||||
await db.flush()
|
|
||||||
except Exception as exc:
|
|
||||||
log_ai_model_event(
|
|
||||||
event_type="ERROR",
|
|
||||||
event_phase="ERROR",
|
|
||||||
event_status="failed",
|
|
||||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
||||||
detail=build_exception_detail(exc, {"stage": "token_usage_persistence"}),
|
|
||||||
error=str(exc),
|
|
||||||
**common_log,
|
|
||||||
)
|
|
||||||
# A local transaction failure must not call a second provider after
|
|
||||||
# the first provider has already returned a valid response.
|
|
||||||
raise
|
|
||||||
|
|
||||||
token_usage = {
|
token_usage = {
|
||||||
"token_usage_id": token_usage_id,
|
|
||||||
"model_config_id": config.id,
|
"model_config_id": config.id,
|
||||||
"model_config_name": config.name,
|
"model_config_name": config.name,
|
||||||
"model_provider": config.provider,
|
"model_provider": config.provider,
|
||||||
@@ -487,5 +473,6 @@ async def _call_openai_compatible(
|
|||||||
"input_tokens": input_tokens,
|
"input_tokens": input_tokens,
|
||||||
"output_tokens": output_tokens,
|
"output_tokens": output_tokens,
|
||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
|
"usage_reported": usage_reported,
|
||||||
}
|
}
|
||||||
return content, token_usage
|
return content, token_usage
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.services.llm_billing.service import (
|
|||||||
release_on_failure,
|
release_on_failure,
|
||||||
settle_success,
|
settle_success,
|
||||||
start_hold,
|
start_hold,
|
||||||
|
validate_retryable_previous_attempt,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -31,6 +32,7 @@ __all__ = [
|
|||||||
"start_hold",
|
"start_hold",
|
||||||
"ensure_hold_exists",
|
"ensure_hold_exists",
|
||||||
"get_llm_ledger_states",
|
"get_llm_ledger_states",
|
||||||
|
"validate_retryable_previous_attempt",
|
||||||
"log_provider_start",
|
"log_provider_start",
|
||||||
"log_provider_success",
|
"log_provider_success",
|
||||||
"log_provider_failure",
|
"log_provider_failure",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Iterable, Mapping
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.enums.credit_record import (
|
from app.enums.credit_record import (
|
||||||
@@ -210,8 +211,21 @@ def _action_valid(record: CreditRecord | None, expected: CreditRecordAction) ->
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _record_meta_matches_context(record: CreditRecord | None, ctx: LlmBillingContext) -> bool:
|
||||||
|
if record is None:
|
||||||
|
return True
|
||||||
|
checks = (
|
||||||
|
(record.owner_type, ctx.owner_type),
|
||||||
|
(record.owner_id, ctx.owner_id),
|
||||||
|
(record.attempt_no, ctx.attempt_no),
|
||||||
|
(record.charge_kind, ctx.charge_kind),
|
||||||
|
)
|
||||||
|
return all(actual is None or str(actual) == str(expected) for actual, expected in checks)
|
||||||
|
|
||||||
|
|
||||||
def _classify_ledger(
|
def _classify_ledger(
|
||||||
*,
|
*,
|
||||||
|
ctx: LlmBillingContext,
|
||||||
hold: CreditRecord | None,
|
hold: CreditRecord | None,
|
||||||
release: CreditRecord | None,
|
release: CreditRecord | None,
|
||||||
charge: CreditRecord | None,
|
charge: CreditRecord | None,
|
||||||
@@ -222,17 +236,39 @@ def _classify_ledger(
|
|||||||
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_action_mismatch")
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_action_mismatch")
|
||||||
if not _action_valid(charge, CreditRecordAction.CHARGE):
|
if not _action_valid(charge, CreditRecordAction.CHARGE):
|
||||||
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_action_mismatch")
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_action_mismatch")
|
||||||
|
if not _record_meta_matches_context(hold, ctx):
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_context_mismatch")
|
||||||
|
if not _record_meta_matches_context(release, ctx):
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_context_mismatch")
|
||||||
|
if not _record_meta_matches_context(charge, ctx):
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_context_mismatch")
|
||||||
if hold is None:
|
if hold is None:
|
||||||
if release is not None or charge is not None:
|
if release is not None or charge is not None:
|
||||||
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_missing_with_followup")
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_missing_with_followup")
|
||||||
return _LedgerRecords(LlmBillingLedgerState.MISSING)
|
return _LedgerRecords(LlmBillingLedgerState.MISSING)
|
||||||
if release is None and charge is None:
|
if release is None and charge is None:
|
||||||
|
if hold.type != "consume" or _round2(float(hold.amount or 0)) >= 0:
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_type_or_sign_invalid")
|
||||||
if _round2(abs(float(hold.amount or 0))) <= 0:
|
if _round2(abs(float(hold.amount or 0))) <= 0:
|
||||||
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_amount_not_positive")
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_amount_not_positive")
|
||||||
return _LedgerRecords(LlmBillingLedgerState.ACTIVE, hold)
|
return _LedgerRecords(LlmBillingLedgerState.ACTIVE, hold)
|
||||||
if release is not None and charge is None:
|
if release is not None and charge is None:
|
||||||
|
if release.type != "refund" or _round2(float(release.amount or 0)) <= 0:
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_type_or_sign_invalid")
|
||||||
|
if str(release.refund_for_biz_key or "") != str(ctx.hold_biz_key):
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_target_mismatch")
|
||||||
|
if _round2(release.amount) != _round2(abs(float(hold.amount or 0))):
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_amount_mismatch")
|
||||||
return _LedgerRecords(LlmBillingLedgerState.RELEASED, hold, release)
|
return _LedgerRecords(LlmBillingLedgerState.RELEASED, hold, release)
|
||||||
if release is not None and charge is not None:
|
if release is not None and charge is not None:
|
||||||
|
if release.type != "refund" or _round2(float(release.amount or 0)) <= 0:
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_type_or_sign_invalid")
|
||||||
|
if str(release.refund_for_biz_key or "") != str(ctx.hold_biz_key):
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_target_mismatch")
|
||||||
|
if _round2(release.amount) != _round2(abs(float(hold.amount or 0))):
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_amount_mismatch")
|
||||||
|
if charge.type != "consume" or _round2(float(charge.amount or 0)) > 0:
|
||||||
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_type_or_sign_invalid")
|
||||||
return _LedgerRecords(LlmBillingLedgerState.CHARGED, hold, release, charge)
|
return _LedgerRecords(LlmBillingLedgerState.CHARGED, hold, release, charge)
|
||||||
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_without_release")
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_without_release")
|
||||||
|
|
||||||
@@ -264,7 +300,7 @@ async def _load_ledgers(
|
|||||||
hold = record_map.get((ctx.user_id, ctx.hold_biz_key))
|
hold = record_map.get((ctx.user_id, ctx.hold_biz_key))
|
||||||
release = record_map.get((ctx.user_id, ctx.hold_release_biz_key))
|
release = record_map.get((ctx.user_id, ctx.hold_release_biz_key))
|
||||||
charge = record_map.get((ctx.user_id, ctx.charge_biz_key))
|
charge = record_map.get((ctx.user_id, ctx.charge_biz_key))
|
||||||
output[ctx.hold_biz_key] = _classify_ledger(hold=hold, release=release, charge=charge)
|
output[ctx.hold_biz_key] = _classify_ledger(ctx=ctx, hold=hold, release=release, charge=charge)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
@@ -291,6 +327,104 @@ async def get_llm_ledger_states(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_retryable_previous_attempt(
|
||||||
|
db: AsyncSession,
|
||||||
|
ctx: LlmBillingContext,
|
||||||
|
) -> LlmHoldValidation:
|
||||||
|
"""校验失败 attempt 的账务是否已关闭,供业务创建下一 attempt 前调用。
|
||||||
|
|
||||||
|
只有已释放 HOLD,或当前计费明确关闭且旧 attempt 没有流水时,才允许创建
|
||||||
|
新 attempt。这里不主动退款,避免重试入口承担失败补偿职责。
|
||||||
|
"""
|
||||||
|
_log(
|
||||||
|
ctx,
|
||||||
|
LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_START,
|
||||||
|
status="started",
|
||||||
|
)
|
||||||
|
ledger = await _load_ledger(db, ctx)
|
||||||
|
|
||||||
|
if ledger.state == LlmBillingLedgerState.RELEASED:
|
||||||
|
result = LlmHoldValidation(
|
||||||
|
True,
|
||||||
|
ledger.hold_amount,
|
||||||
|
ledger.state,
|
||||||
|
hold_record_id=ledger.hold.id if ledger.hold else None,
|
||||||
|
)
|
||||||
|
_log(
|
||||||
|
ctx,
|
||||||
|
LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS,
|
||||||
|
detail=_context_detail(
|
||||||
|
ctx,
|
||||||
|
ledger_state=result.state.value,
|
||||||
|
hold_credits=result.amount,
|
||||||
|
hold_record_id=result.hold_record_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if ledger.state == LlmBillingLedgerState.MISSING:
|
||||||
|
policy = await get_llm_billing_policy(
|
||||||
|
db,
|
||||||
|
config_key=ctx.hold_config_key,
|
||||||
|
explicit_hold_credits=None,
|
||||||
|
)
|
||||||
|
if policy.bypassed:
|
||||||
|
result = LlmHoldValidation(
|
||||||
|
True,
|
||||||
|
0.0,
|
||||||
|
LlmBillingLedgerState.BILLING_BYPASSED,
|
||||||
|
"billing_disabled",
|
||||||
|
)
|
||||||
|
_log(
|
||||||
|
ctx,
|
||||||
|
LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS,
|
||||||
|
status="skipped",
|
||||||
|
detail=_context_detail(
|
||||||
|
ctx,
|
||||||
|
ledger_state=result.state.value,
|
||||||
|
billing_bypassed=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
if not policy.valid:
|
||||||
|
result = LlmHoldValidation(
|
||||||
|
False,
|
||||||
|
0.0,
|
||||||
|
LlmBillingLedgerState.INVALID,
|
||||||
|
policy.error or "billing_config_invalid",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = LlmHoldValidation(
|
||||||
|
False,
|
||||||
|
0.0,
|
||||||
|
ledger.state,
|
||||||
|
"previous_attempt_hold_missing",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = LlmHoldValidation(
|
||||||
|
False,
|
||||||
|
ledger.hold_amount,
|
||||||
|
ledger.state,
|
||||||
|
ledger.reason or f"previous_attempt_ledger_{ledger.state.value}",
|
||||||
|
ledger.hold.id if ledger.hold else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
_log(
|
||||||
|
ctx,
|
||||||
|
LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_BLOCKED,
|
||||||
|
status="failed",
|
||||||
|
detail=_context_detail(
|
||||||
|
ctx,
|
||||||
|
ledger_state=result.state.value,
|
||||||
|
hold_credits=result.amount,
|
||||||
|
hold_record_id=result.hold_record_id,
|
||||||
|
skip_reason=result.reason,
|
||||||
|
),
|
||||||
|
error="旧attempt账务尚未关闭,拒绝创建新的分析attempt",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def start_hold(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldResult:
|
async def start_hold(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldResult:
|
||||||
# 幂等/异常 attempt 优先由已落库流水判定;只有全新 attempt 才读取配置。
|
# 幂等/异常 attempt 优先由已落库流水判定;只有全新 attempt 才读取配置。
|
||||||
ledger = await _load_ledger(db, ctx)
|
ledger = await _load_ledger(db, ctx)
|
||||||
@@ -659,6 +793,147 @@ async def release_on_failure(db: AsyncSession, ctx: LlmBillingContext, *, error:
|
|||||||
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[item])
|
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[item])
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_settlement_usage(
|
||||||
|
ctx: LlmBillingContext,
|
||||||
|
usage: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
snapshot = dict(usage or {})
|
||||||
|
try:
|
||||||
|
if snapshot.get("usage_reported") is False and not bool(snapshot.get("billing_free")):
|
||||||
|
raise ValueError("provider_usage_missing")
|
||||||
|
if "input_tokens" not in snapshot or "output_tokens" not in snapshot:
|
||||||
|
raise ValueError("input_or_output_tokens_missing")
|
||||||
|
input_tokens = int(snapshot.get("input_tokens"))
|
||||||
|
output_tokens = int(snapshot.get("output_tokens"))
|
||||||
|
if input_tokens < 0 or output_tokens < 0:
|
||||||
|
raise ValueError("token_count_negative")
|
||||||
|
normalized_total = input_tokens + output_tokens
|
||||||
|
raw_total = snapshot.get("total_tokens")
|
||||||
|
if raw_total not in (None, ""):
|
||||||
|
total_tokens = int(raw_total)
|
||||||
|
if total_tokens < 0:
|
||||||
|
raise ValueError("total_tokens_negative")
|
||||||
|
else:
|
||||||
|
total_tokens = normalized_total
|
||||||
|
if total_tokens != normalized_total:
|
||||||
|
snapshot["reported_total_tokens"] = total_tokens
|
||||||
|
total_tokens = normalized_total
|
||||||
|
snapshot["input_tokens"] = input_tokens
|
||||||
|
snapshot["output_tokens"] = output_tokens
|
||||||
|
snapshot["total_tokens"] = total_tokens
|
||||||
|
return snapshot
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
_log(
|
||||||
|
ctx,
|
||||||
|
LlmBillingEvent.USAGE_INVALID,
|
||||||
|
status="failed",
|
||||||
|
detail=_context_detail(ctx, usage=snapshot, reason=str(exc)),
|
||||||
|
error="LLM usage 无效,拒绝释放 HOLD 和创建真实扣费",
|
||||||
|
)
|
||||||
|
raise LlmBillingStateError(f"LLM usage 无效:{exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_token_usage_once(
|
||||||
|
db: AsyncSession,
|
||||||
|
ctx: LlmBillingContext,
|
||||||
|
usage: dict[str, Any],
|
||||||
|
) -> TokenUsage:
|
||||||
|
supplied_id = str(usage.get("token_usage_id") or "").strip() or None
|
||||||
|
if supplied_id:
|
||||||
|
supplied_result = await db.execute(
|
||||||
|
select(TokenUsage).where(TokenUsage.id == supplied_id).limit(1)
|
||||||
|
)
|
||||||
|
supplied = supplied_result.scalar_one_or_none()
|
||||||
|
if supplied is not None:
|
||||||
|
if supplied.user_id not in (None, ctx.user_id):
|
||||||
|
raise LlmBillingStateError("TokenUsage 用户归属与当前账务上下文不一致")
|
||||||
|
if supplied.biz_key not in (None, ctx.charge_biz_key):
|
||||||
|
raise LlmBillingStateError("TokenUsage biz_key 与当前 charge 不一致")
|
||||||
|
supplied.user_id = supplied.user_id or ctx.user_id
|
||||||
|
supplied.owner_type = supplied.owner_type or ctx.owner_type
|
||||||
|
supplied.owner_id = supplied.owner_id or ctx.owner_id
|
||||||
|
supplied.biz_key = supplied.biz_key or ctx.charge_biz_key
|
||||||
|
supplied.source_module = supplied.source_module or ctx.source_module
|
||||||
|
supplied.source_step_code = supplied.source_step_code or ctx.source_step_code
|
||||||
|
usage["token_usage_id"] = supplied.id
|
||||||
|
ctx.token_usage_id = supplied.id
|
||||||
|
_log(
|
||||||
|
ctx,
|
||||||
|
LlmBillingEvent.TOKEN_USAGE_REUSED,
|
||||||
|
detail=_context_detail(ctx, token_usage_id=supplied.id, source="supplied_id"),
|
||||||
|
)
|
||||||
|
return supplied
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(TokenUsage)
|
||||||
|
.where(
|
||||||
|
TokenUsage.user_id == ctx.user_id,
|
||||||
|
TokenUsage.biz_key == ctx.charge_biz_key,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
usage["token_usage_id"] = existing.id
|
||||||
|
ctx.token_usage_id = existing.id
|
||||||
|
_log(
|
||||||
|
ctx,
|
||||||
|
LlmBillingEvent.TOKEN_USAGE_REUSED,
|
||||||
|
detail=_context_detail(ctx, token_usage_id=existing.id, source="biz_key"),
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
token_usage = TokenUsage(
|
||||||
|
id=generate_id(),
|
||||||
|
model_config_id=usage.get("model_config_id"),
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
input_tokens=int(usage["input_tokens"]),
|
||||||
|
output_tokens=int(usage["output_tokens"]),
|
||||||
|
total_tokens=int(usage["total_tokens"]),
|
||||||
|
owner_type=ctx.owner_type,
|
||||||
|
owner_id=ctx.owner_id,
|
||||||
|
biz_key=ctx.charge_biz_key,
|
||||||
|
source_module=ctx.source_module,
|
||||||
|
source_step_code=ctx.source_step_code,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with db.begin_nested():
|
||||||
|
db.add(token_usage)
|
||||||
|
await db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
if token_usage in db.sync_session:
|
||||||
|
db.sync_session.expunge(token_usage)
|
||||||
|
result = await db.execute(
|
||||||
|
select(TokenUsage)
|
||||||
|
.where(
|
||||||
|
TokenUsage.user_id == ctx.user_id,
|
||||||
|
TokenUsage.biz_key == ctx.charge_biz_key,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
token_usage = result.scalar_one_or_none()
|
||||||
|
if token_usage is None:
|
||||||
|
raise
|
||||||
|
event = LlmBillingEvent.TOKEN_USAGE_REUSED
|
||||||
|
else:
|
||||||
|
event = LlmBillingEvent.TOKEN_USAGE_CREATED
|
||||||
|
|
||||||
|
usage["token_usage_id"] = token_usage.id
|
||||||
|
ctx.token_usage_id = token_usage.id
|
||||||
|
_log(
|
||||||
|
ctx,
|
||||||
|
event,
|
||||||
|
detail=_context_detail(
|
||||||
|
ctx,
|
||||||
|
token_usage_id=token_usage.id,
|
||||||
|
input_tokens=token_usage.input_tokens,
|
||||||
|
output_tokens=token_usage.output_tokens,
|
||||||
|
total_tokens=token_usage.total_tokens,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return token_usage
|
||||||
|
|
||||||
|
|
||||||
async def _build_charge_meta(db: AsyncSession, ctx: LlmBillingContext, usage: Mapping[str, Any]) -> CreditRecordMeta:
|
async def _build_charge_meta(db: AsyncSession, ctx: LlmBillingContext, usage: Mapping[str, Any]) -> CreditRecordMeta:
|
||||||
usage_snapshot = dict(usage or {})
|
usage_snapshot = dict(usage or {})
|
||||||
ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider
|
ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider
|
||||||
@@ -680,26 +955,6 @@ async def _build_charge_meta(db: AsyncSession, ctx: LlmBillingContext, usage: Ma
|
|||||||
usage=usage_snapshot,
|
usage=usage_snapshot,
|
||||||
)
|
)
|
||||||
if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value:
|
if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value:
|
||||||
if not usage_snapshot.get("token_usage_id"):
|
|
||||||
input_tokens = _safe_int(usage_snapshot.get("input_tokens"))
|
|
||||||
output_tokens = _safe_int(usage_snapshot.get("output_tokens"))
|
|
||||||
token_usage = TokenUsage(
|
|
||||||
id=generate_id(),
|
|
||||||
model_config_id=usage_snapshot.get("model_config_id"),
|
|
||||||
user_id=ctx.user_id,
|
|
||||||
input_tokens=input_tokens,
|
|
||||||
output_tokens=output_tokens,
|
|
||||||
total_tokens=_safe_int(usage_snapshot.get("total_tokens"), input_tokens + output_tokens),
|
|
||||||
owner_type=ctx.owner_type,
|
|
||||||
owner_id=ctx.owner_id,
|
|
||||||
biz_key=ctx.charge_biz_key,
|
|
||||||
source_module=ctx.source_module or CreditRecordSourceModule.SHOT_REPLICATE.value,
|
|
||||||
source_step_code=ctx.source_step_code,
|
|
||||||
)
|
|
||||||
db.add(token_usage)
|
|
||||||
await db.flush()
|
|
||||||
usage_snapshot["token_usage_id"] = token_usage.id
|
|
||||||
ctx.token_usage_id = token_usage.id
|
|
||||||
return await build_shot_video_analysis_meta(
|
return await build_shot_video_analysis_meta(
|
||||||
db,
|
db,
|
||||||
owner_type=ctx.owner_type,
|
owner_type=ctx.owner_type,
|
||||||
@@ -791,12 +1046,27 @@ async def _settle_success_impl(
|
|||||||
f"当前attempt账务状态为{ledger.state.value},不能执行成功结算"
|
f"当前attempt账务状态为{ledger.state.value},不能执行成功结算"
|
||||||
)
|
)
|
||||||
|
|
||||||
_log(ctx, LlmBillingEvent.SETTLE_START, status="started", detail=_context_detail(ctx, ledger_state=ledger.state.value, usage=dict(usage or {})))
|
normalized_usage = _normalize_settlement_usage(ctx, usage)
|
||||||
release_item = await _release_active_hold(db, ctx, hold_record=ledger.hold, reason="success")
|
await _ensure_token_usage_once(db, ctx, normalized_usage)
|
||||||
input_tokens = _safe_int((usage or {}).get("input_tokens"))
|
_log(
|
||||||
output_tokens = _safe_int((usage or {}).get("output_tokens"))
|
ctx,
|
||||||
|
LlmBillingEvent.SETTLE_START,
|
||||||
|
status="started",
|
||||||
|
detail=_context_detail(
|
||||||
|
ctx,
|
||||||
|
ledger_state=ledger.state.value,
|
||||||
|
input_tokens=normalized_usage["input_tokens"],
|
||||||
|
output_tokens=normalized_usage["output_tokens"],
|
||||||
|
total_tokens=normalized_usage["total_tokens"],
|
||||||
|
provider=normalized_usage.get("provider") or normalized_usage.get("model_provider"),
|
||||||
|
model_name=normalized_usage.get("model_name") or normalized_usage.get("model"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
input_tokens = int(normalized_usage["input_tokens"])
|
||||||
|
output_tokens = int(normalized_usage["output_tokens"])
|
||||||
amount = await calc_text_credits(db, input_tokens, output_tokens)
|
amount = await calc_text_credits(db, input_tokens, output_tokens)
|
||||||
meta = await _build_charge_meta(db, ctx, usage)
|
meta = await _build_charge_meta(db, ctx, normalized_usage)
|
||||||
|
release_item = await _release_active_hold(db, ctx, hold_record=ledger.hold, reason="success")
|
||||||
if meta.charge_action is None:
|
if meta.charge_action is None:
|
||||||
meta.charge_action = CreditRecordAction.CHARGE.value
|
meta.charge_action = CreditRecordAction.CHARGE.value
|
||||||
meta.billing_scene = meta.billing_scene or ctx.billing_scene
|
meta.billing_scene = meta.billing_scene or ctx.billing_scene
|
||||||
@@ -831,7 +1101,7 @@ async def _settle_success_impl(
|
|||||||
step = result.scalar_one_or_none()
|
step = result.scalar_one_or_none()
|
||||||
if step:
|
if step:
|
||||||
step.token_usage_id = meta.token_usage_id
|
step.token_usage_id = meta.token_usage_id
|
||||||
step.model_config_id = (usage or {}).get("model_config_id")
|
step.model_config_id = normalized_usage.get("model_config_id")
|
||||||
step.input_tokens = meta.input_tokens
|
step.input_tokens = meta.input_tokens
|
||||||
step.output_tokens = meta.output_tokens
|
step.output_tokens = meta.output_tokens
|
||||||
step.total_tokens = meta.total_tokens
|
step.total_tokens = meta.total_tokens
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
if owner_type == "task_set":
|
if owner_type == "task_set":
|
||||||
analyze_original_video.apply_async(
|
analyze_original_video.apply_async(
|
||||||
args=[owner_id],
|
args=[owner_id, attempt],
|
||||||
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||||
countdown=0,
|
countdown=0,
|
||||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||||
@@ -335,7 +335,7 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
analyze_custom_segment_video.apply_async(
|
analyze_custom_segment_video.apply_async(
|
||||||
args=[owner_id],
|
args=[owner_id, attempt],
|
||||||
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||||
countdown=0,
|
countdown=0,
|
||||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ from app.schemas.shot_replicate import (
|
|||||||
ShotTaskSetOut,
|
ShotTaskSetOut,
|
||||||
)
|
)
|
||||||
from app.services.module_generation_log_service import log_module_event_file
|
from app.services.module_generation_log_service import log_module_event_file
|
||||||
from app.services.llm_billing import LlmBillingContext, release_on_failure, start_hold
|
from app.services.llm_billing import (
|
||||||
|
LlmBillingContext,
|
||||||
|
release_on_failure,
|
||||||
|
start_hold,
|
||||||
|
validate_retryable_previous_attempt,
|
||||||
|
)
|
||||||
from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source
|
from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source
|
||||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||||||
from app.services.upload_resource import release_upload_resources_by_source
|
from app.services.upload_resource import release_upload_resources_by_source
|
||||||
@@ -398,10 +403,15 @@ async def _next_segment_index(db: AsyncSession, task_set_id: str) -> int:
|
|||||||
return int(result.scalar() or 0) + 1
|
return int(result.scalar() or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
async def refresh_task_set_split_summaries(db: AsyncSession, task_set_ids: set[str] | list[str]) -> None:
|
async def refresh_task_set_split_summaries(
|
||||||
|
db: AsyncSession,
|
||||||
|
task_set_ids: set[str] | list[str],
|
||||||
|
*,
|
||||||
|
log_changes: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
ids = sorted({str(item) for item in task_set_ids if item})
|
ids = sorted({str(item) for item in task_set_ids if item})
|
||||||
if not ids:
|
if not ids:
|
||||||
return
|
return []
|
||||||
|
|
||||||
task_set_result = await db.execute(
|
task_set_result = await db.execute(
|
||||||
select(ShotReplicateTaskSet)
|
select(ShotReplicateTaskSet)
|
||||||
@@ -411,7 +421,11 @@ async def refresh_task_set_split_summaries(db: AsyncSession, task_set_ids: set[s
|
|||||||
)
|
)
|
||||||
task_sets = list(task_set_result.scalars().all())
|
task_sets = list(task_set_result.scalars().all())
|
||||||
if not task_sets:
|
if not task_sets:
|
||||||
return
|
return []
|
||||||
|
|
||||||
|
# 项目 AsyncSession 关闭了 autoflush。聚合查询前必须把当前事务中刚修改的
|
||||||
|
# segment.split_status/deleted_at 等字段落到数据库,否则最后一个片段会少统计一次。
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
count_result = await db.execute(
|
count_result = await db.execute(
|
||||||
select(
|
select(
|
||||||
@@ -441,14 +455,18 @@ async def refresh_task_set_split_summaries(db: AsyncSession, task_set_ids: set[s
|
|||||||
for row in count_result.all()
|
for row in count_result.all()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changes: list[dict[str, Any]] = []
|
||||||
for task_set in task_sets:
|
for task_set in task_sets:
|
||||||
total, completed, failed = count_map.get(str(task_set.id), (0, 0, 0))
|
total, completed, failed = count_map.get(str(task_set.id), (0, 0, 0))
|
||||||
|
old_status = task_set.status
|
||||||
|
old_split_status = task_set.split_status
|
||||||
|
old_total = int(task_set.segment_count or 0)
|
||||||
|
old_completed = int(task_set.completed_segment_count or 0)
|
||||||
|
old_failed = int(task_set.failed_segment_count or 0)
|
||||||
task_set.segment_count = total
|
task_set.segment_count = total
|
||||||
task_set.completed_segment_count = completed
|
task_set.completed_segment_count = completed
|
||||||
task_set.failed_segment_count = failed
|
task_set.failed_segment_count = failed
|
||||||
|
|
||||||
old_status = task_set.status
|
|
||||||
old_split_status = task_set.split_status
|
|
||||||
if total <= 0:
|
if total <= 0:
|
||||||
task_set.split_status = ShotSplitStatusEnum.NONE.value
|
task_set.split_status = ShotSplitStatusEnum.NONE.value
|
||||||
if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
|
if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
|
||||||
@@ -466,24 +484,39 @@ async def refresh_task_set_split_summaries(db: AsyncSession, task_set_ids: set[s
|
|||||||
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
|
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
|
||||||
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
|
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
|
||||||
|
|
||||||
if old_status != task_set.status or old_split_status != task_set.split_status:
|
changed = (
|
||||||
log_module_event_file(
|
old_status != task_set.status
|
||||||
module=MODULE,
|
or old_split_status != task_set.split_status
|
||||||
event_type="SHOT_SPLIT_STATUS_CHANGED",
|
or old_total != total
|
||||||
project_id=task_set.id,
|
or old_completed != completed
|
||||||
user_id=task_set.user_id,
|
or old_failed != failed
|
||||||
message="拆镜总任务集拆分状态变更",
|
)
|
||||||
detail={
|
if changed:
|
||||||
"task_set_id": task_set.id,
|
change = {
|
||||||
"from_status": old_status,
|
"task_set_id": str(task_set.id),
|
||||||
"to_status": task_set.status,
|
"user_id": str(task_set.user_id),
|
||||||
"from_split_status": old_split_status,
|
"from_status": old_status,
|
||||||
"to_split_status": task_set.split_status,
|
"to_status": task_set.status,
|
||||||
"segment_count": total,
|
"from_split_status": old_split_status,
|
||||||
"completed_segment_count": completed,
|
"to_split_status": task_set.split_status,
|
||||||
"failed_segment_count": failed,
|
"from_segment_count": old_total,
|
||||||
},
|
"segment_count": total,
|
||||||
)
|
"from_completed_segment_count": old_completed,
|
||||||
|
"completed_segment_count": completed,
|
||||||
|
"from_failed_segment_count": old_failed,
|
||||||
|
"failed_segment_count": failed,
|
||||||
|
}
|
||||||
|
changes.append(change)
|
||||||
|
if log_changes:
|
||||||
|
log_module_event_file(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=ShotReplicateLogEventEnum.SPLIT_STATUS_CHANGED.value,
|
||||||
|
project_id=task_set.id,
|
||||||
|
user_id=task_set.user_id,
|
||||||
|
message="拆镜总任务集拆分状态变更",
|
||||||
|
detail=change,
|
||||||
|
)
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
async def refresh_task_set_split_summary(db: AsyncSession, task_set_id: str) -> None:
|
async def refresh_task_set_split_summary(db: AsyncSession, task_set_id: str) -> None:
|
||||||
@@ -1100,39 +1133,60 @@ async def prepare_reanalyze_task_set(
|
|||||||
*,
|
*,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
task_set_id: str,
|
task_set_id: str,
|
||||||
force: bool = False,
|
|
||||||
reason: str | None = None,
|
reason: str | None = None,
|
||||||
) -> ShotReanalyzeOut:
|
) -> ShotReanalyzeOut:
|
||||||
"""重置原视频分析状态,供 API 重新投递 Celery。"""
|
"""仅对已失败且旧账务已关闭的原视频分析创建新 attempt。"""
|
||||||
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
|
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
|
||||||
if task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value:
|
previous_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
|
||||||
|
if task_set.analysis_status != ShotAnalysisStatusEnum.FAILED.value:
|
||||||
log_module_event_file(
|
log_module_event_file(
|
||||||
module=MODULE,
|
module=MODULE,
|
||||||
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_REJECTED.value,
|
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_REJECTED.value,
|
||||||
project_id=task_set.id,
|
project_id=task_set.id,
|
||||||
user_id=task_set.user_id,
|
user_id=task_set.user_id,
|
||||||
message="原视频分析正在处理中,拒绝再次分析",
|
message="原视频分析不是失败终态,拒绝再次分析",
|
||||||
detail={"task_set_id": task_set.id, "analysis_status": task_set.analysis_status, "reason": reason},
|
detail={
|
||||||
|
"task_set_id": task_set.id,
|
||||||
|
"analysis_status": task_set.analysis_status,
|
||||||
|
"analysis_attempt_no": previous_attempt_no,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
event_status="rejected",
|
event_status="rejected",
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=409, detail="原视频分析正在处理中,不能重复投递")
|
raise HTTPException(
|
||||||
if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value and not force:
|
status_code=409,
|
||||||
raise HTTPException(status_code=409, detail="原视频分析已完成,如确需重跑请传 force=true")
|
detail=f"只有分析失败的原视频任务才能重新分析,当前状态:{task_set.analysis_status}",
|
||||||
if force:
|
)
|
||||||
active_segments_result = await db.execute(
|
|
||||||
select(func.count())
|
previous_context = build_task_set_analysis_billing_context(task_set)
|
||||||
.select_from(ShotReplicateSegment)
|
previous_validation = await validate_retryable_previous_attempt(db, previous_context)
|
||||||
.where(
|
if not previous_validation.can_execute:
|
||||||
ShotReplicateSegment.task_set_id == task_set.id,
|
log_module_event_file(
|
||||||
ShotReplicateSegment.deleted_at.is_(None),
|
module=MODULE,
|
||||||
)
|
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_REJECTED.value,
|
||||||
|
project_id=task_set.id,
|
||||||
|
user_id=task_set.user_id,
|
||||||
|
message="原视频旧分析 attempt 账务未关闭,拒绝再次分析",
|
||||||
|
detail={
|
||||||
|
"task_set_id": task_set.id,
|
||||||
|
"analysis_attempt_no": previous_attempt_no,
|
||||||
|
"ledger_state": previous_validation.state.value,
|
||||||
|
"ledger_reason": previous_validation.reason,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
event_status="rejected",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"上一次原视频分析的冻结积分尚未完成释放或账务状态异常,"
|
||||||
|
f"当前账务状态:{previous_validation.state.value}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if int(active_segments_result.scalar() or 0) > 0:
|
|
||||||
raise HTTPException(status_code=409, detail="当前总任务集已存在拆镜片段,不能强制重跑原视频分析")
|
|
||||||
|
|
||||||
task_set.status = ShotTaskSetStatusEnum.PENDING_ANALYSIS.value
|
task_set.status = ShotTaskSetStatusEnum.PENDING_ANALYSIS.value
|
||||||
task_set.analysis_status = ShotAnalysisStatusEnum.PENDING.value
|
task_set.analysis_status = ShotAnalysisStatusEnum.PENDING.value
|
||||||
task_set.analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1)) + 1
|
task_set.analysis_attempt_no = previous_attempt_no + 1
|
||||||
task_set.analysis_claim_token = None
|
task_set.analysis_claim_token = None
|
||||||
task_set.analysis_started_at = None
|
task_set.analysis_started_at = None
|
||||||
task_set.analysis_lease_until = None
|
task_set.analysis_lease_until = None
|
||||||
@@ -1151,7 +1205,14 @@ async def prepare_reanalyze_task_set(
|
|||||||
project_id=task_set.id,
|
project_id=task_set.id,
|
||||||
user_id=task_set.user_id,
|
user_id=task_set.user_id,
|
||||||
message="原视频再次分析已重置状态",
|
message="原视频再次分析已重置状态",
|
||||||
detail={"task_set_id": task_set.id, "force": force, "reason": reason, "video_url": task_set.video_url},
|
detail={
|
||||||
|
"task_set_id": task_set.id,
|
||||||
|
"previous_analysis_attempt_no": previous_attempt_no,
|
||||||
|
"analysis_attempt_no": int(task_set.analysis_attempt_no),
|
||||||
|
"previous_ledger_state": previous_validation.state.value,
|
||||||
|
"reason": reason,
|
||||||
|
"video_url": task_set.video_url,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return ShotReanalyzeOut(
|
return ShotReanalyzeOut(
|
||||||
message="原视频再次分析任务已准备投递",
|
message="原视频再次分析任务已准备投递",
|
||||||
@@ -1168,34 +1229,69 @@ async def prepare_reanalyze_segment(
|
|||||||
*,
|
*,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
segment_id: str,
|
segment_id: str,
|
||||||
force: bool = False,
|
|
||||||
reason: str | None = None,
|
reason: str | None = None,
|
||||||
) -> ShotReanalyzeOut:
|
) -> ShotReanalyzeOut:
|
||||||
"""重置自定义切片视频分析状态,供 API 重新投递 Celery。"""
|
"""仅对已失败且旧账务已关闭的自定义切片分析创建新 attempt。"""
|
||||||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
||||||
if segment.split_status != ShotSplitStatusEnum.COMPLETED.value:
|
if segment.split_status != ShotSplitStatusEnum.COMPLETED.value:
|
||||||
raise HTTPException(status_code=409, detail="当前片段还未切割完成,不能再次分析")
|
raise HTTPException(status_code=409, detail="当前片段还未切割完成,不能再次分析")
|
||||||
if not segment.segment_video_url:
|
if not segment.segment_video_url:
|
||||||
raise HTTPException(status_code=409, detail="当前片段缺少 segment_video_url,不能再次分析")
|
raise HTTPException(status_code=409, detail="当前片段缺少 segment_video_url,不能再次分析")
|
||||||
if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value:
|
if segment.source_mode != ShotSegmentSourceModeEnum.CUSTOM.value:
|
||||||
|
raise HTTPException(status_code=409, detail="只有自定义切片视频支持重新分析")
|
||||||
|
|
||||||
|
previous_attempt_no = max(1, int(segment.analysis_attempt_no or 1))
|
||||||
|
if segment.analysis_status != ShotSegmentAnalysisStatusEnum.FAILED.value:
|
||||||
log_module_event_file(
|
log_module_event_file(
|
||||||
module=MODULE,
|
module=MODULE,
|
||||||
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_REJECTED.value,
|
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_REJECTED.value,
|
||||||
project_id=segment.task_set_id,
|
project_id=segment.task_set_id,
|
||||||
step_id=segment.id,
|
step_id=segment.id,
|
||||||
user_id=segment.user_id,
|
user_id=segment.user_id,
|
||||||
message="切片视频分析正在处理中,拒绝再次分析",
|
message="切片视频分析不是失败终态,拒绝再次分析",
|
||||||
detail={"segment_id": segment.id, "analysis_status": segment.analysis_status, "reason": reason},
|
detail={
|
||||||
|
"segment_id": segment.id,
|
||||||
|
"analysis_status": segment.analysis_status,
|
||||||
|
"analysis_attempt_no": previous_attempt_no,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
event_status="rejected",
|
event_status="rejected",
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=409, detail="切片视频分析正在处理中,不能重复投递")
|
raise HTTPException(
|
||||||
if segment.analysis_status == ShotSegmentAnalysisStatusEnum.COMPLETED.value and not force:
|
status_code=409,
|
||||||
raise HTTPException(status_code=409, detail="切片视频分析已完成,如确需重跑请传 force=true")
|
detail=f"只有分析失败的切片视频才能重新分析,当前状态:{segment.analysis_status}",
|
||||||
if segment.source_mode != ShotSegmentSourceModeEnum.CUSTOM.value and not force:
|
)
|
||||||
raise HTTPException(status_code=409, detail="AI 建议片段默认无需单独分析,如确需重跑请传 force=true")
|
|
||||||
|
previous_context = build_segment_analysis_billing_context(segment)
|
||||||
|
previous_validation = await validate_retryable_previous_attempt(db, previous_context)
|
||||||
|
if not previous_validation.can_execute:
|
||||||
|
log_module_event_file(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_REJECTED.value,
|
||||||
|
project_id=segment.task_set_id,
|
||||||
|
step_id=segment.id,
|
||||||
|
user_id=segment.user_id,
|
||||||
|
message="切片视频旧分析 attempt 账务未关闭,拒绝再次分析",
|
||||||
|
detail={
|
||||||
|
"segment_id": segment.id,
|
||||||
|
"task_set_id": segment.task_set_id,
|
||||||
|
"analysis_attempt_no": previous_attempt_no,
|
||||||
|
"ledger_state": previous_validation.state.value,
|
||||||
|
"ledger_reason": previous_validation.reason,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
event_status="rejected",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"上一次切片视频分析的冻结积分尚未完成释放或账务状态异常,"
|
||||||
|
f"当前账务状态:{previous_validation.state.value}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.PENDING.value
|
segment.analysis_status = ShotSegmentAnalysisStatusEnum.PENDING.value
|
||||||
segment.analysis_attempt_no = max(1, int(segment.analysis_attempt_no or 1)) + 1
|
segment.analysis_attempt_no = previous_attempt_no + 1
|
||||||
segment.analysis_claim_token = None
|
segment.analysis_claim_token = None
|
||||||
segment.analysis_started_at = None
|
segment.analysis_started_at = None
|
||||||
segment.analysis_lease_until = None
|
segment.analysis_lease_until = None
|
||||||
@@ -1216,7 +1312,15 @@ async def prepare_reanalyze_segment(
|
|||||||
step_id=segment.id,
|
step_id=segment.id,
|
||||||
user_id=segment.user_id,
|
user_id=segment.user_id,
|
||||||
message="切片视频再次分析已重置状态",
|
message="切片视频再次分析已重置状态",
|
||||||
detail={"segment_id": segment.id, "task_set_id": segment.task_set_id, "force": force, "reason": reason, "video_url": segment.segment_video_url},
|
detail={
|
||||||
|
"segment_id": segment.id,
|
||||||
|
"task_set_id": segment.task_set_id,
|
||||||
|
"previous_analysis_attempt_no": previous_attempt_no,
|
||||||
|
"analysis_attempt_no": int(segment.analysis_attempt_no),
|
||||||
|
"previous_ledger_state": previous_validation.state.value,
|
||||||
|
"reason": reason,
|
||||||
|
"video_url": segment.segment_video_url,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return ShotReanalyzeOut(
|
return ShotReanalyzeOut(
|
||||||
message="切片视频再次分析任务已准备投递",
|
message="切片视频再次分析任务已准备投递",
|
||||||
@@ -1233,9 +1337,12 @@ async def mark_task_set_analysis_dispatch_failed(
|
|||||||
*,
|
*,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
task_set_id: str,
|
task_set_id: str,
|
||||||
|
expected_attempt_no: int,
|
||||||
error_message: str,
|
error_message: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
|
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True)
|
||||||
|
if int(task_set.analysis_attempt_no or 1) != int(expected_attempt_no):
|
||||||
|
return False
|
||||||
if (
|
if (
|
||||||
task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
||||||
and task_set.analysis_claim_token
|
and task_set.analysis_claim_token
|
||||||
@@ -1245,12 +1352,11 @@ async def mark_task_set_analysis_dispatch_failed(
|
|||||||
return False
|
return False
|
||||||
if task_set.analysis_status in (ShotAnalysisStatusEnum.COMPLETED.value, ShotAnalysisStatusEnum.FAILED.value):
|
if task_set.analysis_status in (ShotAnalysisStatusEnum.COMPLETED.value, ShotAnalysisStatusEnum.FAILED.value):
|
||||||
return False
|
return False
|
||||||
if task_set.analysis_status not in (ShotAnalysisStatusEnum.COMPLETED.value, ShotAnalysisStatusEnum.FAILED.value):
|
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||||
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
task_set.analysis_claim_token = None
|
||||||
task_set.analysis_claim_token = None
|
task_set.analysis_lease_until = None
|
||||||
task_set.analysis_lease_until = None
|
task_set.analysis_error_message = error_message
|
||||||
task_set.analysis_error_message = error_message
|
|
||||||
await release_on_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message)
|
await release_on_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message)
|
||||||
log_module_event_file(
|
log_module_event_file(
|
||||||
module=MODULE,
|
module=MODULE,
|
||||||
@@ -1269,9 +1375,12 @@ async def mark_segment_analysis_dispatch_failed(
|
|||||||
*,
|
*,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
segment_id: str,
|
segment_id: str,
|
||||||
|
expected_attempt_no: int,
|
||||||
error_message: str,
|
error_message: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
||||||
|
if int(segment.analysis_attempt_no or 1) != int(expected_attempt_no):
|
||||||
|
return False
|
||||||
if (
|
if (
|
||||||
segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||||
and segment.analysis_claim_token
|
and segment.analysis_claim_token
|
||||||
@@ -1281,11 +1390,10 @@ async def mark_segment_analysis_dispatch_failed(
|
|||||||
return False
|
return False
|
||||||
if segment.analysis_status in (ShotSegmentAnalysisStatusEnum.COMPLETED.value, ShotSegmentAnalysisStatusEnum.FAILED.value):
|
if segment.analysis_status in (ShotSegmentAnalysisStatusEnum.COMPLETED.value, ShotSegmentAnalysisStatusEnum.FAILED.value):
|
||||||
return False
|
return False
|
||||||
if segment.analysis_status not in (ShotSegmentAnalysisStatusEnum.COMPLETED.value, ShotSegmentAnalysisStatusEnum.FAILED.value):
|
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
segment.analysis_claim_token = None
|
||||||
segment.analysis_claim_token = None
|
segment.analysis_lease_until = None
|
||||||
segment.analysis_lease_until = None
|
segment.analysis_error_message = error_message
|
||||||
segment.analysis_error_message = error_message
|
|
||||||
await release_on_failure(db, build_segment_analysis_billing_context(segment), error=error_message)
|
await release_on_failure(db, build_segment_analysis_billing_context(segment), error=error_message)
|
||||||
log_module_event_file(
|
log_module_event_file(
|
||||||
module=MODULE,
|
module=MODULE,
|
||||||
|
|||||||
@@ -756,7 +756,12 @@ async def analyze_video_for_shot_split(
|
|||||||
result = ensure_result_schema(result)
|
result = ensure_result_schema(result)
|
||||||
result = filter_and_normalize_breakdown(result, mode=mode)
|
result = filter_and_normalize_breakdown(result, mode=mode)
|
||||||
|
|
||||||
usage = raw.get("usage") or {}
|
raw_usage = raw.get("usage")
|
||||||
|
usage_reported = bool(
|
||||||
|
isinstance(raw_usage, dict)
|
||||||
|
and any(key in raw_usage for key in ("prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "total_tokens"))
|
||||||
|
)
|
||||||
|
usage = raw_usage if isinstance(raw_usage, dict) else {}
|
||||||
token_usage = {
|
token_usage = {
|
||||||
"input_tokens": _int_usage(usage.get("prompt_tokens") or usage.get("input_tokens")),
|
"input_tokens": _int_usage(usage.get("prompt_tokens") or usage.get("input_tokens")),
|
||||||
"output_tokens": _int_usage(usage.get("completion_tokens") or usage.get("output_tokens")),
|
"output_tokens": _int_usage(usage.get("completion_tokens") or usage.get("output_tokens")),
|
||||||
@@ -771,6 +776,7 @@ async def analyze_video_for_shot_split(
|
|||||||
"split_max_seconds": _split_max_seconds(),
|
"split_max_seconds": _split_max_seconds(),
|
||||||
"analysis_mode": mode,
|
"analysis_mode": mode,
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
|
"usage_reported": usage_reported,
|
||||||
}
|
}
|
||||||
if not token_usage["total_tokens"]:
|
if not token_usage["total_tokens"]:
|
||||||
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
|
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ from app.services.redis_registry_service import (
|
|||||||
)
|
)
|
||||||
from app.services.celery_runtime.recovery_service import guard_periodic_recovery
|
from app.services.celery_runtime.recovery_service import guard_periodic_recovery
|
||||||
from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity
|
from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity
|
||||||
from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary
|
from app.services.shot_replicate_taskset_service import (
|
||||||
|
build_segment_analysis_billing_context,
|
||||||
|
refresh_task_set_split_summary,
|
||||||
|
)
|
||||||
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
|
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
|
||||||
from app.services.llm_billing import (
|
from app.services.llm_billing import (
|
||||||
LlmBillingContext,
|
LlmBillingContext,
|
||||||
@@ -126,9 +129,237 @@ def _analysis_lock_key(owner_type: str, owner_id: str, attempt_no: int) -> str:
|
|||||||
return f"{settings.SHOT_ANALYSIS_LOCK_KEY_PREFIX}:{owner_type}:{owner_id}:attempt:{attempt_no}"
|
return f"{settings.SHOT_ANALYSIS_LOCK_KEY_PREFIX}:{owner_type}:{owner_id}:attempt:{attempt_no}"
|
||||||
|
|
||||||
|
|
||||||
async def _run_analyze_original_video(task_set_id: str) -> None:
|
def _log_stale_analysis_attempt(
|
||||||
|
*,
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
expected_attempt_no: int | None,
|
||||||
|
actual_attempt_no: int | None,
|
||||||
|
analysis_status: str | None,
|
||||||
|
task_set_id: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
project_id = task_set_id or (owner_id if owner_type == "shot_task_set" else None)
|
||||||
|
log_module_event_file(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=ShotReplicateLogEventEnum.ANALYSIS_STALE_ATTEMPT_SKIPPED.value,
|
||||||
|
project_id=project_id,
|
||||||
|
step_id=owner_id if owner_type == "shot_segment" else None,
|
||||||
|
user_id=user_id,
|
||||||
|
message="拆镜分析任务 attempt 已失效,跳过执行",
|
||||||
|
detail={
|
||||||
|
"owner_type": owner_type,
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"expected_attempt_no": expected_attempt_no,
|
||||||
|
"actual_attempt_no": actual_attempt_no,
|
||||||
|
"analysis_status": analysis_status,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
event_status="skipped",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_original_analysis_result(
|
||||||
|
*,
|
||||||
|
task_set_id: str,
|
||||||
|
attempt_no: int,
|
||||||
|
token: str,
|
||||||
|
video_url: str | None,
|
||||||
|
analyzed: Any,
|
||||||
|
billing_context: LlmBillingContext,
|
||||||
|
allow_business_write: bool,
|
||||||
|
) -> str:
|
||||||
|
"""持久化原视频分析结果并完成账务;供应商成功后禁止再次调用模型。"""
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ShotReplicateTaskSet)
|
||||||
|
.where(
|
||||||
|
ShotReplicateTaskSet.id == task_set_id,
|
||||||
|
ShotReplicateTaskSet.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
task_set = result.scalar_one_or_none()
|
||||||
|
is_current = bool(
|
||||||
|
allow_business_write
|
||||||
|
and task_set
|
||||||
|
and int(task_set.analysis_attempt_no or 1) == attempt_no
|
||||||
|
and task_set.analysis_claim_token == token
|
||||||
|
and str(task_set.video_url) == str(video_url)
|
||||||
|
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
||||||
|
)
|
||||||
|
if is_current and task_set is not None:
|
||||||
|
result_json = analyzed.result
|
||||||
|
task_set.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||||
|
task_set.original_video_category = str(result_json.get("原视频分类") or "无")
|
||||||
|
task_set.original_video_audience = str(result_json.get("原视频受众人群") or "无")
|
||||||
|
task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or []
|
||||||
|
task_set.analysis_raw_json = analyzed.raw_response
|
||||||
|
task_set.analysis_result_json = result_json
|
||||||
|
task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value
|
||||||
|
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
|
||||||
|
task_set.analysis_claim_token = None
|
||||||
|
task_set.analysis_lease_until = None
|
||||||
|
task_set.analysis_error_message = None
|
||||||
|
description = "拆镜复刻-原视频分析"
|
||||||
|
outcome = "completed"
|
||||||
|
else:
|
||||||
|
description = "拆镜复刻-原视频分析(失效结果结算)"
|
||||||
|
outcome = "stale_settled"
|
||||||
|
await settle_success(
|
||||||
|
db,
|
||||||
|
billing_context,
|
||||||
|
usage=analyzed.usage,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_segment_analysis_result(
|
||||||
|
*,
|
||||||
|
segment_id: str,
|
||||||
|
attempt_no: int,
|
||||||
|
token: str,
|
||||||
|
video_url: str | None,
|
||||||
|
analyzed: Any,
|
||||||
|
billing_context: LlmBillingContext,
|
||||||
|
allow_business_write: bool,
|
||||||
|
) -> str:
|
||||||
|
"""持久化自定义切片分析结果并完成账务;供应商成功后禁止再次调用模型。"""
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ShotReplicateSegment)
|
||||||
|
.where(
|
||||||
|
ShotReplicateSegment.id == segment_id,
|
||||||
|
ShotReplicateSegment.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
segment = result.scalar_one_or_none()
|
||||||
|
is_current = bool(
|
||||||
|
allow_business_write
|
||||||
|
and segment
|
||||||
|
and int(segment.analysis_attempt_no or 1) == attempt_no
|
||||||
|
and segment.analysis_claim_token == token
|
||||||
|
and str(segment.segment_video_url) == str(video_url)
|
||||||
|
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||||
|
)
|
||||||
|
if is_current and segment is not None:
|
||||||
|
result_json = analyzed.result
|
||||||
|
segment.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||||
|
segment.original_video_category = str(result_json.get("原视频分类") or "无")
|
||||||
|
segment.original_video_audience = str(result_json.get("原视频受众人群") or "无")
|
||||||
|
segment.segment_content = segment.original_video_content
|
||||||
|
segment.segment_category = segment.original_video_category
|
||||||
|
segment.segment_audience = segment.original_video_audience
|
||||||
|
segment.analysis_json = result_json
|
||||||
|
segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value
|
||||||
|
segment.analysis_claim_token = None
|
||||||
|
segment.analysis_lease_until = None
|
||||||
|
segment.analysis_error_message = None
|
||||||
|
description = "拆镜复刻-片段视频分析"
|
||||||
|
outcome = "completed"
|
||||||
|
else:
|
||||||
|
description = "拆镜复刻-片段视频分析(失效结果结算)"
|
||||||
|
outcome = "stale_settled"
|
||||||
|
await settle_success(
|
||||||
|
db,
|
||||||
|
billing_context,
|
||||||
|
usage=analyzed.usage,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_original_provider_success_pending_manual(
|
||||||
|
*,
|
||||||
|
task_set_id: str,
|
||||||
|
attempt_no: int,
|
||||||
|
token: str,
|
||||||
|
error_message: str,
|
||||||
|
) -> bool:
|
||||||
|
"""供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ShotReplicateTaskSet)
|
||||||
|
.where(
|
||||||
|
ShotReplicateTaskSet.id == task_set_id,
|
||||||
|
ShotReplicateTaskSet.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
task_set = result.scalar_one_or_none()
|
||||||
|
if not (
|
||||||
|
task_set
|
||||||
|
and int(task_set.analysis_attempt_no or 1) == attempt_no
|
||||||
|
and task_set.analysis_claim_token == token
|
||||||
|
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
||||||
|
):
|
||||||
|
await db.rollback()
|
||||||
|
return False
|
||||||
|
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||||
|
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||||
|
task_set.analysis_claim_token = None
|
||||||
|
task_set.analysis_lease_until = None
|
||||||
|
task_set.analysis_error_message = error_message
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_segment_provider_success_pending_manual(
|
||||||
|
*,
|
||||||
|
segment_id: str,
|
||||||
|
attempt_no: int,
|
||||||
|
token: str,
|
||||||
|
error_message: str,
|
||||||
|
) -> bool:
|
||||||
|
"""供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ShotReplicateSegment)
|
||||||
|
.where(
|
||||||
|
ShotReplicateSegment.id == segment_id,
|
||||||
|
ShotReplicateSegment.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
segment = result.scalar_one_or_none()
|
||||||
|
if not (
|
||||||
|
segment
|
||||||
|
and int(segment.analysis_attempt_no or 1) == attempt_no
|
||||||
|
and segment.analysis_claim_token == token
|
||||||
|
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||||
|
):
|
||||||
|
await db.rollback()
|
||||||
|
return False
|
||||||
|
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||||
|
segment.analysis_claim_token = None
|
||||||
|
segment.analysis_lease_until = None
|
||||||
|
segment.analysis_error_message = error_message
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int | None) -> None:
|
||||||
token = uuid.uuid4().hex
|
token = uuid.uuid4().hex
|
||||||
attempt_no = 1
|
if expected_attempt_no is None:
|
||||||
|
_log_stale_analysis_attempt(
|
||||||
|
owner_type="shot_task_set",
|
||||||
|
owner_id=task_set_id,
|
||||||
|
expected_attempt_no=None,
|
||||||
|
actual_attempt_no=None,
|
||||||
|
analysis_status=None,
|
||||||
|
reason="missing_expected_attempt_no",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
attempt_no = max(1, int(expected_attempt_no))
|
||||||
|
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
row = await db.execute(
|
row = await db.execute(
|
||||||
@@ -137,9 +368,25 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
initial = row.scalar_one_or_none()
|
initial = row.scalar_one_or_none()
|
||||||
if not initial or initial.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
|
if not initial:
|
||||||
|
return
|
||||||
|
actual_attempt_no = int(initial.analysis_attempt_no or 1)
|
||||||
|
if (
|
||||||
|
actual_attempt_no != attempt_no
|
||||||
|
or initial.analysis_status
|
||||||
|
not in (ShotAnalysisStatusEnum.PENDING.value, ShotAnalysisStatusEnum.PROCESSING.value)
|
||||||
|
):
|
||||||
|
_log_stale_analysis_attempt(
|
||||||
|
owner_type="shot_task_set",
|
||||||
|
owner_id=task_set_id,
|
||||||
|
expected_attempt_no=attempt_no,
|
||||||
|
actual_attempt_no=actual_attempt_no,
|
||||||
|
analysis_status=initial.analysis_status,
|
||||||
|
user_id=str(initial.user_id),
|
||||||
|
reason="attempt_or_status_mismatch_before_lock",
|
||||||
|
)
|
||||||
|
await db.rollback()
|
||||||
return
|
return
|
||||||
attempt_no = int(initial.analysis_attempt_no or 1)
|
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
|
|
||||||
lease = await CeleryRuntimeLease.acquire(
|
lease = await CeleryRuntimeLease.acquire(
|
||||||
@@ -174,7 +421,24 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
task_set = result.scalar_one_or_none()
|
task_set = result.scalar_one_or_none()
|
||||||
if not task_set or task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
|
if not task_set:
|
||||||
|
await db.rollback()
|
||||||
|
return
|
||||||
|
actual_attempt_no = int(task_set.analysis_attempt_no or 1)
|
||||||
|
if (
|
||||||
|
actual_attempt_no != attempt_no
|
||||||
|
or task_set.analysis_status
|
||||||
|
not in (ShotAnalysisStatusEnum.PENDING.value, ShotAnalysisStatusEnum.PROCESSING.value)
|
||||||
|
):
|
||||||
|
_log_stale_analysis_attempt(
|
||||||
|
owner_type="shot_task_set",
|
||||||
|
owner_id=task_set_id,
|
||||||
|
expected_attempt_no=attempt_no,
|
||||||
|
actual_attempt_no=actual_attempt_no,
|
||||||
|
analysis_status=task_set.analysis_status,
|
||||||
|
user_id=str(task_set.user_id),
|
||||||
|
reason="attempt_or_status_mismatch_after_lock",
|
||||||
|
)
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
return
|
return
|
||||||
current_lease = task_set.analysis_lease_until
|
current_lease = task_set.analysis_lease_until
|
||||||
@@ -186,9 +450,6 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
|||||||
):
|
):
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
return
|
return
|
||||||
if int(task_set.analysis_attempt_no or 1) != attempt_no:
|
|
||||||
await db.rollback()
|
|
||||||
return
|
|
||||||
task_set_user_id = str(task_set.user_id)
|
task_set_user_id = str(task_set.user_id)
|
||||||
video_url = str(task_set.video_url)
|
video_url = str(task_set.video_url)
|
||||||
task_set.status = ShotTaskSetStatusEnum.ANALYZING.value
|
task_set.status = ShotTaskSetStatusEnum.ANALYZING.value
|
||||||
@@ -263,52 +524,25 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
|||||||
task_set_id=task_set_id,
|
task_set_id=task_set_id,
|
||||||
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
|
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
|
||||||
)
|
)
|
||||||
provider_succeeded = True
|
provider_succeeded = True
|
||||||
log_provider_success(llm_billing_context, usage=analyzed.usage)
|
log_provider_success(llm_billing_context, usage=analyzed.usage)
|
||||||
|
try:
|
||||||
await lease.ensure_owned()
|
await lease.ensure_owned()
|
||||||
result = await call_db.execute(
|
allow_business_write = True
|
||||||
select(ShotReplicateTaskSet)
|
except RedisExecutionLockError:
|
||||||
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
|
# Provider 已成功后不能让 Celery retry 再次调用模型;失去执行权时仅结算。
|
||||||
.with_for_update()
|
allow_business_write = False
|
||||||
.limit(1)
|
persist_outcome = await _persist_original_analysis_result(
|
||||||
)
|
task_set_id=task_set_id,
|
||||||
task_set = result.scalar_one_or_none()
|
attempt_no=attempt_no,
|
||||||
if (
|
token=token,
|
||||||
not task_set
|
video_url=video_url,
|
||||||
or int(task_set.analysis_attempt_no or 1) != attempt_no
|
analyzed=analyzed,
|
||||||
or task_set.analysis_claim_token != token
|
billing_context=llm_billing_context,
|
||||||
or str(task_set.video_url) != str(video_url)
|
allow_business_write=allow_business_write,
|
||||||
or task_set.analysis_status != ShotAnalysisStatusEnum.PROCESSING.value
|
)
|
||||||
):
|
if persist_outcome != "completed":
|
||||||
await call_db.rollback()
|
return
|
||||||
# Provider 已成功,旧业务对象失效也必须按真实 usage 结算。
|
|
||||||
await settle_success(
|
|
||||||
call_db,
|
|
||||||
llm_billing_context,
|
|
||||||
usage=analyzed.usage,
|
|
||||||
description="拆镜复刻-原视频分析(失效结果结算)",
|
|
||||||
)
|
|
||||||
await call_db.commit()
|
|
||||||
return
|
|
||||||
result_json = analyzed.result
|
|
||||||
task_set.original_video_content = str(result_json.get("原视频内容") or "无")
|
|
||||||
task_set.original_video_category = str(result_json.get("原视频分类") or "无")
|
|
||||||
task_set.original_video_audience = str(result_json.get("原视频受众人群") or "无")
|
|
||||||
task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or []
|
|
||||||
task_set.analysis_raw_json = analyzed.raw_response
|
|
||||||
task_set.analysis_result_json = result_json
|
|
||||||
task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value
|
|
||||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
|
|
||||||
task_set.analysis_claim_token = None
|
|
||||||
task_set.analysis_lease_until = None
|
|
||||||
task_set.analysis_error_message = None
|
|
||||||
await settle_success(
|
|
||||||
call_db,
|
|
||||||
llm_billing_context,
|
|
||||||
usage=analyzed.usage,
|
|
||||||
description="拆镜复刻-原视频分析",
|
|
||||||
)
|
|
||||||
await call_db.commit()
|
|
||||||
|
|
||||||
log_module_prompt_event(
|
log_module_prompt_event(
|
||||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_SUCCESS.value,
|
event_type=ShotReplicateLogEventEnum.ANALYSIS_SUCCESS.value,
|
||||||
@@ -334,40 +568,67 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
|
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
|
||||||
log_provider_failure(llm_billing_context, error=str(exc))
|
log_provider_failure(llm_billing_context, error=str(exc))
|
||||||
async with async_session() as db:
|
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
||||||
result = await db.execute(
|
try:
|
||||||
select(ShotReplicateTaskSet)
|
try:
|
||||||
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
|
await lease.ensure_owned()
|
||||||
.with_for_update()
|
allow_business_write = True
|
||||||
.limit(1)
|
except RedisExecutionLockError:
|
||||||
)
|
allow_business_write = False
|
||||||
task_set = result.scalar_one_or_none()
|
persist_outcome = await _persist_original_analysis_result(
|
||||||
task_set_is_current = bool(
|
task_set_id=task_set_id,
|
||||||
task_set
|
attempt_no=attempt_no,
|
||||||
and int(task_set.analysis_attempt_no or 1) == attempt_no
|
token=token,
|
||||||
and task_set.analysis_claim_token == token
|
video_url=video_url,
|
||||||
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
analyzed=analyzed,
|
||||||
)
|
billing_context=llm_billing_context,
|
||||||
if task_set_is_current and task_set is not None:
|
allow_business_write=allow_business_write,
|
||||||
task_set_user_id = task_set_user_id or str(task_set.user_id)
|
)
|
||||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
if persist_outcome == "completed":
|
||||||
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
log_module_event_file(
|
||||||
task_set.analysis_claim_token = None
|
module=MODULE,
|
||||||
task_set.analysis_lease_until = None
|
event_type=ShotReplicateLogEventEnum.ANALYSIS_SUCCESS.value,
|
||||||
task_set.analysis_error_message = str(exc)
|
project_id=task_set_id,
|
||||||
# 业务对象是否仍有效,不影响本 attempt 的账务终态:provider 已成功必须结算,
|
user_id=task_set_user_id,
|
||||||
# provider 未成功则幂等释放。这样人工删库/异常换 attempt 也不会遗留 active HOLD。
|
message="原视频分析本地异常后幂等恢复成功",
|
||||||
if "llm_billing_context" in locals():
|
detail={"task_set_id": task_set_id, "analysis_attempt_no": attempt_no},
|
||||||
if locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
|
||||||
await settle_success(
|
|
||||||
db,
|
|
||||||
llm_billing_context,
|
|
||||||
usage=analyzed.usage,
|
|
||||||
description="拆镜复刻-原视频分析(本地失败结算)",
|
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
await release_on_failure(db, llm_billing_context, error=str(exc))
|
except Exception as settlement_exc:
|
||||||
await db.commit()
|
manual_error = (
|
||||||
|
"供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分,"
|
||||||
|
f"需人工对账。error={settlement_exc}"
|
||||||
|
)
|
||||||
|
await _mark_original_provider_success_pending_manual(
|
||||||
|
task_set_id=task_set_id,
|
||||||
|
attempt_no=attempt_no,
|
||||||
|
token=token,
|
||||||
|
error_message=manual_error,
|
||||||
|
)
|
||||||
|
exc = settlement_exc
|
||||||
|
elif "llm_billing_context" in locals():
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ShotReplicateTaskSet)
|
||||||
|
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
task_set = result.scalar_one_or_none()
|
||||||
|
if (
|
||||||
|
task_set
|
||||||
|
and int(task_set.analysis_attempt_no or 1) == attempt_no
|
||||||
|
and task_set.analysis_claim_token == token
|
||||||
|
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
||||||
|
):
|
||||||
|
task_set_user_id = task_set_user_id or str(task_set.user_id)
|
||||||
|
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||||
|
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||||
|
task_set.analysis_claim_token = None
|
||||||
|
task_set.analysis_lease_until = None
|
||||||
|
task_set.analysis_error_message = str(exc)
|
||||||
|
await release_on_failure(db, llm_billing_context, error=str(exc))
|
||||||
|
await db.commit()
|
||||||
log_module_error(
|
log_module_error(
|
||||||
module=MODULE,
|
module=MODULE,
|
||||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value,
|
event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value,
|
||||||
@@ -381,9 +642,19 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
|||||||
await lease.close()
|
await lease.close()
|
||||||
|
|
||||||
|
|
||||||
async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no: int | None) -> None:
|
||||||
token = uuid.uuid4().hex
|
token = uuid.uuid4().hex
|
||||||
attempt_no = 1
|
if expected_attempt_no is None:
|
||||||
|
_log_stale_analysis_attempt(
|
||||||
|
owner_type="shot_segment",
|
||||||
|
owner_id=segment_id,
|
||||||
|
expected_attempt_no=None,
|
||||||
|
actual_attempt_no=None,
|
||||||
|
analysis_status=None,
|
||||||
|
reason="missing_expected_attempt_no",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
attempt_no = max(1, int(expected_attempt_no))
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
row = await db.execute(
|
row = await db.execute(
|
||||||
select(ShotReplicateSegment)
|
select(ShotReplicateSegment)
|
||||||
@@ -391,9 +662,26 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
initial = row.scalar_one_or_none()
|
initial = row.scalar_one_or_none()
|
||||||
if not initial or not initial.segment_video_url or initial.analysis_status == ShotSegmentAnalysisStatusEnum.COMPLETED.value:
|
if not initial or not initial.segment_video_url:
|
||||||
|
return
|
||||||
|
actual_attempt_no = int(initial.analysis_attempt_no or 1)
|
||||||
|
if (
|
||||||
|
actual_attempt_no != attempt_no
|
||||||
|
or initial.analysis_status
|
||||||
|
not in (ShotSegmentAnalysisStatusEnum.PENDING.value, ShotSegmentAnalysisStatusEnum.PROCESSING.value)
|
||||||
|
):
|
||||||
|
_log_stale_analysis_attempt(
|
||||||
|
owner_type="shot_segment",
|
||||||
|
owner_id=segment_id,
|
||||||
|
expected_attempt_no=attempt_no,
|
||||||
|
actual_attempt_no=actual_attempt_no,
|
||||||
|
analysis_status=initial.analysis_status,
|
||||||
|
task_set_id=str(initial.task_set_id),
|
||||||
|
user_id=str(initial.user_id),
|
||||||
|
reason="attempt_or_status_mismatch_before_lock",
|
||||||
|
)
|
||||||
|
await db.rollback()
|
||||||
return
|
return
|
||||||
attempt_no = int(initial.analysis_attempt_no or 1)
|
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
|
|
||||||
lease = await CeleryRuntimeLease.acquire(
|
lease = await CeleryRuntimeLease.acquire(
|
||||||
@@ -429,16 +717,31 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
segment = result.scalar_one_or_none()
|
segment = result.scalar_one_or_none()
|
||||||
if not segment or not segment.segment_video_url or segment.analysis_status == ShotSegmentAnalysisStatusEnum.COMPLETED.value:
|
if not segment or not segment.segment_video_url:
|
||||||
|
await db.rollback()
|
||||||
|
return
|
||||||
|
actual_attempt_no = int(segment.analysis_attempt_no or 1)
|
||||||
|
if (
|
||||||
|
actual_attempt_no != attempt_no
|
||||||
|
or segment.analysis_status
|
||||||
|
not in (ShotSegmentAnalysisStatusEnum.PENDING.value, ShotSegmentAnalysisStatusEnum.PROCESSING.value)
|
||||||
|
):
|
||||||
|
_log_stale_analysis_attempt(
|
||||||
|
owner_type="shot_segment",
|
||||||
|
owner_id=segment_id,
|
||||||
|
expected_attempt_no=attempt_no,
|
||||||
|
actual_attempt_no=actual_attempt_no,
|
||||||
|
analysis_status=segment.analysis_status,
|
||||||
|
task_set_id=str(segment.task_set_id),
|
||||||
|
user_id=str(segment.user_id),
|
||||||
|
reason="attempt_or_status_mismatch_after_lock",
|
||||||
|
)
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
return
|
return
|
||||||
current_lease = segment.analysis_lease_until
|
current_lease = segment.analysis_lease_until
|
||||||
if segment.analysis_claim_token and segment.analysis_claim_token != token and current_lease and current_lease > _now():
|
if segment.analysis_claim_token and segment.analysis_claim_token != token and current_lease and current_lease > _now():
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
return
|
return
|
||||||
if int(segment.analysis_attempt_no or 1) != attempt_no:
|
|
||||||
await db.rollback()
|
|
||||||
return
|
|
||||||
user_id = str(segment.user_id)
|
user_id = str(segment.user_id)
|
||||||
task_set_id = str(segment.task_set_id)
|
task_set_id = str(segment.task_set_id)
|
||||||
video_url = str(segment.segment_video_url)
|
video_url = str(segment.segment_video_url)
|
||||||
@@ -509,51 +812,24 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
|||||||
segment_id=segment_id,
|
segment_id=segment_id,
|
||||||
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
|
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
|
||||||
)
|
)
|
||||||
provider_succeeded = True
|
provider_succeeded = True
|
||||||
log_provider_success(llm_billing_context, usage=analyzed.usage)
|
log_provider_success(llm_billing_context, usage=analyzed.usage)
|
||||||
|
try:
|
||||||
await lease.ensure_owned()
|
await lease.ensure_owned()
|
||||||
result = await call_db.execute(
|
allow_business_write = True
|
||||||
select(ShotReplicateSegment)
|
except RedisExecutionLockError:
|
||||||
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
|
allow_business_write = False
|
||||||
.with_for_update()
|
persist_outcome = await _persist_segment_analysis_result(
|
||||||
.limit(1)
|
segment_id=segment_id,
|
||||||
)
|
attempt_no=attempt_no,
|
||||||
segment = result.scalar_one_or_none()
|
token=token,
|
||||||
if (
|
video_url=video_url,
|
||||||
not segment
|
analyzed=analyzed,
|
||||||
or int(segment.analysis_attempt_no or 1) != attempt_no
|
billing_context=llm_billing_context,
|
||||||
or segment.analysis_claim_token != token
|
allow_business_write=allow_business_write,
|
||||||
or str(segment.segment_video_url) != str(video_url)
|
)
|
||||||
or segment.analysis_status != ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
if persist_outcome != "completed":
|
||||||
):
|
return
|
||||||
await call_db.rollback()
|
|
||||||
await settle_success(
|
|
||||||
call_db,
|
|
||||||
llm_billing_context,
|
|
||||||
usage=analyzed.usage,
|
|
||||||
description="拆镜复刻-片段视频分析(失效结果结算)",
|
|
||||||
)
|
|
||||||
await call_db.commit()
|
|
||||||
return
|
|
||||||
result_json = analyzed.result
|
|
||||||
segment.original_video_content = str(result_json.get("原视频内容") or "无")
|
|
||||||
segment.original_video_category = str(result_json.get("原视频分类") or "无")
|
|
||||||
segment.original_video_audience = str(result_json.get("原视频受众人群") or "无")
|
|
||||||
segment.segment_content = segment.original_video_content
|
|
||||||
segment.segment_category = segment.original_video_category
|
|
||||||
segment.segment_audience = segment.original_video_audience
|
|
||||||
segment.analysis_json = result_json
|
|
||||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value
|
|
||||||
segment.analysis_claim_token = None
|
|
||||||
segment.analysis_lease_until = None
|
|
||||||
segment.analysis_error_message = None
|
|
||||||
await settle_success(
|
|
||||||
call_db,
|
|
||||||
llm_billing_context,
|
|
||||||
usage=analyzed.usage,
|
|
||||||
description="拆镜复刻-片段视频分析",
|
|
||||||
)
|
|
||||||
await call_db.commit()
|
|
||||||
|
|
||||||
log_module_prompt_event(
|
log_module_prompt_event(
|
||||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_SUCCESS.value,
|
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_SUCCESS.value,
|
||||||
@@ -580,38 +856,68 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
|
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
|
||||||
log_provider_failure(llm_billing_context, error=str(exc))
|
log_provider_failure(llm_billing_context, error=str(exc))
|
||||||
async with async_session() as db:
|
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
||||||
result = await db.execute(
|
try:
|
||||||
select(ShotReplicateSegment)
|
try:
|
||||||
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
|
await lease.ensure_owned()
|
||||||
.with_for_update()
|
allow_business_write = True
|
||||||
.limit(1)
|
except RedisExecutionLockError:
|
||||||
)
|
allow_business_write = False
|
||||||
segment = result.scalar_one_or_none()
|
persist_outcome = await _persist_segment_analysis_result(
|
||||||
segment_is_current = bool(
|
segment_id=segment_id,
|
||||||
segment
|
attempt_no=attempt_no,
|
||||||
and int(segment.analysis_attempt_no or 1) == attempt_no
|
token=token,
|
||||||
and segment.analysis_claim_token == token
|
video_url=video_url,
|
||||||
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
analyzed=analyzed,
|
||||||
)
|
billing_context=llm_billing_context,
|
||||||
if segment_is_current and segment is not None:
|
allow_business_write=allow_business_write,
|
||||||
user_id = user_id or str(segment.user_id)
|
)
|
||||||
task_set_id = task_set_id or str(segment.task_set_id)
|
if persist_outcome == "completed":
|
||||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
log_module_event_file(
|
||||||
segment.analysis_claim_token = None
|
module=MODULE,
|
||||||
segment.analysis_lease_until = None
|
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_SUCCESS.value,
|
||||||
segment.analysis_error_message = str(exc)
|
project_id=task_set_id,
|
||||||
if "llm_billing_context" in locals():
|
step_id=segment_id,
|
||||||
if locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
user_id=user_id,
|
||||||
await settle_success(
|
message="切片视频分析本地异常后幂等恢复成功",
|
||||||
db,
|
detail={"segment_id": segment_id, "analysis_attempt_no": attempt_no},
|
||||||
llm_billing_context,
|
|
||||||
usage=analyzed.usage,
|
|
||||||
description="拆镜复刻-片段视频分析(本地失败结算)",
|
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
await release_on_failure(db, llm_billing_context, error=str(exc))
|
except Exception as settlement_exc:
|
||||||
await db.commit()
|
manual_error = (
|
||||||
|
"供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分,"
|
||||||
|
f"需人工对账。error={settlement_exc}"
|
||||||
|
)
|
||||||
|
await _mark_segment_provider_success_pending_manual(
|
||||||
|
segment_id=segment_id,
|
||||||
|
attempt_no=attempt_no,
|
||||||
|
token=token,
|
||||||
|
error_message=manual_error,
|
||||||
|
)
|
||||||
|
exc = settlement_exc
|
||||||
|
elif "llm_billing_context" in locals():
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ShotReplicateSegment)
|
||||||
|
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
segment = result.scalar_one_or_none()
|
||||||
|
if (
|
||||||
|
segment
|
||||||
|
and int(segment.analysis_attempt_no or 1) == attempt_no
|
||||||
|
and segment.analysis_claim_token == token
|
||||||
|
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||||
|
):
|
||||||
|
user_id = user_id or str(segment.user_id)
|
||||||
|
task_set_id = task_set_id or str(segment.task_set_id)
|
||||||
|
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||||
|
segment.analysis_claim_token = None
|
||||||
|
segment.analysis_lease_until = None
|
||||||
|
segment.analysis_error_message = str(exc)
|
||||||
|
await release_on_failure(db, llm_billing_context, error=str(exc))
|
||||||
|
await db.commit()
|
||||||
log_module_error(
|
log_module_error(
|
||||||
module=MODULE,
|
module=MODULE,
|
||||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value,
|
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value,
|
||||||
@@ -643,6 +949,65 @@ async def _renew_split_lease(segment_id: str, attempt_no: int, token: str) -> bo
|
|||||||
return bool(result.rowcount == 1)
|
return bool(result.rowcount == 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_auto_segment_analysis_dispatch_failed(
|
||||||
|
*,
|
||||||
|
segment_id: str,
|
||||||
|
expected_attempt_no: int,
|
||||||
|
error_message: str,
|
||||||
|
) -> bool:
|
||||||
|
"""切片成功后的自动分析投递失败补偿;不回滚或清理已完成的切片文件。"""
|
||||||
|
task_set_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ShotReplicateSegment)
|
||||||
|
.where(
|
||||||
|
ShotReplicateSegment.id == segment_id,
|
||||||
|
ShotReplicateSegment.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
segment = result.scalar_one_or_none()
|
||||||
|
if (
|
||||||
|
not segment
|
||||||
|
or int(segment.analysis_attempt_no or 1) != int(expected_attempt_no)
|
||||||
|
or segment.analysis_status != ShotSegmentAnalysisStatusEnum.PENDING.value
|
||||||
|
):
|
||||||
|
await db.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
task_set_id = str(segment.task_set_id)
|
||||||
|
user_id = str(segment.user_id)
|
||||||
|
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||||
|
segment.analysis_claim_token = None
|
||||||
|
segment.analysis_started_at = None
|
||||||
|
segment.analysis_lease_until = None
|
||||||
|
segment.analysis_error_message = error_message
|
||||||
|
await release_on_failure(
|
||||||
|
db,
|
||||||
|
build_segment_analysis_billing_context(segment),
|
||||||
|
error=error_message,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
log_module_error(
|
||||||
|
module=MODULE,
|
||||||
|
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||||
|
project_id=task_set_id,
|
||||||
|
step_id=segment_id,
|
||||||
|
user_id=user_id,
|
||||||
|
message="自定义切片分析任务投递失败,已标记分析失败并释放冻结积分",
|
||||||
|
detail={
|
||||||
|
"segment_id": segment_id,
|
||||||
|
"task_set_id": task_set_id,
|
||||||
|
"analysis_attempt_no": expected_attempt_no,
|
||||||
|
},
|
||||||
|
exc=RuntimeError(error_message),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _run_split_one_segment(segment_id: str) -> None:
|
async def _run_split_one_segment(segment_id: str) -> None:
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
row = await db.execute(
|
row = await db.execute(
|
||||||
@@ -826,6 +1191,7 @@ async def _run_split_one_segment(segment_id: str) -> None:
|
|||||||
source_mode = str(segment.source_mode)
|
source_mode = str(segment.source_mode)
|
||||||
final_task_set_id = str(segment.task_set_id)
|
final_task_set_id = str(segment.task_set_id)
|
||||||
final_user_id = str(segment.user_id)
|
final_user_id = str(segment.user_id)
|
||||||
|
analysis_attempt_no = max(1, int(segment.analysis_attempt_no or 1))
|
||||||
await refresh_task_set_split_summary(db, segment.task_set_id)
|
await refresh_task_set_split_summary(db, segment.task_set_id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -847,12 +1213,19 @@ async def _run_split_one_segment(segment_id: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if source_mode == ShotSegmentSourceModeEnum.CUSTOM.value and celery_app:
|
if source_mode == ShotSegmentSourceModeEnum.CUSTOM.value and celery_app:
|
||||||
analyze_custom_segment_video.apply_async(
|
try:
|
||||||
args=[segment_id],
|
analyze_custom_segment_video.apply_async(
|
||||||
queue=ANALYSIS_QUEUE,
|
args=[segment_id, analysis_attempt_no],
|
||||||
countdown=0,
|
queue=ANALYSIS_QUEUE,
|
||||||
task_id=f"shot-analysis:segment:{segment_id}:attempt:1",
|
countdown=0,
|
||||||
)
|
task_id=f"shot-analysis:segment:{segment_id}:attempt:{analysis_attempt_no}",
|
||||||
|
)
|
||||||
|
except Exception as dispatch_exc:
|
||||||
|
await _mark_auto_segment_analysis_dispatch_failed(
|
||||||
|
segment_id=segment_id,
|
||||||
|
expected_attempt_no=analysis_attempt_no,
|
||||||
|
error_message=f"自定义切片分析任务投递失败: {dispatch_exc}",
|
||||||
|
)
|
||||||
except RedisExecutionLockError:
|
except RedisExecutionLockError:
|
||||||
cleanup_split_result(split_result)
|
cleanup_split_result(split_result)
|
||||||
raise
|
raise
|
||||||
@@ -980,9 +1353,9 @@ if celery_app:
|
|||||||
time_limit=settings.SHOT_ANALYSIS_TIME_LIMIT_SECONDS,
|
time_limit=settings.SHOT_ANALYSIS_TIME_LIMIT_SECONDS,
|
||||||
ignore_result=True,
|
ignore_result=True,
|
||||||
)
|
)
|
||||||
def analyze_original_video(self, task_set_id: str) -> None:
|
def analyze_original_video(self, task_set_id: str, expected_attempt_no: int | None = None) -> None:
|
||||||
try:
|
try:
|
||||||
return run_async(_run_analyze_original_video(task_set_id))
|
return run_async(_run_analyze_original_video(task_set_id, expected_attempt_no))
|
||||||
except RedisExecutionLockError as exc:
|
except RedisExecutionLockError as exc:
|
||||||
raise self.retry(exc=exc, countdown=60)
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
@@ -1004,9 +1377,9 @@ if celery_app:
|
|||||||
time_limit=settings.SHOT_ANALYSIS_TIME_LIMIT_SECONDS,
|
time_limit=settings.SHOT_ANALYSIS_TIME_LIMIT_SECONDS,
|
||||||
ignore_result=True,
|
ignore_result=True,
|
||||||
)
|
)
|
||||||
def analyze_custom_segment_video(self, segment_id: str) -> None:
|
def analyze_custom_segment_video(self, segment_id: str, expected_attempt_no: int | None = None) -> None:
|
||||||
try:
|
try:
|
||||||
return run_async(_run_analyze_custom_segment_video(segment_id))
|
return run_async(_run_analyze_custom_segment_video(segment_id, expected_attempt_no))
|
||||||
except RedisExecutionLockError as exc:
|
except RedisExecutionLockError as exc:
|
||||||
raise self.retry(exc=exc, countdown=60)
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user