From a4c36e4fee02d22df54e3f3eebff010ee71ba642 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Tue, 31 Mar 2026 20:05:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.15.1=20=E7=94=9F=E6=88=90=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E8=BD=AF=E5=88=A0=E9=99=A4=20+=20=E5=8F=8C=E9=87=8D?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 软删除:GenerationRecord 新增 is_deleted 字段,DELETE 接口改为标记不真删, 用户生成列表/资产页/任务详情过滤已删除记录,消费记录和管理后台不过滤 2. 双重结算修复:前端轮询(video_task_detail_view)不再调火山API和结算, 只读数据库状态,结算完全交给 Celery 3. _settle_payment 加防重入检查(frozen_amount==0 直接 return) 4. 部署需跑 migration 0016_add_is_deleted_to_generationrecord Co-Authored-By: Claude Opus 4.6 (1M context) --- ...0016_add_is_deleted_to_generationrecord.py | 18 +++++ backend/apps/generation/models.py | 1 + backend/apps/generation/views.py | 67 +++---------------- 3 files changed, 28 insertions(+), 58 deletions(-) create mode 100644 backend/apps/generation/migrations/0016_add_is_deleted_to_generationrecord.py diff --git a/backend/apps/generation/migrations/0016_add_is_deleted_to_generationrecord.py b/backend/apps/generation/migrations/0016_add_is_deleted_to_generationrecord.py new file mode 100644 index 0000000..cfc5273 --- /dev/null +++ b/backend/apps/generation/migrations/0016_add_is_deleted_to_generationrecord.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-03-31 05:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generation', '0015_add_fast_token_price'), + ] + + operations = [ + migrations.AddField( + model_name='generationrecord', + name='is_deleted', + field=models.BooleanField(default=False, verbose_name='用户已删除'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 4d92155..92b4c84 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -46,6 +46,7 @@ class GenerationRecord(models.Model): raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息') reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息') is_favorited = models.BooleanField(default=False, verbose_name='已收藏') + is_deleted = models.BooleanField(default=False, verbose_name='用户已删除') seed = models.BigIntegerField(default=-1, verbose_name='种子值') created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index e98e40a..685e3a7 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -489,6 +489,8 @@ def _release_freeze(record): def _settle_payment(record, total_tokens): """任务完成时结算:按实际 tokens 扣费并释放冻结。""" + if record.frozen_amount == 0: + return # 已结算,防止重复扣费 team = record.user.team if not team: return @@ -530,7 +532,7 @@ def video_tasks_list_view(request): page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100) offset = max(_safe_int(request.query_params.get('offset', 0), 0), 0) - qs = user.generation_records.order_by('-created_at') + qs = user.generation_records.filter(is_deleted=False).order_by('-created_at') total = qs.count() records = _eval_qs(qs, limit=offset + page_size) # Apply offset after evaluation (defer compat) @@ -547,73 +549,22 @@ def video_tasks_list_view(request): @api_view(['GET', 'DELETE']) @permission_classes([IsAuthenticated]) def video_task_detail_view(request, task_id): - """GET /api/v1/video/tasks/ — Poll Seedance + refund on failure. - DELETE /api/v1/video/tasks/ — Delete task record.""" + """GET /api/v1/video/tasks/ — Read task status from DB (settlement by Celery only). + DELETE /api/v1/video/tasks/ — Soft-delete task record.""" try: record = _eval_qs( - GenerationRecord.objects.filter(user=request.user), + GenerationRecord.objects.filter(user=request.user, is_deleted=False), get_kwargs={'task_id': task_id}, ) except GenerationRecord.DoesNotExist: return Response({'error': '任务不存在'}, status=status.HTTP_404_NOT_FOUND) if request.method == 'DELETE': - record.delete() + record.is_deleted = True + record.save(update_fields=['is_deleted']) return Response(status=status.HTTP_204_NO_CONTENT) - # If task is still active, poll AirDrama API for latest status - ark_task_id = record.__dict__.get('ark_task_id', '') - if record.status in ('queued', 'processing') and ark_task_id: - try: - ark_resp = query_task(ark_task_id) - new_status = map_status(ark_resp.get('status', '')) - record.status = new_status - - # 保存火山返回的实际 seed 值 - returned_seed = ark_resp.get('seed') - if returned_seed is not None: - record.seed = returned_seed - - if new_status == 'completed': - video_url = extract_video_url(ark_resp) - if video_url: - # Persist to TOS for permanent storage (Seedance URLs expire in 24h) - try: - from utils.tos_client import upload_from_url - record.result_url = upload_from_url(video_url, folder='results') - except Exception: - logger.exception('Failed to persist video to TOS, using temporary URL') - record.result_url = video_url - # 结算:按实际 tokens 扣费 - usage = ark_resp.get('usage', {}) - total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0 - if total_tokens > 0: - _settle_payment(record, total_tokens) - else: - # API 没返回 tokens(异常),释放冻结不扣费 - _release_freeze(record) - elif new_status == 'failed': - error = ark_resp.get('error', {}) - code = error.get('code', '') if isinstance(error, dict) else '' - raw_msg = error.get('message', '') if isinstance(error, dict) else str(error) - from utils.airdrama_client import ERROR_MESSAGES - record.error_message = ERROR_MESSAGES.get(code, raw_msg) - record.raw_error = f'{code}: {raw_msg}' if code else raw_msg - # 失败时检查是否产生了 token 消耗 - usage = ark_resp.get('usage', {}) - total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0 - if total_tokens > 0: - # Seedance 已计费,按实际扣费(允许透支) - _settle_payment(record, total_tokens) - else: - # Seedance 未计费,释放冻结 - _release_freeze(record) - - if new_status in ('completed', 'failed'): - record.completed_at = timezone.now() - record.save(update_fields=['status', 'result_url', 'error_message', 'raw_error', 'seed', 'completed_at']) - except Exception as e: - logger.exception('AirDrama API query failed for %s', ark_task_id) + # Frontend polling only reads DB state — settlement is handled exclusively by Celery return Response(_serialize_task(record))