完善视频提炼和视频复刻完善提交测试
This commit is contained in:
@@ -523,6 +523,53 @@ class VideoDigestApiTests(TestCase):
|
||||
self.assertEqual(polled.data["task_id"], str(task.id))
|
||||
self.assertEqual(polled.data["file_name"], "参考视频.mp4")
|
||||
|
||||
def test_inflight_without_preview_urls_is_still_restorable(self):
|
||||
"""封面/原片直链拿不到(TOS 慢、签名失败)不代表任务死了。
|
||||
|
||||
前端曾拿「有没有预览图」当判活依据,退出再进来时把正在跑的任务直接取消掉,
|
||||
用户看到的就是「任务凭空消失」。这里锁住:没有任何预览 URL 也照样回 processing。
|
||||
"""
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = self._seed_digest_task(status=AITask.Status.SUBMITTED, extra_key="nopreview")
|
||||
task.request_payload = {
|
||||
**task.request_payload,
|
||||
"video_url": "", "cover_url": "", "source_url": "", "source_key": "", "cover_key": "",
|
||||
}
|
||||
task.save(update_fields=["request_payload"])
|
||||
|
||||
listed = self.client.get("/api/ai/video-digest/")
|
||||
self.assertEqual(listed.data["inflight"]["id"], str(task.id))
|
||||
self.assertEqual(listed.data["inflight"]["status"], "processing")
|
||||
self.assertEqual(listed.data["inflight"]["video_url"], "")
|
||||
self.assertEqual(listed.data["inflight"]["cover_url"], "")
|
||||
|
||||
polled = self.client.get(f"/api/ai/video-digest/{task.id}/")
|
||||
self.assertEqual(polled.data["status"], "processing")
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.SUBMITTED) # 只是查看,不该被动到
|
||||
|
||||
def test_second_submit_is_blocked_while_one_is_running(self):
|
||||
"""已有提炼在跑时再提交要 409,并把在跑的那条带回去让前端接上。"""
|
||||
from apps.ai.models import AITask
|
||||
|
||||
running = self._seed_digest_task(status=AITask.Status.SUBMITTED, extra_key="busy")
|
||||
resp = self.client.post(
|
||||
"/api/ai/video-digest/",
|
||||
{"reuse_task_id": str(running.id)},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 409)
|
||||
self.assertIn("完成或取消后", resp.json()["detail"])
|
||||
self.assertEqual(resp.json()["inflight"]["id"], str(running.id))
|
||||
|
||||
# 收掉之后放行(这里只验闸门,不验后面的真实提炼)
|
||||
running.status = AITask.Status.CANCELLED
|
||||
running.save(update_fields=["status"])
|
||||
from apps.ai.video_digest import get_inflight_team_digest
|
||||
|
||||
self.assertIsNone(get_inflight_team_digest(team=self.team))
|
||||
|
||||
def test_poll_missing_job_returns_404(self):
|
||||
missing = self.client.get("/api/ai/video-digest/00000000-0000-0000-0000-000000000099/")
|
||||
self.assertEqual(missing.status_code, 404)
|
||||
|
||||
@@ -172,6 +172,12 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
self.assertNotIn("reference_video", roles) # 参考视频不再发给火山
|
||||
self.assertNotIn("video_url", [item.get("type") for item in content])
|
||||
|
||||
def _retire(self, task):
|
||||
"""把任务收成终态,好让单飞闸放行下一次提交。"""
|
||||
task.status = AITask.Status.CANCELLED
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
return task
|
||||
|
||||
def _fake_source_file(self):
|
||||
"""_FileFromPath 会 stat 真实文件,给个空的临时 mp4 顶替 TOS 下载结果。"""
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
||||
@@ -240,6 +246,38 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
# 提炼阶段绝不能掺进商品名,否则拆出来的就不是「这条参考视频原本的样子」
|
||||
self.assertNotIn("净颜精华", str(seen["messages"]))
|
||||
|
||||
def test_digest_call_is_recorded_for_admin_task_monitor(self):
|
||||
"""复刻里的提炼要在运营后台任务详情的尝试链里看得到,否则出问题无从查起。"""
|
||||
from apps.ai.models import AIModelAttempt
|
||||
|
||||
task = self._run_digest(self._submit())
|
||||
attempts = list(AIModelAttempt.objects.filter(task=task, operation="video_digest").order_by("sequence"))
|
||||
self.assertEqual(len(attempts), 1)
|
||||
self.assertEqual(attempts[0].status, AIModelAttempt.Status.SUCCEEDED)
|
||||
self.assertEqual(attempts[0].capability, "text")
|
||||
self.assertEqual(attempts[0].request_summary.get("for"), "video_replace")
|
||||
self.assertTrue(attempts[0].response_summary.get("shots"))
|
||||
self.assertIsNotNone(attempts[0].finished_at)
|
||||
|
||||
def test_failed_digest_attempts_are_recorded_too(self):
|
||||
"""废稿重试也要各留一条,才能看出是「模型抽风」而不是「没跑」。"""
|
||||
from apps.ai import video_digest
|
||||
from apps.ai.models import AIModelAttempt
|
||||
|
||||
task = self._submit()
|
||||
text_model = ModelConfig.objects.filter(capability="text", status="active").first()
|
||||
with patch.object(video_digest, "_download_source_to_temp", return_value=self._fake_source_file()), \
|
||||
patch.object(
|
||||
video_digest, "digest_input_from_upload",
|
||||
return_value=(None, [], 8.0, {"width": 720, "height": 1280, "file_name": "参考.mp4"}),
|
||||
), \
|
||||
patch.object(video_digest, "resolve_digest_model_config", return_value=text_model), \
|
||||
patch("apps.ai.services._collect_extract_text", side_effect=[("", {}), (DIGEST_SAMPLE, {})]):
|
||||
run_replace_digest(task)
|
||||
attempts = list(AIModelAttempt.objects.filter(task=task, operation="video_digest").order_by("sequence"))
|
||||
self.assertEqual([a.status for a in attempts], ["failed", "succeeded"])
|
||||
self.assertTrue(attempts[1].is_retry)
|
||||
|
||||
def test_digest_retries_once_before_giving_up(self):
|
||||
"""Gemini 偶尔吐废稿。复刻失败要用户从头重选素材,所以同模型再试一次。"""
|
||||
from apps.ai import video_digest
|
||||
@@ -278,6 +316,52 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
self.assertFalse(self.provider.create_video_task.called)
|
||||
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
|
||||
|
||||
def test_second_submit_is_blocked_while_one_is_running(self):
|
||||
"""刷新页面时前端拉状态有空档,用户容易以为「没任务」再点一次。
|
||||
后端必须拦住,否则重复建任务、重复扣费。"""
|
||||
from apps.ai.video_replace import VideoReplaceInProgress, get_inflight_video_replace
|
||||
|
||||
first = self._submit()
|
||||
with self.assertRaises(VideoReplaceInProgress) as ctx:
|
||||
self._submit()
|
||||
self.assertEqual(ctx.exception.task.id, first.id) # 409 要带上在跑的那条
|
||||
self.assertIn("完成或取消后", str(ctx.exception))
|
||||
self.assertEqual(get_inflight_video_replace(self.team).id, first.id)
|
||||
|
||||
# 收掉之后放行
|
||||
self._retire(first)
|
||||
self.assertIsNone(get_inflight_video_replace(self.team))
|
||||
self.assertEqual(self._submit().status, AITask.Status.CREATED)
|
||||
|
||||
def test_replace_api_returns_409_with_inflight(self):
|
||||
"""前端拿这个 409 直接切到「进行中」,而不是弹个错就完了。"""
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import TeamMember
|
||||
|
||||
TeamMember.objects.get_or_create(
|
||||
team=self.team, user=self.user, defaults={"role": "owner", "status": "active"}
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.user)
|
||||
payload = {
|
||||
"replace_mode": "product",
|
||||
"video_asset_id": str(self.video.id),
|
||||
"product_id": str(self.product.id),
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
}
|
||||
first = client.post("/api/ai/video-replace/", payload, format="json")
|
||||
self.assertEqual(first.status_code, 202)
|
||||
again = client.post("/api/ai/video-replace/", payload, format="json")
|
||||
self.assertEqual(again.status_code, 409)
|
||||
self.assertEqual(again.json()["inflight"]["id"], first.json()["task"]["id"])
|
||||
|
||||
listed = client.get("/api/ai/video-replace/").json()
|
||||
self.assertEqual(listed["inflight"]["id"], first.json()["task"]["id"])
|
||||
|
||||
def test_missing_digest_model_is_rejected_at_submit(self):
|
||||
"""提炼模型没配好要当场 400,不能建了任务、等一轮提炼才告诉用户。"""
|
||||
|
||||
@@ -340,6 +424,26 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
self.assertNotIn("三视图", prompt)
|
||||
self.assertIn("@目标商品", prompt)
|
||||
|
||||
def test_triview_only_product_still_fills_target_slot(self):
|
||||
"""商品只有三视图、没有实拍图时,三视图要顶上来当 @目标商品 ——
|
||||
否则提示词里的 @目标商品 找不到同名 label,火山收到的是一句没有指代的字面量。"""
|
||||
bare = Product.objects.create(team=self.team, created_by=self.user, title="只有三视图")
|
||||
tri = _asset(self.team, self.user, name="三视图.png", preview="http://tos/only-tri.png")
|
||||
tri.metadata = {"product_id": str(bare.id), "view": "three_view"}
|
||||
tri.save(update_fields=["metadata"])
|
||||
|
||||
task = self._submit(product_id=str(bare.id))
|
||||
labels = [ref["label"] for ref in task.request_payload["references"]]
|
||||
self.assertEqual(labels, ["目标商品"])
|
||||
|
||||
task = self._run_digest(task)
|
||||
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||
prompt = task.request_payload["prompt"]
|
||||
self.assertIn("@目标商品", prompt)
|
||||
self.assertNotIn("@目标商品三视图", prompt) # 没有第二张图就别点名它
|
||||
api_prompt = task.request_payload.get("api_prompt") or ""
|
||||
self.assertNotIn("@目标商品", api_prompt) # 已被换成火山认的「图片N」
|
||||
|
||||
def test_triview_yields_budget_so_refs_never_exceed_nine(self):
|
||||
"""三视图占一张名额:商品图要让位,否则火山那边超 9 张直接拒。"""
|
||||
from apps.ai.video_replace import MAX_IMAGES
|
||||
@@ -369,10 +473,51 @@ class SubmitVideoReplaceTests(TestCase):
|
||||
self.assertEqual(task.request_payload["replace_mode"], "character")
|
||||
self.assertEqual(task.request_payload["subject_name"], "薇薇")
|
||||
|
||||
def test_rejects_video_longer_than_15s(self):
|
||||
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="长片.mp4", duration_ms=20000)
|
||||
def test_product_accepts_up_to_60s_but_character_still_caps_at_15s(self):
|
||||
"""商品复刻的参考视频只喂提炼模型,不发火山 → 放宽到 60 秒;
|
||||
角色复刻仍直传火山,15 秒是火山硬限制,不能跟着放宽。"""
|
||||
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
|
||||
model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait)
|
||||
mid = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=30000)
|
||||
long_ok = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="一分钟.mp4", duration_ms=60000)
|
||||
too_long = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="超长.mp4", duration_ms=75000)
|
||||
|
||||
# 商品复刻:30s / 60s 都放行。单飞闸只允许一条在跑,所以每次提交后收掉再试下一条。
|
||||
self.assertEqual(self._retire(self._submit(video_asset_id=str(mid.id))).status, AITask.Status.CANCELLED)
|
||||
self.assertEqual(self._retire(self._submit(video_asset_id=str(long_ok.id))).status, AITask.Status.CANCELLED)
|
||||
with self.assertRaisesMessage(ValueError, "不能超过 60 秒"):
|
||||
self._submit(video_asset_id=str(too_long.id))
|
||||
|
||||
# 角色复刻:超过 15 秒仍然拒
|
||||
with self.assertRaisesMessage(ValueError, "不能超过 15 秒"):
|
||||
self._submit(video_asset_id=str(long_video.id))
|
||||
self._submit(
|
||||
replace_mode="character", product_id="", model_id=str(model.id),
|
||||
video_asset_id=str(mid.id),
|
||||
)
|
||||
|
||||
def test_long_source_asks_model_to_condense(self):
|
||||
"""60 秒参考稿 + 15 秒成片:必须让模型压缩改编,不然会照着拍到一半戛然而止。"""
|
||||
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="一分钟.mp4", duration_ms=58000)
|
||||
# duration=None → 按参考视频推导成片时长(火山单次上限 15 秒),与线上一致
|
||||
task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=None))
|
||||
prompt = task.request_payload["prompt"]
|
||||
self.assertIn("压缩改编", prompt)
|
||||
self.assertIn("58 秒", prompt)
|
||||
self.assertIn("15 秒", prompt)
|
||||
|
||||
def test_short_source_skips_condense_note(self):
|
||||
"""参考视频和成片时长差不多时别啰嗦。"""
|
||||
task = self._run_digest(self._submit(duration=None)) # setUp 的参考视频 8 秒 → 成片也 8 秒
|
||||
self.assertNotIn("压缩改编", task.request_payload["prompt"])
|
||||
|
||||
def test_prompt_always_pins_character_consistency(self):
|
||||
"""上传图里可能有人物(服饰模特图/真人手持图),要锁住人物一致;
|
||||
没有人物时也要说清楚「别从商品图凭空套脸」。"""
|
||||
prompt = self._run_digest(self._submit()).request_payload["prompt"]
|
||||
self.assertIn("自始至终是同一个人", prompt)
|
||||
self.assertIn("不要中途换脸", prompt)
|
||||
self.assertIn("参考图里如果有人物", prompt)
|
||||
self.assertIn("不要从商品图上凭空套一张脸", prompt)
|
||||
|
||||
def test_accepts_15s_video_with_probe_slack(self):
|
||||
from apps.ai.media_probe import REF_DURATION_MAX
|
||||
|
||||
@@ -59,6 +59,14 @@ class VideoDigestError(ValueError):
|
||||
"""用户可见的失败(文件不合格 / ffmpeg 读不动),一律 400。"""
|
||||
|
||||
|
||||
class VideoDigestInProgress(VideoDigestError):
|
||||
"""本团队已有提炼在跑。视图转 409,并把在跑的那条带回去让前端接上。"""
|
||||
|
||||
def __init__(self, job: dict):
|
||||
super().__init__("已有一个视频正在提炼中,完成或取消后才能再提交")
|
||||
self.job = job
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoFrame:
|
||||
at_seconds: int
|
||||
@@ -923,6 +931,14 @@ def submit_team_digest(*, team, user, upload=None, reuse_task_id=None, model_con
|
||||
from apps.billing.pricing import quote_video_digest
|
||||
from apps.billing.services.ledger import reserve_credit
|
||||
|
||||
# 单飞闸:一个团队同时只允许一条提炼在跑。
|
||||
# 刷新页面时前端要异步拉状态,这个空档用户很容易以为「没任务」再传一次 —— 前端加载态
|
||||
# 只是治标,真正兜底在这里。先跑过期回收,死任务不会把人永久锁住。
|
||||
expire_stale_team_digests(team=team)
|
||||
running = get_inflight_team_digest(team=team)
|
||||
if running:
|
||||
raise VideoDigestInProgress(running)
|
||||
|
||||
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||
if model_config is None:
|
||||
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
|
||||
@@ -1359,6 +1375,9 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
|
||||
复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻
|
||||
task 上(AIModelAttempt),出问题查得到。
|
||||
"""
|
||||
import time
|
||||
|
||||
from apps.ai.models import AIModelAttempt
|
||||
from apps.ai.services import _collect_extract_text, get_text_provider
|
||||
|
||||
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||
@@ -1408,7 +1427,12 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
|
||||
expected_frames = len(frames) or (24 if video else 0)
|
||||
digest = ""
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, DIGEST_MAX_ATTEMPTS + 1):
|
||||
for attempt_no in range(1, DIGEST_MAX_ATTEMPTS + 1):
|
||||
# 这一步不走 execute_model_call(它要求 task.model_config 就是本次主模型,
|
||||
# 而复刻任务的主模型是 Seedance),所以尝试记录得自己落 —— 不落的话
|
||||
# 运营后台的任务详情看不到这次 Gemini 调用,出问题无从查起。
|
||||
record = _open_digest_attempt(task, model_config, attempt_no, duration, expected_frames, video)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
text, _payload = _collect_extract_text(
|
||||
provider,
|
||||
@@ -1418,12 +1442,19 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
|
||||
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
|
||||
)
|
||||
digest = validate_digest_text(text, duration=duration, frame_count=expected_frames)
|
||||
_close_digest_attempt(
|
||||
record, AIModelAttempt.Status.SUCCEEDED, started,
|
||||
summary={"chars": len(digest), "shots": shot_count(digest)},
|
||||
)
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001 — 空文/废稿/网络抖动都值得再来一次
|
||||
last_error = exc
|
||||
_close_digest_attempt(
|
||||
record, AIModelAttempt.Status.FAILED, started, error=str(exc),
|
||||
)
|
||||
logger.warning(
|
||||
"replace digest attempt %s/%s failed for task %s: %s",
|
||||
attempt, DIGEST_MAX_ATTEMPTS, task.id, exc,
|
||||
attempt_no, DIGEST_MAX_ATTEMPTS, task.id, exc,
|
||||
)
|
||||
if not digest:
|
||||
raise VideoDigestError(f"参考视频拆解失败:{last_error}")
|
||||
@@ -1439,3 +1470,73 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
|
||||
finally:
|
||||
if local_path:
|
||||
Path(local_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _open_digest_attempt(task, model_config, attempt_no: int, duration: float, frames: int, video):
|
||||
"""给复刻任务补一条「提炼」尝试记录,让运营后台的尝试链能看到这次 Gemini 调用。
|
||||
|
||||
审计失败绝不能拖垮拆解本身,所以整段吞异常、返回 None。
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AIModelAttempt, AITask
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
AITask.objects.select_for_update().only("id").get(pk=task.pk)
|
||||
sequence = (
|
||||
AIModelAttempt.objects.filter(task_id=task.pk).aggregate(m=Max("sequence"))["m"] or 0
|
||||
) + 1
|
||||
provider = model_config.provider
|
||||
return AIModelAttempt.objects.create(
|
||||
task_id=task.pk,
|
||||
sequence=sequence,
|
||||
provider=provider,
|
||||
model_config=model_config,
|
||||
provider_name=provider.name,
|
||||
provider_display_name=provider.display_name,
|
||||
model_name=model_config.name,
|
||||
model_display_name=model_config.display_name,
|
||||
public_model_name=model_config.display_name or model_config.name,
|
||||
capability="text",
|
||||
operation="video_digest",
|
||||
status=AIModelAttempt.Status.STARTED,
|
||||
is_retry=attempt_no > 1,
|
||||
started_at=timezone.now(),
|
||||
request_summary={
|
||||
"for": "video_replace",
|
||||
"duration_seconds": round(float(duration or 0), 2),
|
||||
"frame_count": frames,
|
||||
"input": "native_video" if video is not None else "frames",
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 审计写失败不影响出片
|
||||
logger.warning("video replace digest attempt record failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _close_digest_attempt(record, status, started_at: float, *, summary: dict | None = None, error: str = ""):
|
||||
import time
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
if record is None:
|
||||
return
|
||||
try:
|
||||
record.status = status
|
||||
record.finished_at = timezone.now()
|
||||
record.duration_ms = max(0, round((time.monotonic() - started_at) * 1000))
|
||||
if summary:
|
||||
record.response_summary = summary
|
||||
if error:
|
||||
record.error_type = "processing_failed"
|
||||
record.raw_error = error[:2000]
|
||||
record.safe_error_summary = "参考视频拆解未通过校验"
|
||||
record.save(update_fields=[
|
||||
"status", "finished_at", "duration_ms", "response_summary",
|
||||
"error_type", "raw_error", "safe_error_summary", "updated_at",
|
||||
])
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("video replace digest attempt close failed", exc_info=True)
|
||||
|
||||
@@ -43,10 +43,38 @@ LEGACY_PROMPT_PREFIX = "[视频复刻]"
|
||||
# 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id),
|
||||
# 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。
|
||||
TRIVIEW_LABEL = "目标商品三视图"
|
||||
# 商品复刻的参考视频不进火山、只喂提炼模型,所以不受火山 15 秒参考视频限制。
|
||||
# 半秒容差同 media_probe 的老规矩(60 秒成片常被探成 60.02)。角色复刻仍是 15 秒。
|
||||
PRODUCT_REF_DURATION_MAX = 60.5
|
||||
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
|
||||
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
|
||||
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
|
||||
|
||||
|
||||
class VideoReplaceInProgress(ValueError):
|
||||
"""本团队已有复刻在跑。视图转 409,并把在跑的那条带回去让前端接上。"""
|
||||
|
||||
def __init__(self, task):
|
||||
super().__init__("已有一个视频正在复刻中,完成或取消后才能再提交")
|
||||
self.task = task
|
||||
|
||||
|
||||
def get_inflight_video_replace(team):
|
||||
"""本团队正在跑的复刻任务(含审核中 / 提炼中)。没有返回 None。"""
|
||||
_reap_stale_free_video_tasks(team=team)
|
||||
return (
|
||||
AITask.objects.filter(
|
||||
team=team,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status__in=IN_FLIGHT_STATUSES,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
)
|
||||
.filter(video_replace_q())
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
# 商品复刻不再把参考视频直接丢给火山(实测换不动商品,出片跑偏),改走两步:
|
||||
# 先用「提炼提示词」那一整套(同一份 SKILL.md + 同一个 Gemini 3.1 Pro)把参考视频拆成
|
||||
# 中文分镜稿,再把分镜稿当导演脚本、连同商品图一起交给 Seedance。
|
||||
@@ -62,8 +90,22 @@ PRODUCT_DIGEST_TAIL = (
|
||||
"不要改造型、不要改配色、不要凭空补细节。\n"
|
||||
"{triview}"
|
||||
"3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n"
|
||||
" · 全片的人物必须自始至终是同一个人:五官、发型、体态和服装在所有镜头里保持一致,"
|
||||
"不要中途换脸、换人或换装。\n"
|
||||
" · 参考图里如果有人物(例如服饰类的模特图、真人手持商品的图),画面中的人物就以那张参考图为准,"
|
||||
"五官、发型、体态和服装必须与之一致;参考图里只有商品、没有人物时,就按分镜稿描述的人物去演,"
|
||||
"不要从商品图上凭空套一张脸。\n"
|
||||
"4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n"
|
||||
"5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。"
|
||||
"{condense}"
|
||||
)
|
||||
|
||||
# 参考视频比成片长时才追加(火山单次出片上限 15 秒,60 秒参考稿必须压缩改编,
|
||||
# 否则模型会照着分镜稿从头拍、拍到 15 秒戛然而止)。
|
||||
PRODUCT_CONDENSE_NOTE = (
|
||||
"\n6. 参考视频约 {source} 秒,而本次成片只有 {output} 秒:请按分镜稿的结构和节奏**压缩改编**,"
|
||||
"保留主线、最关键的卖点镜头和结尾收束,可以合并或省略次要镜头;"
|
||||
"不要只拍开头几镜就停,也不要把画面加速成快放。"
|
||||
)
|
||||
# 商品库里有三视图时才追加这一条(没有就不提,免得模型去找一张不存在的图)。
|
||||
PRODUCT_TRIVIEW_NOTE = (
|
||||
@@ -107,7 +149,14 @@ def compact_digest_for_video(digest_text: str) -> str:
|
||||
return "\n".join(line for line in lines if line.strip()).strip()
|
||||
|
||||
|
||||
def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_triview: bool = False) -> str:
|
||||
def build_product_replace_prompt(
|
||||
digest_text: str,
|
||||
subject_name: str,
|
||||
*,
|
||||
has_triview: bool = False,
|
||||
source_seconds: float = 0,
|
||||
output_seconds: int = 0,
|
||||
) -> str:
|
||||
"""分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。
|
||||
|
||||
@目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items
|
||||
@@ -119,9 +168,18 @@ def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_tri
|
||||
if not body:
|
||||
raise ValueError("参考视频拆解结果为空,请重试")
|
||||
subject = (subject_name or "").strip() or "目标商品"
|
||||
source = int(round(float(source_seconds or 0)))
|
||||
output = int(output_seconds or 0)
|
||||
# 差 3 秒以内不啰嗦,模型自己会收;差得多才明确要求压缩改编。
|
||||
condense = (
|
||||
PRODUCT_CONDENSE_NOTE.format(source=source, output=output)
|
||||
if source and output and source - output >= 3
|
||||
else ""
|
||||
)
|
||||
tail = PRODUCT_DIGEST_TAIL.format(
|
||||
subject=subject,
|
||||
triview=PRODUCT_TRIVIEW_NOTE if has_triview else "",
|
||||
condense=condense,
|
||||
)
|
||||
return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}")
|
||||
|
||||
@@ -190,9 +248,20 @@ def submit_video_replace(*, team, user, params: dict):
|
||||
if not has_model and not has_temp:
|
||||
raise ValueError("请选择角色或上传角色参考图")
|
||||
|
||||
# 单飞闸:一个团队同时只允许一条复刻在跑。刷新页面时前端拉状态有空档,
|
||||
# 用户容易以为「没任务」再点一次,重复扣费。_reap_stale_free_video_tasks 会先回收
|
||||
# 死任务(CREATED 超 16 分钟等),所以不会被永久锁住。
|
||||
running = get_inflight_video_replace(team)
|
||||
if running is not None:
|
||||
raise VideoReplaceInProgress(running)
|
||||
|
||||
video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频")
|
||||
video_seconds = _asset_duration_seconds(video)
|
||||
if video_seconds > REF_DURATION_MAX:
|
||||
if replace_mode == "product":
|
||||
if video_seconds > PRODUCT_REF_DURATION_MAX:
|
||||
raise ValueError("参考视频不能超过 60 秒,请剪短后重试")
|
||||
elif video_seconds > REF_DURATION_MAX:
|
||||
# 角色复刻把参考视频直传火山,15 秒是火山那边的硬限制,不能跟着放宽。
|
||||
raise ValueError("参考视频不能超过 15 秒,请剪短后重试")
|
||||
|
||||
if has_product:
|
||||
@@ -345,7 +414,11 @@ def run_replace_digest(task) -> None:
|
||||
(ref or {}).get("label") == TRIVIEW_LABEL for ref in (payload.get("references") or [])
|
||||
)
|
||||
prompt = build_product_replace_prompt(
|
||||
digest, str(payload.get("subject_name") or ""), has_triview=has_triview
|
||||
digest,
|
||||
str(payload.get("subject_name") or ""),
|
||||
has_triview=has_triview,
|
||||
source_seconds=float(payload.get("digest_source_duration") or 0),
|
||||
output_seconds=int(payload.get("duration") or 0),
|
||||
)
|
||||
except (VideoDigestError, ValueError) as exc:
|
||||
_fail_reviewing_task(task, str(exc), error_code="processing_failed")
|
||||
@@ -776,10 +849,15 @@ def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
|
||||
assets.append(asset)
|
||||
if len(assets) >= image_budget:
|
||||
break
|
||||
if not assets and product.cover_asset_id and not product.cover_asset.is_deleted:
|
||||
if not assets and product.cover_asset_id and product.cover_asset_id not in seen and not product.cover_asset.is_deleted:
|
||||
assets.append(product.cover_asset)
|
||||
if not assets and triview is None:
|
||||
raise ValueError("这个商品还没有可用图片")
|
||||
if not assets:
|
||||
# 只有三视图、没有任何实拍图时,把三视图顶上来当 @目标商品。
|
||||
# 否则提示词里的 @目标商品 找不到同名 label,火山拿到的是一句没有指代的字面量。
|
||||
if triview is None:
|
||||
raise ValueError("这个商品还没有可用图片")
|
||||
assets = [triview]
|
||||
triview = None
|
||||
refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)]
|
||||
if triview is not None:
|
||||
# 放最后:@目标商品 仍指向第一张实拍图,三视图作为「各面长什么样」的补充证据。
|
||||
|
||||
@@ -728,7 +728,7 @@ class VideoDigestView(APIView):
|
||||
if upload is None and not reuse_task_id:
|
||||
return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
require_worker_task("apps.ai.tasks.run_video_digest_task")
|
||||
from .video_digest import VideoDigestError, submit_team_digest
|
||||
from .video_digest import VideoDigestError, VideoDigestInProgress, submit_team_digest
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
@@ -739,6 +739,9 @@ class VideoDigestView(APIView):
|
||||
reuse_task_id=reuse_task_id,
|
||||
model_config_id=request.data.get("model_config_id") or None,
|
||||
)
|
||||
except VideoDigestInProgress as exc:
|
||||
# 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了
|
||||
return Response({"detail": str(exc), "inflight": exc.job}, status=status.HTTP_409_CONFLICT)
|
||||
except VideoDigestError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案
|
||||
@@ -866,11 +869,21 @@ class VideoReplaceView(APIView):
|
||||
# 商品复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。
|
||||
if str((request.data or {}).get("replace_mode") or "") == "product":
|
||||
require_worker_task("apps.ai.tasks.run_video_replace_digest_task")
|
||||
from .video_replace import serialize_video_replace_task, submit_video_replace
|
||||
from .video_replace import (
|
||||
VideoReplaceInProgress,
|
||||
serialize_video_replace_task,
|
||||
submit_video_replace,
|
||||
)
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
task = submit_video_replace(team=team, user=request.user, params=request.data or {})
|
||||
except VideoReplaceInProgress as exc:
|
||||
# 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了
|
||||
return Response(
|
||||
{"detail": str(exc), "inflight": serialize_video_replace_task(exc.task)},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
internal_kind = (
|
||||
@@ -906,11 +919,16 @@ class VideoReplaceView(APIView):
|
||||
qs = _free_video_list_queryset(team, include_replace=True).order_by("-created_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
from .video_replace import get_inflight_video_replace
|
||||
|
||||
running = get_inflight_video_replace(team) if offset == 0 else None
|
||||
return Response(
|
||||
{
|
||||
"results": [serialize_video_replace_task(t) for t in tasks],
|
||||
"total": total,
|
||||
"has_more": offset + page_size < total,
|
||||
# 首页才带:前端刷新后据此恢复「进行中」,不必自己在列表里翻状态
|
||||
"inflight": serialize_video_replace_task(running) if running is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1059,6 +1077,13 @@ _FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
|
||||
_FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"}
|
||||
_FREE_REF_IMAGE_MAX = 30 * 1024 * 1024
|
||||
_FREE_REF_VIDEO_MAX = 50 * 1024 * 1024
|
||||
# 视频复刻·商品:参考视频只喂给提炼模型(不发火山),没有 15 秒的硬限制,
|
||||
# 按「提炼提示词」页那套放宽到 60 秒 / 200MB。自由创作和角色复刻仍走上面的 15 秒。
|
||||
# 拿这个 purpose 传上来的长视频,若被拿去自由创作/角色复刻,下游 build_content_items
|
||||
# 与 submit_video_replace 各自还有 15 秒校验兜底,越不过去。
|
||||
_REPLACE_SOURCE_PURPOSE = "video_replace_product"
|
||||
_REPLACE_SOURCE_VIDEO_MAX = 200 * 1024 * 1024
|
||||
_REPLACE_SOURCE_DURATION_MAX = 60.5
|
||||
_FREE_REF_AUDIO_MAX = 15 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -1088,6 +1113,7 @@ class FreeVideoUploadView(APIView):
|
||||
team = get_current_team(request.user)
|
||||
content_type = (upload.content_type or "").lower()
|
||||
size = upload.size or 0
|
||||
long_source = str(request.data.get("purpose") or "").strip() == _REPLACE_SOURCE_PURPOSE
|
||||
|
||||
if content_type in _FREE_REF_IMAGE_TYPES:
|
||||
kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type]
|
||||
@@ -1095,8 +1121,12 @@ class FreeVideoUploadView(APIView):
|
||||
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _FREE_REF_VIDEO_TYPES:
|
||||
kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov"
|
||||
if size > _FREE_REF_VIDEO_MAX:
|
||||
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
video_max = _REPLACE_SOURCE_VIDEO_MAX if long_source else _FREE_REF_VIDEO_MAX
|
||||
if size > video_max:
|
||||
return Response(
|
||||
{"detail": f"视频大小不能超过 {video_max // 1024 // 1024}MB"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
elif content_type in _FREE_REF_AUDIO_TYPES:
|
||||
kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav"
|
||||
if size > _FREE_REF_AUDIO_MAX:
|
||||
@@ -1132,9 +1162,16 @@ class FreeVideoUploadView(APIView):
|
||||
duration = probe_duration(str(tmp_path))
|
||||
if duration is None:
|
||||
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not duration_in_ref_range(duration):
|
||||
label = "视频" if kind == "video" else "音频"
|
||||
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
from .media_probe import REF_DURATION_MIN
|
||||
|
||||
if long_source and kind == "video":
|
||||
in_range = REF_DURATION_MIN <= duration <= _REPLACE_SOURCE_DURATION_MAX
|
||||
range_hint = "视频时长需在 2-60 秒之间"
|
||||
else:
|
||||
in_range = duration_in_ref_range(duration)
|
||||
range_hint = f"{'视频' if kind == 'video' else '音频'}时长需在 2-15 秒之间"
|
||||
if not in_range:
|
||||
return Response({"detail": range_hint}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if kind == "video":
|
||||
poster_bytes = extract_video_poster(str(tmp_path))
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class ProductSerializer(serializers.ModelSerializer):
|
||||
images = ProductImageSerializer(many=True, required=False)
|
||||
selling_points = ProductSellingPointSerializer(many=True, required=False)
|
||||
cover_preview_url = serializers.SerializerMethodField()
|
||||
triview_preview_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
@@ -51,6 +52,7 @@ class ProductSerializer(serializers.ModelSerializer):
|
||||
"status",
|
||||
"cover_asset",
|
||||
"cover_preview_url",
|
||||
"triview_preview_url",
|
||||
"images",
|
||||
"selling_points",
|
||||
"created_at",
|
||||
@@ -61,6 +63,24 @@ class ProductSerializer(serializers.ModelSerializer):
|
||||
def get_cover_preview_url(self, obj) -> str:
|
||||
return _asset_preview_url(obj.cover_asset)
|
||||
|
||||
def get_triview_preview_url(self, obj) -> str:
|
||||
"""商品三视图(白底多角度单张 16:9)。商品库存的是 standalone Asset,不在 images 里,
|
||||
视频复刻要靠它锁商品各面,前端也要能直接告诉用户「这次带了三视图」。"""
|
||||
from apps.assets.models import Asset
|
||||
|
||||
asset = (
|
||||
Asset.objects.filter(
|
||||
team_id=obj.team_id,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
metadata__product_id=str(obj.id),
|
||||
metadata__view="three_view",
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
return _asset_preview_url(asset) if asset else ""
|
||||
|
||||
def validate(self, attrs):
|
||||
# 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空,故事板/视频退化纯文生图。
|
||||
# 例外:本地生活团购是虚拟商品(无实物),允许不传主图,沿用主流程 SOP。
|
||||
|
||||
@@ -242,3 +242,43 @@ class ProductBusinessTypeTests(TestCase):
|
||||
self.assertIn("火锅套餐", local)
|
||||
self.assertNotIn("面膜", local)
|
||||
|
||||
|
||||
|
||||
class ProductTriviewFieldTests(TestCase):
|
||||
"""商品三视图是 standalone Asset(metadata.view=three_view + product_id),不在 images 里。
|
||||
视频复刻靠它锁商品各面,前端也要能直接告诉用户「这次带了三视图」。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="ptri", password="p")
|
||||
self.team = Team.objects.create(name="PT", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
|
||||
|
||||
def _asset(self, name, preview, metadata=None):
|
||||
asset = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name=name,
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.UPLOAD, metadata=metadata or {},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset, object_key="k/1", bucket="b", content_type="image/png",
|
||||
size_bytes=1, preview_url=preview, is_primary=True,
|
||||
)
|
||||
return asset
|
||||
|
||||
def test_triview_url_exposed_when_present(self):
|
||||
self._asset("三视图", "http://tos/tri.png", {"product_id": str(self.product.id), "view": "three_view"})
|
||||
data = self.client.get(f"/api/products/{self.product.id}/").json()
|
||||
self.assertEqual(data["triview_preview_url"], "http://tos/tri.png")
|
||||
|
||||
def test_blank_when_no_triview(self):
|
||||
data = self.client.get(f"/api/products/{self.product.id}/").json()
|
||||
self.assertEqual(data["triview_preview_url"], "")
|
||||
|
||||
def test_other_products_triview_does_not_leak(self):
|
||||
other = Product.objects.create(team=self.team, created_by=self.user, title="别的商品")
|
||||
self._asset("三视图", "http://tos/other.png", {"product_id": str(other.id), "view": "three_view"})
|
||||
data = self.client.get(f"/api/products/{self.product.id}/").json()
|
||||
self.assertEqual(data["triview_preview_url"], "")
|
||||
|
||||
@@ -259,6 +259,29 @@ def _should_mark_project_failed(job: QuickCreateJob) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# 编排器活着时会不断推进并保存 job(updated_at 跟着动)。超过这个时长没动过,
|
||||
# 基本就是 worker 挂了 —— 不当它在跑,否则一次抽风会把团队永久锁在"有任务进行中"。
|
||||
QUICK_CREATE_STALE_AFTER = timedelta(minutes=30)
|
||||
|
||||
|
||||
def get_inflight_quick_create(team):
|
||||
"""本团队正在跑的一键成片。没有 / 已经僵死的返回 None。"""
|
||||
from django.utils import timezone
|
||||
|
||||
return (
|
||||
QuickCreateJob.objects.select_related("project")
|
||||
.filter(
|
||||
team=team,
|
||||
status__in=(QuickCreateJob.Status.QUEUED, QuickCreateJob.Status.RUNNING),
|
||||
project__is_deleted=False,
|
||||
project__purged_at__isnull=True,
|
||||
updated_at__gte=timezone.now() - QUICK_CREATE_STALE_AFTER,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def restore_false_failed_quick_creates(team) -> None:
|
||||
jobs = (
|
||||
QuickCreateJob.objects.select_related("project")
|
||||
|
||||
@@ -111,6 +111,55 @@ class QuickCreateApiTests(TestCase):
|
||||
{str(image.asset_id) for image in product.images.order_by("sort_order")},
|
||||
)
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model")
|
||||
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_second_submit_is_blocked_while_one_is_running(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
|
||||
"""刷新页面时前端拉状态有空档,用户容易以为「没任务」再提交一次 ——
|
||||
那会重复建商品、重复扣费。后端必须拦住,并把在跑的那条带回去。"""
|
||||
store_asset.side_effect = self._uploaded_asset
|
||||
video_model = SimpleNamespace(
|
||||
id=uuid.uuid4(),
|
||||
name="doubao-seedance-2-0-fast-260128",
|
||||
display_name="Seedance 2.0 Fast",
|
||||
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
|
||||
)
|
||||
get_model.side_effect = [object(), video_model, object(), video_model]
|
||||
|
||||
def _post():
|
||||
return self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "轻醒咖啡",
|
||||
"images": [SimpleUploadedFile("front.png", b"png-one", content_type="image/png")],
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"total_duration": "15",
|
||||
"category": "食品饮料",
|
||||
},
|
||||
)
|
||||
|
||||
first = _post()
|
||||
self.assertEqual(first.status_code, 202)
|
||||
job_id = str(QuickCreateJob.objects.get(project__product__title="轻醒咖啡").id)
|
||||
|
||||
again = _post()
|
||||
self.assertEqual(again.status_code, 409)
|
||||
self.assertIn("完成或取消后", again.json()["detail"])
|
||||
self.assertEqual(again.json()["inflight"]["id"], job_id)
|
||||
|
||||
# 历史接口也要把在跑的那条带出来,前端刷新后才能恢复进度
|
||||
listed = self.client.get("/api/projects/quick-create-history/").json()
|
||||
self.assertEqual(listed["inflight"]["id"], job_id)
|
||||
|
||||
# 收成终态后放行
|
||||
QuickCreateJob.objects.filter(id=job_id).update(status=QuickCreateJob.Status.CANCELLED)
|
||||
from apps.projects.services.quick_create import get_inflight_quick_create
|
||||
|
||||
self.assertIsNone(get_inflight_quick_create(self.team))
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model")
|
||||
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
|
||||
|
||||
@@ -485,6 +485,20 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def quick_create(self, request):
|
||||
"""商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。"""
|
||||
require_worker_task("apps.projects.tasks.advance_quick_create_task")
|
||||
# 单飞闸:一个团队同时只允许一条一键成片。刷新页面时前端拉状态有空档,
|
||||
# 用户容易以为「没任务」再提交一次 —— 那会重复建商品、重复扣费。
|
||||
# get_inflight_quick_create 会跳过 30 分钟没动过的僵尸 job,不至于把人永久锁住。
|
||||
from .services.quick_create import get_inflight_quick_create
|
||||
|
||||
running_job = get_inflight_quick_create(self.get_team())
|
||||
if running_job is not None:
|
||||
return Response(
|
||||
{
|
||||
"detail": "已有一个一键成片正在进行中,完成或取消后才能再提交",
|
||||
"inflight": QuickCreateJobSerializer(running_job).data,
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]")
|
||||
source_product_id = str(request.data.get("source_product_id") or "").strip()
|
||||
@@ -775,9 +789,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
)
|
||||
.order_by("-created_at")
|
||||
)
|
||||
from .services.quick_create import get_inflight_quick_create
|
||||
|
||||
running = get_inflight_quick_create(self.get_team())
|
||||
return Response({
|
||||
"count": jobs.count(),
|
||||
"results": QuickCreateJobSerializer(jobs[:30], many=True).data,
|
||||
# 进行中的那条:前端刷新/重进页面据此恢复进度,不再显示成空表单
|
||||
"inflight": QuickCreateJobSerializer(running).data if running is not None else None,
|
||||
})
|
||||
|
||||
@transaction.atomic
|
||||
|
||||
Reference in New Issue
Block a user