fix bug report
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s

This commit is contained in:
zyc 2026-03-13 17:17:01 +08:00
parent 7aa1788035
commit 060d3a726f
5 changed files with 133 additions and 17 deletions

View File

@ -23,6 +23,10 @@ COPY . /app/
# Collect static files # Collect static files
RUN python manage.py collectstatic --noinput 2>/dev/null || true RUN python manage.py collectstatic --noinput 2>/dev/null || true
# Make entrypoint executable
RUN chmod +x /app/entrypoint.sh
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "config.wsgi:application"] CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "config.wsgi:application"]

View File

@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model
from django.utils import timezone from django.utils import timezone
from django.db.models import Sum, Q from django.db.models import Sum, Q
from django.db.models.functions import TruncDate from django.db.models.functions import TruncDate
from django.db.utils import OperationalError as DbOperationalError
from datetime import timedelta from datetime import timedelta
from .models import GenerationRecord, QuotaConfig from .models import GenerationRecord, QuotaConfig
@ -29,6 +30,36 @@ ALLOWED_VIDEO_EXTS = {'mp4', 'mov'}
MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB
# Columns added in migration 0003; may not exist in production DB yet.
_M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls')
_m0003_ok = None # None = unknown, True = columns exist, False = missing
def _eval_qs(qs, limit=None, get_kwargs=None):
"""Evaluate a GenerationRecord queryset, deferring migration-0003 columns if missing."""
global _m0003_ok
def _run(q, defer):
if defer:
q = q.defer(*_M0003_COLS)
if get_kwargs is not None:
return q.get(**get_kwargs)
if limit is not None:
return list(q[:limit])
return list(q)
if _m0003_ok is False:
return _run(qs, defer=True)
try:
result = _run(qs, defer=False)
_m0003_ok = True
return result
except DbOperationalError as e:
if 'ark_task_id' in str(e):
_m0003_ok = False
return _run(qs, defer=True)
raise
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Media Upload # Media Upload
@ -238,12 +269,10 @@ def video_tasks_list_view(request):
user = request.user user = request.user
page_size = min(int(request.query_params.get('page_size', 50)), 100) page_size = min(int(request.query_params.get('page_size', 50)), 100)
records = user.generation_records.order_by('-created_at')[:page_size] qs = user.generation_records.order_by('-created_at')
records = _eval_qs(qs, limit=page_size)
results = []
for r in records:
results.append(_serialize_task(r))
results = [_serialize_task(r) for r in records]
return Response({'results': results}) return Response({'results': results})
@ -252,14 +281,18 @@ def video_tasks_list_view(request):
def video_task_detail_view(request, task_id): def video_task_detail_view(request, task_id):
"""GET /api/v1/video/tasks/<task_id> — Get task status, poll Seedance if active.""" """GET /api/v1/video/tasks/<task_id> — Get task status, poll Seedance if active."""
try: try:
record = GenerationRecord.objects.get(task_id=task_id, user=request.user) record = _eval_qs(
GenerationRecord.objects.filter(user=request.user),
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 task is still active, poll Seedance API for latest status # If task is still active, poll Seedance API for latest status
if record.status in ('queued', 'processing') and record.ark_task_id: ark_task_id = record.__dict__.get('ark_task_id', '')
if record.status in ('queued', 'processing') and ark_task_id:
try: try:
ark_resp = query_task(record.ark_task_id) ark_resp = query_task(ark_task_id)
new_status = map_status(ark_resp.get('status', '')) new_status = map_status(ark_resp.get('status', ''))
record.status = new_status record.status = new_status
@ -275,17 +308,18 @@ def video_task_detail_view(request, task_id):
record.save(update_fields=['status', 'result_url', 'error_message']) record.save(update_fields=['status', 'result_url', 'error_message'])
except Exception as e: except Exception as e:
logger.exception('Seedance API query failed for %s', record.ark_task_id) logger.exception('Seedance API query failed for %s', ark_task_id)
return Response(_serialize_task(record)) return Response(_serialize_task(record))
def _serialize_task(record): def _serialize_task(record):
"""Serialize a GenerationRecord for the frontend.""" """Serialize a GenerationRecord for the frontend."""
d = record.__dict__
return { return {
'id': record.id, 'id': record.id,
'task_id': str(record.task_id), 'task_id': str(record.task_id),
'ark_task_id': record.ark_task_id, 'ark_task_id': d.get('ark_task_id', ''),
'prompt': record.prompt, 'prompt': record.prompt,
'mode': record.mode, 'mode': record.mode,
'model': record.model, 'model': record.model,
@ -293,9 +327,9 @@ def _serialize_task(record):
'duration': record.duration, 'duration': record.duration,
'seconds_consumed': record.seconds_consumed, 'seconds_consumed': record.seconds_consumed,
'status': record.status, 'status': record.status,
'result_url': record.result_url, 'result_url': d.get('result_url', ''),
'error_message': record.error_message, 'error_message': d.get('error_message', ''),
'reference_urls': record.reference_urls or [], 'reference_urls': d.get('reference_urls') or [],
'created_at': record.created_at.isoformat(), 'created_at': record.created_at.isoformat(),
} }
@ -469,7 +503,7 @@ def admin_user_detail_view(request, user_id):
total=Sum('seconds_consumed') total=Sum('seconds_consumed')
)['total'] or 0 )['total'] or 0
recent_records = user.generation_records.order_by('-created_at')[:20] recent_records = _eval_qs(user.generation_records.order_by('-created_at'), limit=20)
return Response({ return Response({
'id': user.id, 'id': user.id,
@ -607,7 +641,7 @@ def admin_records_view(request):
total = qs.count() total = qs.count()
offset = (page - 1) * page_size offset = (page - 1) * page_size
records = qs[offset:offset + page_size] records = _eval_qs(qs[offset:offset + page_size])
results = [] results = []
for r in records: for r in records:
@ -731,7 +765,7 @@ def profile_records_view(request):
qs = user.generation_records.order_by('-created_at') qs = user.generation_records.order_by('-created_at')
total = qs.count() total = qs.count()
offset = (page - 1) * page_size offset = (page - 1) * page_size
records = qs[offset:offset + page_size] records = _eval_qs(qs[offset:offset + page_size])
results = [] results = []
for r in records: for r in records:

View File

@ -31,6 +31,7 @@ INSTALLED_APPS = [
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'utils.log_center.LogCenterMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware', 'corsheaders.middleware.CorsMiddleware',

8
backend/entrypoint.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
set -e
echo "Running database migrations..."
python manage.py migrate --noinput
echo "Starting server..."
exec "$@"

View File

@ -1,7 +1,8 @@
"""Log Center integration — runtime error reporting.""" """Log Center integration — runtime error reporting + exception middleware."""
import os import os
import json import json
import logging
import traceback import traceback
import threading import threading
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@ -12,6 +13,8 @@ LOG_CENTER_ENABLED = os.getenv('LOG_CENTER_ENABLED', 'false').lower() in ('true'
PROJECT_ID = 'video_backend' PROJECT_ID = 'video_backend'
ENVIRONMENT = os.getenv('ENVIRONMENT', 'development') ENVIRONMENT = os.getenv('ENVIRONMENT', 'development')
logger = logging.getLogger('log_center')
def report_error(exc, context=None): def report_error(exc, context=None):
"""Report a runtime error to Log Center (async, non-blocking).""" """Report a runtime error to Log Center (async, non-blocking)."""
@ -54,9 +57,64 @@ def _send(payload):
pass # Silent failure — never affect main business pass # Silent failure — never affect main business
# ──────────────────────────────────────────────
# Django Middleware: 捕获 DRF 之外的所有异常
# (模块导入失败、中间件错误、URL 解析错误等)
# ──────────────────────────────────────────────
class LogCenterMiddleware:
"""Django middleware to catch ALL unhandled exceptions and report to Log Center.
DRF custom_exception_handler 只能捕获视图内的异常
模块导入失败中间件异常URL 解析错误等发生在 DRF 之前
不会经过 DRF 的异常处理需要这个中间件来兜底上报
异常捕获分工:
- DRF 视图异常 custom_exception_handler 上报 (DRF 内部 try/except 拦截,不会传播到中间件)
- DRF 视图异常 process_exception 上报
- 中间件/导入异常 __call__ try/except 上报
两层互不重复
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
response = self.get_response(request)
except Exception as exc:
# 捕获中间件链中的异常(如其他中间件报错、模块导入失败等)
logger.error('Unhandled middleware exception: %s', exc, exc_info=True)
report_error(exc, {
'source': 'django_middleware',
'request_path': getattr(request, 'path', 'unknown'),
'request_method': getattr(request, 'method', 'unknown'),
'user': str(getattr(request, 'user', 'anonymous')),
})
raise
return response
def process_exception(self, request, exception):
"""捕获非 DRF 视图的异常(普通 Django 视图、URL 解析错误等)。"""
logger.error('Unhandled view exception: %s', exception, exc_info=True)
report_error(exception, {
'source': 'django_process_exception',
'request_path': request.path,
'request_method': request.method,
'user': str(getattr(request, 'user', 'anonymous')),
})
return None # 不处理响应,交给 Django 默认 500 handler
# ──────────────────────────────────────────────
# DRF Exception Handler: 捕获视图内异常 + 返回 JSON
# ──────────────────────────────────────────────
def custom_exception_handler(exc, context): def custom_exception_handler(exc, context):
"""DRF custom exception handler with Log Center reporting.""" """DRF custom exception handler with Log Center reporting."""
from rest_framework.views import exception_handler from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status
from rest_framework.exceptions import ( from rest_framework.exceptions import (
MethodNotAllowed, NotAuthenticated, AuthenticationFailed, MethodNotAllowed, NotAuthenticated, AuthenticationFailed,
NotFound, PermissionDenied, NotFound, PermissionDenied,
@ -68,6 +126,7 @@ def custom_exception_handler(exc, context):
PermissionDenied)): PermissionDenied)):
request = context.get('request') request = context.get('request')
report_error(exc, { report_error(exc, {
'source': 'drf_exception_handler',
'view': str(context.get('view', '')), 'view': str(context.get('view', '')),
'request_path': getattr(request, 'path', None), 'request_path': getattr(request, 'path', None),
'request_method': getattr(request, 'method', None), 'request_method': getattr(request, 'method', None),
@ -76,4 +135,14 @@ def custom_exception_handler(exc, context):
# Default DRF handling # Default DRF handling
response = exception_handler(exc, context) response = exception_handler(exc, context)
# If DRF didn't handle it (unhandled exception), return JSON 500
# instead of letting Django render an HTML error page
if response is None:
logger.error('Unhandled DRF exception: %s', exc, exc_info=True)
response = Response(
{'error': f'{type(exc).__name__}: {exc}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return response return response