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}"