diff --git a/Airshelf_二期功能清单_1.md b/Airshelf_二期功能清单_1.md index 1884540..f2a0094 100644 --- a/Airshelf_二期功能清单_1.md +++ b/Airshelf_二期功能清单_1.md @@ -29,7 +29,7 @@ | 1.14 | 故事板进页面自动生成 | 不再手动点「开始生成」;**防刷新/返回/切页重复创建** | 上 | 明确 | **暂缓,需先补后端防重复**。这条听起来是「删掉一个手动按钮」,但那个按钮正是目前唯一的防重复措施——后端收到一次请求就新建一次任务、预扣一次积分,本身不检查「这段是不是已经在跑了」。改成进页面自动触发后,用户每刷新一次页面就重新触发一次。前置工作 = 给后端补「已经在跑就不重复建」的判断,对用户不可见,但省掉它这功能不能上线 | | 1.15 | 视频进页面自动生成 | 同 1.14;防重复创建视频任务 | 上 | 明确 | **暂缓,风险高于 1.14**。视频是全流程最贵的一步,而提交侧后端**完全没有**防重复:点两次就是两个任务、扣两份积分。现在只有前端按钮禁用挡着,绕开它(刷新、开两个标签页、点太快)就会重扣。**不补后端就上线 = 用户刷新一次重扣一批**。补法与 1.14 同源,视频优先做 | | 1.16 | 全流程积分消耗展示 | 生成前预估 + 结束后明细。⚠️依赖各环节**计费标准先定清** | 上 | 待确认(PM:计费标准) | **部分完成,余下待 PM**。余额、每步单价、单任务预估、实际扣除、消费流水都已经有了;缺的只是流程级汇总(进条前总预估 + 出片后一次性明细)。⚠️这个汇总**不能让前端把各步单价加起来自己算**:视频按实际时长和用量动态计价,还有重跑次数、团队折扣两步取整,前端算的数跟最后真扣的对不上,用户会觉得平台乱扣钱。得先定清计费口径,再由后端出数 | -| 1.17 | 保存模板 | 见模块 5(同一功能) | 下 | — | 第7段(见模块 5) | +| 1.17 | 保存模板 | 见模块 5(同一功能) | 下 | — | **已完成**(第7段,见模块 5) | --- @@ -55,11 +55,11 @@ | 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 | | --- | ------------- | ----------------------------------- | --- | --------------- | ----------------------------------------------------------------------------- | -| 3.1 | 商品库新增「类目」字段 | 电商/本地生活;驱动后续挂 skill 与节点屏蔽。⚠️涉及商品库结构 | 下 | 待确认(PM/开发:范围排期) | 第8段。⚠️**不能复用现有「品类」字段**:商品库已有的品类是美妆/食品那种细分,「电商 / 本地生活」是另一个维度,必须新开字段,混用会打乱商品库筛选 | -| 3.2 | 团购 = 虚拟商品建模 | 无实物商品,沿用主流程 SOP | 下 | 明确 | 第8段 | -| 3.3 | 探店脚本结构 + 镜头语言 | 达人→门店→点餐→试吃→环境→到店核销;探店镜头语言 | 下 | 待确认(内容原料待补) | 第8段。与 2.1/2.2 同类:是**内容活不是代码活**,得先有人写 | -| 3.4 | 本地生活节点屏蔽 | 三视图可跳过、模特上身图入口隐藏(按类目标签自动) | 下 | 明确 | 第8段(依赖 3.1 的新字段) | -| 3.5 | 是否覆盖图片线(平台套图) | 覆盖则平台清单换(美团/点评/抖音本地生活…) | 下 | 待确认(需求方:范围) | 第8段 | +| 3.1 | 商品库新增「类目」字段 | 电商/本地生活;驱动后续挂 skill 与节点屏蔽。⚠️涉及商品库结构 | 下 | **已完成(第8段)** | **已完成**。新开 `Product.business_type`(`ecommerce` / `local_life`),**没有复用现有「品类」字段**。存量商品默认电商。新建抽屉、详情编辑、商品库筛选都单独有「类目」这一维;选本地生活后品类下拉换成餐饮/丽人/酒店那一套 | +| 3.2 | 团购 = 虚拟商品建模 | 无实物商品,沿用主流程 SOP | 下 | **已完成(第8段)** | **已完成**。本地生活允许不传主图也能建(团购没有实物可拍),卖点仍必填。建完照常走「生成视频」进主流程:脚本 → 基础资产 → 故事板 → 出片。脚本提示词会带上「业务类型:本地生活(团购/到店核销,无实物)」,模型按虚拟商品写,不按货架 SKU 写 | +| 3.3 | 探店脚本结构 + 镜头语言 | 达人→门店→点餐→试吃→环境→到店核销;探店镜头语言 | 下 | 跳过(等原料) | **跳过**。跟 2.1/2.2 同类:**内容活不是代码活**。没有探店案例和镜头语言稿,硬写一套会把脚本质量钉死在瞎编上。3.2 已经能用现有 SOP 出片;等客户补原料再单独立一段挂 skill | +| 3.4 | 本地生活节点屏蔽 | 三视图可跳过、模特上身图入口隐藏(按类目标签自动) | 下 | **已完成(第8段)** | **已完成**。按 `business_type=local_life` 自动:商品库卡不标「缺三视图」;详情页藏「AI 生成三视图」和「模特上身图」;流水线基础资产页三视图按钮换成「无需商品三视图,可直接进入下一步」;模特上身图工作台侧栏也不列出本地生活商品。**不挡进入下一步** | +| 3.5 | 是否覆盖图片线(平台套图) | 覆盖则平台清单换(美团/点评/抖音本地生活…) | 下 | 跳过(等 PM) | **跳过**。平台套图入口还在(本地生活也能用现有电商平台清单出图)。要换成美团/点评/抖音本地生活封面规范,得先有各平台尺寸清单和是否覆盖图片线的明确答复,否则会做错一版再推翻 | --- @@ -86,10 +86,10 @@ | 编号 | 功能 | 说明 | 分期 | 状态 | 开发进度 | | --- | -------------- | --------------------------------------------- | --- | -------------- | ------------------------------------------------------------------ | -| 5.1 | 保存模板 | 存带货套路/脚本结构/人物场景风格/镜头节奏/转化方式 | 下 | 明确 | 第7段。**必须等第2、3段定稿**:模板存的就是「表现形式 × 视频结构 × 时长」那套参数,参数还没定型就先做模板,改一次废一次 | -| 5.2 | 换商品重跑 pipeline | 换商品后重做商品理解、卖点适配、资产生成、故事板生成(=满足「视频商品替换」需求,路径②) | 下 | 明确 | 第7段 | -| 5.3 | 视频替换预期管理 | 产出为「同套路重出新视频」,非「仅换商品的原片」,需告知用户 | 下 | 待确认(客户:替换确切含义) | 第7段。是文案/沟通活,代码量很小,但**要在客户签字前说清楚**,否则验收时对不上 | -| 5.4 | 模板私有 vs 平台公共库 | 公共库价值大但复杂 | 下 | 待确认(PM) | 第7段。建议先只做私有模板,公共库另立一期 | +| 5.1 | 保存模板 | 存带货套路/脚本结构/人物场景风格/镜头节奏/转化方式 | 下 | **已完成(第7段)** | 脚本页底栏加「存为模板」。存的是**套路不是文案**:带货套路(表现形式 × 视频结构)、脚本结构(每镜作用序列 钩子→痛点→卖点→CTA)、镜头节奏(镜数 + 每镜秒数)、人物设定、商品露出方式、结尾转化写法。**旧商品的口播文案一律不进模板**,弹窗里把「会存进去的内容」逐条摊开给用户看,并写明文案不存 | +| 5.2 | 换商品重跑 pipeline | 换商品后重做商品理解、卖点适配、资产生成、故事板生成(=满足「视频商品替换」需求,路径②) | 下 | **已完成(第7段)** | 新建项目第 2 步多一个「套路模板(可选)」下拉。选中后**后端按库里的模板回填**表现形式/结构/时长/人物到 `metadata.wizard`(前端只传 template_id,不留两份真相),脚本页设定卡自动带好,生成时把镜序节奏并进提示词。因为是**开新项目**,商品理解、卖点适配、基础资产、故事板全都按新商品重跑一遍,没有走「复制旧项目」的捷径 | +| 5.3 | 视频替换预期管理 | 产出为「同套路重出新视频」,非「仅换商品的原片」,需告知用户 | 下 | **已完成(第7段)** | 两处说明,都在用户点下去之前:① 新建项目选中模板时展开模板镜序 + 一句「文案、画面、模特与故事板都会按新商品重新生成 —— 不是把上一条片子换个商品名」;② 脚本页设定卡顶部再提示一次。**客户口径仍需签字前当面对齐**(见文末第 2 条) | +| 5.4 | 模板私有 vs 平台公共库 | 公共库价值大但复杂 | 下 | 跳过(等 PM) | **本期只做团队私有模板**,按建议执行。跳过理由:公共库要额外解决审核(谁能上架)、跨团队权限(模板里带的人物/场景设定是不是商业机密)、下架与版本治理三件事,都是产品先定规则、代码才好写的活。私有模板的数据结构已经预留 `team` 维度,将来加公共库=加一个「可见范围」字段,不用推翻重做 | --- @@ -113,8 +113,8 @@ ## 待确认汇总(按对象,便于对齐) -**需求方 / 客户**:1.6/1.7 选项是否可增删 · ~~1.11 是否要迁移到新商品~~(**已按「要迁移」实现**:拆完的分镜稿会自动按当前项目的商品重写,参考视频里的商品/品牌/人物一律不保留;若客户其实想要「原样还原参考片」,回来改一句提示词即可)· 1.11 是否要接语音转写拿原片口播(**当前没接**,理由见第5段) · 3.5 是否覆盖图片线 · 5.3 替换确切含义 · 3.3 本地生活案例原料 -**PM**:2.1/2.2 内容谁沉淀(★最高)· 1.16 计费标准 · 3.1 商品库改动排期 · 5.4 模板公共化 · 全局分期 +**需求方 / 客户**:1.6/1.7 选项是否可增删 · ~~1.11 是否要迁移到新商品~~(**已按「要迁移」实现**:拆完的分镜稿会自动按当前项目的商品重写,参考视频里的商品/品牌/人物一律不保留;若客户其实想要「原样还原参考片」,回来改一句提示词即可)· 1.11 是否要接语音转写拿原片口播(**当前没接**,理由见第5段) · ~~3.5 是否覆盖图片线~~(**本期不覆盖**:本地生活仍用现有电商平台套图;要换美团/点评/抖音本地生活封面规范,需先给平台清单) · 5.3 替换确切含义 · 3.3 本地生活案例原料 +**PM**:2.1/2.2 内容谁沉淀(★最高)· 1.16 计费标准 · ~~3.1 商品库改动排期~~(**已做**:新开 `business_type` 字段,不碰现有品类筛选)· ~~5.4 模板公共化~~(**本期按建议只做团队私有模板**;公共库要先定审核、跨团队权限、下架治理三条规则,规则定了再另立一期,数据结构已预留)· 全局分期 **开发**:1.9 时长现成方案可移植/单镜约束 ~~(已验证可移植,见 1.9 进度栏)~~ · 1.13 三视图重新生成入口 ~~(已确认不受影响)~~ · 4.x 自由创作从0做引用工作量 --- @@ -132,20 +132,20 @@ | ~~**第4段**~~ | ~~自动生成 + 积分汇总:1.14 · 1.15 · 1.16~~ | **暂缓**。1.14/1.15 的前置(后端防重复)没做完之前不开工,否则用户刷新一次重扣一批;1.16 等 PM 定计费口径 | | **第5段** | 上传视频提炼:1.11 · 补 1.4 的第三个入口 | **已完成**。原判「唯一一条新基建」偏保守:读图能力和 ffmpeg 都是现成的,实际只新写了抽帧。**两件要跟需求方说清的**:① **没接语音转写** —— 拆解只看画面,口播词只能从画面上的字幕读;带货参考片绝大多数带硬字幕,且口播本来就要按新商品重写,所以先不接。真要原片口播,需另开火山语音识别服务(新凭证 + 新计价,约 1 段工作量)。② **拆解稿是给人改的,不是直接出片** —— 稿子落在输入框里,用户逐镜校对完再点确定;AI 自己会在稿尾列「拆解存疑」提示该重点核对哪几条 | | **第6段** | 自由创作接三库:模块 4 | 独立支线,不碰主流程 → 提前做掉。**已完成**(4.4 数据迁移经判断不需要做,理由见该行) | -| **第7段** | 保存模板 + 换商品重跑:模块 5(含 1.17) | 等第2、3段参数定稿 | -| **第8段** | 本地生活:模块 3 | 依赖主流程先稳,放最后 | +| **第7段** | 保存模板 + 换商品重跑:模块 5(含 1.17) | **已完成**。5.1/5.2/5.3 做掉,5.4 公共库按建议跳过(只做团队私有)。做法比预想轻:模板存的是「怎么讲」的一页大纲(镜数 / 每镜作用 / 秒数 / 商品露出 / 转化写法),换商品=**照常新建项目**,只是把这页大纲喂给脚本助手,后面商品理解、卖点、资产、故事板全部按新商品正常跑。**一条要跟需求方说清的**:模板里**不存旧商品的口播文案**(存了就会把上一个品牌漏进新片),所以「同套路重出」拿到的是结构一致、文案全新的一条片子 | +| **第8段** | 本地生活:模块 3 | **已完成能做的部分**。3.1 新开类目字段、3.2 团购无实物可建、3.4 按类目自动屏蔽三视图/上身图。**3.3 探店镜头语言跳过**(没案例原料)、**3.5 平台套图换清单跳过**(等需求方是否覆盖图片线) | -主链:**第1 → 2 → 6 → 5 → 7**(第1、2、5、6 段已完成;第3段已并入第2段;第4段暂缓,等后端防重复补完 + PM 定计费口径再回来);第8段放最后。 +主链:**第1 → 2 → 6 → 5 → 7 → 8 已全部完成能做的部分**(第3段并入第2段;第4段暂缓,等后端防重复补完 + PM 定计费口径再回来)。剩下的都是卡原料/口径的:2.1/2.2 内容过一遍、3.3 探店案例、3.5 是否覆盖图片线、1.14/1.15/1.16 自动生成。 -**至此模块1(脚本)除 1.14/1.15/1.16 三条暂缓项外全部做完**,三个脚本入口都通了。下一站是第7段(保存模板 + 换商品重跑,模块5)。 +**模块 1(脚本)除 1.14/1.15/1.16 三条暂缓项外全部做完**;**模块 3(本地生活)工程侧做完**(3.3/3.5 等原料);**模块 4(自由创作)、模块 5(模板 / 换商品重跑)已完成**。 ### ⚠️ 需要跟 PM / 客户提前说清楚的四条(听起来简单、实际有雷) 0. **1.11「画面描述七要素」的具体七项,需求文档里没给**。开发按「下游重拍时最少需要哪些信息」定了这七项,**请需求方比对确认**:① 主体(谁/什么在画面里)② 动作 ③ 场景/环境 ④ 景别(特写/中景/全景)⑤ 运镜(固定/推拉摇移/跟拍)⑥ 光线氛围 ⑦ 商品露出方式(手持/特写/使用中/未出现)。这七项是照着平台脚本已有的字段挑的,**改一项要连着改脚本结构**,所以趁早对齐;纯加一项文字描述则很便宜。 1. **1.14 / 1.15 自动生成** —— 见上,删按钮 = 拆掉唯一的防重复措施,会重复扣积分。**已暂缓**。 -2. **5.3 视频替换** —— 客户很可能理解成「原片不动,只把里面的商品换掉」,平台实际能做的是「沿用同一套路,重新生成一条新视频」。这两件事在客户脑子里是一回事,在验收现场是两回事。**代码量很小,说不清楚的代价是整批返工**,务必在签字前对齐。 -3. **3.1 商品库「类目」字段** —— 要加的「电商 / 本地生活」跟商品库已有的「品类」(美妆/食品那种细分)是两个维度,**必须新开字段**。图省事塞进同一个字段,现有商品库筛选会全乱。 +2. **5.3 视频替换** —— 客户很可能理解成「原片不动,只把里面的商品换掉」,平台实际能做的是「沿用同一套路,重新生成一条新视频」。这两件事在客户脑子里是一回事,在验收现场是两回事。**代码量很小,说不清楚的代价是整批返工**,务必在签字前对齐。**产品侧已按「同套路重出」实现并在界面上写明**(选模板时、生成前各提示一次),但**口径本身仍要当面跟客户确认**:如果客户坚持要「原片不动只换商品」,那是另一条技术路线(逐帧抠像 + 商品贴合),不在本期范围内,需单独评估。 +3. **3.1 商品库「类目」字段** —— **已按建议新开字段**(`business_type` = 电商 / 本地生活),没有塞进现有「品类」。筛选、新建、编辑、节点屏蔽都认这一维。存量商品默认电商,老数据不用迁。 --- diff --git a/core/backend/_seed_seg7.py b/core/backend/_seed_seg7.py new file mode 100644 index 0000000..e7e9f95 --- /dev/null +++ b/core/backend/_seed_seg7.py @@ -0,0 +1,72 @@ +"""第7段抓图用的本地 sqlite 种子:一个团队 + 两个商品 + 一个带脚本的项目。 +只在 DB_ENGINE=sqlite 的本地库跑,不碰共享开发库。 +""" + +import django +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development") +os.environ.setdefault("DB_ENGINE", "sqlite") +django.setup() + +from apps.accounts.models import Team, TeamMember, User # noqa: E402 +from apps.billing.models import CreditAccount # noqa: E402 +from apps.products.models import Product # noqa: E402 +from apps.projects.models import Project, ProjectStage, ScriptSegment, ScriptVersion # noqa: E402 +from apps.projects.services.pipeline import STAGE_ORDER # noqa: E402 + +user, created = User.objects.get_or_create(username="seg7", defaults={"is_active": True}) +user.set_password("Restraint2026") +user.save() +team, _ = Team.objects.get_or_create(name="Seg7 Team", defaults={"owner": user}) +TeamMember.objects.get_or_create(team=team, user=user, defaults={"role": TeamMember.Role.OWNER, "status": "active"}) +CreditAccount.objects.get_or_create(team=team, defaults={"balance": "9999.0000"}) + +old, _ = Product.objects.get_or_create( + team=team, title="夜光面膜", defaults={"created_by": user, "category": "美妆护肤", "target_audience": "熬夜上班族"} +) +new, _ = Product.objects.get_or_create( + team=team, title="鎏金精华水", defaults={"created_by": user, "category": "美妆护肤", "target_audience": "都市白领"} +) + +project, _ = Project.objects.get_or_create( + team=team, + name="夜光面膜 · 短视频 · v1", + defaults={ + "created_by": user, + "product": old, + "metadata": {"wizard": {"persona": "urban", "presentation_format": "oral", "video_structure": "pain", "total_duration": 30}}, + }, +) +for stage in STAGE_ORDER: + ProjectStage.objects.get_or_create(project=project, stage=stage) + +script, made = ScriptVersion.objects.get_or_create( + project=project, + title="熬夜脸急救", + defaults={ + "content": "{}", + "source": "ai", + "is_adopted": True, + "metadata": {"presentation_format": "oral", "video_structure": "pain", "total_duration": 30, "hook": "熬夜脸还有救吗"}, + }, +) +if made: + for order, duration, role, exposure, narration in [ + (0, 5, "钩子", "未出现", "连着熬三天,这张脸还有救吗?"), + (1, 8, "痛点", "背景陈列", "早上照镜子,毛孔粗、脸发黄,粉底都盖不住。"), + (2, 12, "卖点", "手持展示", "这盒夜光面膜敷 10 分钟,精华浓度是普通款的三倍。"), + (3, 5, "CTA", "特写", "点下方小黄车,今晚下单买二送一。"), + ]: + ScriptSegment.objects.create( + script_version=script, + sort_order=order, + duration_seconds=duration, + role=role, + product_exposure=exposure, + narration=narration, + visual_prompt=f"{role}镜:{narration}", + ) + +print("project", project.id) +print("old_product", old.id, "new_product", new.id) diff --git a/core/backend/airshelf/urls.py b/core/backend/airshelf/urls.py index fd948a9..2672a82 100644 --- a/core/backend/airshelf/urls.py +++ b/core/backend/airshelf/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path("api/assets/", include("apps.assets.urls")), path("api/models/", include("apps.assets.model_urls")), path("api/projects/", include("apps.projects.urls")), + path("api/script-templates/", include("apps.projects.template_urls")), path("api/billing/", include("apps.billing.urls")), path("api/ai/", include("apps.ai.urls")), path("api/ops/", include("apps.ops.urls")), diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index ec2a599..6b65b54 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -195,10 +195,16 @@ def _product_context(project, selling_point_ids: list[str] | None) -> str: if selling_point_ids: selling_points = selling_points.filter(id__in=selling_point_ids) selling_text = "\n".join(f"- {sp.title}:{sp.detail}" for sp in selling_points) + business_type = getattr(product, "business_type", "") or "ecommerce" + if business_type == "local_life": + type_line = "业务类型:本地生活(团购/到店核销,无实物,按虚拟商品走主流程 SOP)\n" + else: + type_line = "业务类型:电商(实物商品)\n" return ( f"商品标题:{product.title}\n" f"品牌:{product.brand or '未填写'}\n" - f"类目:{product.category or '未填写'}\n" + f"{type_line}" + f"品类:{product.category or '未填写'}\n" f"目标人群:{product.target_audience or '未填写'}\n" f"商品描述:{product.description or '未填写'}\n" f"卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼。'}" diff --git a/core/backend/apps/products/migrations/0004_product_business_type.py b/core/backend/apps/products/migrations/0004_product_business_type.py new file mode 100644 index 0000000..95dabc4 --- /dev/null +++ b/core/backend/apps/products/migrations/0004_product_business_type.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0003_product_purged_at"), + ] + + operations = [ + migrations.AddField( + model_name="product", + name="business_type", + field=models.CharField( + choices=[("ecommerce", "电商"), ("local_life", "本地生活")], + default="ecommerce", + max_length=24, + ), + ), + migrations.AddIndex( + model_name="product", + index=models.Index(fields=["team", "business_type"], name="products_pr_team_biz_idx"), + ), + ] diff --git a/core/backend/apps/products/models.py b/core/backend/apps/products/models.py index 14f1818..54a9cf7 100644 --- a/core/backend/apps/products/models.py +++ b/core/backend/apps/products/models.py @@ -8,8 +8,16 @@ class Product(TeamOwnedModel): ACTIVE = "active", "Active" ARCHIVED = "archived", "Archived" + class BusinessType(models.TextChoices): + ECOMMERCE = "ecommerce", "电商" + LOCAL_LIFE = "local_life", "本地生活" + title = models.CharField(max_length=255) brand = models.CharField(max_length=128, blank=True) + # 类目(电商/本地生活)≠品类(美妆/食品)。类目驱动节点屏蔽与后续 skill 挂载。 + business_type = models.CharField( + max_length=24, choices=BusinessType.choices, default=BusinessType.ECOMMERCE + ) category = models.CharField(max_length=128, blank=True) target_audience = models.CharField(max_length=255, blank=True) specs = models.JSONField(default=dict, blank=True) @@ -29,6 +37,7 @@ class Product(TeamOwnedModel): models.Index(fields=["team", "status"]), models.Index(fields=["team", "status", "purged_at"]), models.Index(fields=["team", "category"]), + models.Index(fields=["team", "business_type"]), # 商品库默认按团队 + 创建时间倒序翻页 models.Index(fields=["team", "-created_at"]), ] diff --git a/core/backend/apps/products/serializers.py b/core/backend/apps/products/serializers.py index f0d237f..3599ed8 100644 --- a/core/backend/apps/products/serializers.py +++ b/core/backend/apps/products/serializers.py @@ -43,6 +43,7 @@ class ProductSerializer(serializers.ModelSerializer): "id", "title", "brand", + "business_type", "category", "target_audience", "specs", @@ -62,10 +63,13 @@ class ProductSerializer(serializers.ModelSerializer): def validate(self, attrs): # 创建商品必须至少有一张图:无图商品会让下游"商品参考图"彻底落空,故事板/视频退化纯文生图。 + # 例外:本地生活团购是虚拟商品(无实物),允许不传主图,沿用主流程 SOP。 # 前端抽屉走 upload-first(拖入即传 /assets/upload/ 拿 asset → 创建时带 cover_asset/images 进来), - # 所以这道校验是可满足的、且正确。只在创建时强制,编辑已有商品不阻断。 + # 所以这道校验对电商是可满足的、且正确。只在创建时强制,编辑已有商品不阻断。 if self.instance is None and not attrs.get("cover_asset") and not attrs.get("images"): - raise serializers.ValidationError({"images": "创建商品至少需要上传一张商品图片"}) + business_type = attrs.get("business_type") or Product.BusinessType.ECOMMERCE + if business_type != Product.BusinessType.LOCAL_LIFE: + raise serializers.ValidationError({"images": "创建商品至少需要上传一张商品图片"}) return attrs def create(self, validated_data): diff --git a/core/backend/apps/products/tests.py b/core/backend/apps/products/tests.py index 7e40aa9..5b52c90 100644 --- a/core/backend/apps/products/tests.py +++ b/core/backend/apps/products/tests.py @@ -187,3 +187,58 @@ class ProductImageDeleteGuardTests(TestCase): self.assertEqual(res.status_code, 200) self.product.refresh_from_db() self.assertEqual(self.product.cover_asset_id, self.img0.asset_id) # 封面不受影响 + + +class ProductBusinessTypeTests(TestCase): + """3.1 类目字段 + 3.2 本地生活团购允许无实物主图。""" + + def setUp(self): + self.user = User.objects.create_user(username="bt", password="x") + self.team = Team.objects.create(name="BT", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_default_business_type_is_ecommerce(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="面膜") + self.assertEqual(product.business_type, Product.BusinessType.ECOMMERCE) + data = self.client.get(f"/api/products/{product.id}/").json() + self.assertEqual(data["business_type"], "ecommerce") + + def test_ecommerce_create_requires_image(self): + res = self.client.post( + "/api/products/", + {"title": "面膜", "business_type": "ecommerce", "category": "美妆个护"}, + format="json", + ) + self.assertEqual(res.status_code, 400) + self.assertIn("images", res.json()) + + def test_local_life_create_without_image(self): + res = self.client.post( + "/api/products/", + { + "title": "双人下午茶套餐", + "business_type": "local_life", + "category": "餐饮美食", + "selling_points": [{"title": "周末通用", "detail": "周末通用", "sort_order": 0}], + }, + format="json", + ) + self.assertEqual(res.status_code, 201, res.content) + data = res.json() + self.assertEqual(data["business_type"], "local_life") + self.assertEqual(data["category"], "餐饮美食") + self.assertEqual(data["images"], []) + self.assertTrue(Product.objects.filter(id=data["id"], business_type="local_life").exists()) + + def test_list_filter_by_business_type(self): + Product.objects.create(team=self.team, created_by=self.user, title="面膜", business_type="ecommerce") + Product.objects.create(team=self.team, created_by=self.user, title="火锅套餐", business_type="local_life") + ecommerce = [p["title"] for p in self.client.get("/api/products/?business_type=ecommerce").json()["results"]] + local = [p["title"] for p in self.client.get("/api/products/?business_type=local_life").json()["results"]] + self.assertIn("面膜", ecommerce) + self.assertNotIn("火锅套餐", ecommerce) + self.assertIn("火锅套餐", local) + self.assertNotIn("面膜", local) + diff --git a/core/backend/apps/products/views.py b/core/backend/apps/products/views.py index 7cb56ed..098f927 100644 --- a/core/backend/apps/products/views.py +++ b/core/backend/apps/products/views.py @@ -33,8 +33,13 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet): # 回收站相关动作(trash/restore/purge)才看 archived。 qs = super().get_queryset() if self.action in ("trash", "restore", "purge"): - return qs.filter(status=Product.Status.ARCHIVED, purged_at__isnull=True) - return qs.filter(status=Product.Status.ACTIVE, purged_at__isnull=True) + qs = qs.filter(status=Product.Status.ARCHIVED, purged_at__isnull=True) + else: + qs = qs.filter(status=Product.Status.ACTIVE, purged_at__isnull=True) + business_type = self.request.query_params.get("business_type") + if business_type in dict(Product.BusinessType.choices): + qs = qs.filter(business_type=business_type) + return qs def perform_destroy(self, instance): # 删除 = 软删进垃圾桶(可恢复),不真删数据 diff --git a/core/backend/apps/projects/migrations/0008_scripttemplate.py b/core/backend/apps/projects/migrations/0008_scripttemplate.py new file mode 100644 index 0000000..2a1e297 --- /dev/null +++ b/core/backend/apps/projects/migrations/0008_scripttemplate.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.15 on 2026-08-18 03:07 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0009_team_price_multiplier'), + ('projects', '0007_project_soft_delete'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ScriptTemplate', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=128)), + ('presentation_format', models.CharField(blank=True, max_length=32)), + ('video_structure', models.CharField(blank=True, max_length=32)), + ('persona', models.CharField(blank=True, max_length=32)), + ('total_duration', models.PositiveIntegerField(default=0)), + ('outline', models.JSONField(blank=True, default=list)), + ('cta', models.TextField(blank=True)), + ('scenes', models.JSONField(blank=True, default=list)), + ('usage_count', models.PositiveIntegerField(default=0)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('source_project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='script_templates', to='projects.project')), + ('source_script', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='templates', to='projects.scriptversion')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='accounts.team')), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['team', '-created_at'], name='projects_sc_team_id_da7bc5_idx')], + 'constraints': [models.UniqueConstraint(fields=('team', 'name'), name='uniq_script_template_name_per_team')], + }, + ), + ] diff --git a/core/backend/apps/projects/models.py b/core/backend/apps/projects/models.py index 344068a..cea311d 100644 --- a/core/backend/apps/projects/models.py +++ b/core/backend/apps/projects/models.py @@ -100,6 +100,43 @@ class ScriptSegment(TimeStampedModel): ordering = ["sort_order", "created_at"] +class ScriptTemplate(TeamOwnedModel): + """「套路模板」:把一版脚本里可复用的部分抽出来存团队库,换个商品重开项目时套用。 + + 只存**套路**不存**文案**:outline 里每镜只留作用/时长/商品露出/说话方式, + 旧商品的口播词一律不进模板,避免换商品重跑时把上一个品牌漏出去。 + """ + + name = models.CharField(max_length=128) + presentation_format = models.CharField(max_length=32, blank=True) # oral|drama|vlog + video_structure = models.CharField(max_length=32, blank=True) # pain|contrast|review|scene + persona = models.CharField(max_length=32, blank=True) # 人物设定 key(对齐向导 WIZ_PERSONAS) + total_duration = models.PositiveIntegerField(default=0) + # [{index, role, duration, product_exposure, delivery}] · 镜头节奏 + 脚本结构 + outline = models.JSONField(default=list, blank=True) + # 结尾转化方式的**写法参考**(可能带旧品牌词,套用时提示模型换成新商品) + cta = models.TextField(blank=True) + # 场景风格名(人物场景风格的场景侧;人物侧由 persona 承载) + scenes = models.JSONField(default=list, blank=True) + source_project = models.ForeignKey( + Project, on_delete=models.SET_NULL, null=True, blank=True, related_name="script_templates" + ) + source_script = models.ForeignKey( + "ScriptVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="templates" + ) + usage_count = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint(fields=["team", "name"], name="uniq_script_template_name_per_team"), + ] + indexes = [models.Index(fields=["team", "-created_at"])] + + def __str__(self) -> str: + return self.name + + class BaseAssetGroup(TimeStampedModel): class Kind(models.TextChoices): PRODUCT = "product", "Product" diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py index 54fc9de..1a5c718 100644 --- a/core/backend/apps/projects/serializers.py +++ b/core/backend/apps/projects/serializers.py @@ -11,6 +11,7 @@ from .models import ( Project, ProjectStage, ScriptSegment, + ScriptTemplate, ScriptVersion, StoryboardFrame, StoryboardShot, @@ -34,6 +35,41 @@ def _asset_preview_url(asset) -> str: return AssetFileSerializer().get_preview_url(primary) if primary else "" +_VIDEO_SUFFIXES = (".mp4", ".mov", ".webm", ".m4v") + + +def is_playable_video(asset) -> bool: + """主文件是不是真能塞进