fix(ai): 平台套图改走商品主图参考 image_edit · 模特上身图锁商品+模特双图
- 平台套图(cover)以前纯文生图,不参考真实商品主图 → 出图与商品对不上; 现以商品主图为参考图1走 image_edit,锁包装一致性,有模特则参考图2=模特 - 新增 build_platform_cover_prompt_refs;model_url 解析对 cover 模式同样生效 - 补回归测试:cover 传商品主图、model 传商品主图+模特图、无主图回落文生图 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
from django.db.models import Q
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q, Sum
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@@ -14,7 +17,7 @@ class NotificationPagination(PageNumberPagination):
|
||||
max_page_size = 100
|
||||
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditAccount
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
from apps.common.api import TeamScopedViewSetMixin
|
||||
from apps.projects.models import Project
|
||||
|
||||
@@ -32,6 +35,20 @@ def project_stage_label(project):
|
||||
}.get(project.current_stage, "Stage 1 · 脚本")
|
||||
|
||||
|
||||
# AI 任务类型 → 计费消息里的中文阶段标签(脚本/图片/视频/配音都会扣费)
|
||||
TASK_TYPE_LABEL = {
|
||||
"script_generation": "脚本生成",
|
||||
"script_optimization": "脚本优化",
|
||||
"product_image": "商品图生成",
|
||||
"person_image": "模特图生成",
|
||||
"scene_image": "场景图生成",
|
||||
"storyboard": "故事板生成",
|
||||
"video_segment": "视频生成",
|
||||
"voiceover": "配音生成",
|
||||
"export": "成片导出",
|
||||
}
|
||||
|
||||
|
||||
def project_priority(project):
|
||||
if project.status == Project.Status.COMPLETED:
|
||||
return Notification.Priority.OK
|
||||
@@ -57,11 +74,23 @@ def ensure_team_notifications(team, user):
|
||||
dedupe_key=dedupe_key,
|
||||
defaults=payload,
|
||||
)
|
||||
# 已存在的旧通知补齐「处理记录」timeline(早期建的没存),让历史消息也能显示处理记录
|
||||
if created:
|
||||
return
|
||||
# 已存在的旧通知:补齐早期没存的字段(历史消息也能显示),不重建
|
||||
updates = []
|
||||
# 1) 处理记录 timeline
|
||||
timeline = (payload.get("metadata") or {}).get("timeline")
|
||||
if not created and timeline and not (obj.metadata or {}).get("timeline"):
|
||||
if timeline and not (obj.metadata or {}).get("timeline"):
|
||||
obj.metadata = {**(obj.metadata or {}), "timeline": timeline}
|
||||
obj.save(update_fields=["metadata", "updated_at"])
|
||||
updates.append("metadata")
|
||||
# 2) 费用:早期建的是占位「-」,现在能从计费账本算出真实金额了就回填
|
||||
new_cost = payload.get("cost_label")
|
||||
if new_cost and new_cost != "-" and obj.cost_label in ("", "-"):
|
||||
obj.cost_label = new_cost
|
||||
updates.append("cost_label")
|
||||
if updates:
|
||||
updates.append("updated_at")
|
||||
obj.save(update_fields=updates)
|
||||
|
||||
create_once(
|
||||
"system:welcome",
|
||||
@@ -80,6 +109,11 @@ def ensure_team_notifications(team, user):
|
||||
|
||||
for project in Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5]:
|
||||
product_title = project.product.title if project.product_id else "未绑定商品"
|
||||
# 项目累计花费 = 该项目所有 AI 扣费流水之和(脚本/图片/视频/导出),无扣费则显示「-」
|
||||
project_spend = project.credit_ledgers.filter(
|
||||
ledger_type=CreditLedger.Type.CHARGE
|
||||
).aggregate(total=Sum("amount"))["total"]
|
||||
project_cost = f"¥{project_spend:.2f}" if project_spend else "-"
|
||||
create_once(
|
||||
f"project:{project.id}:status:{project.status}:{project.current_stage}",
|
||||
notification_type=Notification.Type.TASK,
|
||||
@@ -91,7 +125,7 @@ def ensure_team_notifications(team, user):
|
||||
project=project,
|
||||
stage=project_stage_label(project),
|
||||
owner_label=project.created_by.username if project.created_by_id else "成员",
|
||||
cost_label="-",
|
||||
cost_label=project_cost,
|
||||
related_url=f"pipeline.html?project_id={project.id}",
|
||||
metadata={
|
||||
"status": project.status,
|
||||
@@ -104,7 +138,10 @@ def ensure_team_notifications(team, user):
|
||||
},
|
||||
)
|
||||
|
||||
for asset in Asset.objects.filter(team=team).select_related("created_by").order_by("-updated_at")[:3]:
|
||||
for asset in Asset.objects.filter(team=team).select_related("created_by", "origin_task").order_by("-updated_at")[:3]:
|
||||
# AI 生成的资产带 origin_task,可取其实际扣费;手动上传的资产无扣费 → 「-」
|
||||
origin = asset.origin_task
|
||||
asset_cost = f"¥{origin.actual_cost:.2f}" if origin and origin.actual_cost else "-"
|
||||
create_once(
|
||||
f"asset:{asset.id}:created",
|
||||
notification_type=Notification.Type.TASK,
|
||||
@@ -115,7 +152,7 @@ def ensure_team_notifications(team, user):
|
||||
source="资产库",
|
||||
stage="资产入库",
|
||||
owner_label=asset.created_by.username if asset.created_by_id else "成员",
|
||||
cost_label="-",
|
||||
cost_label=asset_cost,
|
||||
related_url="library.html",
|
||||
metadata={
|
||||
"asset_id": str(asset.id),
|
||||
@@ -128,6 +165,53 @@ def ensure_team_notifications(team, user):
|
||||
},
|
||||
)
|
||||
|
||||
# 计费消息:把真实扣费流水(图片/视频/脚本/配音生成)写成「计费」类通知,带真实金额。
|
||||
# 原先 ensure_team_notifications 完全没读账本,所以计费消息的费用都是空的。
|
||||
# 计费通知覆盖「消息保留期(90 天)」内的全部扣费流水,而不是固定最近 10 条 ——
|
||||
# 否则计费 tab 计数会被卡在 10,与实际扣费条数严重不符。上限 500 仅作暴走兜底。
|
||||
charge_window_start = timezone.now() - timedelta(days=90)
|
||||
recent_charges = (
|
||||
CreditLedger.objects.filter(
|
||||
team=team,
|
||||
ledger_type=CreditLedger.Type.CHARGE,
|
||||
created_at__gte=charge_window_start,
|
||||
)
|
||||
.select_related("task", "project", "user")
|
||||
.order_by("-created_at")[:500]
|
||||
)
|
||||
for ledger in recent_charges:
|
||||
task = ledger.task
|
||||
type_label = TASK_TYPE_LABEL.get(task.task_type, "AI 生成") if task else "AI 生成"
|
||||
project_name = ledger.project.name if ledger.project_id else ""
|
||||
amount_text = f"¥{ledger.amount:.2f}"
|
||||
scope = f" · {project_name}" if project_name else ""
|
||||
create_once(
|
||||
f"billing:charge:{ledger.id}",
|
||||
notification_type=Notification.Type.BILLING,
|
||||
priority=Notification.Priority.INFO,
|
||||
title=f"{type_label}已扣费 {amount_text}",
|
||||
brief=f"{ledger.reason or 'AI 任务扣费'}{scope} · 扣费后余额 ¥{ledger.balance_after:.2f}",
|
||||
body=(
|
||||
f"本次{type_label}消耗 {amount_text},来自真实计费账本(CreditLedger)。"
|
||||
f"扣费后团队余额为 ¥{ledger.balance_after:.2f}。明细可在消费页查看完整账本。"
|
||||
),
|
||||
source="计费中心",
|
||||
project=ledger.project,
|
||||
stage=type_label,
|
||||
owner_label=ledger.user.username if ledger.user_id else "系统",
|
||||
cost_label=amount_text,
|
||||
related_url="account.html",
|
||||
metadata={
|
||||
"ledger_id": str(ledger.id),
|
||||
"amount": str(ledger.amount),
|
||||
"timeline": [
|
||||
[_step_time(ledger.created_at), f"{type_label}提交并预留额度"],
|
||||
[_step_time(ledger.created_at), f"实际扣费 {amount_text}"],
|
||||
[_step_time(ledger.created_at), f"扣费后余额 ¥{ledger.balance_after:.2f}"],
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
account, _ = CreditAccount.objects.get_or_create(team=team)
|
||||
if account.balance <= 100:
|
||||
create_once(
|
||||
@@ -174,8 +258,23 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
queryset = queryset.filter(is_read=False)
|
||||
return queryset
|
||||
|
||||
def _refresh_notifications(self, request):
|
||||
"""生成/补齐通知:搬到 Celery 后台执行,不再阻塞请求。
|
||||
- 首次(团队还没有任何通知)同步跑一次,避免初次打开面板是空的;
|
||||
- 之后每团队最多每 60s 触发一次后台刷新,其余请求只读现有数据 → 接口秒回。
|
||||
"""
|
||||
team = self.get_team()
|
||||
if not Notification.objects.filter(team=team).exists():
|
||||
ensure_team_notifications(team, request.user)
|
||||
return
|
||||
if cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60):
|
||||
from .tasks import ensure_team_notifications_task
|
||||
|
||||
user_id = str(request.user.id) if getattr(request.user, "id", None) else None
|
||||
ensure_team_notifications_task.delay(str(team.id), user_id)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
ensure_team_notifications(self.get_team(), request.user)
|
||||
self._refresh_notifications(request)
|
||||
response = super().list(request, *args, **kwargs)
|
||||
data = response.data
|
||||
if isinstance(data, dict):
|
||||
|
||||
Reference in New Issue
Block a user