Files
yingqing/core/backend/apps/ai/models.py
T
zycandClaude Opus 4.8 6e7425048c fix: PMC 测试清单未完成项 + YYX#row22 未读角标
PMC#16 账户登录地址不正确:
- 写死的 https://airshelf.com/login(不存在的域名)改成 ${location.origin}/login,
  与邀请链接同源,成员拿到的就是当前真实访问地址。

PMC#17/#18 成员三档额度只有月度生效、日/总纯装饰 → 列表显示与配置对不上:
- TeamMember 新增 daily_credit_limit / total_credit_limit(0=不限,迁移 0006);
- reserve_credit 逐档真实管控(每日/每月/累计总额),口径=窗口内 CHARGE 流水 + 在途预留;
- 成员列表"每日额度"由写死"不限"改成真实日额度+今日已用,并新增"总额度"列;
- 创建/编辑弹窗三档额度真入库真生效;补 3 个计费单测(日/总拦截、0=不限)。

PMC#24 同批生成的图片文字/内容雷同:
- 根因=套图/自由创作同批 N 张用同一 prompt 且无变化(只有模特上身图按 index 变);
- 给平台套图/自由参考/纯文生图都加按 index 的版式变体,避开传 seed(防中转站 400)。

PMC#25 重跑应在原任务上重跑而非新开任务:
- 全批"重跑"改为原地复位同一批次卡;"重跑这张/再生成"改为补图进原卡(不覆盖好图、不新开任务)。

YYX#row22 图片生成未读角标(本仓库既有改动一并提交):
- AITask 新增 read_at(迁移 0020 + 索引一致性迁移 0021);导航栏"图片生成"未读胶囊、
  商品预览右下角未读分数;查看某商品即标记其生成任务已读、清零角标。

注:PMC#14/#15/#20/#22/#23 早前提交已修,需部署重测;PMC#13/#21 为 provider 侧
生成失败,代码侧重试/退费/僵尸回收/错误外露已就位,需部署后看真实 error_message 定位;
PMC#19 收件箱标记与设计稿逐字一致,待截图复现。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 18:27:15 +08:00

211 lines
9.1 KiB
Python

