Files
yingqing/core/backend/apps/adminpanel/serializers.py
T
zycandClaude Sonnet 5 d466bd60c6 feat(adminpanel): 任务监控「回收退款」手动兜底
新增 POST /api/admin/tasks/<id>/reap/:仅限卡在「已预留」超 10 分钟的僵尸任务(SUBMITTED/
POLLING 故意不许杀,视频合法耗时 5-10 分钟+,强杀会与轮询结算竞态),标失败 + 退还冻结积分,
与自动回收 _reap_stale_standalone_image_tasks 同款账务路径,幂等。

自动回收只在同团队下次提交时顺手触发,团队弃用某功能后冻结积分会永久躺着——这是给超管的
手动出口。序列化器加 reapable 字段(与端点闸同口径),任务监控行级「回收退款」按钮。
4 条测试(成功退款+审计/窗口内拒绝/非 RESERVED 拒绝/非超管 403)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 10:38:45 +08:00

236 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from decimal import Decimal
from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
from apps.assets.models import Asset
from apps.billing.models import CreditLedger, QuotaPolicy
from apps.projects.models import Project
# 成本异常阈值:实际成本 > 预估 × 此倍数(且预估 > 0)即标异常
COST_ANOMALY_RATIO = Decimal("1.5")
def is_cost_anomaly(estimated, actual) -> bool:
est = estimated or Decimal("0")
act = actual or Decimal("0")
return bool(est > 0 and act > est * COST_ANOMALY_RATIO)
class QualityWordSerializer(serializers.ModelSerializer):
class Meta:
model = QualityWord
fields = ["id", "stage", "slot", "text", "sort", "enabled", "created_at"]
read_only_fields = ["id", "created_at"]
# 每条提示词可用的 {占位符}(给 admin 提示;运行时由对应 builder 注入,改错会原样保留不致命)
PROMPT_PLACEHOLDERS = {
"person_portrait": ["描述"],
"person_triview": [],
"product_triview": ["商品", "补充"],
"scene": ["场景描述"],
"storyboard_frame": ["设定", "场景上下文", "时长", "脚本", "补充"],
"video_segment": ["设定", "分镜", "脚本", "时长"],
}
class PromptTemplateSerializer(serializers.ModelSerializer):
label = serializers.CharField(source="get_key_display", read_only=True)
placeholders = serializers.SerializerMethodField()
class Meta:
model = PromptTemplate
fields = ["id", "key", "label", "template", "ratio", "enabled", "placeholders", "updated_at"]
read_only_fields = ["id", "key", "label", "placeholders", "updated_at"] # key 固定,只改正文/比例/启用
def get_placeholders(self, obj) -> list:
return PROMPT_PLACEHOLDERS.get(obj.key, [])
class AdminReviewAssetSerializer(serializers.ModelSerializer):
"""跨团队人像审核队列行:复用 AssetFileSerializer 的首图 preview_url。"""
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
preview_url = serializers.SerializerMethodField()
class Meta:
model = Asset
fields = ["id", "name", "category", "review_status", "review_error", "team", "team_name", "preview_url", "created_at"]
read_only_fields = fields
def get_preview_url(self, obj):
from apps.assets.serializers import AssetFileSerializer
# 用 prefetch 缓存(list(...))而非 .first():后者会另发查询,列表页 N+1 拖慢到数秒
files = list(obj.files.all())
return AssetFileSerializer(files[0]).data.get("preview_url", "") if files else ""
class AdminTeamSerializer(serializers.ModelSerializer):
owner_username = serializers.CharField(source="owner.username", read_only=True, default=None)
member_count = serializers.SerializerMethodField()
balance = serializers.SerializerMethodField()
class Meta:
model = Team
fields = ["id", "name", "status", "owner", "owner_username", "member_count", "balance", "price_multiplier", "created_at"]
read_only_fields = fields
def get_member_count(self, obj):
anno = getattr(obj, "member_count_anno", None)
return anno if anno is not None else obj.members.count()
def get_balance(self, obj):
acct = getattr(obj, "credit_account", None)
return str(acct.balance) if acct is not None else "0"
class AdminTeamMemberSerializer(serializers.ModelSerializer):
username = serializers.CharField(source="user.username", read_only=True)
user_status = serializers.CharField(source="user.status", read_only=True)
class Meta:
model = TeamMember
fields = ["id", "username", "role", "status", "user_status", "monthly_credit_limit"]
read_only_fields = fields
class AdminUserSerializer(serializers.ModelSerializer):
teams = serializers.SerializerMethodField()
class Meta:
model = User
fields = ["id", "username", "status", "is_platform_admin", "date_joined", "teams"]
read_only_fields = fields
def get_teams(self, obj):
return [
{"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role}
for m in obj.team_memberships.select_related("team").all()
]
class AdminTaskSerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
model_name = serializers.CharField(source="model_config.name", read_only=True, default=None)
cost_anomaly = serializers.SerializerMethodField()
# 单任务毛利(¥):actual_cost(积分)÷汇率 base_cost。base_cost=0(成本未知)时 None,报表侧过滤
margin_yuan = serializers.SerializerMethodField()
# 可手动回收:卡在 RESERVED 超过 10 分钟(与 admin_task_reap 的服务端闸完全同口径,前端据此显示按钮)
reapable = serializers.SerializerMethodField()
class Meta:
model = AITask
fields = [
"id", "task_type", "status", "team", "team_name", "model_name",
"estimated_cost", "actual_cost", "base_cost", "margin_yuan", "cost_anomaly", "error_code", "reapable", "created_at",
]
read_only_fields = fields
def get_cost_anomaly(self, obj) -> bool:
return is_cost_anomaly(obj.estimated_cost, obj.actual_cost)
def get_reapable(self, obj) -> bool:
from datetime import timedelta
from django.utils import timezone
return bool(
obj.status == AITask.Status.RESERVED
and obj.updated_at is not None
and obj.updated_at < timezone.now() - timedelta(minutes=10)
)
def get_margin_yuan(self, obj) -> str | None:
base = obj.base_cost or Decimal("0")
actual = obj.actual_cost or Decimal("0")
if base <= 0 or actual <= 0:
return None
from apps.billing.pricing import get_billing_config
# 优先用任务计价当时的汇率快照:汇率调整不追溯历史,毛利报表不整体漂移(review 确认)。
# __dict__ 直取避免触发 deferred 列加载(有的列表 queryset 会 defer payload)。
payload = obj.__dict__.get("request_payload") or {}
try:
rate = Decimal(str(payload.get("points_per_yuan_snapshot") or "")) if payload.get("points_per_yuan_snapshot") else get_billing_config().points_per_yuan
except Exception: # noqa: BLE001
rate = get_billing_config().points_per_yuan
if rate <= 0:
return None
return str((actual / rate - base).quantize(Decimal("0.01")))
class AdminTaskDetailSerializer(AdminTaskSerializer):
class Meta(AdminTaskSerializer.Meta):
fields = AdminTaskSerializer.Meta.fields + [
"project", "idempotency_key", "request_payload", "response_payload",
"error_message", "submitted_at", "completed_at",
]
read_only_fields = fields
class AdminLedgerSerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
username = serializers.CharField(source="user.username", read_only=True, default=None)
class Meta:
model = CreditLedger
fields = ["id", "team", "team_name", "username", "ledger_type", "amount", "balance_after", "reason", "created_at"]
read_only_fields = fields
class AdminQuotaPolicySerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
class Meta:
model = QuotaPolicy
fields = [
"id", "team", "team_name", "user", "project",
"monthly_limit", "project_limit", "per_task_limit", "is_active", "created_at",
]
read_only_fields = ["id", "team_name", "created_at"]
class AdminModelProviderSerializer(serializers.ModelSerializer):
has_api_key = serializers.SerializerMethodField()
model_count = serializers.SerializerMethodField()
class Meta:
model = ModelProvider
fields = ["id", "name", "display_name", "status", "base_url", "api_key", "metadata", "has_api_key", "model_count", "created_at"]
# api_key 只写不读(密钥不回传前端)
extra_kwargs = {"api_key": {"write_only": True, "required": False}}
read_only_fields = ["id", "has_api_key", "model_count", "created_at"]
def get_has_api_key(self, obj) -> bool:
return bool(obj.api_key)
def get_model_count(self, obj) -> int:
anno = getattr(obj, "model_count_anno", None)
return anno if anno is not None else obj.models.count()
class AdminModelConfigSerializer(serializers.ModelSerializer):
provider_name = serializers.CharField(source="provider.name", read_only=True, default=None)
class Meta:
model = ModelConfig
fields = [
"id", "provider", "provider_name", "name", "display_name", "capability",
"endpoint", "unit_price", "status", "is_default", "rate_limit_per_minute", "metadata", "created_at",
]
# is_default 只经 set-default 端点改,不在普通编辑里直接写
read_only_fields = ["id", "provider_name", "is_default", "created_at"]
class AdminProjectSerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
product_title = serializers.CharField(source="product.title", read_only=True, default=None)
class Meta:
model = Project
fields = ["id", "name", "team", "team_name", "product_title", "status", "current_stage", "created_at"]
read_only_fields = fields