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:
zyc
2026-06-17 21:58:39 +08:00
co-authored by Claude Opus 4.8
parent 7c6d4477da
commit 0695c36f4c
12 changed files with 455 additions and 126 deletions
+21
View File
@@ -0,0 +1,21 @@
from django.contrib.auth import get_user_model
from airshelf.celery import app
from apps.accounts.models import Team
@app.task
def ensure_team_notifications_task(team_id: str, user_id: str | None = None) -> None:
"""后台生成/补齐团队站内通知。
原先在 NotificationViewSet.list() 里同步执行:每次刷新都要读计费账本 +
对项目/资产/扣费流水逐条 get_or_create,几十次 DB 往返压在请求线程上,拖慢通知接口。
现改由 Celery 异步执行,请求只负责读现有通知,生成搬到后台 worker。
"""
from apps.ops.views import ensure_team_notifications # 延迟导入避免循环依赖
team = Team.objects.filter(id=team_id).first()
if team is None:
return
user = get_user_model().objects.filter(id=user_id).first() if user_id else None
ensure_team_notifications(team, user)
+86
View File
@@ -0,0 +1,86 @@
from decimal import Decimal
from django.test import TestCase
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.assets.models import Asset
from apps.billing.models import CreditAccount, CreditLedger
from apps.ops.models import Notification
from apps.ops.views import ensure_team_notifications
from apps.products.models import Product
from apps.projects.models import Project
class BillingNotificationTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="owner", password="pass")
self.team = Team.objects.create(name="T", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=self.team, balance="500.0000")
self.provider = ModelProvider.objects.create(name="ops-test-prov", display_name="TSR")
self.model = ModelConfig.objects.create(
provider=self.provider, name="ops-test-image", capability=ModelConfig.Capability.IMAGE
)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="补水面膜")
self.project = Project.objects.create(
team=self.team, name="补水面膜·短视频", product=self.product, created_by=self.user
)
def _charge(self, task_type, amount, balance_after):
task = AITask.objects.create(
team=self.team,
project=self.project,
task_type=task_type,
model_config=self.model,
idempotency_key=f"k-{task_type}-{amount}",
actual_cost=Decimal(amount),
)
CreditLedger.objects.create(
team=self.team,
user=self.user,
project=self.project,
task=task,
ledger_type=CreditLedger.Type.CHARGE,
amount=Decimal(amount),
balance_after=Decimal(balance_after),
reason="AI 任务扣费",
)
return task
def test_charge_becomes_billing_notification_with_real_amount(self):
self._charge("product_image", "0.32", "499.68")
self._charge("video_segment", "18.40", "481.28")
ensure_team_notifications(self.team, self.user)
billing = Notification.objects.filter(team=self.team, notification_type=Notification.Type.BILLING)
# 每笔扣费一条计费消息,且费用不为空、为真实金额
costs = sorted(n.cost_label for n in billing)
self.assertIn("¥0.32", costs)
self.assertIn("¥18.40", costs)
self.assertFalse(billing.filter(cost_label__in=["", "-"]).exists())
self.assertTrue(billing.filter(title__contains="图生成").exists())
self.assertTrue(billing.filter(title__contains="视频生成").exists())
def test_project_notification_shows_aggregated_spend(self):
self._charge("product_image", "0.32", "499.68")
self._charge("video_segment", "18.40", "481.28")
ensure_team_notifications(self.team, self.user)
proj_note = Notification.objects.get(
team=self.team, notification_type=Notification.Type.TASK, title__contains="补水面膜"
)
self.assertEqual(proj_note.cost_label, "¥18.72") # 0.32 + 18.40
def test_existing_dash_cost_is_backfilled(self):
# 早期建的项目通知费用是占位「-」;补上扣费后再跑 ensure 应回填真实金额
ensure_team_notifications(self.team, self.user)
note = Notification.objects.get(team=self.team, title__contains="补水面膜")
self.assertEqual(note.cost_label, "-")
self._charge("product_image", "0.32", "499.68")
ensure_team_notifications(self.team, self.user)
note.refresh_from_db()
self.assertEqual(note.cost_label, "¥0.32")
+108 -9
View File
@@ -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):