from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class ModelProvider(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
name = models.CharField(max_length=64, unique=True)
display_name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
base_url = models.URLField(blank=True)
# 站级 API Key(中转站)。可插拔:换站 = 改这一行的 base_url + api_key,零改代码。
# 留空则 services 层按 provider.name 回退 settings.PROVIDER_KEYS(.env),避免密钥写死/进库。
api_key = models.CharField(max_length=255, blank=True)
metadata = models.JSONField(default=dict, blank=True)
def __str__(self) -> str:
return self.display_name
class ModelConfig(TimeStampedModel):
class Capability(models.TextChoices):
TEXT = "text", "Text"
IMAGE = "image", "Image"
VIDEO = "video", "Video"
VISION = "vision", "Vision"
AUDIO = "audio", "Audio"
EXPORT = "export", "Export"
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
provider = models.ForeignKey(ModelProvider, on_delete=models.CASCADE, related_name="models")
name = models.CharField(max_length=128)
display_name = models.CharField(max_length=128)
capability = models.CharField(max_length=32, choices=Capability.choices)
endpoint = models.CharField(max_length=255, blank=True)
unit_price = models.DecimalField(max_digits=12, decimal_places=4, default=0)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
rate_limit_per_minute = models.PositiveIntegerField(default=60)
# 平台超管显式钦定的「该能力默认模型」。get_default_model 优先取它,未设则回落最早 active(零回归)。
is_default = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
unique_together = [("provider", "name", "capability")]
def __str__(self) -> str:
return f"{self.provider.name}:{self.name}:{self.capability}"
class ImageConversation(TeamOwnedModel):
"""图片创作工作室的「对话」实体:一条对话 = 一个生图会话线程,把多次生成(AITask)串起来。
左栏会话列表、切换、重命名、历史都基于本表。删除走软删(is_deleted),不连带删图——
成图仍在资产库里。mode 区分图片创作 / 模特上身图 / 平台套图三种工作台。"""
class Mode(models.TextChoices):
IMAGE = "image", "图片创作"
MODEL = "model", "模特上身图"
COVER = "cover", "平台套图"
title = models.CharField(max_length=120, default="默认创作")
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.IMAGE)
product = models.ForeignKey(
"products.Product",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="image_conversations",
)
is_deleted = models.BooleanField(default=False)
# 每次在该对话里发起生成都刷新,左栏「最近」按它倒序
last_active_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=["team", "mode", "-last_active_at"]),
]
def __str__(self) -> str:
return f"conv:{self.mode}:{self.title}"
class AITask(TeamOwnedModel):
class Type(models.TextChoices):
SCRIPT_GENERATION = "script_generation", "Script Generation"
SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization"
ENTITY_EXTRACTION = "entity_extraction", "Entity Extraction"
PRODUCT_IMAGE = "product_image", "Product Image"
PERSON_IMAGE = "person_image", "Person Image"
SCENE_IMAGE = "scene_image", "Scene Image"
STORYBOARD = "storyboard", "Storyboard"
VIDEO_SEGMENT = "video_segment", "Video Segment"
VOICEOVER = "voiceover", "Voiceover"
EXPORT = "export", "Export"
class Status(models.TextChoices):
CREATED = "created", "Created"
RESERVED = "reserved", "Reserved"
SUBMITTED = "submitted", "Submitted"
POLLING = "polling", "Polling"
POSTPROCESSING = "postprocessing", "Postprocessing"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
COMPENSATING = "compensating", "Compensating"
CANCELLED = "cancelled", "Cancelled"
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="ai_tasks",
)
# 图片创作工作室的对话归属(独立生图任务才挂;流水线内部任务为空)。删对话不删任务。
conversation = models.ForeignKey(
ImageConversation,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="tasks",
)
task_type = models.CharField(max_length=48, choices=Type.choices)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.CREATED)
model_config = models.ForeignKey(ModelConfig, on_delete=models.PROTECT, related_name="tasks")
idempotency_key = models.CharField(max_length=128, unique=True)
provider_task_id = models.CharField(max_length=255, blank=True)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
estimated_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
actual_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
error_code = models.CharField(max_length=64, blank=True)
error_message = models.TextField(blank=True)
submitted_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
# YYX#row22:用户「已读」时间。null = 未读 → 用于导航栏「图片生成」未读数字胶囊
# 与每个商品预览右下角的未读分数。团队级共享(一人看过即全团队已读)。
read_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["project", "task_type"]),
models.Index(fields=["provider_task_id"]),
# 任务历史默认按团队 + 创建时间倒序(AI 工具页 / asset-factory)
models.Index(fields=["team", "-created_at"]),
# 按对话拉历史(图片创作工作室切换会话时回填批次)
models.Index(fields=["conversation", "created_at"]),
# YYX#row22:按团队 + 已读状态聚合未读数(导航/商品角标)
models.Index(fields=["team", "read_at"]),
]
def __str__(self) -> str:
return f"{self.task_type}:{self.status}:{self.id}"
class QualityWord(TimeStampedModel):
"""平台单层质量词配置:按生成阶段(stage)+ 槽位(slot)挂若干质量词,
生成侧拼提示词时优先读取本配置;**无配置则回落各 builder 的写死值**(零回归)。
全平台共用一套(不挂 team),团队级覆盖以后再说。"""
class Stage(models.TextChoices):
PERSON = "person", "人物立绘"
MODEL_TRYON = "model_tryon", "模特上身图"
STORYBOARD = "storyboard", "故事板"
VIDEO = "video", "视频"
stage = models.CharField(max_length=24, choices=Stage.choices)
slot = models.CharField(max_length=24, default="quality")
text = models.CharField(max_length=200)
sort = models.IntegerField(default=0)
enabled = models.BooleanField(default=True)
class Meta:
ordering = ["stage", "sort", "created_at"]
indexes = [models.Index(fields=["stage", "slot"])]
def __str__(self) -> str:
return f"{self.stage}/{self.slot}/{self.text}"
class PromptTemplate(TimeStampedModel):
"""平台可编辑的「生图/视频提示词模板」(视频项目生产线 6 条)。后台超管在 admin 改正文,
生成侧据 key 读取本表;留空/停用 → 回落各 builder 的写死默认(零回归)。
正文里 {占位符}(如 {商品}/{描述}/{脚本})运行时替换;未知占位符原样保留,改错不致命。
ratio:成品比例(空=按写死默认),gpt-image 仅 1:1 / portrait(竖) / landscape(横)三种。"""
class Key(models.TextChoices):
PERSON_PORTRAIT = "person_portrait", "人物立绘"
PERSON_TRIVIEW = "person_triview", "人物三视图"
PRODUCT_TRIVIEW = "product_triview", "商品三视图"
SCENE = "scene", "场景图"
STORYBOARD_FRAME = "storyboard_frame", "分镜图"
VIDEO_SEGMENT = "video_segment", "视频"
key = models.CharField(max_length=32, choices=Key.choices, unique=True)
template = models.TextField(blank=True, default="")
ratio = models.CharField(max_length=12, blank=True, default="") # "" / "1:1" / "portrait" / "landscape"
enabled = models.BooleanField(default=True)
class Meta:
ordering = ["key"]
def __str__(self) -> str:
return f"prompt:{self.key}"