fix: v0.15.1 生成记录软删除 + 双重结算修复
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m50s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m50s
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) <noreply@anthropic.com>
This commit is contained in:
parent
b50ad147cd
commit
a4c36e4fee
@ -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='用户已删除'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -46,6 +46,7 @@ class GenerationRecord(models.Model):
|
|||||||
raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息')
|
raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息')
|
||||||
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
|
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
|
||||||
is_favorited = models.BooleanField(default=False, 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='种子值')
|
seed = models.BigIntegerField(default=-1, verbose_name='种子值')
|
||||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||||
|
|||||||
@ -489,6 +489,8 @@ def _release_freeze(record):
|
|||||||
|
|
||||||
def _settle_payment(record, total_tokens):
|
def _settle_payment(record, total_tokens):
|
||||||
"""任务完成时结算:按实际 tokens 扣费并释放冻结。"""
|
"""任务完成时结算:按实际 tokens 扣费并释放冻结。"""
|
||||||
|
if record.frozen_amount == 0:
|
||||||
|
return # 已结算,防止重复扣费
|
||||||
team = record.user.team
|
team = record.user.team
|
||||||
if not team:
|
if not team:
|
||||||
return
|
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)
|
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)
|
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()
|
total = qs.count()
|
||||||
records = _eval_qs(qs, limit=offset + page_size)
|
records = _eval_qs(qs, limit=offset + page_size)
|
||||||
# Apply offset after evaluation (defer compat)
|
# Apply offset after evaluation (defer compat)
|
||||||
@ -547,73 +549,22 @@ def video_tasks_list_view(request):
|
|||||||
@api_view(['GET', 'DELETE'])
|
@api_view(['GET', 'DELETE'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def video_task_detail_view(request, task_id):
|
def video_task_detail_view(request, task_id):
|
||||||
"""GET /api/v1/video/tasks/<task_id> — Poll Seedance + refund on failure.
|
"""GET /api/v1/video/tasks/<task_id> — Read task status from DB (settlement by Celery only).
|
||||||
DELETE /api/v1/video/tasks/<task_id> — Delete task record."""
|
DELETE /api/v1/video/tasks/<task_id> — Soft-delete task record."""
|
||||||
try:
|
try:
|
||||||
record = _eval_qs(
|
record = _eval_qs(
|
||||||
GenerationRecord.objects.filter(user=request.user),
|
GenerationRecord.objects.filter(user=request.user, is_deleted=False),
|
||||||
get_kwargs={'task_id': task_id},
|
get_kwargs={'task_id': task_id},
|
||||||
)
|
)
|
||||||
except GenerationRecord.DoesNotExist:
|
except GenerationRecord.DoesNotExist:
|
||||||
return Response({'error': '任务不存在'}, status=status.HTTP_404_NOT_FOUND)
|
return Response({'error': '任务不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
if request.method == 'DELETE':
|
if request.method == 'DELETE':
|
||||||
record.delete()
|
record.is_deleted = True
|
||||||
|
record.save(update_fields=['is_deleted'])
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
# If task is still active, poll AirDrama API for latest status
|
# Frontend polling only reads DB state — settlement is handled exclusively by Celery
|
||||||
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)
|
|
||||||
|
|
||||||
return Response(_serialize_task(record))
|
return Response(_serialize_task(record))
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user