baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
42
.claude/rules/api.md
Normal file
42
.claude/rules/api.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/api/**"
|
||||||
|
- "src/routers/**"
|
||||||
|
- "src/services/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# API 层开发规则(Clover 后端)
|
||||||
|
|
||||||
|
> 承接架构方案v0.3 基石 + API契约.md,冲突以架构方案为准。
|
||||||
|
|
||||||
|
## 路由规范
|
||||||
|
- RESTful 命名:资源用名词复数(/tasks, /products, /api-keys)
|
||||||
|
- 版本前缀:/api/v1/
|
||||||
|
- 控制器只做:参数校验 → 调 service → 格式化响应,**不含业务逻辑**
|
||||||
|
|
||||||
|
## 响应格式(统一包络)
|
||||||
|
- 成功:`{ "code": 0, "data": {...} }`
|
||||||
|
- 失败:`{ "code": <错误码>, "message": "用户可读信息" }`
|
||||||
|
- HTTP 状态码与业务 code 分离(见契约§0七类错误码)
|
||||||
|
|
||||||
|
## 安全(基石B/C)
|
||||||
|
- 所有用户输入必须校验(类型、长度、范围)
|
||||||
|
- SQL 查询使用参数化,禁止字符串拼接
|
||||||
|
- **API Key 绝不进 Celery 参数**,只传 task_id;worker 内查库→Fernet解密→局部变量
|
||||||
|
- FERNET_KEY 走环境变量,绝不进代码库;解密不落盘、不打日志
|
||||||
|
- 敏感操作记审计日志(ai_call_logs)
|
||||||
|
|
||||||
|
## 多租户隔离(红线)
|
||||||
|
- 所有查询强制带 workspace_id 条件
|
||||||
|
- 写操作 + 切 workspace 必须查 workspace_members 校验权限
|
||||||
|
- workspace_id / product_id 从 JWT + 上下文自动带,前端不传(防越权)
|
||||||
|
- 所有 workspace 相关 Repo 继承 base_workspace_repo
|
||||||
|
|
||||||
|
## 错误处理(官网V1坑3)
|
||||||
|
- service 错误不许静默吞,至少打 error log
|
||||||
|
- AI/token站调用失败归因到个人 key(错误码50002)
|
||||||
|
- DB 枚举约束要有对应代码侧常量文件
|
||||||
|
|
||||||
|
## 业务参数不写死(基石A)
|
||||||
|
- 禁止出现品类/数量/角度任何枚举常量
|
||||||
|
- "看起来像枚举"的业务概念 → 做成数据不做成代码
|
||||||
49
.claude/rules/db.md
Normal file
49
.claude/rules/db.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/models/**"
|
||||||
|
- "alembic/**"
|
||||||
|
- "migrations/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 数据库规则(Clover · MySQL + Alembic)
|
||||||
|
|
||||||
|
> 承接架构方案v0.3 第三章(16表)。一期建14张,matrix_accounts/preference_profile 二期预留。
|
||||||
|
|
||||||
|
## 迁移
|
||||||
|
- 每次 schema 变更创建迁移文件,不手动改数据库
|
||||||
|
- 迁移文件不可修改已提交的,只能新增
|
||||||
|
- 迁移必须包含 up 和 down
|
||||||
|
- 按序:001(banana搬3张) → 002(多租户基础) → 003(业务7张) → 004(飞轮)
|
||||||
|
|
||||||
|
## 查询
|
||||||
|
- 禁止 SELECT *,明确列出字段
|
||||||
|
- 大表查询必须有索引支撑,新增查询模式时同步加索引
|
||||||
|
- N+1 问题:关联查询用 JOIN 或批量加载
|
||||||
|
- 飞轮聚合走 MySQL JOIN(MongoDB 只存 AI debug trace,业务不依赖)
|
||||||
|
|
||||||
|
## 命名
|
||||||
|
- 表名:snake_case 复数(users, generation_tasks, preference_events)
|
||||||
|
- 字段名:snake_case(created_at, workspace_id)
|
||||||
|
- 索引名:idx_表名_字段名
|
||||||
|
|
||||||
|
## 关键约束(架构方案)
|
||||||
|
- 任务主键:自增 BIGINT + mongo_trace_id VARCHAR(24)
|
||||||
|
- user_api_keys:UNIQUE(user_id, workspace_id, provider)
|
||||||
|
- 所有业务表必须有 workspace_id(多租户逻辑隔离)
|
||||||
|
- preference_events 必须有 workspace_id + product_id(跨公司隔离+按产品分开学)
|
||||||
|
|
||||||
|
## 状态机(generation_tasks.status)
|
||||||
|
pending → generating → pending_selection → pending_review → approved/rejected → archived
|
||||||
|
- 打回后回到 pending_selection
|
||||||
|
- 非法流转返错误码 40901
|
||||||
|
|
||||||
|
## 字段红线
|
||||||
|
- API Key 字段:encrypted_key(Fernet),**不存 url 字段**(token站固定自家站)
|
||||||
|
- users 表:删除 banana 的 credits 字段
|
||||||
|
- eval_score:留 NULL(不接 banana 假评分)
|
||||||
|
- data_ownership:client_data / platform_asset(洞7分层归属)
|
||||||
|
- banned_words.level:auto_fix / soft_warn / hard_block(三级)
|
||||||
|
|
||||||
|
## 枚举约束
|
||||||
|
- DB 枚举约束要有对应代码侧常量文件(消除三套命名打架)
|
||||||
|
- 品类(products.category)是纯数据字段,禁止任何品类枚举常量
|
||||||
46
.claude/rules/frontend.md
Normal file
46
.claude/rules/frontend.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "src/components/**"
|
||||||
|
- "src/app/**"
|
||||||
|
- "src/pages/**"
|
||||||
|
- "src/stores/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 前端开发规则(Clover · Next.js)
|
||||||
|
|
||||||
|
> 承接 前端交互设计.md(5屏金标准)+ API契约.md §3 DTO。
|
||||||
|
|
||||||
|
## 组件规范
|
||||||
|
- 单一职责:一个组件只做一件事
|
||||||
|
- Props 必须定义类型(TypeScript interface)
|
||||||
|
- 避免 prop drilling 超 3 层,用 Context 或状态管理
|
||||||
|
|
||||||
|
## DTO 对接(堵官网V1坑4)
|
||||||
|
- 所有 DTO 字段严格按 API契约§3,**不许自己假设嵌套结构**
|
||||||
|
- 后端响应与契约不符立即报 Lead,不私下适配
|
||||||
|
- workspace_id / product_id 不在前端传
|
||||||
|
|
||||||
|
## SSE(异步生图电影)
|
||||||
|
- 按 event_seq 去重,断线重连补发历史事件
|
||||||
|
- 11类事件按契约§2渲染:0/N进度 + 谁好谁先冒 + 单批失败重试 + 能离开
|
||||||
|
- flywheel_injected 事件 → 显"本次已注入:最近偏好/打回原因"
|
||||||
|
|
||||||
|
## 样式
|
||||||
|
- 用 CSS Modules 或 Tailwind,不用全局样式
|
||||||
|
- 响应断点统一:sm(640) md(768) lg(1024) xl(1280)
|
||||||
|
- 颜色/间距用 design token,不硬编码
|
||||||
|
|
||||||
|
## 可访问性
|
||||||
|
- 交互元素必须有 aria-label
|
||||||
|
- 图片必须有 alt 文本
|
||||||
|
- 键盘导航可用
|
||||||
|
|
||||||
|
## 红线(CLAUDE.md)
|
||||||
|
- ❌ 不展示 Token 余额、不做积分页(key 去中转站后台看)
|
||||||
|
- ❌ 飞轮隐形:无"训练AI"按钮,只在生成时显注入提示
|
||||||
|
- ❌ 不做"自动发布到小红书"——只到确认预览
|
||||||
|
- ❌ 不做移动端(一期)
|
||||||
|
|
||||||
|
## 状态管理
|
||||||
|
- 4 个 Store:任务 / 候选 / 审核 / 全局
|
||||||
|
- 异步态用骨架屏,错误态对接契约§0错误码
|
||||||
53
.claude/settings.json
Normal file
53
.claude/settings.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"//说明": "Clover 仓库根 Hooks 配置。四道质量门禁,承接治理指南§9.3模板。退出码 0=放行 2=阻止 其他非零=脚本错误。母本在 启动包/.claude/。",
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"//门禁1": "危险命令拦截",
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "cmd=$(jq -r '.tool_input.command // empty'); if echo \"$cmd\" | grep -qE 'rm -rf|DROP TABLE|--force|--no-verify|reset --hard|git push.*main|git push.*master'; then echo \"危险命令被拦截: $cmd\" >&2; exit 2; fi; exit 0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"//门禁2": "敏感文件保护 + 源目录只读保护",
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "file=$(jq -r '.tool_input.file_path // empty'); if echo \"$file\" | grep -qE '\\.env|\\.key|credentials|secrets|FERNET'; then echo \"禁止编辑敏感文件: $file\" >&2; exit 2; fi; if echo \"$file\" | grep -qE 'O2中间件平台/banana|产品包/worker|万牛会L1准备/worker'; then echo \"源目录只读,禁止修改(铁律): $file\" >&2; exit 2; fi; exit 0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"//门禁3": "自动 lint",
|
||||||
|
"matcher": "Write|Edit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "file=$(jq -r '.tool_input.file_path // empty'); case \"$file\" in *.ts|*.tsx|*.js|*.jsx) npx eslint --fix \"$file\" 2>&1 || true;; *.py) ruff check --fix \"$file\" 2>&1 || true;; esac; exit 0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"//门禁4": "测试验证 + 临时文件提醒",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash -c 'echo \"请确认是否已运行测试并通过(软件测试过≠内容质量过,内容需北哥抽检)。\" >&2; tmp=$(find . -name \"*_tmp*\" 2>/dev/null | head -5); if [ -n \"$tmp\" ]; then echo \"发现临时文件待清理:\" >&2; echo \"$tmp\" >&2; fi; exit 0'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
67
.claude/skills/clover-loop/SKILL.md
Normal file
67
.claude/skills/clover-loop/SKILL.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: clover-loop
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Clover 内容生产闭环开发 SKILL(改造自 ralph-loop)。
|
||||||
|
Memory is Poison, Tests are Cure —— 但 Clover 的 Test 不止单元测试,还有北哥的眼睛。
|
||||||
|
六阶段闭环:调研→约束→生成→验证→校准→闭环。质量过关=机器过滤+北哥抽检两阶段。
|
||||||
|
triggers:
|
||||||
|
- clover-loop
|
||||||
|
- 内容闭环
|
||||||
|
- 生产闭环
|
||||||
|
allowed-tools:
|
||||||
|
- Bash
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Glob
|
||||||
|
- AskUserQuestion
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clover Loop SKILL
|
||||||
|
|
||||||
|
> 核心:机器层(machine_passed)只是进抽检队列,北哥点头(human_passed)才是真过关。
|
||||||
|
|
||||||
|
## 六阶段
|
||||||
|
|
||||||
|
### P0 调研(ralph-loop 没有,北哥"先调研")
|
||||||
|
读架构方案v0.3 + 对应 PRD + 扒包落盘,确认本任务边界。
|
||||||
|
不充分就进 P2 = 在错误的层做对的事(PSR-L 教训)。
|
||||||
|
|
||||||
|
### P1 约束
|
||||||
|
冻结 task_spec:输入/输出/验证命令/期望exit_code/质量阈值。
|
||||||
|
`python scripts/loop_state.py create <task_id>`
|
||||||
|
|
||||||
|
### P2 生成
|
||||||
|
写代码 + 写验证脚本(必须含断言、返回exit_code)。
|
||||||
|
`python scripts/loop_state.py advance`
|
||||||
|
|
||||||
|
### P3 验证(机器层)
|
||||||
|
跑机器门禁:lint / test / 违禁词 / 五维≥90 / 去重。
|
||||||
|
全过 → status=machine_passed → 进 P4;不过 → 记失败,回 P2。
|
||||||
|
|
||||||
|
### P4 校准(ralph-loop 没有,专治"分高但烂")
|
||||||
|
机器过的内容样本,按维度比对北哥标准;同质化/跑偏单独标记。
|
||||||
|
产出**抽检样本包**交北哥 → 北哥判断回写质量标准(喂飞轮)。
|
||||||
|
|
||||||
|
### P5 闭环
|
||||||
|
- 北哥抽检通过 → status=human_passed → 归档 + 结晶
|
||||||
|
- 北哥打回 / 验证不过 → 分析根因
|
||||||
|
- 同层撞墙 **2 次** → 强制 Pivot(换层不换参),记 pivots[]
|
||||||
|
- Pivot 后仍不过 / 北哥连续否决同方向 → status=blocked,停循环请人介入
|
||||||
|
|
||||||
|
## 状态机(.clover-loop-state.json)
|
||||||
|
5 态:in_progress / machine_passed / human_passed / pivoted / blocked
|
||||||
|
关键:machine_passed ≠ human_passed
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
| 命令 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| /clover-loop | 启动六阶段闭环 |
|
||||||
|
| /clover-loop:status | 查当前状态 |
|
||||||
|
| /clover-loop:pivot | 手动触发换层 |
|
||||||
|
|
||||||
|
## 铁律
|
||||||
|
- 撞墙 2 次必 Pivot(对齐 CLAUDE.md 失败2次)
|
||||||
|
- 北哥不耐烦/连续否决 = 路径选错层的信号,立即 Pivot 不调参
|
||||||
|
- 软件测试过 ≠ 内容质量过
|
||||||
133
.claude/skills/clover-loop/scripts/loop_state.py
Normal file
133
.claude/skills/clover-loop/scripts/loop_state.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Clover Loop State Manager(改造自 ralph-loop state_manager)
|
||||||
|
六阶段 + 5态 + 机器/人工双层验收 + Pivot 追踪。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STATE_FILE = ".clover-loop-state.json"
|
||||||
|
PHASES = {"0": "调研", "1": "约束", "2": "生成", "3": "验证", "4": "校准", "5": "闭环"}
|
||||||
|
|
||||||
|
|
||||||
|
def _path(work_dir=None):
|
||||||
|
base = Path(work_dir) if work_dir else Path.cwd()
|
||||||
|
return base / STATE_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def load(work_dir=None):
|
||||||
|
p = _path(work_dir)
|
||||||
|
return json.load(open(p, encoding="utf-8")) if p.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def save(state, work_dir=None):
|
||||||
|
state["updated_at"] = datetime.now().isoformat()
|
||||||
|
json.dump(state, open(_path(work_dir), "w", encoding="utf-8"),
|
||||||
|
indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def create(task_id, work_dir=None):
|
||||||
|
state = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
"current_phase": 0,
|
||||||
|
"max_retries_per_layer": 2, # 单层撞墙2次→Pivot
|
||||||
|
"machine_gate": {"lint": None, "test": None, "banned_word": None,
|
||||||
|
"score": None, "dedup": None},
|
||||||
|
"human_review": {"status": "pending", "sampled": 0,
|
||||||
|
"approved": 0, "rejected": 0},
|
||||||
|
"status": "in_progress", # in_progress/machine_passed/human_passed/pivoted/blocked
|
||||||
|
"layer_attempts": {}, # {层名: 失败次数}
|
||||||
|
"pivots": [],
|
||||||
|
"logs": [],
|
||||||
|
}
|
||||||
|
save(state, work_dir)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def advance(state, work_dir=None):
|
||||||
|
cur = state["current_phase"]
|
||||||
|
if cur < 5:
|
||||||
|
state["current_phase"] = cur + 1
|
||||||
|
save(state, work_dir)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def machine_gate(state, key, result, work_dir=None):
|
||||||
|
"""记录机器门禁单项结果。全过则置 machine_passed。"""
|
||||||
|
state["machine_gate"][key] = result
|
||||||
|
g = state["machine_gate"]
|
||||||
|
passed = all(v in ("pass", True) or (isinstance(v, (int, float)) and v >= 90)
|
||||||
|
for v in g.values() if v is not None)
|
||||||
|
if passed and all(v is not None for v in g.values()):
|
||||||
|
state["status"] = "machine_passed"
|
||||||
|
save(state, work_dir)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def record_fail(state, layer, error=None, work_dir=None):
|
||||||
|
"""记录某层失败。同层2次→强制Pivot。"""
|
||||||
|
state["layer_attempts"][layer] = state["layer_attempts"].get(layer, 0) + 1
|
||||||
|
state["logs"].append({"ts": datetime.now().isoformat(),
|
||||||
|
"layer": layer, "error": error})
|
||||||
|
if state["layer_attempts"][layer] >= state["max_retries_per_layer"]:
|
||||||
|
state["status"] = "pivoted"
|
||||||
|
state["pivots"].append({"layer": layer, "ts": datetime.now().isoformat(),
|
||||||
|
"reason": f"{layer}层撞墙{state['layer_attempts'][layer]}次"})
|
||||||
|
save(state, work_dir)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def human_review(state, action, work_dir=None):
|
||||||
|
"""北哥抽检:approve/reject。approve累计达标→human_passed。"""
|
||||||
|
hr = state["human_review"]
|
||||||
|
hr["sampled"] += 1
|
||||||
|
if action == "approve":
|
||||||
|
hr["approved"] += 1
|
||||||
|
hr["status"] = "approved"
|
||||||
|
state["status"] = "human_passed"
|
||||||
|
elif action == "reject":
|
||||||
|
hr["rejected"] += 1
|
||||||
|
hr["status"] = "rejected"
|
||||||
|
state["status"] = "blocked" # 打回=停循环请人介入
|
||||||
|
save(state, work_dir)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("用法: loop_state.py <create|load|advance|gate|fail|review> [args]")
|
||||||
|
print(" create <task_id> / advance / gate <key> <result>")
|
||||||
|
print(" fail <layer> [error] / review <approve|reject>")
|
||||||
|
sys.exit(1)
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == "create":
|
||||||
|
print(json.dumps(create(sys.argv[2]), ensure_ascii=False, indent=2))
|
||||||
|
elif cmd == "load":
|
||||||
|
s = load()
|
||||||
|
print(json.dumps(s, ensure_ascii=False, indent=2) if s else "无状态")
|
||||||
|
elif cmd == "advance":
|
||||||
|
s = load()
|
||||||
|
if s:
|
||||||
|
advance(s)
|
||||||
|
print(f"进入 P{s['current_phase']} {PHASES[str(s['current_phase'])]}")
|
||||||
|
elif cmd == "gate":
|
||||||
|
s = load()
|
||||||
|
if s:
|
||||||
|
key, raw = sys.argv[2], sys.argv[3]
|
||||||
|
val = float(raw) if raw.replace(".", "").isdigit() else raw
|
||||||
|
machine_gate(s, key, val)
|
||||||
|
print(f"门禁 {key}={val}, status={s['status']}")
|
||||||
|
elif cmd == "fail":
|
||||||
|
s = load()
|
||||||
|
if s:
|
||||||
|
record_fail(s, sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else None)
|
||||||
|
print(f"{sys.argv[2]}层失败{s['layer_attempts'][sys.argv[2]]}次, status={s['status']}")
|
||||||
|
elif cmd == "review":
|
||||||
|
s = load()
|
||||||
|
if s:
|
||||||
|
human_review(s, sys.argv[2])
|
||||||
|
print(f"抽检{sys.argv[2]}, status={s['status']}")
|
||||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 密钥 / 环境变量(绝不入库 — CLAUDE.md 红线:key 收口环境变量)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Node / 前端
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# 本地数据 / 日志
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Celery / Redis 本地
|
||||||
|
celerybeat-schedule
|
||||||
|
dump.rdb
|
||||||
|
.env.bak
|
||||||
174
Agent团队与循环任务设计.md
Normal file
174
Agent团队与循环任务设计.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Clover Agent 团队 + 循环任务设计(开工质量把关)
|
||||||
|
|
||||||
|
> 用途:开工前把"agent 怎么组队、循环任务怎么保质量"定死,作为 board 4 启动包 + board 7 编排的地基。
|
||||||
|
> 决策人:倩倩姐拍板机制,北哥定内容质量标准。
|
||||||
|
|
||||||
|
## 0. 三方源材料对照(逐字读完)
|
||||||
|
|
||||||
|
| 来源 | 给了什么 | 对 Clover 的用法 |
|
||||||
|
|------|---------|-----------------|
|
||||||
|
| 官方《Agent Team 详解》17页 | 架构原理 + 4 协作模式 + Hooks 门禁 + 成本控制 | 团队拓扑 + 主协作模式 |
|
||||||
|
| 官方《项目治理指南》41页 | 四级治理 + 质量四件套 + 全套可抄模板 | Clover 定级 L3 + 模板直接套 |
|
||||||
|
| ralph-loop 源码 | 循环任务最小实现(4Phase+状态机+重试) | 循环任务骨架 |
|
||||||
|
| 逆向项目档案(真跑过的6角色team) | PSR-L + 撞墙血泪 + 角色卡三段式 | 循环升级 + 角色卡规范 |
|
||||||
|
| 北哥会议录音 | goal模式 + 闭环架构 + 判断力锚架构 | 循环任务的"魂" |
|
||||||
|
| 官网V1经验萃取(自产) | 6保留 + 6坑 | 规避坑 + 继承机制 |
|
||||||
|
|
||||||
|
## 1. 把关结论速览
|
||||||
|
|
||||||
|
源材料读完,对 Clover 最关键的迭代是 3 条:
|
||||||
|
|
||||||
|
1. **循环任务**从 ralph-loop 的"测试即真理"升级成北哥的"闭环架构"——ralph-loop 只认 exit_code(能跑就过),北哥要"调研→生成→验证→**校准**→不过返工",且**质量过关的最终拍板权在人不在机器**。
|
||||||
|
2. **agent team** 主模式定为"全栈并行·契约优先"——integration-lead 先冻结 OpenAPI 契约,前后端再并行开工,堵住官网 V1"前端假设字段、联调爆炸"的坑。
|
||||||
|
3. **启动包必须提前备好**,启动日只 validate 不 generate——官网 V1 最大的坑是脚手架启动当天临时造、第一天下午才能安全开发;这次源材料给了全套可抄模板,彻底规避。
|
||||||
|
|
||||||
|
定级:Clover 属治理指南的 **L3 协作级**(5-15 人体量、多模块、有质量要求),继承 L3 全套(质量四件套 + Agent Team + CI)。
|
||||||
|
|
||||||
|
两个已拍板决策:
|
||||||
|
- 循环任务质量判断 = **机器过滤 + 北哥抽检两阶段**
|
||||||
|
- 团队规模 = **精简 5 角色**
|
||||||
|
|
||||||
|
## 2. 循环任务设计(loop task)
|
||||||
|
|
||||||
|
ralph-loop 骨架能用,但直接套到 Clover 有 3 个质量漏洞,补 3 道闸。
|
||||||
|
|
||||||
|
### 2.1 三个漏洞 → 三道闸
|
||||||
|
|
||||||
|
| ralph-loop 原版 | Clover 场景漏洞 | 补的闸 |
|
||||||
|
|----------------|----------------|--------|
|
||||||
|
| exit_code=0 就算过 | 文案能生成≠不同质化;图能出≠不跑偏 | 验收**两层**:机器先过滤(违禁词/五维评分≥90/去重) → **北哥人工抽检定标准** |
|
||||||
|
| 4 Phase 无"调研/校准" | 上来就生成,不先看清全流程 | 补成北哥闭环:**调研→生成→验证→校准→返工** |
|
||||||
|
| max_retries=3 硬切 | 撞墙3次还在原地换参数 | 逆向铁律:**撞墙2次必 Pivot**(换层不换参),对齐 CLAUDE.md 失败2次规则 |
|
||||||
|
|
||||||
|
核心一句:**"Memory is Poison, Tests are Cure"是对的,但 Clover 的 Test 不止单元测试,还有北哥的眼睛**——落在架构方案 v0.3 基石 D"人工判断标准落点"和飞轮四点验收。
|
||||||
|
|
||||||
|
### 2.2 Clover 闭环六阶段(升级版 ralph-loop)
|
||||||
|
|
||||||
|
```
|
||||||
|
P0 调研 → 读架构方案v0.3 + PRD + 扒包落盘,确认本任务边界(对应北哥"先调研")
|
||||||
|
P1 约束 → 冻结 task_spec:输入/输出/验证命令/期望exit_code/质量阈值
|
||||||
|
P2 生成 → 写代码 + 写验证脚本(验证脚本必须含断言、返回exit_code)
|
||||||
|
P3 验证 → 跑验证脚本:机器层(lint/test/违禁词/五维≥90/去重)
|
||||||
|
P4 校准 → ⭐新增:机器过的样本,按维度比对北哥标准;同质化/跑偏单独标记
|
||||||
|
P5 闭环 → 通过→归档+结晶;不过→分析根因,撞墙2次换层(Pivot),超限暂停请人介入
|
||||||
|
```
|
||||||
|
|
||||||
|
P4 校准是 ralph-loop 没有的,专治"分数高但实际烂"。校准产出**抽检样本包**交北哥,北哥的判断回写成质量标准(喂飞轮)。
|
||||||
|
|
||||||
|
### 2.3 状态机(沿用 ralph-loop 的 .clover-loop-state.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "TASK-xxx", "current_phase": 2, "attempts": 1,
|
||||||
|
"max_retries_per_layer": 2, // 单层撞墙上限2次→Pivot
|
||||||
|
"machine_gate": {"lint":"pass","test":"pass","banned_word":"pass","score":92,"dedup":"pass"},
|
||||||
|
"human_review": {"status":"pending","sampled":0,"approved":0,"rejected":0},
|
||||||
|
"status": "in_progress", // in_progress / machine_passed / human_passed / pivoted / blocked
|
||||||
|
"pivots": [], "logs": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
关键差异:状态从 ralph-loop 的 2 态(completed/max_retries_exceeded)扩成 5 态,**machine_passed ≠ human_passed**——机器过只是进抽检队列,北哥点头才是真过。这就是"质量过关不是能下载"的落地。
|
||||||
|
|
||||||
|
### 2.4 触发与人介入
|
||||||
|
|
||||||
|
- 单层(同一个错误层)连续失败 **2 次** → 不再换参数,强制 Pivot 换思路/换层,记 `pivots[]`。
|
||||||
|
- Pivot 后仍不过 或 北哥抽检打回 → status=blocked,停循环,结构化报告请北哥/倩倩姐介入。
|
||||||
|
- 北哥不耐烦/连续否决同方向 = **路径选错了层**的信号(逆向项目血泪),立即 Pivot 不要再调参。
|
||||||
|
|
||||||
|
## 3. Agent 团队编排(agent team)
|
||||||
|
|
||||||
|
### 3.1 拓扑:Orchestrator-Workers,精简 5 角色
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ Lead/集成 │ 冻结契约+全局状态+最终验收(兼 integration-lead)
|
||||||
|
└──────┬──────┘
|
||||||
|
┌────────────────┼────────────────┐
|
||||||
|
┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐
|
||||||
|
│ 后端 BE │ │ 前端 FE │ │ 审查 QA │ 审查兼测试运行
|
||||||
|
└─────────┘ └───────────┘ └──────────┘
|
||||||
|
└────────────────┬────────────────┘
|
||||||
|
┌──────▼──────┐
|
||||||
|
│ AI引擎 AIE │ 文案/生图/去水印核心(产品命脉,单列)
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
砍掉官网 V1 的独立"文档 agent",文档并入各 agent 的 standup 自我披露。AI 引擎从后端拆出来单列——它是 Clover 的命脉(文案内核/分镜/去水印),值得一个专人。
|
||||||
|
|
||||||
|
### 3.2 主协作模式:全栈并行·契约优先(Contract First)
|
||||||
|
|
||||||
|
1. **Lead 先冻结接口契约**(OpenAPI + TS 类型),契约里 `type:object` 不许留空(官网 V1 坑4)。
|
||||||
|
2. 契约冻结后,BE / FE / AIE **并行开工**,互相不等。
|
||||||
|
3. integration-lead(=Lead)**开工当天做契约偏离全量扫描**,产出结构化偏离清单(官网 V1 保留3)。
|
||||||
|
|
||||||
|
### 3.3 审查模式:异构对抗(仅用于关键决策点)
|
||||||
|
|
||||||
|
PRD 审查、安全审计、架构选型用对抗三角:Builder 出方案 → Critic(魔鬼代言人)找漏洞 → Synthesizer 综合。日常开发不用,避免 token 浪费。
|
||||||
|
|
||||||
|
### 3.4 成本控制(混合模型)
|
||||||
|
|
||||||
|
| 角色/任务 | 模型 | 理由 |
|
||||||
|
|----------|------|------|
|
||||||
|
| Lead / AI引擎核心 / 架构决策 | opus | 命脉,要最强推理 |
|
||||||
|
| 后端 / 前端 日常编码 | sonnet | 平衡 |
|
||||||
|
| QA 跑测试/分析日志 / 违禁词/评分 | haiku | 量大、逻辑简单、省钱 |
|
||||||
|
|
||||||
|
完成即关闭(Aggressive Cleanup):agent 干完任务立即 shutdown,闲置 agent 维持上下文也烧 token。
|
||||||
|
|
||||||
|
### 3.5 质量门禁(继承 + 叠加)
|
||||||
|
|
||||||
|
- **Stop Hook** 强制跑测试(官网 V1 保留5):不允许"我觉得对了"就算完。
|
||||||
|
- **但软件测试过 ≠ 内容质量过**:循环任务的内容产出叠加 §2 的机器过滤 + 北哥抽检。
|
||||||
|
|
||||||
|
## 4. 角色卡规范(5 角色)
|
||||||
|
|
||||||
|
吸收逆向项目比官网 V1 更狠的"三段式 + 反模式",每张角色卡含 5 块:
|
||||||
|
|
||||||
|
1. **读什么**(必读文档,精确到文件路径)
|
||||||
|
2. **写什么**(产出物边界)
|
||||||
|
3. **依赖谁**(上游契约 / 下游消费方)
|
||||||
|
4. **强制 + 禁止**(禁止项极具体,如"❌ api 层不能 import 基础设施层"而非"别写坏代码")
|
||||||
|
5. **standup 自我披露**(每天写:文件清单/TODO/已知bug/契约不一致处)
|
||||||
|
|
||||||
|
| 角色 | 读 | 写 | 依赖 | 模型 |
|
||||||
|
|------|----|----|------|------|
|
||||||
|
| Lead/集成 | 架构方案v0.3 + 3份PRD + CLAUDE.md | 契约冻结 + 偏离清单 + 最终验收 | 全员 standup | opus |
|
||||||
|
| 后端 BE | PRD-后端 + rules/api.md + rules/db.md | API + 队列 + 存档 + 多租户 | Lead契约 | sonnet |
|
||||||
|
| 前端 FE | PRD-前端 + 前端交互设计.md + rules/frontend.md | 5屏 + 飞轮隐形 + 异步生图电影 | Lead契约 | sonnet |
|
||||||
|
| AI引擎 AIE | 产品包扒包落盘 + banana扒包 + PRD-后端Ch4 | 文案双轨/生图edits/去水印/评分 | Lead契约 | opus |
|
||||||
|
| 审查 QA | 全员产出 + 验证脚本 | 跑测试 + 契约符合 + 清理临时文件 | 全员代码 | haiku |
|
||||||
|
|
||||||
|
## 5. 反模式清单(agent 必须 NOT 做)
|
||||||
|
|
||||||
|
吸收逆向项目的"反模式10条"思路,结合 Clover 红线:
|
||||||
|
|
||||||
|
1. ❌ 不动 banana / 产品包源目录(只读复制,不 cd 进去 Edit/Write)——CLAUDE.md 铁律。
|
||||||
|
2. ❌ 不边盖边修:Clover 是全新独立项目,不在 banana 上建。
|
||||||
|
3. ❌ 契约 `type:object` 不许留空——冻结前所有嵌套对象一级子字段必须定义(官网V1坑4)。
|
||||||
|
4. ❌ service 错误不许静默吞——至少打 error log(官网V1坑3)。
|
||||||
|
5. ❌ 同一层撞墙超 2 次还在调参——必须 Pivot 换层(逆向血泪 + CLAUDE.md失败2次)。
|
||||||
|
6. ❌ 前端不展示 Token 余额 / 不做积分页(CLAUDE.md前端红线)。
|
||||||
|
7. ❌ 不自动发布到小红书——只到"确认预览",发布是人的动作。
|
||||||
|
8. ❌ 内容"评分高"不等于"质量过关"——必须过北哥抽检才算 human_passed。
|
||||||
|
9. ❌ 临时调试文件不许留——带 `_tmp` 后缀,QA agent 收尾清理(官网V1坑5)。
|
||||||
|
10. ❌ P0 模块不许无 plan 就开工——integration-lead 检查项,standup 报"未plan的P0"(官网V1坑6)。
|
||||||
|
11. ❌ 新建文件不超 100 行,单次编辑不超 100 行(CLAUDE.md 约束)。
|
||||||
|
12. ❌ key 不进 Celery、不落盘明文——Fernet 加密只传 task_id(架构方案基石B)。
|
||||||
|
|
||||||
|
## 6. 落到 board 的下一步
|
||||||
|
|
||||||
|
本文档把"怎么组队、循环怎么保质量"定死了,接下来:
|
||||||
|
|
||||||
|
- **board 4 启动包**(启动前一天备好,启动日只 validate):
|
||||||
|
- 5 张角色卡 .md(按 §4 规范展开)
|
||||||
|
- 契约 OpenAPI 初稿(Lead 冻结用)
|
||||||
|
- CLAUDE.md 三层(全局/项目/模块)+ rules/(api/frontend/db)
|
||||||
|
- Hooks 配置(危险命令拦截/自动lint/Stop测试/敏感文件保护,模板已从源材料抄到)
|
||||||
|
- 循环任务 SKILL(按 §2 六阶段,改造 ralph-loop)+ state_manager
|
||||||
|
- 种子数据(品类/角度槽位/违禁词三级表)
|
||||||
|
- **board 5 执行约束**:把 §5 反模式 + 北哥抽检 SOP 固化。
|
||||||
|
- **board 6 验收清单**:三道验收线(内部1A生产链+1B飞轮四点 → 北哥质量过关 → 上线)。
|
||||||
|
- **board 7 Agent 编排**:把 §3 拓扑 + 协作模式写成可执行的开工 prompt(goal 模式)。
|
||||||
|
|
||||||
|
> 全部作业包完成后,按倩倩姐的常规要求:另起一个独立 AI 审核全部开工前流程,再开工。
|
||||||
70
CLAUDE.md
Normal file
70
CLAUDE.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Clover 项目约束(CLAUDE.md)
|
||||||
|
|
||||||
|
> 本文件是 Clover 项目的事件级约束,agent 团队开工必读。
|
||||||
|
> 倩倩姐口头红线当场落盘,不写当不存在。
|
||||||
|
|
||||||
|
## 🔴 架构铁律(最高优先级,违反即返工)
|
||||||
|
|
||||||
|
### 1. Clover 是全新独立项目
|
||||||
|
- 所有代码在 `北哥小红书产品/Clover/` 下从头建立。
|
||||||
|
- **不在 banana 上盖、不在产品包上盖、绝不边盖边修。**
|
||||||
|
|
||||||
|
### 2. banana / 产品包 = 只读素材源
|
||||||
|
- 两个来源文件夹**只做读取和复制**,**禁止任何修改**:
|
||||||
|
- banana:`/Users/qiyu/Documents/企业培训项目/O2中间件平台/banana`(只读)
|
||||||
|
- 产品包✅上线版:`/Users/qiyu/Documents/企业培训项目/万牛会L1准备/worker`(**真正部署上线版,扒这个**)
|
||||||
|
- 🔴产品包旧版 `万牛会L1准备/产品包/worker` 是开发副本/旧版,**不要扒**(copy/image比上线版旧100~200行)
|
||||||
|
- 工作方式:需要哪部分零件 → 复制出来 → 在 Clover 里结合本项目迭代/优化/重建。
|
||||||
|
- 不允许 `cd` 进这两个目录做 Edit/Write/git 操作。
|
||||||
|
|
||||||
|
### 3. 技术栈
|
||||||
|
- 后端统一 Python(FastAPI+Celery+双库+Redis),产品包的 JS 内核**重写成 Python** 融进 Clover。
|
||||||
|
- 重写必须仔细对照防逻辑走样(TDD + agent团队 + 另一AI审核兜底)。
|
||||||
|
|
||||||
|
## 🔴 已拍板的产品决策(不可推翻)
|
||||||
|
- 生图:**gpt-image-2 主**(codeproxy中转站),Gemini 备。
|
||||||
|
- 生图接口:优先 `/images/edits`(带产品参考图),加重试退避。
|
||||||
|
- token:统一走自家中转站 url;key 按账号隔离,各人自录自用各算各的。
|
||||||
|
- **模型只用最强档(倩倩姐2026-06-12拍板)**:Claude 系用 **4.8**(claude-opus-4-8),GPT 系用 **5.5**;通道目前仍只用 apiports 那一套(文案暂不补 codeproxy 第二路径,等真出现频繁挂再说)。
|
||||||
|
- **模型兜底铁律(倩倩姐2026-06-12拍板)**:claude-opus-4-8 一定可用。**若 gpt-5.5 探活不可用,回落到 claude-opus-4-8(最强档),绝不降级到 sonnet 或任何弱档;绝不偷偷降级当没事,探活不可用立即回报。**
|
||||||
|
- **品牌词由客户输入**(产品级字段,随产品固定),植入文案每条+生图特写图(第2/6张)。
|
||||||
|
- 🔴 **品牌词植入位置=文案+图片文字排版层,绝不P瓶身(倩倩姐2026-06-15拍板)**:瓶身=产品本身,忠于参考图原样,禁止改/加/P瓶身文字(合规+真实双重要求)。品牌词"倍分子"出现在①文案每条②图片的标题/贴纸/标注等文字排版层,不是非要印到瓶身上。AC9实测task61 hook图瓶身只印"素颜霜"=正确,不是bug。曾误判要去P瓶身被驳回。
|
||||||
|
- **每用户并发上限 5 个任务**(客户侧实际跑几个再观察调整)。
|
||||||
|
- **生图出3套**:套间差异按 ①文案角度 ②视觉风格 ③叙事结构(套1痛点先行/套2场景先行/套3成分背书先行)三个正交维度拉开,**不按封面分**,要不重复。
|
||||||
|
- **配图去AI化对齐大卫xhs(倩倩姐2026-06-15拍板)**:大卫 `小红书系统图片定制/xhs-tool/backend/infrastructure/imagePostProcess.js`(只读素材源)运营实测去AI化能力强,Clover 后处理对齐它:①**尺寸可选**(RATIO_MAP多比例,**±2%容差内不resize**避免无谓重采样),常规默认 **3:4=1024×1536**(gpt原生尺寸,**不强缩1080×1440**,核销表原S4方向作废) ②SynthID破除(缩2px裁1px边+亮度/饱和微调,开关控制) ③高保真重编码(jpeg quality100/chroma4:4:4去元数据) ④重试里支持选尺寸。**先打通单套配图(含去AI化+尺寸可选),再扩3套正交**。
|
||||||
|
- **配图三套叙事措辞=倩倩姐先起草**(2026-06-15):痛点先行/场景先行/成分背书先行的具体prompt措辞由倩倩姐起草给北哥过目,不用临时占位糊弄;扩3套时填入。
|
||||||
|
- **文案待提质量项(倩倩姐2026-06-15)**:①80合格线是临时妥协,方向是提生成质量顶分数不降标准 ②生成文案每条套路化重复"买不买跟我没关系"收尾句式要去模板化。详见记忆 clover-quality-line-80-temp。
|
||||||
|
- 🔴 **合格线=80,禁止擅改(倩倩姐2026-06-15二次拍板)**:`constants.py QUALITY_PASS_SCORE=80`。coder 曾擅自改85被驳回。提质量靠优化生成prompt顶分数,**绝不靠提高门槛/降标准凑数**。改这个值必须倩倩姐当面拍板。
|
||||||
|
- 🔴 **卖点三套来源·系统通用化(倩倩姐2026-06-15拍板,P0)**:系统是通用的,**产品靠上传才有,绝不内置写死任何具体产品**(素颜霜只是北哥一个客户的一个产品)。卖点三套来源(产品包本有,Clover重写漏了):①**从产品图片提取**(vision读图→卖点/人群/成分/视觉系统,**走GPT现有通道最强档,不补GEMINI_KEY**) ②**用户上传填写**(北哥文案3.txt即此套) ③**产品库内置**(fallback模板,留口即可)。成分(烟酰胺等)**并入selling_points不单列字段**。配图文字图片**先做一致**,后续留**canvas排版口子**(底图+代码叠字)。北哥禁AI是怕查重+AI化,**我们已做去AI化(对齐大卫xhs),此担忧已解决,不再当决策点**。
|
||||||
|
- **文案过滤=先展示后台补(倩倩姐2026-06-12拍板)**:合格文案(passed且≥90分且非hard_block)立即展示不卡用户;合格数不足用户要的条数时,**后台异步补充生成**直到够数或达上限。不是"只展示不补",也不是"凑不够就卡着不出"。
|
||||||
|
- **13环目标导向**:原始是13环(北哥回填解读与落点.md),banana本给了最硬4环引擎(找竞品/写文案/配图/裂变)。这轮**补全第2环竞品爆款分析(=标杆8特征)+第11环裂变**两个缺失引擎环;裂变单独做但在工作台内呈现(1爆款→N套完整笔记包,非只换文案)。
|
||||||
|
- 软件费:MVP 免费,收费逻辑后续单独讨论(先留接口关闭)。
|
||||||
|
- 飞轮:**开放给北哥测试使用即启动**(用即喂料,不是埋点不激活)。
|
||||||
|
- 🔴 **飞轮是产品最大亮点·必须做扎实+前端必须呈现(倩倩姐2026-06-16拍板)**:飞轮不是后台默默跑的暗功能,要让客户**直观感到"确实进入飞轮了、确实越用越好"**。两条入口都要做:
|
||||||
|
- ①**改稿进飞轮(D1,最强信号,必做)**:运营改AI稿→diff喂回飞轮。**入飞轮时机=先激进(改了就入)跑一周,北哥用后按真实信号质量校准是否加"过审才入"闸门**(细案E18/E19)。需补 SignalType.text_edit + TextCandidate.edited字段 + 改稿端点(走task_actions不碰review.py)。
|
||||||
|
- ②**AI评图分进飞轮(E12,做扎实版)**:AI评图分**只做"生成时筛选+前端展示"**,前端要呈现"这张图AI评了X分+已被选入飞轮"。**真正影响下次生成的飞轮权重只认真实信号(选了哪张/改了什么/过审与否),不拿AI主观分当权重**——守住eval_score留NULL铁律,AI分是展示层不是权重层。
|
||||||
|
- ③**前端呈现=轻量化关键节点反馈(不做独立飞轮主视图大页E01)**:在生成/选择/改稿/审核节点即时提示"已学习你的偏好""本次参考了你上周改的稿""飞轮已积累N条信号",嵌现有流程,纵切片友好。
|
||||||
|
- 🔴 **竞品笔记数据监控=不做(倩倩姐2026-06-16拍板)**:E04竞品数据全监控/反推关键词=自动抓小红书,命中合规反爬红线,直接砍,不进任何期。
|
||||||
|
- 发布:不自动发小红书,产出"达人素材交付包"人工发。
|
||||||
|
- 数据归属:原始数据(输入+产出+素材包)归客户可导出;飞轮偏好归平台。
|
||||||
|
- 违禁词:三级处理(自动改写🟢/软提示🟡/硬拦截🔴),默认分级北哥可调。
|
||||||
|
- 链接采集:不做原始抓取(死路),走对外截图手填 / 内部登录态插件。
|
||||||
|
|
||||||
|
## 🔴 验收线
|
||||||
|
- **质量过关才算完,不是"能下载"就算完**,最终北哥认可。
|
||||||
|
|
||||||
|
## 🔴 前端红线
|
||||||
|
- **不展示 Token 余额/用量**:key 和用量去中转站后台看,前端只录 key 不显示余额。
|
||||||
|
- 不做积分中心/billing 页(MVP免费)。
|
||||||
|
|
||||||
|
## 🔴 安全红线
|
||||||
|
- **SSE 认证用一次性短票 ticket,绝不把 JWT 放进 URL**(倩倩姐2026-06-08拍板):EventSource 不支持 header,故 SSE 连接前先用 JWT 调一个签发接口换一张只活 30-60 秒、仅对该 SSE 流有效的一次性 ticket,URL 只带 ticket(`?ticket=`)。就算被日志/浏览器历史记下也立即失效、换不了别的接口。禁止 `?token=` 直传 JWT。
|
||||||
|
|
||||||
|
## 🔴 执行约束
|
||||||
|
- 🔴 **建法铁律(防打补丁循环·先读 启动包/执行约束.md §0)**:①纵切片优先——先用最丑的方式打通端到端一条龙(登录→选品→出1文1图→审核→下载1个包),这条没真跑通禁止精修任何单块功能 ②"完成"唯一定义=验收清单某项真勾上(非"代码写完"),报完成必附对应验收项 ③验收清单当每日门禁,每阶段自问"多勾上哪一项了还是只是码更多了"。看进度问的是"到一条龙走通还差几步"不是"还要补几个洞"。
|
||||||
|
- 新建文件**目标 ≤100 行、上限 200 行**(倩倩姐2026-06-08放宽:ORM模型/枚举常量/内聚算法硬拆反而割裂可读性,故放宽到200行内可接受,**超200行必须拆**);单次编辑 ≤100 行(超了先建框架再Edit补)。
|
||||||
|
- 质量标准/优化prompt 等北哥方案到位再填,架构先留可调位。
|
||||||
|
- 🔴 **本轮交付层级=全量(倩倩姐2026-06-12拍板)**:终点线=一条龙过质量关 + 补全第2环标杆分析 + 第11环裂变。KR1链路连通/KR2质量达标/KR3自主可验/KR4引擎补全 全绿才算"全勾完"。
|
||||||
|
- 🔴 **打断节奏=攒批集中问(倩倩姐2026-06-12拍板)**:自主连续推进,把"只有倩倩姐能判断"的点(某条文案过不过关/某图品牌词够不够清晰/要不要砍需求)攒成批次,每完成一个环集中问一次,不一条条点yes。
|
||||||
|
- 🔴 **核销机制=孙总方法论(2026-06-12录音提炼,已结晶 ~/.claude/crystals/cc-methods/ai-collab-checkpoint-gating.md)**:①双层核销表(总核销表+每模块分核销表),做完一项物理勾一项 ②每模块收尾产"压缩存档/end-of文件"记录做到什么程度,断点可续 ③小步快跑+高频check,不攒大块 ④**验收标准写到90分清晰度**(模糊=放水=AI按40分自认通过) ⑤标准做减法砍到最少步 ⑥门禁要硬,不通过物理卡死 ⑦**绝不信AI第一遍自评**(实测谎报"都做了"实际20-30%),必独立交叉核对。
|
||||||
|
- 🔴 **工作模式=自运行核销机器(倩倩姐2026-06-12拍板)**:①**强制用workflow的agent team执行,不许自己瞎干** ②**必做交叉验证**(另起独立agent重核,不信第一遍) ③规则/验收清单**先全部列出来**再做(你要先看到规则) ④做完一个勾一个、任务只减不增 ⑤倩倩姐**只验最后结果**,过程中要她判断的点**攒成批集中问**,不一条条点yes。
|
||||||
286
Clover架构方案.md
Normal file
286
Clover架构方案.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# Clover 🍀 · 统一产品技术架构方案
|
||||||
|
|
||||||
|
> 项目代号:**Clover(三叶草)** —— 取"种草"之意,接小红书业务 + 倩倩姐设计稿的小草视觉。
|
||||||
|
> 对外正式品牌名待定,开发期用此代号。
|
||||||
|
> 版本:**v0.3(7洞对齐版,2026-06-04)** —— 经9-agent团队5轮审核(R1多维独立+R2挑刺+R3交叉验证+R4一致性+R5汇总),对齐06-04的7洞决策,修复13处脱节/漏洞
|
||||||
|
> 受众:产品负责人(可读)+ 开发团队(可落地)
|
||||||
|
> 关联:[../banana复用评估.md] [../产品架构蓝图.md] [../前端交互设计.md] [../采集框架.md] [test/各洞验证落盘.md]
|
||||||
|
>
|
||||||
|
> **v0.3修订摘要**:M1标杆采集定性(对外截图手填) / M2 Celery不传key(修安全矛盾) / M3去水印+达人素材交付包 / M4文案双轨+可调prompt / M5 delivery_packages表 / M6 token-url收口自家站 / M7软件MVP免费 / M8违禁词三级处理+banned_words表 / M9飞轮权重填定 / M10偏好表data_ownership字段 / M11 JWT表述工程化 / M12-13可选优化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 〇、一句话定性
|
||||||
|
|
||||||
|
一座"小红书内容工厂":北哥是第一个车间,将来郑州客户来了直接开新车间,互不干扰。
|
||||||
|
核心差异化 = **偏好飞轮**(越用越懂你的"老师傅")。技术底座复用 banana 半成品,但新建干净仓库抽离。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 〇.5、基石约束(不可动摇,违反即返工)
|
||||||
|
|
||||||
|
> 经两轮外部AI审核 + 两轮自校验,沉淀出以下铁律。任何代码/设计违反这些 = 必须返工。
|
||||||
|
|
||||||
|
### A. "凡业务参数,一律不写死"(最高原则)
|
||||||
|
- **品类**不写死:预置素颜霜/精华/面膜 + 客户自助上传新品类(source区分),代码和prompt禁止出现品类枚举
|
||||||
|
- **生成数量**不写死:文案出几条、图出几张,由用户自己设定。飞轮信号强弱随用户设定走(设10选3=强信号,设3选1=弱信号),系统不锁死
|
||||||
|
- **文案角度**不写死:痛点/成分/场景等角度跟着产品走,或引导客户自己输入,落进产品档案当可配置项
|
||||||
|
- 判断标准:任何"看起来像枚举/常量"的业务概念,先问"客户会不会想改?"会→做成数据不做成代码
|
||||||
|
|
||||||
|
### B. 安全铁律(API Key)
|
||||||
|
- 明文key**绝不进Celery参数**(Redis broker会存)。Celery只传task_id,worker内部查库→解密→调模型,明文只在函数局部变量
|
||||||
|
- key用Fernet加密存MySQL;**解密用的FERNET_KEY走环境变量/密钥管理,绝不进代码库**(锁和钥匙不放一起)
|
||||||
|
- key解密不落盘、不打日志
|
||||||
|
- 每账号自录自用,按key各算各的,禁止合并站级key混算
|
||||||
|
|
||||||
|
### C. 多租户隔离
|
||||||
|
- workspace_id **逻辑隔离**(同一MySQL用workspace_id强制过滤),**不是物理隔离**(暂不做每客户独立DB)
|
||||||
|
- JWT放user_id/current_workspace_id/role当加速字段,但**所有写操作+切换workspace必须查 workspace_members 校验当前权限**(读操作可信JWT加速)
|
||||||
|
|
||||||
|
### D. 采集↔生产接缝不能断(漏洞C)
|
||||||
|
- 采集框架里北哥团队填的"好图标准/好坏样本/质检标准"等**人工判断标准,必须在Clover里有落点**:进系统→帮组长审核→最终蒸馏成AI审核员
|
||||||
|
- 这是采集层和生产层的接缝,设计时必须打通,不能让采集成果悬空
|
||||||
|
|
||||||
|
### E. 飞轮验收必须可测(不靠"感觉更准")
|
||||||
|
- events正确写入 ✓ / aggregator返回明确prompt片段 ✓ / 下次Gemini prompt trace含该片段 ✓ / UI显示"本次已注入:最近偏好/打回原因" ✓
|
||||||
|
- 四点全过才算飞轮MVP达标
|
||||||
|
|
||||||
|
### F. 文档诚实
|
||||||
|
- 不放"N个agent生成"这类AI背书;用工程化表述(JWT签名防篡改/短期有效/关键接口查membership),不用"写死不可伪造"这种话
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、整体架构总览
|
||||||
|
|
||||||
|
### 分层(从上到下)
|
||||||
|
```
|
||||||
|
浏览器(Next.js)
|
||||||
|
熟手:选题→看生成→8选3文案→挑图→提审
|
||||||
|
组长:看审核队列→通过/打回(写原因)
|
||||||
|
管理员:维护品牌库/标杆/成员
|
||||||
|
│ HTTPS/SSE(流式推进度)
|
||||||
|
FastAPI(API层)
|
||||||
|
认证:JWT(含workspace_id+role,HS256签名防篡改、每请求验签)
|
||||||
|
权限:读操作凭JWT加速;写操作+切换workspace必须查workspace_members校验权限(逻辑隔离)
|
||||||
|
Key解密:Fernet解密,只在worker内存活,不落盘
|
||||||
|
│ 同步↓ 异步↓(推Celery队列)
|
||||||
|
业务应用层 Celery Worker(后台)
|
||||||
|
品牌库管理 ①分析标杆笔记
|
||||||
|
飞轮信号采集 ②批量生成文案(一次出5角度)
|
||||||
|
审核流转 ③并发生成图片(asyncio.gather)
|
||||||
|
偏好上下文组装 ④偏好重算(第二期激活)
|
||||||
|
│ │
|
||||||
|
数据层(双引擎)
|
||||||
|
MySQL(主存储,一期14表) MongoDB(只写AI debug trace)
|
||||||
|
业务全量数据/账号/权限 AI调用过程快照,出错排查用
|
||||||
|
品牌库/候选/飞轮信号 业务逻辑不依赖它
|
||||||
|
API Key(Fernet加密) Redis(Celery队列+SSE推送通道)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一次任务的数据流转
|
||||||
|
```
|
||||||
|
1.管理员预置品牌库(产品档案+标杆笔记)
|
||||||
|
2.熟手发起任务:选产品+选标杆 → 后端检查有没有配key(没有→引导去配)
|
||||||
|
→ **只把task_id推入Celery队列(绝不推key,遵基石B)**
|
||||||
|
3.Worker异步:①分析标杆提8特征 ②生成5角度文案(动态注入卖点+违禁词+风格+偏好)
|
||||||
|
③并发生图A/B/C → 每步推SSE,前端实时看进度
|
||||||
|
4.熟手8选3文案 → 飞轮信号入口1;挑图 → 入口2
|
||||||
|
5.提交审核 → 组长看队列
|
||||||
|
6.组长通过(+5分,最重)/打回(写原因,下次自动注入prompt提醒AI别再犯)
|
||||||
|
7.归档(永久保留)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 偏好飞轮怎么转(产品的魂)
|
||||||
|
```
|
||||||
|
【采信号】选文案+3 / 选图+3 / 审核通过+5(最重) / 打回写原因-3 / 重新生成-1 → 全进 preference_events 表
|
||||||
|
(此为开发起步默认权重,北哥调教后可配置校准)
|
||||||
|
【聚合】 下次生成查最近50条 → 哪个角度被选最多(L3个人) + 打回原因最近3条原文拼进prompt
|
||||||
|
不足5条信号 → 用产品档案冷启动
|
||||||
|
【三层继承】L1公司品牌基线 > L2矩阵号人设 > L3个人手感(运行时叠加,有L3用L3,没有往上兜底)
|
||||||
|
⚠️ 按产品维度分开学:素颜霜偏好不串到精华/面膜
|
||||||
|
【越用越准】1次=全靠静态基线均匀分布;10次=知道你爱"痛点切入";30次=措辞从"供参考"升为明确指令
|
||||||
|
【诚实克制】banana的假评分(88分)MVP彻底不接,eval_score留NULL,等真评分(组长标准蒸馏)再装
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、核心设计决策(已拍板)
|
||||||
|
|
||||||
|
开发期不得推翻,变更需产品负责人重新确认。
|
||||||
|
|
||||||
|
| 决策项 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 主存储 | MySQL存所有业务数据(飞轮聚合要JOIN),MongoDB只存AI debug trace |
|
||||||
|
| API Key | 每账号自录自用、Fernet加密、不同workspace各自独立,禁止混算;**token站url统一收口=自家站endpoint,账号表只存encrypted_key不存url字段**(06-04洞4,旧"兼容任意中转站url"已废止) |
|
||||||
|
| 软件费 | **MVP阶段免费,不做计费墙**(06-04洞4);token耗材费走自家站按key隔离,不在产品层计费;收费逻辑产品成熟后单独议 |
|
||||||
|
| Key解密位置 | 只在Celery worker内存局部变量,不落盘不打日志 |
|
||||||
|
| 多租户隔离 | workspace_id写进JWT,所有查询强制带此条件(安全红线) |
|
||||||
|
| 任务主键 | MySQL自增BIGINT + mongo_trace_id VARCHAR(24),飞轮JOIN性能好 |
|
||||||
|
| 文案生成 | **双轨**(06-04洞2):轨A=自带强模型一次返5角度JSON(不串行不并发5次);轨B=客户导入外部文案(豆包等)直接进候选池跳过AI;**客户可在产品档案配自调prompt** |
|
||||||
|
| 图片生成 | asyncio.gather并发,一次A/B/C三策略各一张(复用banana) |
|
||||||
|
| 加密算法 | cryptography.fernet.Fernet,环境变量FERNET_KEY,全仓统一 |
|
||||||
|
| 策略命名 | 对外API用A/B/C,内部method用minimal_edit系,建中心常量文件消除三套打架 |
|
||||||
|
| 审核记录 | 不建独立notes表,审核字段平铺generation_tasks,打回原因存preference_events |
|
||||||
|
| 偏好快照 | preference_profile表预建结构,MVP不写入,实时查events,第二期开物化缓存 |
|
||||||
|
| 品类动态 | products.category是纯数据字段,代码和prompt禁止出现任何品类枚举 |
|
||||||
|
| **审核演进** | **先组长人工审为主→积累"过/不过+理由"蒸馏人工标准→生图够好+标准清晰后,AI当审核员接班(第三期)** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据模型全览(16张表 = 一期14张 + 二期预留2张)
|
||||||
|
|
||||||
|
> 14张=banana搬3 + 多租户基础3(不含matrix_accounts) + 业务主体7 + 飞轮1。matrix_accounts、preference_profile 二期预留,一期不建。
|
||||||
|
> 06-04增补:业务主体从5张增至7张(加 delivery_packages 素材交付包 + banned_words 违禁词库)。
|
||||||
|
|
||||||
|
```
|
||||||
|
Alembic 001 — 从banana搬3张(改造)
|
||||||
|
users 用户账号(删credits字段)
|
||||||
|
login_records 登录记录(无改)
|
||||||
|
user_preferences UI设置偏好(无改,API Key另建表)
|
||||||
|
|
||||||
|
Alembic 002 — 多租户基础(全新)
|
||||||
|
workspaces 租户/公司(北哥=1个)
|
||||||
|
workspace_members 用户↔workspace+角色
|
||||||
|
user_api_keys 个人API Key(Fernet加密)
|
||||||
|
[matrix_accounts] 矩阵号 —— 二期预留,一期不建(矩阵号一期不做)
|
||||||
|
|
||||||
|
Alembic 003 — 业务主体7张(全新)
|
||||||
|
products 产品档案(卖点/违禁词/风格/调性/标签/文案角度/可调prompt/source/workspace_id)
|
||||||
|
benchmark_notes 标杆笔记(爆款链接/图/亮点说明,一对多挂product)
|
||||||
|
generation_tasks 生产任务(状态机+审核字段平铺)
|
||||||
|
text_candidates 文案候选(角度数量用户设定,MySQL主存储;source=ai/import区分双轨)
|
||||||
|
image_candidates 图片候选(数量用户设定)
|
||||||
|
delivery_packages 达人素材交付包(洞6):id/workspace_id/product_id/theme/status(pending/ready/downloaded)/package_path/created_at
|
||||||
|
banned_words 违禁词库(洞5):word/level(auto_fix自动改/soft_warn软提示/hard_block硬拦截)/replacement/updatable/workspace_id(可覆盖)
|
||||||
|
|
||||||
|
Alembic 004 — 飞轮信号(全新)
|
||||||
|
preference_events 飞轮信号日志(所有信号全在此;含data_ownership字段=client_data/platform_asset,洞7分层归属)
|
||||||
|
[preference_profile] 偏好快照 —— 二期预留,一期不建(一期实时查events;二期同步加data_ownership标记)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键字段
|
||||||
|
**任务状态机**:pending→generating→pending_selection→pending_review→approved/rejected→archived
|
||||||
|
(打回后回到pending_selection;修订轮次靠查events统计)
|
||||||
|
|
||||||
|
**generation_tasks 审核字段(平铺,需明确定义)**:
|
||||||
|
review_status / reviewer_id / reviewed_at / reject_reason / approved_at / archived_at
|
||||||
|
|
||||||
|
**preference_events**:
|
||||||
|
- signal_type: text_select | image_select | approve | reject_with_reason | regenerate
|
||||||
|
- signal_weight(已定初始默认值,北哥调教后可校准):选文案+3 / 选图+3 / 审核通过+5(最重) / 打回-3 / 重新生成-1
|
||||||
|
- angle_label: 跟着产品的"文案角度"配置走,不写死
|
||||||
|
- reason: 打回原因原文(不做AI归纳)
|
||||||
|
- **data_ownership: client_data(原始输入产出,客户可导出) | platform_asset(飞轮蒸馏成果,归平台)** —— 洞7分层归属技术口子
|
||||||
|
- workspace_id + product_id: 都必须有(跨公司隔离 + 按产品分开学)
|
||||||
|
|
||||||
|
**user_api_keys**:UNIQUE(user_id, workspace_id, provider)
|
||||||
|
|
||||||
|
**usage记录(一期必做,否则计费/排障断档)**:每次AI调用记 usage(谁/用了多少token),存 ai_call_logs 或 mongo debug trace 的 usage 字段;token站查询失败要有兜底;调用失败归因到个人key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、复用 / 复活 / 新造清单
|
||||||
|
|
||||||
|
开发第一步先看这张表:哪些直接搬、哪些改造、哪些新写。
|
||||||
|
|
||||||
|
### 🟢 直接复用(复制基本不改)
|
||||||
|
| banana模块 | 改动量 |
|
||||||
|
|---|---|
|
||||||
|
| gemini_service.py 核心AI方法 | 只改__init__ 4行,AI调用方法一行不动 |
|
||||||
|
| json_stream_extractor.py 流式解析 | 加variants到watched_keys |
|
||||||
|
| constants SCHEMA_METHODS | 扩展补全 |
|
||||||
|
| orchestrator analyze流程 | 数据源从Mongo改读MySQL products/benchmark_notes |
|
||||||
|
| celery_tasks 任务框架 | task_id改BIGINT,**只传task_id(绝不传key,遵基石B)**,worker内部查库取encrypted_key→FERNET_KEY解密,加第4任务壳 |
|
||||||
|
| api/stream.py SSE框架 | 补发历史事件(修漏洞),结构不变 |
|
||||||
|
| prompt_composer.py | 新增compose_variants方法,原方法保留 |
|
||||||
|
| docker-compose | 直接复用,调环境变量名 |
|
||||||
|
|
||||||
|
### 🟡 复活(banana写好但没接线的死代码)
|
||||||
|
| 死代码 | 怎么复活 |
|
||||||
|
|---|---|
|
||||||
|
| task_skills.py Invariants 8特征 | analyze后把8特征写入events.signal_meta,飞轮统计维度从单一angle扩到8维(第三期) |
|
||||||
|
| loader.py PromptLoader版本alias | 新建variants模板走版本机制,未来改prompt只改模板不改代码 |
|
||||||
|
| gemini_service evaluate_schema | MVP彻底不搬,eval_score留NULL,第二/三期接真评分(避免假信号污染飞轮) |
|
||||||
|
|
||||||
|
### 🔴 完全新造(banana没有,灵魂部件)
|
||||||
|
| 新造模块 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| gemini_factory.py + Fernet解密 | 解决全局单例,每任务用自己的key构建实例 |
|
||||||
|
| preference_collector.py | 飞轮3信号入口(选文案/选图/审核)写入events |
|
||||||
|
| preference_aggregator.py | 实时聚合events→prompt片段,三层继承叠加 |
|
||||||
|
| api/products + benchmark_notes | 品牌库CRUD,含source=preset预置品类 |
|
||||||
|
| api/matrix_accounts | 矩阵号CRUD(MVP前端可后做,库和接口先建) |
|
||||||
|
| api/api_keys | 录入/删除/查询(只显后4位),首登强制引导 |
|
||||||
|
| middleware/workspace_guard | 所有接口强制注入workspace_id,防串数据 |
|
||||||
|
| api/review | 组长待审列表/通过/打回 |
|
||||||
|
| generate_text_variants | 一次调用5角度JSON,替代banana单条循环 |
|
||||||
|
| text_import_handler | 轨B:导入外部文案(豆包等)直接进候选池,跳过AI生成(洞2文案双轨) |
|
||||||
|
| banned_word_checker | 违禁词三级扫描:🟢自动改写/🟡软提示给建议词/🔴硬拦截,词库可配置(洞5) |
|
||||||
|
| image_postprocessor | 出图后处理:重编码去C2PA元数据+转JPG/轻重采样削像素水印+压缩(洞3去水印) |
|
||||||
|
| package_exporter | 生成达人素材交付包:按笔记分文件夹+图(01/02命名)+文案.txt+发布清单+合规说明(洞6) |
|
||||||
|
| _combine_benchmarks | 1-5篇标杆合并成综合reference |
|
||||||
|
| base_workspace_repo | 所有workspace相关Repo继承,强制过滤 |
|
||||||
|
| Alembic 001-004 | 按序建14张表(一期) |
|
||||||
|
| constants/strategies.py | 统一命名中心文件,消除三套打架 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、构建顺序(三期,一期拆1A/1B)
|
||||||
|
|
||||||
|
> 修正:原"一期3-4周"塞太满。拆成 1A生产链闭环 + 1B飞轮闭环,先证明能产出,再证明会学习。
|
||||||
|
|
||||||
|
### 一期·1A:生产链闭环(先让北哥能产出内容)
|
||||||
|
手动建北哥账号(不接短信)、手动/截图录标杆、产品库、按用户设定数量出文案(双轨)、出图去水印、选择、审核、打包交付。
|
||||||
|
1. **基础设施**:建仓库+6容器;Alembic 001-002(不含matrix_accounts);手动初始化北哥workspace+管理员
|
||||||
|
2. **品牌库**:Alembic 003;产品档案CRUD(含文案角度+可调prompt可配置);标杆笔记CRUD;预置素颜霜1条;初始化banned_words(MVP起步5词:美白/祛斑/速效/医用/药妆,updatable,北哥合规标准来了批量导入)
|
||||||
|
- ⚠️ 标杆录入主通道=**截图+手填亮点**(洞1实测确定可行);登录态采集=北哥内部插件,二期可选
|
||||||
|
3. **API Key录入**:user_api_keys+Fernet+录入界面;FERNET_KEY走环境变量;token站url固定自家站;没配key时按钮置灰引导
|
||||||
|
4. **核心生产链**:中心常量文件;GeminiService改造+gemini_factory(key只在worker内解密);analyze搬入;**文案双轨(轨A生成5角度/轨B导入外部文案)**;**违禁词三级扫描**;并发生图(数量用户设定);Celery只传task_id;SSE推送
|
||||||
|
5. **选择+审核+交付包**:选文案/选图/提审/审核队列/通过打回(审核字段明确定义);**出图后处理(去水印)**→**生成达人素材交付包**
|
||||||
|
- 出图后处理(洞3):图像库重编码去C2PA元数据 + 转JPG/轻重采样削像素水印 + 压缩瘦身,对客户透明
|
||||||
|
- 交付包结构(洞6):按笔记分文件夹,每夹含图(01/02/03命名防传错序)+文案.txt(标题+正文+标签),附📋发布清单.txt + ✅合规说明.txt(违禁词已过/已去水印)
|
||||||
|
- 验收:北哥1名熟手不靠开发,从选产品→生成→选择→组长审→**下载达人能直接发的素材包**,全程跑通
|
||||||
|
|
||||||
|
### 一期·1B:飞轮闭环(让产出会学习)
|
||||||
|
1. Alembic 004(只建preference_events,不建preference_profile)
|
||||||
|
2. 三信号入口接线(选文案/选图/审核)
|
||||||
|
3. compose_preference_context 实时聚合(最近50条,workspace_id硬过滤)
|
||||||
|
4. 偏好+打回原因注入prompt
|
||||||
|
- 验收(四点全过):events正确写入 / aggregator返回明确片段 / 下次prompt trace含该片段 / UI显示"本次已注入"
|
||||||
|
|
||||||
|
**一期整体验收**:北哥熟手独立跑完生产链;飞轮选过3次后下次生成能看到偏好摘要并验证注入。
|
||||||
|
|
||||||
|
### 二期:多团队+对外销售(再2-3周)
|
||||||
|
> 留口子已在地基,二期只"打开注册开关",不回头改表。
|
||||||
|
1. 手机号+验证码自助注册(选短信服务商) + workspace创建 + 邀请成员
|
||||||
|
2. matrix_accounts建表+矩阵号管理UI;L2人设层激活
|
||||||
|
3. preference_profile物化(events满10条触发重算,加载<50ms)
|
||||||
|
4. 管理后台:各workspace用量统计+飞轮健康度
|
||||||
|
|
||||||
|
### 三期:效果回流+AI审核员(看业务)
|
||||||
|
1. 发布效果回填(赞/收藏/评论→outcome_score加权)
|
||||||
|
2. L3 vs L1偏差检测(个人偏好偏离品牌基线>60%提示,防学坏)
|
||||||
|
3. Invariants 8维特征接线(飞轮统计从单一angle扩到8维)
|
||||||
|
4. **AI审核员**:用积累的"组长过不过+理由"蒸馏人工标准,训练AI审核员逐步接班(补banana假评分窟窿,呼应基石D)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、待拍板判断点
|
||||||
|
|
||||||
|
### 已拍板(2026-06-03)
|
||||||
|
- ✅ 项目代号:**Clover**,文件夹建在 北哥小红书产品/Clover/
|
||||||
|
- ✅ 先做北哥单车间,但地基留口子,其他车间可快速打通
|
||||||
|
- ✅ 审核:先组长人工审为主 → 蒸馏人工标准 → 后期AI审核员接班
|
||||||
|
- ✅ 注册方式:**手机号+验证码**(对标小红书,已查证小红书就是手机号注册)。需接短信验证码服务商(阿里云/腾讯云SMS)
|
||||||
|
- ✅ 矩阵号:**一期不做**。L2人设层一期不激活,飞轮一期只跑"L1品牌基线+L3个人手感"两层;matrix_accounts表仍建好留口子,二期补L2
|
||||||
|
- ✅ 排版:**不做独立排版环**,第7环并入第6环——AI生图时直接把文字/版式排在图上(出成品图)
|
||||||
|
- ✅ **标杆采集通道(06-04洞1已拍板,实测3条真链接裸抓全返回风控假页)**:
|
||||||
|
- 对外SaaS一期主通道=**截图+手填亮点**(客户自己截图,零平台风险)
|
||||||
|
- 链接自动读取=**登录态采集方案**(本地已有跑通代码:n8n-nodes-xiaohongshu + xhs-sign-server,Playwright跑小红书自家JS算签名),但"系统替客户取数据"=违规+封号风险,只作**北哥内部自用插件、二期可选模块,不进对外标配**
|
||||||
|
- 官方蒲公英/聚光接口=二期看北哥企业资质再议
|
||||||
|
- ✅ **软件收费(06-04洞4已拍板)**:MVP阶段软件**免费**,不做计费墙;token耗材费走自家token站、按账号key隔离,不在产品层计费。收费逻辑(底层麻烦)产品成熟后单独讨论
|
||||||
|
|
||||||
|
### 待拍板
|
||||||
|
- **P1 短信服务商**:用阿里云短信还是腾讯云短信?(影响成本和接入,都需企业实名)(注:仅二期自助注册才需要,一期手动建北哥账号不阻塞)
|
||||||
|
- ~~P2 标杆链接读不到时的兜底~~ → **已由06-04洞1解决**:对外=截图手填(主通道),登录态采集作内部插件,不再是待拍板项
|
||||||
|
- **P2 北哥账号开通**:一期北哥账号由我们手动建(已定,不接短信);自助注册留二期
|
||||||
55
PRD/PRD-0-总纲.md
Normal file
55
PRD/PRD-0-总纲.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Clover PRD-0 总纲
|
||||||
|
|
||||||
|
> 3份PRD的"对齐层":前后端共享的目标/范围/流程/验收/里程碑,避免两份重复。
|
||||||
|
> **承接层**:决策见 `../Clover架构方案.md`(v0.3) + `../CLAUDE.md`;前端见 `PRD-前端.md`;后端见 `PRD-后端.md`。
|
||||||
|
> 读PRD顺序:先读本总纲对齐全局 → 按角色分工读前端/后端 → 冲突以架构方案v0.3+CLAUDE.md为准。
|
||||||
|
|
||||||
|
## 1. 产品定位
|
||||||
|
- 一句话:北哥团队的小红书达人素材"中央厨房"(多租户SaaS)
|
||||||
|
- 界面名:龙石小红书内容生产平台 ; 项目代号:Clover🍀
|
||||||
|
- 核心差异化 = **偏好飞轮**(越用越懂你的"老师傅")
|
||||||
|
|
||||||
|
## 2. 目标用户与三角色
|
||||||
|
- 内容生产方(北哥团队):熟手(运营·每天产内容) + 组长(审核) + 管理员(北哥/老板·配业务)
|
||||||
|
- 发布方(达人):只收素材包,不进系统
|
||||||
|
- 平台方(我们):收口token,持有飞轮偏好资产
|
||||||
|
|
||||||
|
## 3. 范围边界(做/不做)
|
||||||
|
- ✅做:①产品配置 ②生成文案(双轨) ③生成图 ④裂变 ⑤存档打包交付 + 飞轮(开放测试即启动)
|
||||||
|
- ❌不做:软件收费/达人进系统/自动发布小红书/原始链接裸抓/移动端
|
||||||
|
|
||||||
|
## 4. 核心用户旅程(5屏·前后端共识)
|
||||||
|
```
|
||||||
|
管理员配置台(偶尔) → 熟手:开任务→挑文案(出N选若干)→挑图(出N选若干,N用户设定,图≤8)→确认预览→提审
|
||||||
|
→ 组长:审核台 通过/打回 → 熟手导出打包 → 人工发/给达人
|
||||||
|
```
|
||||||
|
- 飞轮全程隐形记录(挑文案+3/挑图+3/通过+5/打回-3/重生成-1)
|
||||||
|
|
||||||
|
## 5. 全流程5环节定义
|
||||||
|
①产品配置(管理员录) ②生成文案(轨A一次5角度/轨B导入外部) ③生成图(gpt-image-2主+edits+分镜) ④裂变(1爆款→N笔记包) ⑤存档打包(达人素材交付包) ; 飞轮贯穿②③④
|
||||||
|
|
||||||
|
## 6. 关键产品决策(引用,不展开)
|
||||||
|
- 见架构方案§二决策表 + CLAUDE.md:gpt-image-2主/token-url收口/MVP免费/飞轮即用即启/违禁词三级/数据归属分层/质量过关才验收
|
||||||
|
|
||||||
|
## 7. 验收线(总·分三道)
|
||||||
|
1. **内部验收**(我们):一期1A生产链跑通(熟手独立从配置→生成→选择→组长审→下载能发的素材包) + 1B飞轮四点验收(events写入/聚合返片段/下次prompt含片段/UI显示已注入)
|
||||||
|
2. **北哥验收**:质量过关(不是能下载就算完,北哥认可内容质量)
|
||||||
|
3. **上线**:北哥验收通过 → 上线
|
||||||
|
- 🔴 质量标准/优化prompt 由北哥方案注入,架构留可调位
|
||||||
|
|
||||||
|
## 8. 名词表
|
||||||
|
- 达人素材交付包:按笔记分文件夹+图01/02命名+文案.txt+发布清单+合规说明,人工发
|
||||||
|
- 偏好飞轮:隐形记录用户选择,越用越懂,三层继承(L1品牌>L2人设二期>L3个人)
|
||||||
|
- storyboard分镜:一套图N张各担叙事角色(钩子/痛点/证明/质感/背书/转化)
|
||||||
|
- 爆款参考度:裂变时学爆款的强弱(低30/中/高85防抄袭)
|
||||||
|
- 双轨:轨A自家AI生成/轨B导入豆包等外部文案
|
||||||
|
|
||||||
|
## 9. 里程碑(MVP切片·引用架构方案§五)
|
||||||
|
| 期 | 范围 | 验收 |
|
||||||
|
|----|------|------|
|
||||||
|
| 一期1A | 生产链闭环(配置/文案双轨/生图去水印/选择/审核/打包) | 熟手独立跑通下载能发素材包 |
|
||||||
|
| 一期1B | 飞轮闭环(三信号入口/实时聚合/注入prompt) | 飞轮四点全过 |
|
||||||
|
| 二期 | 多团队对外(手机注册/矩阵号L2/偏好物化/管理后台) | 留口子已在地基 |
|
||||||
|
| 三期 | 效果回流+AI审核员(蒸馏组长标准接班) | 看业务 |
|
||||||
|
- 🔴 切法:先证明能产出(1A)→再证明会学习(1B)→再对外(二期)→再智能(三期)
|
||||||
|
- 一期不做但建表:matrix_accounts/preference_profile
|
||||||
106
PRD/PRD-前端.md
Normal file
106
PRD/PRD-前端.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Clover PRD-前端(界面名:龙石小红书内容生产平台)
|
||||||
|
|
||||||
|
> 前端技术框架。**承接层**:5屏画面金标准见 `../../前端交互设计.md`(经倩倩姐确认),视觉见 `../../前端设计参考图.png`。本文①承接5屏②补工程细节③对接后端契约——**唯一冻结源 = `../启动包/API契约.md`**(端点§1 / SSE事件§2 / 错误码§0),不看 PRD-后端。
|
||||||
|
> 技术栈:Next.js+React+TS+Tailwind+Zustand(范式扒banana/frontend,全新重建)。PC优先,最小宽度1280px,不做移动端。
|
||||||
|
> 🔴 红线(CLAUDE.md):不展示Token余额/不做积分页/key只录不显余额。
|
||||||
|
|
||||||
|
## 0. 一句话定位
|
||||||
|
前端就是"挑挑挑":挑文案(出N条选若干)→挑图(出N张选若干,N=用户设定,图最多8)→组长挑过不过。飞轮全程隐形记录。
|
||||||
|
|
||||||
|
## 1. 三角色 + 路由表
|
||||||
|
| 角色 | 默认首页 | 能进的页 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 熟手(运营) | /tasks/new | 开任务/挑文案/挑图/确认预览/历史 |
|
||||||
|
| 组长 | /review | 审核台/历史 |
|
||||||
|
| 管理员(北哥/老板) | /config | 配置台(产品/标杆/成员/违禁词)+全部 |
|
||||||
|
|
||||||
|
路由表:
|
||||||
|
- /login(登录+首登key引导) / /dashboard(工作台) / /tasks/new(开任务)
|
||||||
|
- /tasks/[id]/text(挑文案) / /images(挑图) / /confirm(确认预览·熟手提审前)
|
||||||
|
- /review(组长审核台) / /history(历史归档) / /config(配置台·管理员)
|
||||||
|
- 🔴 角色路由守卫:/review只组长+管理员;/config只管理员;越权重定向工作台
|
||||||
|
|
||||||
|
## 2. 全局布局(对齐设计图)
|
||||||
|
- 顶部:标题"龙石小红书内容生产平台"+右上角当前用户(运营-小李)
|
||||||
|
- 5步流程条(1开新任务/2选择文案/3选择图片/4确认文案/5成片发布)——熟手常驻;组长/管理员登录后不走此条(各进自己首页)
|
||||||
|
- 左侧8项导航(工作台/开新任务/选择文案/选择图片/建立任务/任务列表/历史归档/配置中心)
|
||||||
|
- 主区 + 右侧配置预览面板
|
||||||
|
- 🔴 底部原Token余额位置(设计图568320)→**移除**,替换为版本号/留白小草装饰
|
||||||
|
- 视觉:暖橙/奶油色+绿色小草
|
||||||
|
|
||||||
|
## 3. 五屏详述(承接前端交互设计.md)
|
||||||
|
|
||||||
|
### 屏1 配置台(管理员·/config)
|
||||||
|
- 产品库:素颜霜/面膜/精华卡片+[加产品](品类不写死,客户自助加)
|
||||||
|
- 每产品配:卖点/品牌词/违禁词(三级可调)/固定标签/风格调性/可调prompt/参考图
|
||||||
|
- **标杆笔记管理**(一期主通道=截图上传+手填亮点;列表展示;关联产品)
|
||||||
|
- 成员管理(workspace邀请/角色分配) ; key录入(各人自录,只录不显余额)
|
||||||
|
- → 飞轮的"出厂设置",配一次天天用
|
||||||
|
|
||||||
|
### 屏2 开新任务(熟手·/tasks/new·设计图主表单)
|
||||||
|
- 选产品[素颜霜▾] + **今天主题输入框**(如"黄皮提亮·早八伪素颜",驱动当次方向,关键字段勿漏)
|
||||||
|
- 单条数量/生成数量(可+/-,数量不写死) + 生成方向
|
||||||
|
- 双轨入口:轨A[开始批量生成文案] / 轨B[导入外部文案](粘贴豆包等)
|
||||||
|
- 右侧配置预览面板:产品名称/卖点/成分/质地/标签/参考图3张 + [编辑配置](点击→侧滑面板原地编辑,改完实时刷新预览)
|
||||||
|
- **生成前显示"本次已注入:最近偏好X/打回原因Y"**(调GET /preference/context,基石E④)
|
||||||
|
- 底部"最近任务"横向卡(4个,任务名+状态+进度条)
|
||||||
|
|
||||||
|
### 屏3 挑文案(熟手·/tasks/[id]/text·飞轮第1次学)
|
||||||
|
- 生成N条文案卡(可多选),每卡:标题+正文+标签+**五维分数**(标题/情绪/买点/关键词/合规,展示分数条+总分,数据来自后端scoreCopy)
|
||||||
|
- 轨B导入的文案标注来源区分(source=import角标)
|
||||||
|
- 选中触发飞轮+3(隐形,无"训练AI"按钮) ; [用这N条去生图→]
|
||||||
|
|
||||||
|
### 屏4 挑图+出成品(熟手·/tasks/[id]/images·飞轮第2次学)
|
||||||
|
- N批图缩略图(可多选) ; 选中触发飞轮+3
|
||||||
|
- 选中图+文案→自动套版预览(封面+内页拼成笔记长相)
|
||||||
|
- [满意,交组长审→]
|
||||||
|
|
||||||
|
### 屏4.5 确认预览(熟手提审前·/tasks/[id]/confirm)
|
||||||
|
- 熟手提审前最后预览整套笔记 ; [提交审核] ; [重新生成](飞轮-1)
|
||||||
|
- ⚠️ 注意:通过/打回是组长动作,不在此页(修正骨架视角错位)
|
||||||
|
|
||||||
|
### 屏5 组长审核台(组长·/review·飞轮最强信号)
|
||||||
|
- 待审笔记列表(N):每条[预览]+[✓通过]+[✗打回+原因]
|
||||||
|
- 通过+5(权重最高,飞轮最强对信号) ; 打回必填原因(下次自动注入prompt提醒AI别再犯,-3)
|
||||||
|
- 打回原因输入:模态框,必填校验
|
||||||
|
- 通过→进成品库(熟手可去屏导出打包) ; 打回→任务回pending_selection
|
||||||
|
- 导出后人工去小红书/给达人发(工具到此为止,诚实边界:小红书无接口不自动发)
|
||||||
|
|
||||||
|
## 4. 异步生图电影 + 飞轮隐形 + 错误态
|
||||||
|
|
||||||
|
### 4.1 异步生图进度(承接交互设计.md §2·体验核心)
|
||||||
|
对接后端SSE事件(契约§2):
|
||||||
|
- 点生图→不空白干等:显"正在生成N批…0/N [进度条] 预计30-60秒,可先去忙别的好了提醒"
|
||||||
|
- 并发跑,**谁好谁先冒**(好一批亮一批,对接 image_candidate 事件,每个带 event_seq) ; "3/N"实时计数
|
||||||
|
- 单批失败标红+[⟳重试](不拖垮其他批) ; "完成9/10,这9批够挑了[继续→]"(不强求全齐)
|
||||||
|
- 🔴 铁律:老demo"点完干等18分钟转圈到崩"绝不能再有
|
||||||
|
|
||||||
|
### 4.2 飞轮隐形运转(交互设计.md §3)
|
||||||
|
- 前端**无任何"训练AI/打分"按钮**(会吓到运营),只如实把动作记给后端(带谁+哪账号)
|
||||||
|
- 三动作埋点:屏3勾文案+3 / 屏4勾图+3 / 屏5通过+5打回-3 / 屏4.5重生成-1
|
||||||
|
- "变聪明"在后端,前端只管操作顺滑+显示"本次已注入"
|
||||||
|
|
||||||
|
### 4.3 错误态UI(对接契约§0 数字错误码,前端按 code 映射语义,**不硬编码字符串码**)
|
||||||
|
| code | 语义 | 前端响应(非统一toast) |
|
||||||
|
|------|------|----------------------|
|
||||||
|
| 42201 | 业务校验失败(未配key/违禁词硬拦截) | 未配key→引导配key页/banner按钮置灰(首登强制引导);违禁词→显示触发词+级别,提交前后立即提示 |
|
||||||
|
| 50002 | AI/token站调用失败 | "生图通道繁忙"特殊提示(区别普通错误)+[重试] |
|
||||||
|
| 50001 | 服务端错误 | "生成失败"+[重试]按钮 |
|
||||||
|
| 40901 | 状态机非法流转 | 提示当前状态不可操作,刷新看最新态 |
|
||||||
|
| 40301 | 越权访问 workspace | "无权访问"提示,跳回工作区列表 |
|
||||||
|
| 40101 | 未认证/JWT失效 | 自动触发token刷新(扒banana已实现),失败则跳登录 |
|
||||||
|
| 40001 | 参数校验失败 | 表单字段级红字提示 |
|
||||||
|
|
||||||
|
> 前端维护一份 `errorCodeMap`(code→{文案,处理动作})常量,所有错误统一查表,违禁词/限速等细分信息从 `message` 字段读。
|
||||||
|
- 真实状况兜底(交互设计.md §4):关页面回来任务还在(后端有状态不靠前端内存)/多人各看各的
|
||||||
|
|
||||||
|
## 5. 工程细节
|
||||||
|
- 状态管理4Store(参banana):authStore(user/workspace_id/role/token) / taskStore(任务id/状态/已选文案图id) / generationStore(SSE进度/各图状态/已完成N) / preferenceStore(本次注入偏好摘要)
|
||||||
|
- SSE客户端(扒banana sse.ts,5次重连退避)+断线重连UI("连接断开重连中1/5…"+"重连失败任务后台仍在,可刷新")
|
||||||
|
- 组件库:流程条/文案卡(带五维分)/分镜图卡/配置预览面板/交付包预览/飞轮注入提示横幅/打回原因模态框/单图进度卡/审核操作栏。🔴 删除"Token余额组件"
|
||||||
|
- 骨架屏:文案卡/图卡/审核队列loading ; 表单校验:产品必选/主题字数限/数量范围
|
||||||
|
- API客户端(扒banana api.ts含token刷新) ; AuthGuard+角色守卫(扒banana)
|
||||||
|
- 可扒banana前端零件:authStore范式/api.ts/sse.ts/AuthGuard/NextUI组件;表单字段全重建(Clover字段不同)
|
||||||
|
|
||||||
|
## 6. ❌不做(CLAUDE.md红线)
|
||||||
|
- Token余额/用量展示(去中转站后台看) / 积分中心billing页 / 移动端适配
|
||||||
114
PRD/PRD-后端.md
Normal file
114
PRD/PRD-后端.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Clover PRD-后端
|
||||||
|
|
||||||
|
> 后端技术框架。**本文是"承接层"**:架构决策见 `../Clover架构方案.md`(v0.3,金标准),本文只做①引用架构决策②补PRD独有工程细节③承接扒包上线版新事实。
|
||||||
|
> 技术栈:FastAPI + Celery + MySQL + MongoDB + Redis(栈范式扒自banana,全新重建)
|
||||||
|
> 🔴 源码只读复制:扒**上线版** `万牛会L1准备/worker/src`(非旧版产品包) + banana。
|
||||||
|
|
||||||
|
## 0. 怎么读这份PRD
|
||||||
|
- 数据模型16表/基石ABCDEF/三期构建顺序/复用复活新造清单 → **见架构方案 §三/§〇.5/§五/§四**,本文不复制。
|
||||||
|
- 本文重点:第3章API契约细节、第9章环境变量、第10章工程规范(这些架构方案没细化)。
|
||||||
|
- 与架构方案冲突时:**以架构方案v0.3 + CLAUDE.md为准**。
|
||||||
|
|
||||||
|
## 1. 后端架构总览
|
||||||
|
- 分层:API层 / Service层(AI引擎) / Model层(双库) / 队列层(Celery+SSE) —— 详见架构方案§一
|
||||||
|
- AIProvider抽象层(扒banana):**gpt-image-2主/Gemini备**,由环境变量 IMAGE_PROVIDER_PRIMARY 配置(不写死)
|
||||||
|
- 🔴 三条铁律入口(详见架构方案§〇.5):A业务参数不写死 / B key不进Celery+Fernet加密 / C workspace逻辑隔离
|
||||||
|
|
||||||
|
## 2. 数据模型
|
||||||
|
- **完整16表清单+字段+Alembic001-004分批 → 见架构方案§三**,本文不重抄。
|
||||||
|
- 🔴 PRD骨架旧表名修正(以架构方案为准):
|
||||||
|
- api_keys→**user_api_keys**(UNIQUE user_id+workspace_id+provider)
|
||||||
|
- tasks→**generation_tasks**(审核字段平铺:review_status/reviewer_id/reviewed_at/reject_reason/approved_at/archived_at)
|
||||||
|
- 无assets表→实为 **text_candidates + image_candidates**(text_candidates有source=ai/import区分双轨)
|
||||||
|
- 补 **login_records**(扒banana) / 补 **ai_call_logs**(usage记录,按key计费排障基础,一期必做)
|
||||||
|
- data_ownership是**preference_events的字段**(client_data/platform_asset),不是独立表
|
||||||
|
- 任务状态机7态:pending→generating→pending_selection→pending_review→approved/rejected→archived(打回回pending_selection)
|
||||||
|
- MongoDB**只存AI debug trace**(业务不依赖);可调prompt在 products 表字段,**不在Mongo**
|
||||||
|
- 关键索引(架构没列,PRD补):generation_tasks(workspace_id,status) / preference_events(workspace_id,product_id,created_at)
|
||||||
|
|
||||||
|
## 3. API 契约 → 唯一冻结源:`启动包/API契约.md`
|
||||||
|
|
||||||
|
> ⚠️ 契约统一:本 PRD **不再另写一套契约**。端点/响应包络/错误码/SSE事件/分页,
|
||||||
|
> 全部以 `Clover/启动包/API契约.md` 为唯一冻结源(避免前后端分叉,外部AI审核P0-1/2/8)。
|
||||||
|
> 开工首步:Lead 据 API契约.md 生成并冻结 `Clover/contracts/openapi.yaml`("契约优先"落地,非口号)。
|
||||||
|
|
||||||
|
本 PRD 第3章只补**契约文档未覆盖的后端实现约定**(不重复端点定义):
|
||||||
|
- 任务软删用标记位不物理删;DELETE /tasks/{id} 走 archived 态。
|
||||||
|
- 限流:同用户并发生成任务数上限(建议2,可配);生图每张~4min 异步不占连接。
|
||||||
|
- SSE token 走 header(Authorization),不走 query(对齐契约,纠 banana 旧法)。
|
||||||
|
- preference 写入**不暴露独立埋点端点**:选择/审核业务接口内部写 events,前端只调业务动作(外部AI审核P1-14)。
|
||||||
|
- preference 读:GET /preference/context 已列入 API契约端点清单(外部AI审核P2-13)。
|
||||||
|
|
||||||
|
## 4. AI 引擎(扒上线版 worker/src 重写Python·核心)
|
||||||
|
> 算法逻辑整体扒上线版copy.js/image.js/split.js重写为Python,逻辑照搬防走样(TDD对照)。
|
||||||
|
|
||||||
|
### 4.1 文案引擎(扒copy.js上线版)
|
||||||
|
- **轨A**:一次调强模型返**N角度JSON**(N=用户设定text_count,不串行不并发,新造generate_text_variants)——这是去同质化关键,勿照抄旧版单条循环。数量不写死(基石A)。
|
||||||
|
- **轨B**:导入外部文案(豆包等)直接进候选池跳过AI(text_import_handler,text_candidates.source=import,洞2)
|
||||||
|
- 保留(扒上线版重写Python集成进轨A):三层兜底Claude→Gemini→本地 / 五维打分90过线 / 自动优化循环 / **正文+开头去重(splitPublishableContent/contentSignature)** / **角度槽位(angleSlotsForCategory,作可迁移数据非代码常量)** / 内部提示剥离(content只放可发布正文)
|
||||||
|
- 可调prompt:products表字段,北哥可配(等北哥优质样本+判断标准注入,质量责任在北哥侧)
|
||||||
|
|
||||||
|
### 4.2 生图引擎(扒image.js上线版+改造)
|
||||||
|
- 扒保留:storyboard 10角色分镜 / getNarrativeRoles(3/6/8张链路) / 6品类proofStrategy / 素材路由 / buildVisualSystem成组视觉
|
||||||
|
- 🔴 生图通道(扒上线版已成熟,直接用):
|
||||||
|
- 主备由 IMAGE_PROVIDER_PRIMARY=gpt/FALLBACK=gemini 配置(imageProviderOrder)
|
||||||
|
- GPT走 **edits带产品参考图**(requestGptImageWithReferences),**禁纯文生图防跑偏**(无产品图报错)
|
||||||
|
- 产品图=**不可修改商品锚点**,禁改包装/换产品/混历史产品(防串货)
|
||||||
|
- 🔴 补(上线版仍缺):**生图通道加重试退避**(gpt-image-2实测502,baseline验证必要)
|
||||||
|
- 🔴 一期执行模型(纠正混淆,外部AI审核P0-3/4):
|
||||||
|
- **一期只做 storyboard 分镜图**:一篇笔记出 N 张叙事图(N=用户设定image_count,默认3,**合法1-8**,对齐worker getNarrativeRoles 3/6/8档链路,**不是固定10选3**)
|
||||||
|
- 每张 storyboard 图走 provider 主备(gpt edits→gemini fallback) + 重试退避
|
||||||
|
- **A/B/C 三策略不在一期主链路**:A/B/C 是"同一目标图的三种改图策略",留给二期的"单图重生成/改图"功能,不与 storyboard 分镜混跑(否则任务数×UI 爆)
|
||||||
|
- getNarrativeRoles 角色框架(hook/pain_scene/applied_proof/texture/scenario/tutorial/social_proof/closer)是可迁移数据,按 image_count 取前 N 个角色
|
||||||
|
|
||||||
|
### 4.3 裂变引擎(扒split.js上线版)
|
||||||
|
- 1爆款→N完整笔记包(标题+正文+标签+imagePlan分镜+维度+人群+场景+痛点)
|
||||||
|
- 爆款参考度调节(低30/中/高85防抄袭) ; generateImages=true串生图
|
||||||
|
- _combine_benchmarks:1-5篇标杆合并成综合reference(新造)
|
||||||
|
|
||||||
|
### 4.4 去水印(image_postprocessor·新造·洞3)
|
||||||
|
- 🔴 主方案=**重编码去C2PA元数据+转JPG+轻重采样削像素水印+压缩**(Pillow,对客户透明);保留可视质量,**C2PA元数据可去除,私有像素水印只能削弱不保证100%清除**(诚实表述,外部AI审核P1-9)
|
||||||
|
- 备选=banana remove_watermark(Gemini重绘)——⚠️对海报中文大字有改字风险,仅特殊场景用,非主通道
|
||||||
|
- eval_score:**一期留NULL**,不接banana假评分(88分污染飞轮),等组长标准蒸馏真评分(三期)
|
||||||
|
|
||||||
|
## 5. 异步队列(扒banana骨架)
|
||||||
|
- Celery任务:生图异步(每张~4min) / **只传task_id绝不传key(基石B)** / worker内查库取encrypted_key→FERNET_KEY解密→局部变量,不落盘不打日志
|
||||||
|
- gemini_factory:每任务用自己key构建实例(新造,解决全局单例)
|
||||||
|
- 失败处理:Celery task失败→更新任务状态→SSE推task_failed(带code) ; 生图502重试退避
|
||||||
|
- SSE:Redis Pub/Sub推进度(扒banana event_bus)
|
||||||
|
|
||||||
|
## 6. 多租户与安全(基石BC,扒banana JWT)
|
||||||
|
- JWT HS256每请求验签,放user_id/workspace_id/role加速字段
|
||||||
|
- 🔴 读写分层(基石C,外部AI审核P1-7细化):
|
||||||
|
- **写操作+切workspace**:必须查 workspace_members 校验权限
|
||||||
|
- **列表读**:用 JWT 的 current_workspace_id 过滤即可(加速)
|
||||||
|
- **单资源读**(任务详情/候选详情/下载包):仍须校验 resource.workspace_id == current_workspace_id,不能笼统信JWT(防越权读他人资源)
|
||||||
|
- base_workspace_repo:所有workspace相关Repo继承,强制带workspace_id过滤(新造,防串数据)
|
||||||
|
- workspace_guard中间件:FastAPI依赖注入,所有业务接口强制注入workspace_id
|
||||||
|
- token-url收口:统一自家中转站endpoint;**账号表只存encrypted_key不存url字段**(旧"兼容任意url"已废止)
|
||||||
|
- 上传安全(扒banana):magic number校验+扩展名白名单+路径穿越防护
|
||||||
|
|
||||||
|
## 7. 存档打包(package_exporter·新造·洞6)
|
||||||
|
- 达人素材交付包:按笔记分文件夹+图(01/02/03命名防错序)+文案.txt(标题+正文+标签)+📋发布清单.txt+✅合规说明.txt(违禁词已过/已去水印)
|
||||||
|
- delivery_packages表:status(pending/ready/downloaded)/package_path
|
||||||
|
- 数据可导出(归客户,client_data) ; 飞轮偏好归平台(platform_asset)
|
||||||
|
|
||||||
|
## 8. 偏好飞轮(新造·灵魂·开放测试即启动)
|
||||||
|
- 三信号入口(preference_collector写events):选文案+3/选图+3/通过+5/打回-3/重生成-1(初始默认权重,北哥可校准)
|
||||||
|
- 实时聚合(preference_aggregator):查最近50条→最常选角度+打回原因近3条原文拼进prompt;不足5条用产品档案冷启动
|
||||||
|
- 三层继承:L1品牌基线>L2人设(二期)>L3个人手感;⚠️按产品维度分开学(素颜霜不串精华)
|
||||||
|
- 🔴 飞轮MVP验收四点(基石E,全过才达标):events正确写入✓ / aggregator返回明确prompt片段✓ / 下次prompt trace含该片段✓ / UI显示"本次已注入:最近偏好/打回原因"✓
|
||||||
|
|
||||||
|
## 9. 配置与环境(架构没列,PRD补)
|
||||||
|
- 环境变量完整清单(必填项启动时校验,缺失则启动失败报错不静默):
|
||||||
|
- FERNET_KEY(必填,key解密,绝不进代码库) / JWT_SECRET(必填≥32字符) / DATABASE_URL / MONGO_URI / REDIS_URL
|
||||||
|
- IMAGE_PROVIDER_PRIMARY=gpt / IMAGE_PROVIDER_FALLBACK=gemini / IMAGE_API_BASE(自家中转站url) / IMAGE_MODEL=gpt-image-2
|
||||||
|
- CLAUDE_API_URL / GEMINI_API_URL(都收口自家站)
|
||||||
|
- 6容器Docker Compose(扒banana,调环境变量名) ; 文件存储:交付包路径规则uploads/packages/{workspace}/{task}/
|
||||||
|
|
||||||
|
## 10. 工程规范与可观测
|
||||||
|
- 健康检查 GET /health(Docker健康检查用)
|
||||||
|
- Alembic迁移:001-004按序;回滚策略;FERNET_KEY缺失启动保护
|
||||||
|
- 可观测:每次AI调用记ai_call_logs(谁/多少token,失败归因到个人key) ; MongoDB存调用trace排障
|
||||||
|
- 错误处理:统一错误码(§3.2) ; Celery失败SSE推送
|
||||||
|
|
||||||
34
PRD/前端PRD交叉审核报告.md
Normal file
34
PRD/前端PRD交叉审核报告.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 前端PRD交叉审核报告(2026-06-05)
|
||||||
|
|
||||||
|
> 审核方式:agent对照 设计图 + 前端交互设计.md(5屏金标准) + 架构方案三角色旅程 + 后端PRD契约 + banana前端范式,逐条核对前端PRD骨架。
|
||||||
|
> 重大发现:**已有 `前端交互设计.md` 是经倩倩姐确认的5屏金标准**(配置台/开任务/挑文案/挑图/组长审核+异步生图电影+飞轮隐形+6真实状况),前端PRD骨架没承接它→这是骨架"看不懂"的根因。铁律:好的保留,这份5屏设计原样吃进PRD。
|
||||||
|
|
||||||
|
## 审出问题(11遗漏+4不一致+工程空白)
|
||||||
|
|
||||||
|
### 🔴 重大遗漏
|
||||||
|
1. **组长审核台**整体缺失(屏5):审核队列/预览/通过+5/打回写原因(飞轮最强信号)/审后跳转。三角色只画了熟手
|
||||||
|
2. **"今天主题"输入框**漏了(设计图屏2关键字段):驱动当次文案方向,缺了=无方向生成
|
||||||
|
3. **文案双轨轨B**前端入口缺(导入豆包等外部文案/source=import的UI区分)
|
||||||
|
4. **飞轮"本次已注入偏好"提示UI**缺(基石E验收点④,后端专开GET /preference/context供它调)
|
||||||
|
5. **SSE逐图进度电影**只一行(交互设计.md §2:N/10计数+好一批亮一批+单批失败标红重试+能离开降焦虑)
|
||||||
|
6. **配置预览面板"编辑配置"入口**交互未描述(跳转/侧滑/模态?)
|
||||||
|
7. **错误态UI**只"toast"一笔带过(后端7错误码各不同:KEY_NOT_CONFIGURED=引导配key页/BANNED_WORD=显违禁词级别/502重试等)
|
||||||
|
8. **管理员配置台**缺(屏1:产品档案/标杆/成员/违禁词CRUD)
|
||||||
|
9. **标杆笔记管理界面**缺(一期主通道=截图上传+手填亮点)
|
||||||
|
10. **"最近任务"底部区**缺(设计图底部4个进度条卡)
|
||||||
|
11. **五维评分**没说哪五维/怎么展示(数字/雷达/进度条)/数据来自哪字段
|
||||||
|
|
||||||
|
### 🟡 不一致
|
||||||
|
- C1:设计图有Token余额568320,PRD已正确标"不展示",但没说底部位置替换成什么
|
||||||
|
- C2:第6章组件库还残留"Token余额组件"(与3bis矛盾,删)
|
||||||
|
- C3:3.6"确认文案"混淆熟手/组长视角(通过打回是组长在独立审核台做,不是熟手确认页)
|
||||||
|
- C4:5步流程条对组长/管理员是否隐藏未说(组长主界面是审核队列不走1→5)
|
||||||
|
|
||||||
|
### 🟢 工程空白(建议补)
|
||||||
|
状态管理4Store(auth/task/generation/preference)/路由表/SSE断线重连UI/骨架屏/表单校验/响应式断点(建议最小1280px PC优先)/角色路由守卫(/review只组长管理员)/首登key引导流
|
||||||
|
|
||||||
|
## 🟦 CC处理判断
|
||||||
|
- 铁律好的保留:**前端交互设计.md的5屏设计原样吃进PRD**,不重新发明
|
||||||
|
- 设计图(5步流程条)与交互设计.md(5屏)合并:开新任务/选文案/选图/确认预览(熟手提审前)/组长审核台;配置台进左侧导航
|
||||||
|
- 前端PRD同样做承接层:引用交互设计.md的5屏画面+设计图视觉,补工程细节(路由/Store/错误态/角色守卫)+对接后端契约(SSE事件/错误码)
|
||||||
|
- 优先级:组长审核台/错误态UI/飞轮注入提示 三项直接卡飞轮和生产链验收
|
||||||
42
PRD/后端PRD交叉审核报告.md
Normal file
42
PRD/后端PRD交叉审核报告.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 后端PRD交叉审核报告(2026-06-05)
|
||||||
|
|
||||||
|
> 审核方式:agent对照架构方案v0.3 + CLAUDE.md + 7洞落盘 + 扒包(上线版) 逐条核对后端PRD骨架完整性。
|
||||||
|
> 结论:后端PRD骨架"只提到名词,没承接细节",架构方案v0.3里拍过板的细节几乎都没落进PRD——这是倩倩姐"看不懂"的根因。
|
||||||
|
|
||||||
|
## 审出的问题(18遗漏+5不一致+10工程空白)
|
||||||
|
|
||||||
|
### 🔴 重大遗漏(架构v0.3拍了板但PRD没承接)
|
||||||
|
1. 基石A 业务参数不写死(品类/数量/角度) 无落点
|
||||||
|
2. 基石B key机制不完整(缺FERNET_KEY环境变量/gemini_factory每任务建实例/解密不落盘)
|
||||||
|
3. 基石C 读写权限分层(写操作查workspace_members)+base_workspace_repo中间件 缺失
|
||||||
|
4. 基石D 采集↔生产接缝(人工判断标准落点) 完全没有
|
||||||
|
5. 基石E 飞轮四点验收 没列
|
||||||
|
6. 16表精确清单+Alembic001-004分批 缺(且PRD表名错:api_keys应为user_api_keys/tasks应为generation_tasks/无assets表应为text+image_candidates/无login_records/无ai_call_logs)
|
||||||
|
7. 任务状态机7态(pending→generating→pending_selection→pending_review→approved/rejected→archived) 缺
|
||||||
|
8. generation_tasks审核字段平铺(6字段,不建独立notes表) 缺
|
||||||
|
9. preference_events字段(signal_type5枚举/weight/angle_label/reason/data_ownership/workspace_id+product_id) 缺
|
||||||
|
10. 文案轨A"一次出5角度JSON"规格 缺(易被agent照抄产品包单条串行)
|
||||||
|
11. 文案轨B(导入外部文案text_import_handler+source字段) 完全缺
|
||||||
|
12. storyboard分镜与A/B/C并发两套生图逻辑如何整合 没说清
|
||||||
|
13. IMAGE_PROVIDER_PRIMARY环境变量配主备+禁纯文生图+产品锚点防串货 缺
|
||||||
|
14. 三期构建顺序1A/1B/二期/三期+一期不做项 缺
|
||||||
|
15. eval_score留NULL不接banana假评分 缺
|
||||||
|
16. 标杆采集通道分层(截图手填主/登录态插件内部) 缺
|
||||||
|
17. ai_call_logs(usage记录,按key计费排障基础) 缺
|
||||||
|
18. MongoDB只存debug trace(业务不依赖)边界 缺(PRD误把prompt_template放Mongo,实际可调prompt在products表)
|
||||||
|
|
||||||
|
### 🟡 不一致
|
||||||
|
- C1: PRD把data_ownership列成独立表,实际是preference_events的字段
|
||||||
|
- C2: 生图把产品包10角色/6品类 和 edits新接口 混在一起没说整合
|
||||||
|
- C3: 去水印主次反了(主=re-encode/Pillow,banana重绘是可选且海报有改字风险)
|
||||||
|
- C4: 三层兜底/五维打分要标明"扒产品包重写Python集成进轨A"
|
||||||
|
- C5: token-url收口缺反面约束(账号表不存url字段/旧兼容废止)
|
||||||
|
|
||||||
|
### 🟢 所有来源都没细化、PRD该补的工程细节
|
||||||
|
错误码规范/SSE事件类型清单/分页规范/限流(生图4min)/文件存储路径/DB索引/Alembic回滚+FERNET_KEY缺失启动保护/健康检查/环境变量完整清单/workspace_guard注入时机
|
||||||
|
|
||||||
|
## 🟦 CC的处理判断(不照搬agent建议)
|
||||||
|
- agent建议把后端PRD扩成14章、把架构方案§四§五原文照搬进PRD → **不采纳全照搬**
|
||||||
|
- 理由:架构方案v0.3已是金标准,PRD原文复制=两份打架+维护噩梦
|
||||||
|
- **正确做法:后端PRD做成"承接层"——①引用架构方案(不复制)②重点补PRD独有的工程细节(§四绿色那10项:错误码/SSE/分页/限流/索引/环境变量等)③把扒包上线版的生图新事实写进去**
|
||||||
|
- 数据模型这种基础:PRD用"引用架构方案§三 + 标注扒包修正点"方式,不重抄16表
|
||||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 系统依赖:curl 供 healthcheck;无需 libmysqlclient-dev(用 pymysql 纯 Python 驱动)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
30
backend/README.md
Normal file
30
backend/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Clover Backend
|
||||||
|
|
||||||
|
FastAPI + Celery + MySQL + MongoDB + Redis
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
app/
|
||||||
|
api/v1/ # 路由层(auth/products/tasks/review/delivery)
|
||||||
|
services/ # 业务逻辑层(按模块拆分)
|
||||||
|
models/ # SQLAlchemy ORM 模型(14张表)
|
||||||
|
middleware/ # workspace_guard 等中间件
|
||||||
|
workers/ # Celery 任务(只传task_id,worker内解密key)
|
||||||
|
constants/ # 策略命名中心文件,消除打架
|
||||||
|
utils/ # Fernet加密、SSE工具等
|
||||||
|
alembic/
|
||||||
|
versions/ # 001-004 migration 脚本
|
||||||
|
tests/ # TDD 测试用例
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖(需装)
|
||||||
|
见 requirements.txt(待 BE agent 填)
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
见 docker-compose.yml(待 BE agent 填)
|
||||||
|
|
||||||
|
## 铁律提醒
|
||||||
|
- API Key 绝不进 Celery 参数,只传 task_id
|
||||||
|
- FERNET_KEY 走环境变量
|
||||||
|
- 所有查询强制带 workspace_id
|
||||||
45
backend/alembic.ini
Normal file
45
backend/alembic.ini
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Alembic 配置文件
|
||||||
|
# Clover 项目使用 alembic.ini 在 backend/ 目录
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# 从环境变量读取,不硬编码(基石B)
|
||||||
|
sqlalchemy.url = %(DATABASE_URL)s
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
16
backend/alembic/README.md
Normal file
16
backend/alembic/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# alembic/
|
||||||
|
|
||||||
|
Alembic 数据库迁移脚本占位:
|
||||||
|
|
||||||
|
## 执行顺序
|
||||||
|
001 → 002 → 003 → 004(有外键依赖,必须按顺序)
|
||||||
|
|
||||||
|
## 版本规划
|
||||||
|
- 001_from_banana.py # users/login_records/user_preferences(从banana搬3张,改造)
|
||||||
|
- 002_multi_tenant_base.py # workspaces/workspace_members/user_api_keys
|
||||||
|
- 003_business_core.py # 业务主体7张(products等)
|
||||||
|
- 004_flywheel.py # preference_events
|
||||||
|
|
||||||
|
## 铁律
|
||||||
|
- matrix_accounts / preference_profile 一期不建(二期预留)
|
||||||
|
- Alembic 版本号统一管理,不允许手动改表
|
||||||
62
backend/alembic/env.py
Normal file
62
backend/alembic/env.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
alembic/env.py — Alembic 运行环境配置
|
||||||
|
从环境变量读取 DATABASE_URL,不硬编码。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
# ── 确保 app 包可导入 ────────────────────────────────
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401 — 触发所有模型注册
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# 从环境变量覆盖 DATABASE_URL
|
||||||
|
db_url = os.environ.get("DATABASE_URL")
|
||||||
|
if db_url:
|
||||||
|
config.set_main_option("sqlalchemy.url", db_url)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
29
backend/alembic/script.py.mako
Normal file
29
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
alembic/script.py.mako — 迁移文件模板
|
||||||
|
"""
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
64
backend/alembic/versions/001_banana_users_tables.py
Normal file
64
backend/alembic/versions/001_banana_users_tables.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""001_banana_users_tables
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
Alembic 001: 从 banana 搬 3 张表(users/login_records/user_preferences)
|
||||||
|
删除 credits 字段,其余无改。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("username", sa.String(64), nullable=False),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("hashed_password", sa.String(255), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("username"),
|
||||||
|
sa.UniqueConstraint("email"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"login_records",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("ip_address", sa.String(64), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(512), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_login_records_user_id", "login_records", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"user_preferences",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("preferences_json", sa.Text(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("user_preferences")
|
||||||
|
op.drop_index("idx_login_records_user_id", "login_records")
|
||||||
|
op.drop_table("login_records")
|
||||||
|
op.drop_table("users")
|
||||||
71
backend/alembic/versions/002_multitenant_workspace.py
Normal file
71
backend/alembic/versions/002_multitenant_workspace.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""002_multitenant_workspace
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
Alembic 002: 多租户基础(workspaces/workspace_members/user_api_keys)
|
||||||
|
matrix_accounts 二期预留,一期不建。
|
||||||
|
user_api_keys 不存 url 字段(token站固定自家站,基石B)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "002"
|
||||||
|
down_revision: Union[str, None] = "001"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"workspaces",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(128), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(64), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("slug"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"workspace_members",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("role", sa.Enum("admin", "supervisor", "operator", name="userrole"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_workspace_members_workspace_id", "workspace_members", ["workspace_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"user_api_keys",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(32), nullable=False),
|
||||||
|
sa.Column("encrypted_key", sa.String(512), nullable=False),
|
||||||
|
sa.Column("key_last4", sa.String(4), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "workspace_id", "provider", name="uq_user_workspace_provider"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_user_api_keys_workspace_id", "user_api_keys", ["workspace_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_user_api_keys_workspace_id", "user_api_keys")
|
||||||
|
op.drop_table("user_api_keys")
|
||||||
|
op.drop_index("idx_workspace_members_workspace_id", "workspace_members")
|
||||||
|
op.drop_table("workspace_members")
|
||||||
|
op.drop_table("workspaces")
|
||||||
|
op.execute("DROP TYPE IF EXISTS userrole")
|
||||||
205
backend/alembic/versions/003_business_tables.py
Normal file
205
backend/alembic/versions/003_business_tables.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""003_business_tables
|
||||||
|
|
||||||
|
Revision ID: 003
|
||||||
|
Revises: 002
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
Alembic 003: 业务主体7张表 + ai_call_logs
|
||||||
|
products / benchmark_notes / banned_words /
|
||||||
|
generation_tasks / text_candidates / image_candidates /
|
||||||
|
delivery_packages / ai_call_logs
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "003"
|
||||||
|
down_revision: Union[str, None] = "002"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_TASK_STATUS = sa.Enum(
|
||||||
|
"pending", "generating", "pending_selection",
|
||||||
|
"pending_review", "approved", "rejected", "archived",
|
||||||
|
name="taskstatus",
|
||||||
|
)
|
||||||
|
_REVIEW_STATUS = sa.Enum("pending", "approved", "rejected", name="reviewstatus")
|
||||||
|
_CANDIDATE_SOURCE = sa.Enum("ai", "import", name="candidatesource")
|
||||||
|
_BANNED_LEVEL = sa.Enum("auto_fix", "soft_warn", "hard_block", name="bannedwordlevel")
|
||||||
|
_BANNED_STATUS = sa.Enum("pass", "auto_fixed", "soft_warn", "hard_block", name="bannedwordstatus")
|
||||||
|
_IMAGE_ROLE = sa.Enum("hook", "pain", "proof", "quality", "credit", "convert", "main", name="imagerole")
|
||||||
|
_PKG_STATUS = sa.Enum("pending", "ready", "downloaded", name="packagestatus")
|
||||||
|
_PRODUCT_SOURCE = sa.Enum("preset", "custom", name="productsource")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"products",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(128), nullable=False),
|
||||||
|
sa.Column("category", sa.String(64), nullable=True),
|
||||||
|
sa.Column("source", _PRODUCT_SOURCE, nullable=False, server_default="custom"),
|
||||||
|
sa.Column("selling_points", sa.Text(), nullable=True),
|
||||||
|
sa.Column("style_tone", sa.String(128), nullable=True),
|
||||||
|
sa.Column("text_angles", sa.Text(), nullable=True),
|
||||||
|
sa.Column("custom_prompt", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_products_workspace_id", "products", ["workspace_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"benchmark_notes",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("product_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("screenshot_url", sa.String(512), nullable=True),
|
||||||
|
sa.Column("highlights", sa.Text(), nullable=True),
|
||||||
|
sa.Column("link_url", sa.String(512), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_benchmark_notes_product_id", "benchmark_notes", ["product_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"banned_words",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("word", sa.String(64), nullable=False),
|
||||||
|
sa.Column("level", _BANNED_LEVEL, nullable=False),
|
||||||
|
sa.Column("replacement", sa.String(128), nullable=True),
|
||||||
|
sa.Column("updatable", sa.Boolean(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_banned_words_workspace_id", "banned_words", ["workspace_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"generation_tasks",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("product_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("operator_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("theme", sa.String(256), nullable=True),
|
||||||
|
sa.Column("text_count", sa.Integer(), nullable=False, server_default="5"),
|
||||||
|
sa.Column("image_count", sa.Integer(), nullable=False, server_default="3"),
|
||||||
|
sa.Column("track", sa.String(16), nullable=False, server_default="ai"),
|
||||||
|
sa.Column("status", _TASK_STATUS, nullable=False, server_default="pending"),
|
||||||
|
sa.Column("mongo_trace_id", sa.String(24), nullable=True),
|
||||||
|
sa.Column("review_status", _REVIEW_STATUS, nullable=True),
|
||||||
|
sa.Column("reviewer_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("reject_reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("approved_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("archived_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["operator_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["reviewer_id"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_generation_tasks_workspace_status", "generation_tasks", ["workspace_id", "status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"text_candidates",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("source", _CANDIDATE_SOURCE, nullable=False, server_default="ai"),
|
||||||
|
sa.Column("angle_label", sa.String(64), nullable=True),
|
||||||
|
sa.Column("content", sa.Text(), nullable=True),
|
||||||
|
sa.Column("score_json", sa.Text(), nullable=True),
|
||||||
|
sa.Column("banned_word_status", _BANNED_STATUS, nullable=False, server_default="pass"),
|
||||||
|
sa.Column("eval_score", sa.Float(), nullable=True),
|
||||||
|
sa.Column("is_selected", sa.Boolean(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_text_candidates_task_id", "text_candidates", ["task_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"image_candidates",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("role", _IMAGE_ROLE, nullable=False, server_default="main"),
|
||||||
|
sa.Column("url", sa.String(512), nullable=True),
|
||||||
|
sa.Column("strategy", sa.String(4), nullable=True),
|
||||||
|
sa.Column("seq", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("is_selected", sa.Boolean(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("eval_score", sa.Float(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_image_candidates_task_id", "image_candidates", ["task_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"delivery_packages",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("status", _PKG_STATUS, nullable=False, server_default="pending"),
|
||||||
|
sa.Column("package_path", sa.String(512), nullable=True),
|
||||||
|
sa.Column("download_url", sa.String(512), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ai_call_logs",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("key_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("task_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("provider", sa.String(32), nullable=True),
|
||||||
|
sa.Column("model", sa.String(64), nullable=True),
|
||||||
|
sa.Column("call_type", sa.String(32), nullable=True),
|
||||||
|
sa.Column("prompt_tokens", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("completion_tokens", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("success", sa.Boolean(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("error_code", sa.String(32), nullable=True),
|
||||||
|
sa.Column("latency_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["key_id"], ["user_api_keys.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_ai_call_logs_workspace_user", "ai_call_logs", ["workspace_id", "user_id"])
|
||||||
|
op.create_index("idx_ai_call_logs_task_id", "ai_call_logs", ["task_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_ai_call_logs_task_id", "ai_call_logs")
|
||||||
|
op.drop_index("idx_ai_call_logs_workspace_user", "ai_call_logs")
|
||||||
|
op.drop_table("ai_call_logs")
|
||||||
|
op.drop_table("delivery_packages")
|
||||||
|
op.drop_index("idx_image_candidates_task_id", "image_candidates")
|
||||||
|
op.drop_table("image_candidates")
|
||||||
|
op.drop_index("idx_text_candidates_task_id", "text_candidates")
|
||||||
|
op.drop_table("text_candidates")
|
||||||
|
op.drop_index("idx_generation_tasks_workspace_status", "generation_tasks")
|
||||||
|
op.drop_table("generation_tasks")
|
||||||
|
op.drop_index("idx_banned_words_workspace_id", "banned_words")
|
||||||
|
op.drop_table("banned_words")
|
||||||
|
op.drop_index("idx_benchmark_notes_product_id", "benchmark_notes")
|
||||||
|
op.drop_table("benchmark_notes")
|
||||||
|
op.drop_index("idx_products_workspace_id", "products")
|
||||||
|
op.drop_table("products")
|
||||||
|
# MySQL 不支持 DROP TYPE,枚举存 VARCHAR 无需清理
|
||||||
67
backend/alembic/versions/004_flywheel_preference_events.py
Normal file
67
backend/alembic/versions/004_flywheel_preference_events.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""004_flywheel_preference_events
|
||||||
|
|
||||||
|
Revision ID: 004
|
||||||
|
Revises: 003
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
Alembic 004: 飞轮信号表
|
||||||
|
preference_events 一期建表写入
|
||||||
|
preference_profile 二期预留,一期不建
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "004"
|
||||||
|
down_revision: Union[str, None] = "003"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_SIGNAL_TYPE = sa.Enum(
|
||||||
|
"text_select", "image_select", "approve",
|
||||||
|
"reject_with_reason", "regenerate",
|
||||||
|
name="signaltype",
|
||||||
|
)
|
||||||
|
_DATA_OWNERSHIP = sa.Enum("client_data", "platform_asset", name="dataownership")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"preference_events",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("product_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("signal_type", _SIGNAL_TYPE, nullable=False),
|
||||||
|
sa.Column("signal_weight", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("candidate_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("angle_label", sa.String(64), nullable=True),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("signal_meta", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"data_ownership", _DATA_OWNERSHIP,
|
||||||
|
nullable=False, server_default="client_data",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(),
|
||||||
|
server_default=sa.text("now()"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_preference_events_ws_product_created",
|
||||||
|
"preference_events",
|
||||||
|
["workspace_id", "product_id", "created_at"],
|
||||||
|
)
|
||||||
|
# preference_profile 二期预留,一期不建
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_preference_events_ws_product_created", "preference_events")
|
||||||
|
op.drop_table("preference_events")
|
||||||
|
# MySQL 不支持 DROP TYPE,枚举存 VARCHAR 无需清理
|
||||||
27
backend/alembic/versions/005_product_image_path.py
Normal file
27
backend/alembic/versions/005_product_image_path.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""005_product_image_path — products 表加 image_path 列(产品参考图本地路径)
|
||||||
|
前端图片上传完成后,BE 接口写入此字段;pipeline_io 读取用于生图参考。
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "005"
|
||||||
|
down_revision = "004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"products",
|
||||||
|
sa.Column(
|
||||||
|
"image_path",
|
||||||
|
sa.String(512),
|
||||||
|
nullable=True,
|
||||||
|
comment="产品参考图本地文件路径(前端上传后写入,生图时作 reference_images 注入)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("products", "image_path")
|
||||||
45
backend/alembic/versions/006_image_role_varchar.py
Normal file
45
backend/alembic/versions/006_image_role_varchar.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""006_image_role_varchar — image_candidates.role 从 enum 改 varchar(32)
|
||||||
|
|
||||||
|
根因:storyboard 角色名仍在演进(hook/product_closeup/ingredient/texture/
|
||||||
|
applied_proof/closer/pain_scene/social_proof/scenario/tutorial…),固定 enum
|
||||||
|
每加一个新角色就要迁一次,且旧 enum 漏了 product_closeup/ingredient 直接导致
|
||||||
|
落库 "Data truncated for column 'role'" → 生图任务无限重试卡死。
|
||||||
|
改 varchar 后落库不再受 enum 约束,角色名由 ImageRole 枚举在应用层定义。
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "006"
|
||||||
|
down_revision = "005"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"image_candidates",
|
||||||
|
"role",
|
||||||
|
existing_type=sa.Enum(
|
||||||
|
"hook", "pain", "proof", "quality", "credit", "convert", "main",
|
||||||
|
name="role",
|
||||||
|
),
|
||||||
|
type_=sa.String(32),
|
||||||
|
existing_nullable=False,
|
||||||
|
server_default="hook",
|
||||||
|
comment="分镜角色名(storyboard 输出,应用层 ImageRole 定义,不在 DB 约束)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"image_candidates",
|
||||||
|
"role",
|
||||||
|
existing_type=sa.String(32),
|
||||||
|
type_=sa.Enum(
|
||||||
|
"hook", "pain", "proof", "quality", "credit", "convert", "main",
|
||||||
|
name="role",
|
||||||
|
),
|
||||||
|
existing_nullable=False,
|
||||||
|
server_default="main",
|
||||||
|
)
|
||||||
33
backend/alembic/versions/007_need_product_image.py
Normal file
33
backend/alembic/versions/007_need_product_image.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""add need_product_image to generation_tasks
|
||||||
|
|
||||||
|
Revision ID: 007
|
||||||
|
Revises: 006
|
||||||
|
Create Date: 2026-06-08
|
||||||
|
|
||||||
|
本次产品是否入镜开关:True=必须产品参考图(无图禁生成,不降级),False=允许纯文生图。
|
||||||
|
默认 True(铁律:生图优先带产品参考图)。
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "007"
|
||||||
|
down_revision = "006"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
"generation_tasks",
|
||||||
|
sa.Column(
|
||||||
|
"need_product_image",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.true(),
|
||||||
|
comment="本次产品是否入镜:True需产品图(无图禁生成不降级)/False允许纯文生图",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("generation_tasks", "need_product_image")
|
||||||
32
backend/alembic/versions/008_brand_keyword.py
Normal file
32
backend/alembic/versions/008_brand_keyword.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""add brand_keyword to products
|
||||||
|
|
||||||
|
Revision ID: 008
|
||||||
|
Revises: 007
|
||||||
|
Create Date: 2026-06-13
|
||||||
|
|
||||||
|
第5环品牌词字段:客户输入,植入文案每条+生图特写图(第2/6张)。
|
||||||
|
brand_keyword 是产品级字段,随产品固定,由客户录入(非AI生成)。
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "008"
|
||||||
|
down_revision = "007"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
"products",
|
||||||
|
sa.Column(
|
||||||
|
"brand_keyword",
|
||||||
|
sa.String(64),
|
||||||
|
nullable=True,
|
||||||
|
comment="品牌词(客户输入,植入文案每条+生图特写图第2/6张)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("products", "brand_keyword")
|
||||||
44
backend/alembic/versions/009_benchmark_analyze_fields.py
Normal file
44
backend/alembic/versions/009_benchmark_analyze_fields.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""add features_json and analyze_status to benchmark_notes
|
||||||
|
|
||||||
|
Revision ID: 009
|
||||||
|
Revises: 008
|
||||||
|
Create Date: 2026-06-13
|
||||||
|
|
||||||
|
第2环标杆分析字段:
|
||||||
|
- features_json: 存储8特征结构化分析结果(TEXT JSON)
|
||||||
|
- analyze_status: AI分析状态机 pending/analyzing/done/failed
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "009"
|
||||||
|
down_revision = "008"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
"benchmark_notes",
|
||||||
|
sa.Column(
|
||||||
|
"features_json",
|
||||||
|
sa.Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="爆款8特征分析结果(JSON),第2环AI解析后写入",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"benchmark_notes",
|
||||||
|
sa.Column(
|
||||||
|
"analyze_status",
|
||||||
|
sa.String(20),
|
||||||
|
nullable=False,
|
||||||
|
server_default="pending",
|
||||||
|
comment="AI分析状态: pending/analyzing/done/failed",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("benchmark_notes", "analyze_status")
|
||||||
|
op.drop_column("benchmark_notes", "features_json")
|
||||||
70
backend/alembic/versions/010_fission_tasks_table.py
Normal file
70
backend/alembic/versions/010_fission_tasks_table.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""create fission_tasks table
|
||||||
|
|
||||||
|
Revision ID: 010
|
||||||
|
Revises: 009
|
||||||
|
Create Date: 2026-06-13
|
||||||
|
|
||||||
|
第11环裂变引擎:1爆款→N套完整笔记包(非只换文案)。
|
||||||
|
fission_tasks 是裂变任务主表,挂载在工作台内呈现。
|
||||||
|
reference_level: low/mid/high(参考程度)
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "010"
|
||||||
|
down_revision = "009"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"fission_tasks",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"workspace_id",
|
||||||
|
sa.BigInteger,
|
||||||
|
sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="多租户隔离",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source_note",
|
||||||
|
sa.Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="爆款源笔记内容(文案+图描述,JSON存储)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"reference_level",
|
||||||
|
sa.String(10),
|
||||||
|
nullable=False,
|
||||||
|
server_default="mid",
|
||||||
|
comment="参考程度: low/mid/high",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"fanout_count",
|
||||||
|
sa.Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default="3",
|
||||||
|
comment="裂变套数(默认3套)",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.String(20),
|
||||||
|
nullable=False,
|
||||||
|
server_default="pending",
|
||||||
|
comment="状态: pending/generating/done/failed",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_fission_tasks_workspace_id", "fission_tasks", ["workspace_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("idx_fission_tasks_workspace_id", table_name="fission_tasks")
|
||||||
|
op.drop_table("fission_tasks")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""add benchmark_ids and source_fission_id to generation_tasks
|
||||||
|
|
||||||
|
Revision ID: 011
|
||||||
|
Revises: 010
|
||||||
|
Create Date: 2026-06-13
|
||||||
|
|
||||||
|
合并第2环S12+第11环两个需求:
|
||||||
|
- benchmark_ids: 关联标杆笔记ID列表(TEXT存JSON list,第2环标杆分析引用)
|
||||||
|
- source_fission_id: 裂变来源任务ID(NULL=普通任务,非NULL=裂变产出,第11环)
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "011"
|
||||||
|
down_revision = "010"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
"generation_tasks",
|
||||||
|
sa.Column(
|
||||||
|
"benchmark_ids",
|
||||||
|
sa.Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="关联标杆笔记ID列表(JSON list),第2环标杆分析引用",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"generation_tasks",
|
||||||
|
sa.Column(
|
||||||
|
"source_fission_id",
|
||||||
|
sa.Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="裂变来源fission_task ID;NULL=普通任务,非NULL=裂变产出(第11环)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("generation_tasks", "source_fission_id")
|
||||||
|
op.drop_column("generation_tasks", "benchmark_ids")
|
||||||
29
backend/alembic/versions/012_product_target_audience.py
Normal file
29
backend/alembic/versions/012_product_target_audience.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""012 products 表加 target_audience 字段
|
||||||
|
|
||||||
|
Revision ID: 012_product_target_audience
|
||||||
|
Revises: 011_task_benchmark_fission_fields
|
||||||
|
Create Date: 2026-06-15
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "012"
|
||||||
|
down_revision = "011"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
"products",
|
||||||
|
sa.Column(
|
||||||
|
"target_audience",
|
||||||
|
sa.String(128),
|
||||||
|
nullable=True,
|
||||||
|
comment="目标人群,客户输入,透传进文案/生图prompt",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("products", "target_audience")
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
11
backend/app/api/v1/README.md
Normal file
11
backend/app/api/v1/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# app/api/v1/
|
||||||
|
|
||||||
|
路由层占位,BE agent 按模块创建:
|
||||||
|
- auth.py # 登录/me/切workspace
|
||||||
|
- api_keys.py # API Key录入/列表/删除
|
||||||
|
- products.py # 产品档案CRUD
|
||||||
|
- benchmarks.py # 标杆笔记CRUD(挂在products下)
|
||||||
|
- tasks.py # 生产任务(发起/列表/详情/SSE/选候选)
|
||||||
|
- review.py # 组长审核(队列/通过/打回)
|
||||||
|
- delivery.py # 交付包生成+下载
|
||||||
|
- banned_words.py # 违禁词库CRUD
|
||||||
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
130
backend/app/api/v1/api_keys.py
Normal file
130
backend/app/api/v1/api_keys.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/api_keys.py — API Key 路由
|
||||||
|
只录不显余额(CLAUDE.md 红线)。
|
||||||
|
只返回 provider + key_last4,不返回 encrypted_key。
|
||||||
|
url 字段不接收,token站固定自家站(架构方案§二)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, raise_not_found
|
||||||
|
from app.core.security import encrypt_api_key, mask_api_key
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||||
|
from app.models.workspace import UserApiKey
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── DTO ────────────────────────────────────────────────────
|
||||||
|
class CreateApiKeyRequest(BaseModel):
|
||||||
|
provider: str
|
||||||
|
api_key: str
|
||||||
|
|
||||||
|
@field_validator("provider")
|
||||||
|
@classmethod
|
||||||
|
def provider_not_empty(cls, v: str) -> str:
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError("provider 不能为空")
|
||||||
|
return v.strip().lower()
|
||||||
|
|
||||||
|
@field_validator("api_key")
|
||||||
|
@classmethod
|
||||||
|
def key_not_empty(cls, v: str) -> str:
|
||||||
|
if not v or len(v) < 8:
|
||||||
|
raise ValueError("api_key 无效")
|
||||||
|
return v
|
||||||
|
# 注:不接收 url 字段(token站固定自家站,基石B)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_key(k: UserApiKey) -> dict:
|
||||||
|
"""只显 provider + key_last4,不暴露余额/用量/encrypted_key(红线)。"""
|
||||||
|
return {
|
||||||
|
"id": k.id,
|
||||||
|
"provider": k.provider,
|
||||||
|
"key_last4": k.key_last4,
|
||||||
|
"created_at": k.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 路由 ───────────────────────────────────────────────────
|
||||||
|
@router.get("")
|
||||||
|
def list_api_keys(
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""列出当前用户在此 workspace 的 API Key(只显后4位)。"""
|
||||||
|
keys = (
|
||||||
|
db.query(UserApiKey)
|
||||||
|
.filter(
|
||||||
|
UserApiKey.user_id == current_user.user_id,
|
||||||
|
UserApiKey.workspace_id == current_user.workspace_id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return ok({"items": [_format_key(k) for k in keys]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_api_key(
|
||||||
|
body: CreateApiKeyRequest,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""录入 API Key(Fernet 加密存储,只保存后4位明文用于展示)。"""
|
||||||
|
from app.core.response import raise_business
|
||||||
|
# 检查同 user+workspace+provider 是否已有
|
||||||
|
existing = (
|
||||||
|
db.query(UserApiKey)
|
||||||
|
.filter(
|
||||||
|
UserApiKey.user_id == current_user.user_id,
|
||||||
|
UserApiKey.workspace_id == current_user.workspace_id,
|
||||||
|
UserApiKey.provider == body.provider,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise_business(f"已存在 {body.provider} 的 API Key,请先删除再录入")
|
||||||
|
|
||||||
|
encrypted = encrypt_api_key(body.api_key)
|
||||||
|
key_obj = UserApiKey(
|
||||||
|
user_id=current_user.user_id,
|
||||||
|
workspace_id=current_user.workspace_id,
|
||||||
|
provider=body.provider,
|
||||||
|
encrypted_key=encrypted,
|
||||||
|
key_last4=mask_api_key(body.api_key),
|
||||||
|
)
|
||||||
|
db.add(key_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(key_obj)
|
||||||
|
logger.info("API key created: user=%s provider=%s", current_user.user_id, body.provider)
|
||||||
|
return ok(_format_key(key_obj))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{key_id}")
|
||||||
|
def delete_api_key(
|
||||||
|
key_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""删除 API Key(只能删自己的)。"""
|
||||||
|
key_obj = (
|
||||||
|
db.query(UserApiKey)
|
||||||
|
.filter(
|
||||||
|
UserApiKey.id == key_id,
|
||||||
|
UserApiKey.user_id == current_user.user_id,
|
||||||
|
UserApiKey.workspace_id == current_user.workspace_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not key_obj:
|
||||||
|
raise_not_found("API Key 不存在")
|
||||||
|
db.delete(key_obj)
|
||||||
|
db.commit()
|
||||||
|
return ok({"deleted": key_id})
|
||||||
69
backend/app/api/v1/auth.py
Normal file
69
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/auth.py — 认证路由
|
||||||
|
路由层只做:参数校验 → 调 service → 格式化响应(不含业务逻辑)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, raise_unauthorized
|
||||||
|
from app.core.security import create_access_token, decode_access_token
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.workspace import Workspace
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── DTO ────────────────────────────────────────────────────
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@field_validator("username", "password")
|
||||||
|
@classmethod
|
||||||
|
def not_empty(cls, v: str) -> str:
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError("不能为空")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ── 路由 ───────────────────────────────────────────────────
|
||||||
|
@router.post("/login")
|
||||||
|
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||||
|
"""登录,返回 JWT。密码校验在 service 层(此处调用)。"""
|
||||||
|
from app.services.auth_service import authenticate_user, build_user_response
|
||||||
|
user, workspace_id, role = authenticate_user(db, body.username, body.password)
|
||||||
|
token = create_access_token(user.id, workspace_id, role)
|
||||||
|
return ok({
|
||||||
|
"token": token,
|
||||||
|
"user": build_user_response(user, workspace_id, role),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def get_me(
|
||||||
|
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""当前用户信息 + workspace + role。"""
|
||||||
|
user = db.query(User).filter(User.id == current_user.user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise_unauthorized("用户不存在")
|
||||||
|
ws = db.query(Workspace).filter(Workspace.id == current_user.workspace_id).first()
|
||||||
|
return ok({
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"current_workspace_id": current_user.workspace_id,
|
||||||
|
"role": current_user.role,
|
||||||
|
"workspace": {
|
||||||
|
"id": ws.id, "name": ws.name, "slug": ws.slug,
|
||||||
|
} if ws else None,
|
||||||
|
})
|
||||||
143
backend/app/api/v1/benchmarks.py
Normal file
143
backend/app/api/v1/benchmarks.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/benchmarks.py — 标杆笔记 + 违禁词路由(管理员)
|
||||||
|
主通道:截图+手填亮点(不做原始抓取)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, paginate, raise_not_found
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
||||||
|
from app.models.product import BannedWord, BenchmarkNote
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["products"])
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkCreate(BaseModel):
|
||||||
|
screenshot_url: str | None = None
|
||||||
|
highlights: str | None = None # 手填亮点(主通道)
|
||||||
|
link_url: str | None = None # 可选,不做自动抓取
|
||||||
|
|
||||||
|
|
||||||
|
class BannedWordCreate(BaseModel):
|
||||||
|
word: str
|
||||||
|
level: str # auto_fix | soft_warn | hard_block
|
||||||
|
replacement: str | None = None
|
||||||
|
updatable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_benchmark(b: BenchmarkNote) -> dict:
|
||||||
|
return {
|
||||||
|
"id": b.id, "product_id": b.product_id,
|
||||||
|
"screenshot_url": b.screenshot_url,
|
||||||
|
"highlights": b.highlights, "link_url": b.link_url,
|
||||||
|
"created_at": b.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_banned(bw: BannedWord) -> dict:
|
||||||
|
return {
|
||||||
|
"id": bw.id, "word": bw.word, "level": bw.level,
|
||||||
|
"replacement": bw.replacement, "updatable": bw.updatable,
|
||||||
|
"workspace_id": bw.workspace_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 标杆笔记 ────────────────────────────────────────────────
|
||||||
|
@router.get("/products/{product_id}/benchmarks")
|
||||||
|
def list_benchmarks(
|
||||||
|
product_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
items = (
|
||||||
|
db.query(BenchmarkNote)
|
||||||
|
.filter(
|
||||||
|
BenchmarkNote.product_id == product_id,
|
||||||
|
BenchmarkNote.workspace_id == current_user.workspace_id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return ok({"items": [_fmt_benchmark(b) for b in items]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/benchmarks")
|
||||||
|
def create_benchmark(
|
||||||
|
product_id: int, body: BenchmarkCreate,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
b = BenchmarkNote(
|
||||||
|
workspace_id=current_user.workspace_id,
|
||||||
|
product_id=product_id,
|
||||||
|
screenshot_url=body.screenshot_url,
|
||||||
|
highlights=body.highlights,
|
||||||
|
link_url=body.link_url,
|
||||||
|
)
|
||||||
|
db.add(b); db.commit(); db.refresh(b)
|
||||||
|
return ok(_fmt_benchmark(b))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 违禁词库 ────────────────────────────────────────────────
|
||||||
|
@router.get("/banned-words")
|
||||||
|
def list_banned_words(
|
||||||
|
page: int = 1, page_size: int = 50,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(BannedWord).filter(BannedWord.workspace_id == current_user.workspace_id)
|
||||||
|
total = q.count()
|
||||||
|
items = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
return ok(paginate([_fmt_banned(bw) for bw in items], total, page, page_size))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/banned-words")
|
||||||
|
def create_banned_word(
|
||||||
|
body: BannedWordCreate,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bw = BannedWord(
|
||||||
|
workspace_id=current_user.workspace_id,
|
||||||
|
word=body.word, level=body.level,
|
||||||
|
replacement=body.replacement, updatable=body.updatable,
|
||||||
|
)
|
||||||
|
db.add(bw); db.commit(); db.refresh(bw)
|
||||||
|
return ok(_fmt_banned(bw))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/banned-words/{word_id}")
|
||||||
|
def update_banned_word(
|
||||||
|
word_id: int, body: BannedWordCreate,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
|
||||||
|
if not bw:
|
||||||
|
raise_not_found("违禁词不存在")
|
||||||
|
if not bw.updatable:
|
||||||
|
from app.core.response import raise_forbidden
|
||||||
|
raise_forbidden("该违禁词不可修改")
|
||||||
|
bw.word = body.word; bw.level = body.level
|
||||||
|
bw.replacement = body.replacement; bw.updatable = body.updatable
|
||||||
|
db.commit(); db.refresh(bw)
|
||||||
|
return ok(_fmt_banned(bw))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/banned-words/{word_id}")
|
||||||
|
def delete_banned_word(
|
||||||
|
word_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
|
||||||
|
if not bw:
|
||||||
|
raise_not_found("违禁词不存在")
|
||||||
|
db.delete(bw); db.commit()
|
||||||
|
return ok({"deleted": word_id})
|
||||||
184
backend/app/api/v1/delivery.py
Normal file
184
backend/app/api/v1/delivery.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/delivery.py — 交付包路由
|
||||||
|
POST /tasks/{id}/package — 生成达人素材交付包
|
||||||
|
GET /delivery-packages/{id}/download — 下载(status: pending/ready/downloaded)
|
||||||
|
POST /delivery-packages/{id}/download-token — 签发60s一次性下载令牌(C1坑修复)
|
||||||
|
GET /delivery-packages/{id}/download-file?token= — 带令牌下载,前端可用 window.open
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, raise_not_found
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||||
|
from app.models.task import DeliveryPackage, GenerationTask
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["delivery"])
|
||||||
|
|
||||||
|
|
||||||
|
class PackageRequest(BaseModel):
|
||||||
|
note_ids: list[int] = [] # 可选,指定要打包的内容
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_package(pkg: DeliveryPackage) -> dict:
|
||||||
|
return {
|
||||||
|
"id": pkg.id,
|
||||||
|
"status": pkg.status,
|
||||||
|
"download_url": pkg.download_url,
|
||||||
|
"expires_at": pkg.expires_at.isoformat() if pkg.expires_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/package")
|
||||||
|
def create_package(
|
||||||
|
task_id: int, body: PackageRequest,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
生成达人素材交付包。
|
||||||
|
任务推入 Celery build_delivery_package 队列(只传 package_id)。
|
||||||
|
"""
|
||||||
|
task = db.query(GenerationTask).filter(
|
||||||
|
GenerationTask.id == task_id,
|
||||||
|
GenerationTask.workspace_id == current_user.workspace_id,
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
raise_not_found("任务不存在")
|
||||||
|
if task.status not in ("approved", "pending_selection"):
|
||||||
|
from app.core.response import raise_business
|
||||||
|
raise_business("任务尚未生成完成,无法打包")
|
||||||
|
|
||||||
|
pkg = DeliveryPackage(
|
||||||
|
workspace_id=current_user.workspace_id,
|
||||||
|
task_id=task_id,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(pkg)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(pkg)
|
||||||
|
|
||||||
|
# 推 Celery 队列,只传 package_id(基石B思路:不传任何敏感信息)
|
||||||
|
from app.workers.tasks import build_delivery_package
|
||||||
|
build_delivery_package.delay(pkg.id)
|
||||||
|
|
||||||
|
return ok({"delivery_package_id": pkg.id, "status": "pending"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/delivery-packages/{package_id}/download")
|
||||||
|
def get_package_download(
|
||||||
|
package_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""下载交付包(status: pending/ready/downloaded)。"""
|
||||||
|
pkg = db.query(DeliveryPackage).filter(
|
||||||
|
DeliveryPackage.id == package_id,
|
||||||
|
DeliveryPackage.workspace_id == current_user.workspace_id,
|
||||||
|
).first()
|
||||||
|
if not pkg:
|
||||||
|
raise_not_found("交付包不存在")
|
||||||
|
|
||||||
|
if pkg.status == "ready" and pkg.download_url:
|
||||||
|
# 标记为 downloaded(只能下一次,防重复公开 URL)
|
||||||
|
pkg.status = "downloaded"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return ok(_fmt_package(pkg))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/delivery-packages/{package_id}/download-token")
|
||||||
|
def create_download_token(
|
||||||
|
package_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
C1坑修复:签发一次性下载令牌(60s有效)。
|
||||||
|
前端先用 JWT 调此接口,再用 token 直接 window.open/fetch,无需在URL里传JWT。
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
import redis as sync_redis
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
pkg = db.query(DeliveryPackage).filter(
|
||||||
|
DeliveryPackage.id == package_id,
|
||||||
|
DeliveryPackage.workspace_id == current_user.workspace_id,
|
||||||
|
).first()
|
||||||
|
if not pkg:
|
||||||
|
raise_not_found("交付包不存在")
|
||||||
|
if pkg.status not in ("ready", "downloaded"):
|
||||||
|
from app.core.response import raise_business
|
||||||
|
raise_business("交付包尚未准备好")
|
||||||
|
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True)
|
||||||
|
r.setex(f"dl_token:{token}", 60, str(package_id))
|
||||||
|
return ok({"token": token, "expires_in": 60})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/delivery-packages/{package_id}/download-file")
|
||||||
|
def download_package_file(
|
||||||
|
package_id: int,
|
||||||
|
token: str = Query(default=""),
|
||||||
|
authorization: str = Header(default=""),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
直接下载交付包 zip 文件(FileResponse)。
|
||||||
|
支持两种认证:
|
||||||
|
1. ?token=<download-token>(前端 window.open 用,C1坑修复)
|
||||||
|
2. Authorization: Bearer <JWT>(API 调用用)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import redis as sync_redis
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
import jwt as pyjwt
|
||||||
|
|
||||||
|
# token 认证(一次性,60s有效,跳过JWT)
|
||||||
|
if token:
|
||||||
|
r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True)
|
||||||
|
stored = r.getdel(f"dl_token:{token}")
|
||||||
|
if not stored or int(stored) != package_id:
|
||||||
|
from app.core.response import raise_business
|
||||||
|
raise_business("下载令牌无效或已过期")
|
||||||
|
pkg = db.query(DeliveryPackage).filter(DeliveryPackage.id == package_id).first()
|
||||||
|
else:
|
||||||
|
# JWT 认证
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
from app.core.response import raise_unauthorized
|
||||||
|
raise_unauthorized("缺少认证信息")
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(authorization.split(" ", 1)[1])
|
||||||
|
except (pyjwt.PyJWTError, Exception):
|
||||||
|
from app.core.response import raise_unauthorized
|
||||||
|
raise_unauthorized("Token 无效")
|
||||||
|
workspace_id = int(payload["current_workspace_id"])
|
||||||
|
pkg = db.query(DeliveryPackage).filter(
|
||||||
|
DeliveryPackage.id == package_id,
|
||||||
|
DeliveryPackage.workspace_id == workspace_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not pkg:
|
||||||
|
raise_not_found("交付包不存在")
|
||||||
|
if pkg.status not in ("ready", "downloaded"):
|
||||||
|
from app.core.response import raise_business
|
||||||
|
raise_business("交付包尚未准备好,请稍后重试")
|
||||||
|
if not pkg.package_path or not os.path.exists(pkg.package_path):
|
||||||
|
from app.core.response import raise_business
|
||||||
|
raise_business("交付包文件不存在,请重新打包")
|
||||||
|
|
||||||
|
filename = os.path.basename(pkg.package_path)
|
||||||
|
return FileResponse(
|
||||||
|
path=pkg.package_path,
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
308
backend/app/api/v1/products.py
Normal file
308
backend/app/api/v1/products.py
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/products.py — 品牌库路由(管理员)
|
||||||
|
products / benchmark_notes / banned_words
|
||||||
|
category 是纯数据字段,不在代码里做枚举(基石A)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, paginate, raise_not_found
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
||||||
|
from app.models.product import BannedWord, BenchmarkNote, Product
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["products"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── DTO ────────────────────────────────────────────────────
|
||||||
|
class ProductCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
category: str | None = None # 纯数据字段,不做枚举(基石A)
|
||||||
|
source: str = "custom" # preset | custom
|
||||||
|
selling_points: list[str] = []
|
||||||
|
style_tone: str | None = None
|
||||||
|
text_angles: list[str] = [] # 用户设定,不写死(基石A)
|
||||||
|
custom_prompt: str | None = None # 等北哥方案注入
|
||||||
|
banned_word_ids: list[int] = []
|
||||||
|
image_path: str | None = None # 产品参考图(可建档即带;通常走 upload-image 接口)
|
||||||
|
brand_keyword: str | None = None # 品牌词,客户录入,012:套2字段暴露
|
||||||
|
target_audience: str | None = None # 目标人群,客户录入,012:套2字段暴露
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkCreate(BaseModel):
|
||||||
|
screenshot_url: str | None = None
|
||||||
|
highlights: str | None = None
|
||||||
|
link_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BannedWordCreate(BaseModel):
|
||||||
|
word: str
|
||||||
|
level: str # auto_fix | soft_warn | hard_block
|
||||||
|
replacement: str | None = None
|
||||||
|
updatable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_product(p: Product) -> dict:
|
||||||
|
import json
|
||||||
|
return {
|
||||||
|
"id": p.id, "name": p.name, "category": p.category,
|
||||||
|
"source": p.source, "is_active": p.is_active,
|
||||||
|
"selling_points": json.loads(p.selling_points) if p.selling_points else [],
|
||||||
|
"style_tone": p.style_tone,
|
||||||
|
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
|
||||||
|
"custom_prompt": p.custom_prompt,
|
||||||
|
"image_path": p.image_path,
|
||||||
|
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
|
||||||
|
"target_audience": p.target_audience, # 012: 套2字段暴露
|
||||||
|
"created_at": p.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 产品档案 ────────────────────────────────────────────────
|
||||||
|
@router.get("/products")
|
||||||
|
def list_products(
|
||||||
|
page: int = 1, page_size: int = 20, source: str | None = None,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(Product).filter(Product.workspace_id == current_user.workspace_id, Product.is_active == True)
|
||||||
|
if source in ("preset", "custom"):
|
||||||
|
q = q.filter(Product.source == source)
|
||||||
|
total = q.count()
|
||||||
|
items = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
return ok(paginate([_fmt_product(p) for p in items], total, page, page_size))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products")
|
||||||
|
def create_product(
|
||||||
|
body: ProductCreate,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
import json
|
||||||
|
p = Product(
|
||||||
|
workspace_id=current_user.workspace_id,
|
||||||
|
name=body.name, category=body.category, source=body.source,
|
||||||
|
selling_points=json.dumps(body.selling_points, ensure_ascii=False),
|
||||||
|
style_tone=body.style_tone,
|
||||||
|
text_angles=json.dumps(body.text_angles, ensure_ascii=False),
|
||||||
|
custom_prompt=body.custom_prompt,
|
||||||
|
image_path=body.image_path or None,
|
||||||
|
brand_keyword=body.brand_keyword or None, # 012: 套2字段
|
||||||
|
target_audience=body.target_audience or None, # 012: 套2字段
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return ok(_fmt_product(p))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products/{product_id}")
|
||||||
|
def get_product(
|
||||||
|
product_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||||||
|
if not p:
|
||||||
|
raise_not_found("产品不存在")
|
||||||
|
return ok(_fmt_product(p))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/products/{product_id}")
|
||||||
|
def update_product(
|
||||||
|
product_id: int, body: ProductCreate,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
import json
|
||||||
|
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||||||
|
if not p:
|
||||||
|
raise_not_found("产品不存在")
|
||||||
|
p.name = body.name; p.category = body.category; p.source = body.source
|
||||||
|
p.selling_points = json.dumps(body.selling_points, ensure_ascii=False)
|
||||||
|
p.style_tone = body.style_tone
|
||||||
|
p.text_angles = json.dumps(body.text_angles, ensure_ascii=False)
|
||||||
|
p.custom_prompt = body.custom_prompt
|
||||||
|
p.brand_keyword = body.brand_keyword or None # 012: 套2字段
|
||||||
|
p.target_audience = body.target_audience or None # 012: 套2字段
|
||||||
|
db.commit(); db.refresh(p)
|
||||||
|
return ok(_fmt_product(p))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/products/{product_id}")
|
||||||
|
def delete_product(
|
||||||
|
product_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||||||
|
if not p:
|
||||||
|
raise_not_found("产品不存在")
|
||||||
|
p.is_active = False # 软删
|
||||||
|
db.commit()
|
||||||
|
return ok({"deleted": product_id})
|
||||||
|
|
||||||
|
|
||||||
|
# ── 产品参考图上传 ──────────────────────────────────────────
|
||||||
|
_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||||
|
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/upload-image")
|
||||||
|
async def upload_product_image(
|
||||||
|
product_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
上传产品参考图(铁律:生图必须带产品图)。
|
||||||
|
文件类型:JPEG/PNG/WebP;大小上限 10 MB。
|
||||||
|
存储路径:uploads/products/{workspace_id}/{product_id}/{filename}
|
||||||
|
写回 product.image_path,生图管道读此字段构建 reference_images。
|
||||||
|
"""
|
||||||
|
from app.core.response import raise_business
|
||||||
|
from app.core.config import get_settings
|
||||||
|
import os, uuid
|
||||||
|
|
||||||
|
p = db.query(Product).filter(
|
||||||
|
Product.id == product_id,
|
||||||
|
Product.workspace_id == current_user.workspace_id,
|
||||||
|
).first()
|
||||||
|
if not p:
|
||||||
|
raise_not_found("产品不存在")
|
||||||
|
|
||||||
|
if file.content_type not in _ALLOWED_CONTENT_TYPES:
|
||||||
|
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
||||||
|
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) > _MAX_SIZE_BYTES:
|
||||||
|
raise_business("文件超过 10 MB 限制")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
|
||||||
|
# 存绝对路径:锚定 StaticFiles 根 /app/uploads,避免 worker(cwd=/) 读不到。
|
||||||
|
abs_dir = os.path.join(settings.UPLOAD_ABS_ROOT, "products",
|
||||||
|
str(current_user.workspace_id), str(product_id))
|
||||||
|
os.makedirs(abs_dir, exist_ok=True)
|
||||||
|
filename = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
save_path = os.path.join(abs_dir, filename)
|
||||||
|
with open(save_path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
p.image_path = save_path # 绝对路径,worker 直接 open 可读
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path)
|
||||||
|
return ok(_fmt_product(p))
|
||||||
|
|
||||||
|
|
||||||
|
# ── 套1 产品图视觉分析 ────────────────────────────────────────
|
||||||
|
_VISION_PROMPT = """你是小红书产品种草策划。请根据产品图分析卖点与人群。
|
||||||
|
硬性规则:只分析本次上传图片,不沿用历史案例,不默认护肤品。
|
||||||
|
返回JSON(仅JSON,无其他文字):
|
||||||
|
{
|
||||||
|
"productName": "从包装识别,识别不到填空",
|
||||||
|
"category": "美妆护肤/个护护理/食品饮品/营养健康/家居生活/服饰穿搭/电商产品之一",
|
||||||
|
"sellingPoints": ["转成用户买点,不要品牌空话,3-6个"],
|
||||||
|
"targetAudience": "一句话描述核心人群",
|
||||||
|
"scenarios": ["使用场景,2-4个"],
|
||||||
|
"keywords": ["小红书搜索关键词,3-5个"],
|
||||||
|
"bannedWords": ["合规禁用词"],
|
||||||
|
"imageDirection": "这组图如何拍/排版,一句话",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"source": "vision"
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_vision_json(raw: str) -> dict:
|
||||||
|
"""从模型返回文本中提取JSON(容错 markdown ```json 代码块)"""
|
||||||
|
import json, re
|
||||||
|
# 去掉 markdown 代码块包裹
|
||||||
|
cleaned = re.sub(r"```json\s*", "", raw)
|
||||||
|
cleaned = re.sub(r"```\s*", "", cleaned)
|
||||||
|
# 提取第一个 {...} 块
|
||||||
|
m = re.search(r"\{[\s\S]*\}", cleaned)
|
||||||
|
if not m:
|
||||||
|
raise ValueError("vision 返回内容中未找到 JSON")
|
||||||
|
return json.loads(m.group())
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_analysis(product_name: str = "") -> dict:
|
||||||
|
"""vision 失败时的文字兜底,保证接口不空手返回"""
|
||||||
|
return {
|
||||||
|
"productName": product_name or "产品",
|
||||||
|
"category": "电商产品",
|
||||||
|
"sellingPoints": ["使用方便", "适合日常场景"],
|
||||||
|
"targetAudience": "有明确使用场景和效率诉求的人群",
|
||||||
|
"scenarios": ["日常使用", "通勤出门"],
|
||||||
|
"keywords": [product_name or "好物", "真实测评", "种草分享"],
|
||||||
|
"bannedWords": ["美白", "祛斑", "速效", "医用", "药妆"],
|
||||||
|
"imageDirection": "产品白底图保证准确,真实场景图突出使用体验",
|
||||||
|
"confidence": 0.45,
|
||||||
|
"source": "fallback",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/analyze-image")
|
||||||
|
async def analyze_product_image(
|
||||||
|
product_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
套1:上传产品图 → GPT vision 读图 → 返回结构化卖点/人群分析。
|
||||||
|
key 链路:从当前用户 API key 解密(基石B);plain_key 只活在局部变量。
|
||||||
|
"""
|
||||||
|
from app.core.response import raise_business
|
||||||
|
from app.models.workspace import UserApiKey
|
||||||
|
from app.utils.fernet_utils import decrypt_key
|
||||||
|
from app.services.ai_engine.gemini_factory import build_ai_clients
|
||||||
|
|
||||||
|
# 文件校验(复用 upload_product_image 同款规则)
|
||||||
|
if file.content_type not in _ALLOWED_CONTENT_TYPES:
|
||||||
|
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) > _MAX_SIZE_BYTES:
|
||||||
|
raise_business("文件超过 10 MB 限制")
|
||||||
|
|
||||||
|
# key 解密(照抄 pipeline_steps.decrypt_user_key,基石B)
|
||||||
|
api_key_row = db.query(UserApiKey).filter(
|
||||||
|
UserApiKey.user_id == current_user.user_id,
|
||||||
|
UserApiKey.workspace_id == current_user.workspace_id,
|
||||||
|
UserApiKey.provider.in_(["openai", "apiports"]),
|
||||||
|
).first()
|
||||||
|
if not api_key_row:
|
||||||
|
raise_business("未配置 API Key,请先在设置中录入")
|
||||||
|
plain_key = decrypt_key(api_key_row.encrypted_key)
|
||||||
|
|
||||||
|
clients = build_ai_clients(plain_key)
|
||||||
|
plain_key = None # 立即清零,不传出(基石B)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await clients.gpt_vision_analyze(_VISION_PROMPT, [data])
|
||||||
|
try:
|
||||||
|
result = _parse_vision_json(raw)
|
||||||
|
result["source"] = "vision"
|
||||||
|
except Exception as parse_err:
|
||||||
|
logger.warning("vision JSON parse failed, fallback: %s", parse_err)
|
||||||
|
result = _fallback_analysis()
|
||||||
|
result["warning"] = f"视觉分析解析失败,已使用文字兜底:{parse_err}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("vision_analyze failed, fallback: %s", e)
|
||||||
|
result = _fallback_analysis()
|
||||||
|
result["warning"] = f"视觉分析失败,已使用文字兜底:{e}"
|
||||||
|
finally:
|
||||||
|
await clients.aclose()
|
||||||
|
|
||||||
|
logger.info("analyze_product_image done: product_id=%s source=%s conf=%.2f",
|
||||||
|
product_id, result.get("source"), result.get("confidence", 0))
|
||||||
|
return ok(result)
|
||||||
140
backend/app/api/v1/review.py
Normal file
140
backend/app/api/v1/review.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/review.py — 审核路由(组长)
|
||||||
|
通过(+5,最重) / 打回(写原因,-3,回 pending_selection)
|
||||||
|
打回原因存 preference_events.reason,不做 AI 归纳。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, paginate, raise_not_found, raise_state_invalid
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, require_supervisor_or_above
|
||||||
|
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/review", tags=["review"])
|
||||||
|
|
||||||
|
|
||||||
|
class RejectRequest(BaseModel):
|
||||||
|
reason: str # 原文存入 preference_events,不做 AI 归纳(契约§3)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/queue")
|
||||||
|
def review_queue(
|
||||||
|
page: int = 1, page_size: int = 20,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""待审队列(pending_review 状态)。"""
|
||||||
|
q = (
|
||||||
|
db.query(GenerationTask)
|
||||||
|
.filter(
|
||||||
|
GenerationTask.workspace_id == current_user.workspace_id,
|
||||||
|
GenerationTask.status == "pending_review",
|
||||||
|
)
|
||||||
|
.order_by(GenerationTask.updated_at.asc())
|
||||||
|
)
|
||||||
|
total = q.count()
|
||||||
|
tasks = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
items = [_fmt_queue_item(t, db) for t in tasks]
|
||||||
|
return ok(paginate(items, total, page, page_size))
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
|
||||||
|
import json
|
||||||
|
from app.models.product import Product
|
||||||
|
product = db.query(Product).filter(Product.id == t.product_id).first()
|
||||||
|
operator = db.query(User).filter(User.id == t.operator_id).first()
|
||||||
|
selected_text = db.query(TextCandidate).filter(
|
||||||
|
TextCandidate.task_id == t.id, TextCandidate.is_selected == True
|
||||||
|
).first()
|
||||||
|
selected_image = db.query(ImageCandidate).filter(
|
||||||
|
ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# content 列存 JSON dict,解包取 title/content 分字段返回
|
||||||
|
text_parsed: dict = {}
|
||||||
|
if selected_text and selected_text.content:
|
||||||
|
try:
|
||||||
|
text_parsed = json.loads(selected_text.content)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
text_parsed = {"content": selected_text.content}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"task_id": t.id,
|
||||||
|
"product_name": product.name if product else None,
|
||||||
|
"theme": t.theme,
|
||||||
|
"submitted_at": t.updated_at.isoformat(),
|
||||||
|
"operator_name": operator.username if operator else None,
|
||||||
|
"selected_text": {
|
||||||
|
"candidate_id": selected_text.id,
|
||||||
|
"angle_label": selected_text.angle_label,
|
||||||
|
"title": text_parsed.get("title", ""),
|
||||||
|
"content": text_parsed.get("content", ""), # 纯正文
|
||||||
|
"tags": text_parsed.get("tags", []),
|
||||||
|
} if selected_text else None,
|
||||||
|
"selected_image": {
|
||||||
|
"candidate_id": selected_image.id,
|
||||||
|
"strategy": selected_image.strategy,
|
||||||
|
"url": selected_image.url,
|
||||||
|
} if selected_image else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/approve")
|
||||||
|
def approve_task(
|
||||||
|
task_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""通过(飞轮 +5,最重,状态 → approved)。"""
|
||||||
|
from app.services.flywheel_service import record_signal
|
||||||
|
task = db.query(GenerationTask).filter(
|
||||||
|
GenerationTask.id == task_id,
|
||||||
|
GenerationTask.workspace_id == current_user.workspace_id,
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
raise_not_found("任务不存在")
|
||||||
|
if task.status != "pending_review":
|
||||||
|
raise_state_invalid("只有 pending_review 状态可审核")
|
||||||
|
task.status = "approved"
|
||||||
|
task.review_status = "approved"
|
||||||
|
task.reviewer_id = current_user.user_id
|
||||||
|
task.reviewed_at = datetime.now(timezone.utc)
|
||||||
|
task.approved_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
record_signal(db, current_user, task, "approve")
|
||||||
|
return ok({"task_id": task_id, "status": "approved"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/reject")
|
||||||
|
def reject_task(
|
||||||
|
task_id: int, body: RejectRequest,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""打回(飞轮 -3,回 pending_selection,原因下次注入 prompt)。"""
|
||||||
|
from app.services.flywheel_service import record_signal
|
||||||
|
task = db.query(GenerationTask).filter(
|
||||||
|
GenerationTask.id == task_id,
|
||||||
|
GenerationTask.workspace_id == current_user.workspace_id,
|
||||||
|
).first()
|
||||||
|
if not task:
|
||||||
|
raise_not_found("任务不存在")
|
||||||
|
if task.status != "pending_review":
|
||||||
|
raise_state_invalid("只有 pending_review 状态可打回")
|
||||||
|
task.status = "pending_selection" # 打回回 pending_selection
|
||||||
|
task.review_status = "rejected"
|
||||||
|
task.reviewer_id = current_user.user_id
|
||||||
|
task.reviewed_at = datetime.now(timezone.utc)
|
||||||
|
task.reject_reason = body.reason
|
||||||
|
db.commit()
|
||||||
|
record_signal(db, current_user, task, "reject_with_reason", reason=body.reason)
|
||||||
|
return ok({"task_id": task_id, "status": "pending_selection"})
|
||||||
97
backend/app/api/v1/stream.py
Normal file
97
backend/app/api/v1/stream.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/stream.py — SSE ticket 签发 + SSE 流路由
|
||||||
|
安全红线(倩倩姐2026-06-08拍板):SSE 认证用一次性短票 ticket,禁止 ?token= 直传 JWT。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. FE 带 JWT 调 POST /tasks/{id}/sse-ticket → 拿 ticket(60s有效,一次性)
|
||||||
|
2. FE 用 ticket 建 EventSource: GET /tasks/{id}/stream?ticket=<ticket>
|
||||||
|
3. BE 验 ticket(原子 getdel),用后即失效,换不了其他接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import redis as sync_redis
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, raise_not_found, raise_unauthorized
|
||||||
|
from app.core.sse_ticket import consume_ticket, issue_ticket
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, get_current_user
|
||||||
|
from app.models.task import GenerationTask
|
||||||
|
from app.utils.sse_utils import stream_events
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 签发接口(用 JWT 换 ticket)────────────────────────────
|
||||||
|
@router.post("/tasks/{task_id}/sse-ticket")
|
||||||
|
def create_sse_ticket(
|
||||||
|
task_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
签发一次性 SSE ticket(60s 有效,仅对该 task 有效)。
|
||||||
|
FE 拿到 ticket 后立即建 EventSource,不要存起来复用。
|
||||||
|
"""
|
||||||
|
task = (
|
||||||
|
db.query(GenerationTask)
|
||||||
|
.filter(
|
||||||
|
GenerationTask.id == task_id,
|
||||||
|
GenerationTask.workspace_id == current_user.workspace_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not task:
|
||||||
|
raise_not_found("任务不存在")
|
||||||
|
|
||||||
|
r = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
ticket = issue_ticket(r, task_id, current_user.workspace_id)
|
||||||
|
return ok({"ticket": ticket, "expires_in": 60})
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSE 流(ticket 认证,绝不传 JWT)─────────────────────
|
||||||
|
@router.get("/tasks/{task_id}/stream")
|
||||||
|
async def task_stream(
|
||||||
|
task_id: int,
|
||||||
|
ticket: str = Query(...),
|
||||||
|
last_seq: int = Query(default=0),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
SSE 实时进度推送。
|
||||||
|
ticket 验证:原子 getdel,用后即失效,无法重放、无法访问其他接口。
|
||||||
|
支持断线重连补发(?last_seq=<上次收到的seq>)。
|
||||||
|
断线重连时 FE 须重新调 sse-ticket 换新 ticket。
|
||||||
|
"""
|
||||||
|
r_sync = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
result = consume_ticket(r_sync, ticket)
|
||||||
|
if not result:
|
||||||
|
raise_unauthorized("ticket 无效或已过期")
|
||||||
|
|
||||||
|
verified_task_id, workspace_id = result
|
||||||
|
if verified_task_id != task_id:
|
||||||
|
raise_unauthorized("ticket 与任务不匹配")
|
||||||
|
|
||||||
|
redis_async = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream_events(
|
||||||
|
redis_client=redis_async,
|
||||||
|
task_id=task_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
last_seq=last_seq,
|
||||||
|
),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
)
|
||||||
146
backend/app/api/v1/task_actions.py
Normal file
146
backend/app/api/v1/task_actions.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/task_actions.py — 任务操作端点
|
||||||
|
选文案 / 选图 / 导入文案 / 重新生成 / 提交审核 / 偏好上下文
|
||||||
|
DTOs 和辅助函数从 tasks.py 导入。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, raise_not_found, raise_state_invalid
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||||
|
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||||
|
|
||||||
|
from app.api.v1.tasks import (
|
||||||
|
SelectCandidateRequest,
|
||||||
|
ImportTextRequest,
|
||||||
|
_fmt_text,
|
||||||
|
_fmt_image,
|
||||||
|
_check_task_ownership,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/text-candidates/select")
|
||||||
|
def select_text(
|
||||||
|
task_id: int, body: SelectCandidateRequest,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""选文案(飞轮信号 text_select +3)。"""
|
||||||
|
from app.services.flywheel_service import record_signal
|
||||||
|
task = _check_task_ownership(
|
||||||
|
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||||
|
current_user.workspace_id,
|
||||||
|
)
|
||||||
|
tc = db.query(TextCandidate).filter(
|
||||||
|
TextCandidate.id == body.candidate_id, TextCandidate.task_id == task_id
|
||||||
|
).first()
|
||||||
|
if not tc:
|
||||||
|
raise_not_found("文案候选不存在")
|
||||||
|
tc.is_selected = True
|
||||||
|
db.commit()
|
||||||
|
record_signal(db, current_user, task, "text_select", candidate_id=tc.id, angle_label=tc.angle_label)
|
||||||
|
return ok({"selected": body.candidate_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/image-candidates/select")
|
||||||
|
def select_image(
|
||||||
|
task_id: int, body: SelectCandidateRequest,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""选图(飞轮信号 image_select +3)。"""
|
||||||
|
from app.services.flywheel_service import record_signal
|
||||||
|
task = _check_task_ownership(
|
||||||
|
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||||
|
current_user.workspace_id,
|
||||||
|
)
|
||||||
|
ic = db.query(ImageCandidate).filter(
|
||||||
|
ImageCandidate.id == body.candidate_id, ImageCandidate.task_id == task_id
|
||||||
|
).first()
|
||||||
|
if not ic:
|
||||||
|
raise_not_found("图片候选不存在")
|
||||||
|
ic.is_selected = True
|
||||||
|
db.commit()
|
||||||
|
record_signal(db, current_user, task, "image_select", candidate_id=ic.id)
|
||||||
|
return ok({"selected": body.candidate_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/import-text")
|
||||||
|
def import_text(
|
||||||
|
task_id: int, body: ImportTextRequest,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""轨B:导入外部文案直接进候选池,跳过 AI 生成(洞2)。"""
|
||||||
|
_check_task_ownership(
|
||||||
|
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||||
|
current_user.workspace_id,
|
||||||
|
)
|
||||||
|
tc = TextCandidate(
|
||||||
|
workspace_id=current_user.workspace_id, task_id=task_id,
|
||||||
|
source="import", content=body.content, angle_label=body.angle_label,
|
||||||
|
)
|
||||||
|
db.add(tc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tc)
|
||||||
|
return ok(_fmt_text(tc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/regenerate")
|
||||||
|
def regenerate(
|
||||||
|
task_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""重新生成(飞轮信号 regenerate -1)。"""
|
||||||
|
from app.services.flywheel_service import record_signal
|
||||||
|
from app.services.task_service import enqueue_generation
|
||||||
|
task = _check_task_ownership(
|
||||||
|
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||||
|
current_user.workspace_id,
|
||||||
|
)
|
||||||
|
record_signal(db, current_user, task, "regenerate")
|
||||||
|
enqueue_generation(task.id)
|
||||||
|
return ok({"task_id": task_id, "status": "regenerating"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/submit-review")
|
||||||
|
def submit_review(
|
||||||
|
task_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""提交审核(状态 pending_selection → pending_review)。"""
|
||||||
|
task = _check_task_ownership(
|
||||||
|
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||||
|
current_user.workspace_id,
|
||||||
|
)
|
||||||
|
if task.status != "pending_selection":
|
||||||
|
raise_state_invalid("只有 pending_selection 状态可提审")
|
||||||
|
task.status = "pending_review"
|
||||||
|
db.commit()
|
||||||
|
return ok({"task_id": task_id, "status": "pending_review"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{task_id}/preference/context")
|
||||||
|
def get_preference_context(
|
||||||
|
task_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""取本任务飞轮偏好上下文(最近选中样本+打回原因)。"""
|
||||||
|
task = _check_task_ownership(
|
||||||
|
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||||
|
current_user.workspace_id,
|
||||||
|
)
|
||||||
|
from app.services.flywheel_service import get_preference_context
|
||||||
|
ctx = get_preference_context(db, current_user.workspace_id, task.product_id)
|
||||||
|
return ok(ctx)
|
||||||
191
backend/app/api/v1/tasks.py
Normal file
191
backend/app/api/v1/tasks.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/tasks.py — 任务路由(查询层)
|
||||||
|
发起任务 / 任务列表 / 任务详情
|
||||||
|
操作类端点(选文案/选图/导入/重生成/提审/偏好上下文)在 task_actions.py。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, paginate, raise_not_found
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||||
|
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── DTO ────────────────────────────────────────────────────
|
||||||
|
class CreateTaskRequest(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
benchmark_ids: list[int] = []
|
||||||
|
theme: str | None = None
|
||||||
|
text_count: int = 5 # 不写死(基石A),用户设定
|
||||||
|
image_count: int = 3 # 不写死(基石A),用户设定
|
||||||
|
track: str = "ai" # ai | import
|
||||||
|
need_product_image: bool = True # 本次产品是否入镜:True=无图禁生成不降级
|
||||||
|
|
||||||
|
@field_validator("text_count", "image_count")
|
||||||
|
@classmethod
|
||||||
|
def positive(cls, v: int) -> int:
|
||||||
|
if v < 1 or v > 20:
|
||||||
|
raise ValueError("数量范围 1-20")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("track")
|
||||||
|
@classmethod
|
||||||
|
def valid_track(cls, v: str) -> str:
|
||||||
|
if v not in ("ai", "import"):
|
||||||
|
raise ValueError("track 必须是 ai 或 import")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class SelectCandidateRequest(BaseModel):
|
||||||
|
candidate_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class ImportTextRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
angle_label: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 格式化辅助 ──────────────────────────────────────────────
|
||||||
|
def _fmt_task(t: GenerationTask) -> dict:
|
||||||
|
return {
|
||||||
|
"id": t.id, "product_id": t.product_id, "theme": t.theme,
|
||||||
|
"status": t.status, "text_count": t.text_count, "image_count": t.image_count,
|
||||||
|
"track": t.track, "need_product_image": t.need_product_image,
|
||||||
|
"created_at": t.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _score_array_to_obj(arr) -> dict:
|
||||||
|
"""score_json 是评分明细数组 [{item,score,max,note}]。
|
||||||
|
AI评委7维(2026-06-15新版:痛点18/情绪18/买点18/钩子15/标题13/真实感13+合规5)
|
||||||
|
或旧机械5维均可处理。
|
||||||
|
total 直接对明细求和(不依赖固定key),透传 dims 给前端 ScoreDimBars 数据驱动渲染。
|
||||||
|
同时填旧5维key保持兼容(能映射的填,映射不上的为0)。"""
|
||||||
|
# 旧5维 + 新7维维度名 → 旧key 的映射表(兼容新旧两套维度名)
|
||||||
|
legacy = {
|
||||||
|
# 旧机械维度
|
||||||
|
"标题吸引力": "title", "标题点击力": "title",
|
||||||
|
"情绪共鸣": "emotion", "情绪张力": "emotion",
|
||||||
|
"买点表达": "selling", "买点转化": "selling",
|
||||||
|
"关键词覆盖": "keyword", "合规性": "compliance",
|
||||||
|
# 新AI评委维度(2026-06-15)→ 最近似旧key
|
||||||
|
"痛点人群精准": "keyword", # 痛点人群精准语义最接近旧关键词(均为"内容精准度")
|
||||||
|
"开头钩子": "emotion", # 钩子即情绪抓点,归入 emotion
|
||||||
|
"真实感": "selling", # 真实感/买点转化同属"用户感知"维度
|
||||||
|
# "产品聚焦一件事" 已被"真实感"替换,保留映射避免旧存量数据报 KeyError
|
||||||
|
"产品聚焦一件事": "selling",
|
||||||
|
}
|
||||||
|
obj = {"title": 0, "emotion": 0, "selling": 0, "keyword": 0, "compliance": 0,
|
||||||
|
"total": 0, "dims": []}
|
||||||
|
if isinstance(arr, list):
|
||||||
|
total = 0
|
||||||
|
for d in arr:
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
continue
|
||||||
|
item = d.get("item", "")
|
||||||
|
sc = d.get("score", 0) or 0
|
||||||
|
total += sc
|
||||||
|
obj["dims"].append({"item": item, "score": sc,
|
||||||
|
"max": d.get("max", 0), "note": d.get("note", "")})
|
||||||
|
key = legacy.get(item)
|
||||||
|
if key:
|
||||||
|
obj[key] = sc
|
||||||
|
obj["total"] = max(0, min(100, total))
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_text(tc: TextCandidate) -> dict:
|
||||||
|
import json
|
||||||
|
# content 列存整条 JSON dict(含 title/content/tags/angle 等),解包后分字段返回
|
||||||
|
parsed: dict = {}
|
||||||
|
if tc.content:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(tc.content)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
# 兼容旧数据:如果 content 已经是纯正文则原样返回
|
||||||
|
parsed = {"content": tc.content}
|
||||||
|
score_raw = json.loads(tc.score_json) if tc.score_json else None
|
||||||
|
return {
|
||||||
|
"candidate_id": tc.id,
|
||||||
|
"angle_label": tc.angle_label,
|
||||||
|
"title": parsed.get("title", ""),
|
||||||
|
"content": parsed.get("content", ""), # 纯正文,前端直接展示
|
||||||
|
"tags": parsed.get("tags", []),
|
||||||
|
"cover_title": parsed.get("coverTitle", ""),
|
||||||
|
"image_brief": parsed.get("imageBrief", ""),
|
||||||
|
"source": tc.source,
|
||||||
|
"score": _score_array_to_obj(score_raw), # 转成 {title,emotion,...,total,dims} 对象
|
||||||
|
"banned_word_status": tc.banned_word_status,
|
||||||
|
"is_selected": tc.is_selected,
|
||||||
|
# AI 评委总评(verdict/summary 存在 content 列,新数据有,旧数据为空字符串降级)
|
||||||
|
"verdict": parsed.get("verdict", ""), # "优秀"|"合格"|"不合格"
|
||||||
|
"summary": parsed.get("summary", ""), # 一句话总评含改进点
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_image(ic: ImageCandidate) -> dict:
|
||||||
|
return {
|
||||||
|
"candidate_id": ic.id, "role": ic.role, "url": ic.url,
|
||||||
|
"strategy": ic.strategy, "seq": ic.seq, "is_selected": ic.is_selected,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_task_ownership(task: GenerationTask | None, workspace_id: int) -> GenerationTask:
|
||||||
|
if not task or task.workspace_id != workspace_id:
|
||||||
|
raise_not_found("任务不存在")
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
# ── 路由 ───────────────────────────────────────────────────
|
||||||
|
@router.post("")
|
||||||
|
def create_task(
|
||||||
|
body: CreateTaskRequest,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""发起任务:校验有无 key → 只推 task_id 入队,绝不传 key。"""
|
||||||
|
from app.services.task_service import create_generation_task
|
||||||
|
task = create_generation_task(db, current_user, body)
|
||||||
|
return ok(_fmt_task(task))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_tasks(
|
||||||
|
page: int = 1, page_size: int = 20,
|
||||||
|
status: list[str] | None = Query(default=None),
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
|
||||||
|
if status:
|
||||||
|
# 支持多状态(?status=approved&status=rejected),单值也兼容
|
||||||
|
q = q.filter(GenerationTask.status.in_(status))
|
||||||
|
total = q.count()
|
||||||
|
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
return ok(paginate([_fmt_task(t) for t in items], total, page, page_size))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{task_id}")
|
||||||
|
def get_task(
|
||||||
|
task_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
|
||||||
|
task = _check_task_ownership(task, current_user.workspace_id)
|
||||||
|
texts = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).all()
|
||||||
|
images = db.query(ImageCandidate).filter(ImageCandidate.task_id == task_id).all()
|
||||||
|
return ok({
|
||||||
|
**_fmt_task(task),
|
||||||
|
"text_candidates": [_fmt_text(tc) for tc in texts],
|
||||||
|
"image_candidates": [_fmt_image(ic) for ic in images],
|
||||||
|
})
|
||||||
43
backend/app/api/v1/workspaces.py
Normal file
43
backend/app/api/v1/workspaces.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
app/api/v1/workspaces.py — workspace 切换路由
|
||||||
|
POST /workspaces/switch → /api/v1/workspaces/switch(契约路径)
|
||||||
|
从 auth.py 独立出来,避免 /auth 前缀错误。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.security import create_access_token
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import ok, raise_forbidden
|
||||||
|
from app.middleware.workspace_guard import CurrentUser, get_current_user
|
||||||
|
from app.models.workspace import WorkspaceMember
|
||||||
|
|
||||||
|
router = APIRouter(tags=["workspaces"])
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchWorkspaceRequest(BaseModel):
|
||||||
|
workspace_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workspaces/switch")
|
||||||
|
def switch_workspace(
|
||||||
|
body: SwitchWorkspaceRequest,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""切换当前 workspace(必须查 membership 校验)。"""
|
||||||
|
member = db.query(WorkspaceMember).filter(
|
||||||
|
WorkspaceMember.user_id == current_user.user_id,
|
||||||
|
WorkspaceMember.workspace_id == body.workspace_id,
|
||||||
|
).first()
|
||||||
|
if not member:
|
||||||
|
raise_forbidden("无权访问目标 workspace")
|
||||||
|
token = create_access_token(current_user.user_id, body.workspace_id, member.role)
|
||||||
|
return ok({
|
||||||
|
"current_workspace_id": body.workspace_id,
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
8
backend/app/constants/README.md
Normal file
8
backend/app/constants/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# app/constants/
|
||||||
|
|
||||||
|
中心常量文件,消除三套命名打架(架构方案§二决策):
|
||||||
|
- strategies.py # 图片策略命名:对外API用A/B/C,内部method用minimal_edit系
|
||||||
|
- signal_weights.py # 飞轮信号默认权重(可调,不写死)
|
||||||
|
- status.py # 任务状态机枚举
|
||||||
|
- roles.py # 角色枚举:admin/supervisor/operator
|
||||||
|
- providers.py # AI提供商枚举(开放接口,不硬编码)
|
||||||
0
backend/app/constants/__init__.py
Normal file
0
backend/app/constants/__init__.py
Normal file
132
backend/app/constants/enums.py
Normal file
132
backend/app/constants/enums.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
constants/enums.py — Clover 命名中心
|
||||||
|
DB枚举约束在此定义,消除三套命名打架。
|
||||||
|
业务参数(品类/数量/角度)不在此定义(基石A)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
# ── 用户角色 ────────────────────────────────────────────
|
||||||
|
class UserRole(str, Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
SUPERVISOR = "supervisor"
|
||||||
|
OPERATOR = "operator"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 任务状态机 ──────────────────────────────────────────
|
||||||
|
class TaskStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
GENERATING = "generating"
|
||||||
|
PENDING_SELECTION = "pending_selection"
|
||||||
|
PENDING_REVIEW = "pending_review"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 审核状态(generation_tasks 平铺字段)──────────────────
|
||||||
|
class ReviewStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 文案候选来源 ───────────────────────────────────────
|
||||||
|
class CandidateSource(str, Enum):
|
||||||
|
AI = "ai"
|
||||||
|
IMPORT = "import"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 违禁词等级 ────────────────────────────────────────
|
||||||
|
class BannedWordLevel(str, Enum):
|
||||||
|
AUTO_FIX = "auto_fix"
|
||||||
|
SOFT_WARN = "soft_warn"
|
||||||
|
HARD_BLOCK = "hard_block"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 违禁词扫描结果 ───────────────────────────────────
|
||||||
|
class BannedWordStatus(str, Enum):
|
||||||
|
PASS = "pass"
|
||||||
|
AUTO_FIXED = "auto_fixed"
|
||||||
|
SOFT_WARN = "soft_warn"
|
||||||
|
HARD_BLOCK = "hard_block"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 飞轮信号类型 ─────────────────────────────────────
|
||||||
|
class SignalType(str, Enum):
|
||||||
|
TEXT_SELECT = "text_select"
|
||||||
|
IMAGE_SELECT = "image_select"
|
||||||
|
APPROVE = "approve"
|
||||||
|
REJECT_WITH_REASON = "reject_with_reason"
|
||||||
|
REGENERATE = "regenerate"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 飞轮信号权重默认值(北哥可校准)─────────────────────
|
||||||
|
SIGNAL_WEIGHTS: dict[str, int] = {
|
||||||
|
SignalType.TEXT_SELECT: 3,
|
||||||
|
SignalType.IMAGE_SELECT: 3,
|
||||||
|
SignalType.APPROVE: 5,
|
||||||
|
SignalType.REJECT_WITH_REASON: -3,
|
||||||
|
SignalType.REGENERATE: -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 数据归属 ──────────────────────────────────────────
|
||||||
|
class DataOwnership(str, Enum):
|
||||||
|
CLIENT_DATA = "client_data" # 原始输入产出,客户可导出
|
||||||
|
PLATFORM_ASSET = "platform_asset" # 飞轮蒸馏成果,归平台
|
||||||
|
|
||||||
|
|
||||||
|
# ── 交付包状态 ────────────────────────────────────────
|
||||||
|
class PackageStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
READY = "ready"
|
||||||
|
DOWNLOADED = "downloaded"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 图片分镜角色(G5坑修复:对齐 storyboard.py 实际角色名)────
|
||||||
|
class ImageRole(str, Enum):
|
||||||
|
# storyboard 实际输出角色名
|
||||||
|
HOOK = "hook"
|
||||||
|
PAIN_SCENE = "pain_scene"
|
||||||
|
PRODUCT_CLOSEUP = "product_closeup"
|
||||||
|
INGREDIENT = "ingredient"
|
||||||
|
APPLIED_PROOF = "applied_proof"
|
||||||
|
TEXTURE = "texture"
|
||||||
|
SOCIAL_PROOF = "social_proof"
|
||||||
|
CLOSER = "closer"
|
||||||
|
SCENARIO = "scenario"
|
||||||
|
TUTORIAL = "tutorial"
|
||||||
|
# 旧值保留向后兼容
|
||||||
|
PAIN = "pain"
|
||||||
|
PROOF = "proof"
|
||||||
|
QUALITY = "quality"
|
||||||
|
CREDIT = "credit"
|
||||||
|
CONVERT = "convert"
|
||||||
|
MAIN = "main"
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI 图片提供商 ─────────────────────────────────────
|
||||||
|
class ImageProvider(str, Enum):
|
||||||
|
GPT = "gpt"
|
||||||
|
GEMINI = "gemini"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 产品来源 ──────────────────────────────────────────
|
||||||
|
class ProductSource(str, Enum):
|
||||||
|
PRESET = "preset"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 错误码(契约§0七类)─────────────────────────────────
|
||||||
|
class ErrorCode:
|
||||||
|
SUCCESS = 0
|
||||||
|
PARAM_INVALID = 40001
|
||||||
|
UNAUTHORIZED = 40101
|
||||||
|
FORBIDDEN = 40301
|
||||||
|
NOT_FOUND = 40401
|
||||||
|
STATE_INVALID = 40901
|
||||||
|
BUSINESS_ERROR = 42201
|
||||||
|
SERVER_ERROR = 50001
|
||||||
|
AI_CALL_FAILED = 50002
|
||||||
3
backend/app/core/__init__.py
Normal file
3
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
app/core/__init__.py
|
||||||
|
"""
|
||||||
80
backend/app/core/config.py
Normal file
80
backend/app/core/config.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
app/core/config.py — 环境变量加载 + 启动校验
|
||||||
|
必填项缺失则启动失败,不静默。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import field_validator
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# ── 必填(缺失启动保护)───────────────────────────
|
||||||
|
FERNET_KEY: str
|
||||||
|
JWT_SECRET: str
|
||||||
|
DATABASE_URL: str
|
||||||
|
MONGO_URI: str
|
||||||
|
REDIS_URL: str
|
||||||
|
|
||||||
|
# ── AI 提供商(不写死默认,可配置)──────────────────
|
||||||
|
IMAGE_PROVIDER_PRIMARY: str = "gpt"
|
||||||
|
IMAGE_PROVIDER_FALLBACK: str = "gemini"
|
||||||
|
IMAGE_API_BASE: str = ""
|
||||||
|
IMAGE_MODEL: str = "gpt-image-2"
|
||||||
|
MODEL_IMAGE: str = "gpt-image-2" # 别名,与 IMAGE_MODEL 同义
|
||||||
|
MODEL_TEXT: str = "claude-opus-4-8"
|
||||||
|
MODEL_VISION: str = "" # 视觉模型(verify_real_image 脚本用)
|
||||||
|
CLAUDE_API_URL: str = ""
|
||||||
|
GEMINI_API_URL: str = ""
|
||||||
|
|
||||||
|
# ── 多 Provider Key(各人自录,测试/真实出图脚本用)──
|
||||||
|
APIPORTS_BASE_URL: str = ""
|
||||||
|
APIPORTS_KEY: str = ""
|
||||||
|
CODEPROXY_BASE_URL: str = ""
|
||||||
|
CODEPROXY_KEY: str = ""
|
||||||
|
|
||||||
|
# ── 应用配置 ──────────────────────────────────────
|
||||||
|
APP_ENV: str = "development"
|
||||||
|
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
|
||||||
|
CELERY_BROKER_URL: str = ""
|
||||||
|
CELERY_RESULT_BACKEND: str = ""
|
||||||
|
|
||||||
|
# ── 并发限制(可配置,不写死)─────────────────────
|
||||||
|
MAX_CONCURRENT_TASKS_PER_USER: int = 2
|
||||||
|
|
||||||
|
# ── 文件存储路径 ──────────────────────────────────
|
||||||
|
UPLOAD_BASE_PATH: str = "uploads/packages"
|
||||||
|
# StaticFiles 实际服务的绝对目录(与 main.py app.mount("/uploads", .../uploads) 一致)。
|
||||||
|
# 容器内 main.py 在 /app/main.py,故 uploads 绝对根 = /app/uploads。
|
||||||
|
# 所有写盘/读盘统一锚定此根,避免 api(cwd=/app) 与 worker(cwd=/) 相对路径解析不一致。
|
||||||
|
UPLOAD_ABS_ROOT: str = "/app/uploads"
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "case_sensitive": True, "extra": "ignore"}
|
||||||
|
|
||||||
|
@field_validator("FERNET_KEY")
|
||||||
|
@classmethod
|
||||||
|
def fernet_key_not_empty(cls, v: str) -> str:
|
||||||
|
if not v or len(v) < 32:
|
||||||
|
raise ValueError("FERNET_KEY must be set and at least 32 chars")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("JWT_SECRET")
|
||||||
|
@classmethod
|
||||||
|
def jwt_secret_not_empty(cls, v: str) -> str:
|
||||||
|
if not v or len(v) < 32:
|
||||||
|
raise ValueError("JWT_SECRET must be at least 32 characters")
|
||||||
|
return v
|
||||||
|
|
||||||
|
def celery_broker(self) -> str:
|
||||||
|
return self.CELERY_BROKER_URL or self.REDIS_URL
|
||||||
|
|
||||||
|
def celery_backend(self) -> str:
|
||||||
|
return self.CELERY_RESULT_BACKEND or self.REDIS_URL
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""单例配置,启动时校验一次。"""
|
||||||
|
return Settings()
|
||||||
53
backend/app/core/database.py
Normal file
53
backend/app/core/database.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
app/core/database.py — SQLAlchemy 双库连接(MySQL + MongoDB)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# ── MySQL(主业务库)──────────────────────────────────
|
||||||
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
echo=(settings.APP_ENV == "development"),
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""所有 ORM 模型的基类。"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""FastAPI 依赖注入:获取 DB Session,用完自动关闭。"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── MongoDB(只存 AI debug trace,业务不依赖)──────────
|
||||||
|
_mongo_client: AsyncIOMotorClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_mongo_client() -> AsyncIOMotorClient:
|
||||||
|
global _mongo_client
|
||||||
|
if _mongo_client is None:
|
||||||
|
_mongo_client = AsyncIOMotorClient(settings.MONGO_URI)
|
||||||
|
return _mongo_client
|
||||||
|
|
||||||
|
|
||||||
|
def get_mongo_db():
|
||||||
|
"""获取 MongoDB 数据库实例(clover_trace)。"""
|
||||||
|
client = get_mongo_client()
|
||||||
|
return client["clover_trace"]
|
||||||
63
backend/app/core/response.py
Normal file
63
backend/app/core/response.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
app/core/response.py — 统一响应包络
|
||||||
|
成功:{ "code": 0, "data": {...} }
|
||||||
|
失败:{ "code": <错误码>, "message": "用户可读信息" }
|
||||||
|
HTTP 状态码与业务 code 分离(契约§0)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.constants.enums import ErrorCode
|
||||||
|
|
||||||
|
|
||||||
|
def ok(data: Any = None) -> dict:
|
||||||
|
"""标准成功响应。"""
|
||||||
|
return {"code": ErrorCode.SUCCESS, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
def err(code: int, message: str) -> dict:
|
||||||
|
"""标准失败响应体(不含 HTTP 状态,由调用方决定)。"""
|
||||||
|
return {"code": code, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
def paginate(items: list, total: int, page: int, page_size: int) -> dict:
|
||||||
|
"""分页数据包装。"""
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 常用异常 ──────────────────────────────────────────────
|
||||||
|
class CloverHTTPException(HTTPException):
|
||||||
|
"""携带业务 code 的 HTTP 异常,供全局 handler 格式化。"""
|
||||||
|
|
||||||
|
def __init__(self, http_status: int, code: int, message: str):
|
||||||
|
super().__init__(status_code=http_status, detail=message)
|
||||||
|
self.biz_code = code
|
||||||
|
self.biz_message = message
|
||||||
|
|
||||||
|
|
||||||
|
def raise_not_found(message: str = "资源不存在") -> None:
|
||||||
|
raise CloverHTTPException(404, ErrorCode.NOT_FOUND, message)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_forbidden(message: str = "无权限访问") -> None:
|
||||||
|
raise CloverHTTPException(403, ErrorCode.FORBIDDEN, message)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_unauthorized(message: str = "未认证或 Token 失效") -> None:
|
||||||
|
raise CloverHTTPException(401, ErrorCode.UNAUTHORIZED, message)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_business(message: str) -> None:
|
||||||
|
raise CloverHTTPException(422, ErrorCode.BUSINESS_ERROR, message)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_state_invalid(message: str = "状态机非法流转") -> None:
|
||||||
|
raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message)
|
||||||
73
backend/app/core/security.py
Normal file
73
backend/app/core/security.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
app/core/security.py — JWT + Fernet 工具函数
|
||||||
|
FERNET_KEY 走环境变量,绝不进代码库(基石B)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# ── Fernet 加密(API Key 存取)──────────────────────────
|
||||||
|
_fernet: Fernet | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
global _fernet
|
||||||
|
if _fernet is None:
|
||||||
|
_fernet = Fernet(settings.FERNET_KEY.encode())
|
||||||
|
return _fernet
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_api_key(plain_key: str) -> str:
|
||||||
|
"""加密 API Key,返回密文字符串。绝不打印 plain_key。"""
|
||||||
|
return _get_fernet().encrypt(plain_key.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_api_key(encrypted_key: str) -> str:
|
||||||
|
"""
|
||||||
|
解密 API Key。只在 Celery worker 内部调用。
|
||||||
|
解密结果只活在调用函数的局部变量,不落盘、不打日志。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _get_fernet().decrypt(encrypted_key.encode()).decode()
|
||||||
|
except InvalidToken:
|
||||||
|
logger.error("Fernet decrypt failed: invalid token (key may be rotated)")
|
||||||
|
raise ValueError("API key decryption failed")
|
||||||
|
|
||||||
|
|
||||||
|
# ── JWT ──────────────────────────────────────────────────
|
||||||
|
def create_access_token(
|
||||||
|
user_id: int,
|
||||||
|
workspace_id: int,
|
||||||
|
role: str,
|
||||||
|
expires_delta: timedelta | None = None,
|
||||||
|
) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + (
|
||||||
|
expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"user_id": user_id,
|
||||||
|
"current_workspace_id": workspace_id,
|
||||||
|
"role": role,
|
||||||
|
"exp": expire,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict[str, Any]:
|
||||||
|
"""验签并返回 payload。无效则抛 jwt.PyJWTError。"""
|
||||||
|
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||||
|
|
||||||
|
|
||||||
|
def mask_api_key(plain_key: str) -> str:
|
||||||
|
"""只返回后4位(展示用),不暴露完整 key。"""
|
||||||
|
return plain_key[-4:] if len(plain_key) >= 4 else "****"
|
||||||
43
backend/app/core/sse_ticket.py
Normal file
43
backend/app/core/sse_ticket.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
app/core/sse_ticket.py — SSE 一次性短票 (ticket) 签发与验证
|
||||||
|
倩倩姐2026-06-08拍板:SSE 认证不传 JWT,改传 ticket。
|
||||||
|
ticket 存 Redis,TTL 60s,验一次即删,换不了其他接口。
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
TICKET_TTL_SECONDS = 60
|
||||||
|
TICKET_KEY_PREFIX = "sse_ticket:"
|
||||||
|
|
||||||
|
|
||||||
|
def _redis_key(ticket: str) -> str:
|
||||||
|
return f"{TICKET_KEY_PREFIX}{ticket}"
|
||||||
|
|
||||||
|
|
||||||
|
def issue_ticket(redis_client, task_id: int, workspace_id: int) -> str:
|
||||||
|
"""
|
||||||
|
签发一次性 SSE ticket,写入 Redis,TTL 60s。
|
||||||
|
返回 ticket 字符串(32字节 hex,共64字符)。
|
||||||
|
"""
|
||||||
|
ticket = secrets.token_hex(32)
|
||||||
|
value = f"{task_id}:{workspace_id}"
|
||||||
|
redis_client.setex(_redis_key(ticket), TICKET_TTL_SECONDS, value)
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
def consume_ticket(redis_client, ticket: str) -> Optional[tuple[int, int]]:
|
||||||
|
"""
|
||||||
|
验证并消费 ticket(用后即删,一次性)。
|
||||||
|
成功返回 (task_id, workspace_id),失败返回 None。
|
||||||
|
"""
|
||||||
|
if not ticket:
|
||||||
|
return None
|
||||||
|
key = _redis_key(ticket)
|
||||||
|
value = redis_client.getdel(key) # 原子取出并删除
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
task_id_str, ws_str = value.split(":", 1)
|
||||||
|
return int(task_id_str), int(ws_str)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
5
backend/app/middleware/README.md
Normal file
5
backend/app/middleware/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# app/middleware/
|
||||||
|
|
||||||
|
中间件占位:
|
||||||
|
- workspace_guard.py # 所有接口强制注入workspace_id,防串数据(所有查询强制带此条件)
|
||||||
|
- auth_middleware.py # JWT验签(HS256,每请求验签)
|
||||||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
106
backend/app/middleware/workspace_guard.py
Normal file
106
backend/app/middleware/workspace_guard.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
app/middleware/workspace_guard.py — 多租户隔离中间件
|
||||||
|
所有业务接口强制注入 workspace_id(基石C)。
|
||||||
|
读操作信任 JWT;写操作+切换 workspace 查 workspace_members 校验。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import Depends, Header
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.response import raise_forbidden, raise_unauthorized
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.models.workspace import WorkspaceMember
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUser:
|
||||||
|
"""JWT 解码后的请求上下文,注入到每个路由函数。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
workspace_id: int,
|
||||||
|
role: str,
|
||||||
|
):
|
||||||
|
self.user_id = user_id
|
||||||
|
self.workspace_id = workspace_id
|
||||||
|
self.role = role
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token(authorization: str | None) -> str:
|
||||||
|
"""从 Authorization: Bearer <token> 中提取 token。"""
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise_unauthorized("缺少 Authorization header")
|
||||||
|
return authorization.split(" ", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
|
) -> CurrentUser:
|
||||||
|
"""
|
||||||
|
FastAPI 依赖:解码 JWT,返回 CurrentUser。
|
||||||
|
所有业务路由 Depends(get_current_user)。
|
||||||
|
"""
|
||||||
|
token = _extract_token(authorization)
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise_unauthorized("Token 已过期")
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise_unauthorized("Token 无效")
|
||||||
|
|
||||||
|
return CurrentUser(
|
||||||
|
user_id=int(payload["user_id"]),
|
||||||
|
workspace_id=int(payload["current_workspace_id"]),
|
||||||
|
role=payload["role"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_write_permission(
|
||||||
|
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> CurrentUser:
|
||||||
|
"""
|
||||||
|
写操作依赖:查 workspace_members 校验当前用户确实属于此 workspace。
|
||||||
|
读操作用 get_current_user 即可(JWT 加速)。
|
||||||
|
"""
|
||||||
|
member = (
|
||||||
|
db.query(WorkspaceMember)
|
||||||
|
.filter(
|
||||||
|
WorkspaceMember.workspace_id == current_user.workspace_id,
|
||||||
|
WorkspaceMember.user_id == current_user.user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not member:
|
||||||
|
logger.warning(
|
||||||
|
"workspace permission denied: user=%s workspace=%s",
|
||||||
|
current_user.user_id,
|
||||||
|
current_user.workspace_id,
|
||||||
|
)
|
||||||
|
raise_forbidden("无权限访问此 workspace")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||||
|
) -> CurrentUser:
|
||||||
|
"""仅管理员可访问的路由依赖。"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise_forbidden("需要管理员权限")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def require_supervisor_or_above(
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||||
|
) -> CurrentUser:
|
||||||
|
"""组长及以上(supervisor/admin)。"""
|
||||||
|
if current_user.role not in ("supervisor", "admin"):
|
||||||
|
raise_forbidden("需要组长或管理员权限")
|
||||||
|
return current_user
|
||||||
30
backend/app/models/README.md
Normal file
30
backend/app/models/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# app/models/
|
||||||
|
|
||||||
|
SQLAlchemy ORM 模型占位,Alembic 001-004 按顺序建:
|
||||||
|
|
||||||
|
## Alembic 001 — 从banana搬3张(改造)
|
||||||
|
- user.py # users(删credits字段)
|
||||||
|
- login_record.py # login_records
|
||||||
|
- user_preference.py # user_preferences(UI设置偏好)
|
||||||
|
|
||||||
|
## Alembic 002 — 多租户基础(全新)
|
||||||
|
- workspace.py # workspaces
|
||||||
|
- workspace_member.py # workspace_members(user+workspace+角色)
|
||||||
|
- user_api_key.py # user_api_keys(Fernet加密,UNIQUE user_id+workspace_id+provider)
|
||||||
|
|
||||||
|
## Alembic 003 — 业务主体7张(全新)
|
||||||
|
- product.py # products(含text_angles/custom_prompt/source/workspace_id)
|
||||||
|
- benchmark_note.py # benchmark_notes
|
||||||
|
- generation_task.py # generation_tasks(状态机+审核字段平铺)
|
||||||
|
- text_candidate.py # text_candidates(source=ai/import双轨)
|
||||||
|
- image_candidate.py # image_candidates
|
||||||
|
- delivery_package.py # delivery_packages
|
||||||
|
- banned_word.py # banned_words(三级level)
|
||||||
|
|
||||||
|
## Alembic 004 — 飞轮(全新)
|
||||||
|
- preference_event.py # preference_events(含data_ownership字段)
|
||||||
|
|
||||||
|
## 铁律
|
||||||
|
- 所有业务表有 workspace_id 字段
|
||||||
|
- generation_tasks 审核字段平铺:review_status/reviewer_id/reviewed_at/reject_reason/approved_at/archived_at
|
||||||
|
- preference_events.signal_weight 初始默认值:选文案+3/选图+3/通过+5/打回-3/重生成-1
|
||||||
24
backend/app/models/__init__.py
Normal file
24
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
app/models/__init__.py — 统一导出所有模型,Alembic autogenerate 能扫到
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.user import LoginRecord, User, UserPreference
|
||||||
|
from app.models.workspace import UserApiKey, Workspace, WorkspaceMember
|
||||||
|
from app.models.product import BannedWord, BenchmarkNote, Product
|
||||||
|
from app.models.task import (
|
||||||
|
DeliveryPackage,
|
||||||
|
GenerationTask,
|
||||||
|
ImageCandidate,
|
||||||
|
TextCandidate,
|
||||||
|
)
|
||||||
|
from app.models.flywheel import AiCallLog, PreferenceEvent
|
||||||
|
from app.models.fission import FissionTask
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User", "LoginRecord", "UserPreference",
|
||||||
|
"Workspace", "WorkspaceMember", "UserApiKey",
|
||||||
|
"Product", "BenchmarkNote", "BannedWord",
|
||||||
|
"GenerationTask", "TextCandidate", "ImageCandidate",
|
||||||
|
"DeliveryPackage", "PreferenceEvent", "AiCallLog",
|
||||||
|
"FissionTask",
|
||||||
|
]
|
||||||
53
backend/app/models/fission.py
Normal file
53
backend/app/models/fission.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
app/models/fission.py — fission_tasks
|
||||||
|
Alembic 010 第11环裂变引擎(1爆款→N套完整笔记包)
|
||||||
|
|
||||||
|
reference_level: low/mid/high(参考程度)
|
||||||
|
status: pending/generating/done/failed
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, DateTime, ForeignKey,
|
||||||
|
Index, Integer, String, Text, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FissionTask(Base):
|
||||||
|
"""裂变任务:1爆款→N套完整笔记包(第11环),在工作台内呈现。"""
|
||||||
|
__tablename__ = "fission_tasks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
# 爆款源笔记内容(文案+图描述),JSON存储
|
||||||
|
source_note: Mapped[str | None] = mapped_column(
|
||||||
|
Text, comment="爆款源笔记内容(文案+图描述,JSON存储)"
|
||||||
|
)
|
||||||
|
# 参考程度:low/mid/high
|
||||||
|
reference_level: Mapped[str] = mapped_column(
|
||||||
|
String(10), default="mid", nullable=False,
|
||||||
|
comment="参考程度: low/mid/high"
|
||||||
|
)
|
||||||
|
# 裂变套数(默认3套)
|
||||||
|
fanout_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, default=3, nullable=False,
|
||||||
|
comment="裂变套数(默认3套)"
|
||||||
|
)
|
||||||
|
# 状态机
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), default="pending", nullable=False,
|
||||||
|
comment="状态: pending/generating/done/failed"
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_fission_tasks_workspace_id", "workspace_id"),
|
||||||
|
)
|
||||||
97
backend/app/models/flywheel.py
Normal file
97
backend/app/models/flywheel.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
app/models/flywheel.py — preference_events / ai_call_logs
|
||||||
|
Alembic 003(ai_call_logs)+ Alembic 004(preference_events)
|
||||||
|
preference_profile 二期预留,一期不建。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, DateTime, Enum, ForeignKey,
|
||||||
|
Index, Integer, String, Text, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.constants.enums import DataOwnership, SignalType
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PreferenceEvent(Base):
|
||||||
|
"""
|
||||||
|
飞轮信号日志。
|
||||||
|
workspace_id + product_id 都必须有(跨公司隔离 + 按产品分开学)。
|
||||||
|
angle_label 跟着产品的文案角度走,不写死(基石A)。
|
||||||
|
"""
|
||||||
|
__tablename__ = "preference_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
product_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("products.id"), nullable=False
|
||||||
|
)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("generation_tasks.id"), nullable=False
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
signal_type: Mapped[str] = mapped_column(
|
||||||
|
Enum(SignalType, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
signal_weight: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
candidate_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
angle_label: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text) # 打回原因原文
|
||||||
|
signal_meta: Mapped[str | None] = mapped_column(Text) # JSON,扩展用
|
||||||
|
data_ownership: Mapped[str] = mapped_column(
|
||||||
|
Enum(DataOwnership, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
default=DataOwnership.CLIENT_DATA, nullable=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"idx_preference_events_ws_product_created",
|
||||||
|
"workspace_id", "product_id", "created_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AiCallLog(Base):
|
||||||
|
"""
|
||||||
|
AI 调用记录(usage + 排障基础)。
|
||||||
|
调用失败归因到个人 key(错误码50002)。
|
||||||
|
绝不记录明文 key,只记录 key_id。
|
||||||
|
"""
|
||||||
|
__tablename__ = "ai_call_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
key_id: Mapped[int | None] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("user_api_keys.id")
|
||||||
|
)
|
||||||
|
task_id: Mapped[int | None] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("generation_tasks.id")
|
||||||
|
)
|
||||||
|
provider: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
model: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
call_type: Mapped[str | None] = mapped_column(String(32)) # text/image/analyze
|
||||||
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
success: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||||
|
error_code: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
latency_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_ai_call_logs_workspace_user", "workspace_id", "user_id"),
|
||||||
|
Index("idx_ai_call_logs_task_id", "task_id"),
|
||||||
|
)
|
||||||
110
backend/app/models/product.py
Normal file
110
backend/app/models/product.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
app/models/product.py — products / benchmark_notes / banned_words
|
||||||
|
Alembic 003 业务主体(1/3)
|
||||||
|
products.category 是纯数据字段,禁止任何品类枚举(基石A)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, DateTime, Enum, ForeignKey,
|
||||||
|
Index, Integer, String, Text, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.constants.enums import BannedWordLevel, ProductSource
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
"""产品档案(卖点/违禁词/风格/调性/文案角度/可调prompt/source)"""
|
||||||
|
__tablename__ = "products"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
# category 是纯数据字段,不在代码里做枚举(基石A)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
source: Mapped[str] = mapped_column(
|
||||||
|
Enum(ProductSource, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
default=ProductSource.CUSTOM, nullable=False,
|
||||||
|
)
|
||||||
|
selling_points: Mapped[str | None] = mapped_column(Text) # JSON数组
|
||||||
|
style_tone: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
text_angles: Mapped[str | None] = mapped_column(Text) # JSON数组,用户设定
|
||||||
|
custom_prompt: Mapped[str | None] = mapped_column(Text) # 等北哥方案注入
|
||||||
|
image_path: Mapped[str | None] = mapped_column(String(512)) # 产品参考图路径(前端上传后写入)
|
||||||
|
# 008: 品牌词(客户输入,植入文案每条+生图特写图第2/6张,第5环)
|
||||||
|
brand_keyword: Mapped[str | None] = mapped_column(String(64), comment="品牌词,客户录入,随产品固定")
|
||||||
|
# 012: 目标人群(客户输入,透传进文案/生图 prompt,storyboard.py:52 原恒空现可填)
|
||||||
|
target_audience: Mapped[str | None] = mapped_column(String(128), comment="目标人群,客户输入,透传进文案/生图prompt")
|
||||||
|
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship(
|
||||||
|
back_populates="product", lazy="noload"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_products_workspace_id", "workspace_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkNote(Base):
|
||||||
|
"""标杆笔记(截图+手填亮点为主通道)"""
|
||||||
|
__tablename__ = "benchmark_notes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
product_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
screenshot_url: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
highlights: Mapped[str | None] = mapped_column(Text) # 手填亮点
|
||||||
|
link_url: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
# 009: 第2环标杆分析字段
|
||||||
|
features_json: Mapped[str | None] = mapped_column(Text, comment="爆款8特征分析结果(JSON),AI解析后写入")
|
||||||
|
analyze_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False, comment="AI分析状态: pending/analyzing/done/failed")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
product: Mapped["Product"] = relationship(back_populates="benchmark_notes")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_benchmark_notes_product_id", "product_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BannedWord(Base):
|
||||||
|
"""违禁词库(三级:auto_fix/soft_warn/hard_block)"""
|
||||||
|
__tablename__ = "banned_words"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
word: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
level: Mapped[str] = mapped_column(
|
||||||
|
Enum(BannedWordLevel, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
replacement: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
updatable: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_banned_words_workspace_id", "workspace_id"),
|
||||||
|
)
|
||||||
151
backend/app/models/task.py
Normal file
151
backend/app/models/task.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
app/models/task.py — generation_tasks / text_candidates / image_candidates / delivery_packages
|
||||||
|
Alembic 003 业务主体(2/3)
|
||||||
|
任务主键:自增 BIGINT + mongo_trace_id VARCHAR(24)
|
||||||
|
eval_score 留 NULL,不接 banana 假评分(基石)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, Boolean, DateTime, Enum, Float, ForeignKey,
|
||||||
|
Index, Integer, String, Text, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.constants.enums import (
|
||||||
|
BannedWordStatus, CandidateSource, ImageRole,
|
||||||
|
PackageStatus, ReviewStatus, TaskStatus,
|
||||||
|
)
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationTask(Base):
|
||||||
|
"""生产任务,审核字段平铺(不建独立审核表)。"""
|
||||||
|
__tablename__ = "generation_tasks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
product_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("products.id"), nullable=False
|
||||||
|
)
|
||||||
|
operator_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
theme: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
text_count: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||||
|
image_count: Mapped[int] = mapped_column(Integer, default=3, nullable=False)
|
||||||
|
track: Mapped[str] = mapped_column(String(16), default="ai", nullable=False)
|
||||||
|
# 本次产品是否入镜:True=必须用产品参考图(无图禁生成,不降级纯文生图);False=允许纯文生图
|
||||||
|
need_product_image: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
Enum(TaskStatus, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
default=TaskStatus.PENDING, nullable=False,
|
||||||
|
)
|
||||||
|
mongo_trace_id: Mapped[str | None] = mapped_column(String(24)) # MongoDB trace
|
||||||
|
# ── 审核字段(平铺)──────────────────────────────
|
||||||
|
review_status: Mapped[str | None] = mapped_column(
|
||||||
|
Enum(ReviewStatus, values_callable=lambda x: [e.value for e in x])
|
||||||
|
)
|
||||||
|
reviewer_id: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("users.id"))
|
||||||
|
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
reject_reason: Mapped[str | None] = mapped_column(Text)
|
||||||
|
approved_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
archived_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
# 011: 第2环S12+第11环裂变
|
||||||
|
benchmark_ids: Mapped[str | None] = mapped_column(Text, comment="关联标杆笔记ID列表(JSON list),第2环引用")
|
||||||
|
source_fission_id: Mapped[int | None] = mapped_column(Integer, comment="裂变来源fission_task ID;NULL=普通任务")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_generation_tasks_workspace_status", "workspace_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TextCandidate(Base):
|
||||||
|
"""文案候选(source=ai/import 区分双轨)。eval_score 留 NULL。"""
|
||||||
|
__tablename__ = "text_candidates"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("generation_tasks.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
source: Mapped[str] = mapped_column(
|
||||||
|
Enum(CandidateSource, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
default=CandidateSource.AI, nullable=False,
|
||||||
|
)
|
||||||
|
angle_label: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
content: Mapped[str | None] = mapped_column(Text)
|
||||||
|
score_json: Mapped[str | None] = mapped_column(Text) # 五维分 JSON
|
||||||
|
banned_word_status: Mapped[str] = mapped_column(
|
||||||
|
Enum(BannedWordStatus, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
default=BannedWordStatus.PASS, nullable=False,
|
||||||
|
)
|
||||||
|
eval_score: Mapped[float | None] = mapped_column(Float) # 一期留 NULL
|
||||||
|
is_selected: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_text_candidates_task_id", "task_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageCandidate(Base):
|
||||||
|
"""图片候选。eval_score 留 NULL。"""
|
||||||
|
__tablename__ = "image_candidates"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("generation_tasks.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
# role 用 varchar 不用 enum:storyboard 角色名仍在演进,约束放应用层 ImageRole
|
||||||
|
role: Mapped[str] = mapped_column(
|
||||||
|
String(32), default=ImageRole.HOOK.value, nullable=False,
|
||||||
|
)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
strategy: Mapped[str | None] = mapped_column(String(4)) # A/B/C(二期)
|
||||||
|
seq: Mapped[int] = mapped_column(Integer, default=1) # 分镜序号
|
||||||
|
is_selected: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
eval_score: Mapped[float | None] = mapped_column(Float) # 一期留 NULL
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_image_candidates_task_id", "task_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryPackage(Base):
|
||||||
|
"""达人素材交付包。"""
|
||||||
|
__tablename__ = "delivery_packages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("generation_tasks.id"), nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
Enum(PackageStatus, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
default=PackageStatus.PENDING, nullable=False,
|
||||||
|
)
|
||||||
|
package_path: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
download_url: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
68
backend/app/models/user.py
Normal file
68
backend/app/models/user.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
app/models/user.py — users / login_records / user_preferences
|
||||||
|
Alembic 001:从 banana 搬,删除 credits 字段(架构方案规定)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, Boolean, DateTime, ForeignKey,
|
||||||
|
Integer, String, Text, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
# 注:删除 banana 的 credits 字段(架构方案规定)
|
||||||
|
|
||||||
|
login_records: Mapped[list["LoginRecord"]] = relationship(
|
||||||
|
back_populates="user", lazy="noload"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRecord(Base):
|
||||||
|
__tablename__ = "login_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="login_records")
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreference(Base):
|
||||||
|
"""UI 设置偏好(主题/语言等),不含 API Key。"""
|
||||||
|
__tablename__ = "user_preferences"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
unique=True, nullable=False,
|
||||||
|
)
|
||||||
|
preferences_json: Mapped[str | None] = mapped_column(Text) # JSON字符串
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship()
|
||||||
88
backend/app/models/workspace.py
Normal file
88
backend/app/models/workspace.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
app/models/workspace.py — workspaces / workspace_members / user_api_keys
|
||||||
|
Alembic 002:多租户基础,全新建。
|
||||||
|
matrix_accounts 二期预留,一期不建。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, DateTime, Enum, ForeignKey,
|
||||||
|
Index, String, UniqueConstraint, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.constants.enums import UserRole
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Workspace(Base):
|
||||||
|
__tablename__ = "workspaces"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
members: Mapped[list["WorkspaceMember"]] = relationship(
|
||||||
|
back_populates="workspace", lazy="noload"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceMember(Base):
|
||||||
|
__tablename__ = "workspace_members"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(
|
||||||
|
Enum(UserRole, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace: Mapped["Workspace"] = relationship(back_populates="members")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserApiKey(Base):
|
||||||
|
"""
|
||||||
|
个人 API Key(Fernet 加密)。
|
||||||
|
encrypted_key 只存密文,不存 url(token站固定自家站)。
|
||||||
|
UNIQUE(user_id, workspace_id, provider)。
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_api_keys"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
workspace_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
encrypted_key: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
key_last4: Mapped[str] = mapped_column(String(4), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"user_id", "workspace_id", "provider",
|
||||||
|
name="uq_user_workspace_provider",
|
||||||
|
),
|
||||||
|
Index("idx_user_api_keys_workspace_id", "workspace_id"),
|
||||||
|
)
|
||||||
3
backend/app/repositories/__init__.py
Normal file
3
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
app/repositories/__init__.py
|
||||||
|
"""
|
||||||
85
backend/app/repositories/base_workspace_repo.py
Normal file
85
backend/app/repositories/base_workspace_repo.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
app/repositories/base_workspace_repo.py — 多租户基础 Repo
|
||||||
|
所有 workspace 相关 Repo 继承此类,强制过滤 workspace_id(基石C)。
|
||||||
|
禁止 SELECT *,明确字段(db.md规范)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.response import raise_not_found
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class BaseWorkspaceRepo(Generic[T]):
|
||||||
|
"""
|
||||||
|
workspace 感知的基础 Repo。
|
||||||
|
子类设置 model_class,所有查询自动注入 workspace_id 过滤。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_class: type[T]
|
||||||
|
|
||||||
|
def __init__(self, db: Session, workspace_id: int):
|
||||||
|
self.db = db
|
||||||
|
self.workspace_id = workspace_id
|
||||||
|
|
||||||
|
def _base_query(self):
|
||||||
|
"""所有查询的基础:强制带 workspace_id 过滤(多租户红线)。"""
|
||||||
|
return self.db.query(self.model_class).filter(
|
||||||
|
self.model_class.workspace_id == self.workspace_id # type: ignore[attr-defined]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_by_id(self, record_id: int) -> T | None:
|
||||||
|
"""按 ID 查单条,强制带 workspace_id 防越权读。"""
|
||||||
|
return self._base_query().filter(
|
||||||
|
self.model_class.id == record_id # type: ignore[attr-defined]
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def get_by_id_or_404(self, record_id: int) -> T:
|
||||||
|
obj = self.get_by_id(record_id)
|
||||||
|
if not obj:
|
||||||
|
raise_not_found(f"{self.model_class.__name__} {record_id} 不存在")
|
||||||
|
return obj # type: ignore[return-value]
|
||||||
|
|
||||||
|
def list_all(
|
||||||
|
self,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
filters: list[Any] | None = None,
|
||||||
|
) -> tuple[list[T], int]:
|
||||||
|
"""分页列表,返回 (items, total)。"""
|
||||||
|
q = self._base_query()
|
||||||
|
if filters:
|
||||||
|
q = q.filter(*filters)
|
||||||
|
total = q.count()
|
||||||
|
items = q.offset(offset).limit(limit).all()
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
def create(self, obj: T) -> T:
|
||||||
|
"""插入一条记录,自动设置 workspace_id。"""
|
||||||
|
obj.workspace_id = self.workspace_id # type: ignore[attr-defined]
|
||||||
|
self.db.add(obj)
|
||||||
|
self.db.flush()
|
||||||
|
self.db.refresh(obj)
|
||||||
|
logger.debug("Created %s id=%s ws=%s", self.model_class.__name__, obj.id, self.workspace_id) # type: ignore[attr-defined]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def delete(self, obj: T) -> None:
|
||||||
|
"""物理删除(软删各子类自己处理 archived 态)。"""
|
||||||
|
self.db.delete(obj)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""提交事务,错误不静默(官网V1坑3)。"""
|
||||||
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
except Exception:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error("DB commit failed in %s ws=%s", self.model_class.__name__, self.workspace_id)
|
||||||
|
raise
|
||||||
13
backend/app/services/README.md
Normal file
13
backend/app/services/README.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# app/services/
|
||||||
|
|
||||||
|
业务逻辑层占位,按模块:
|
||||||
|
- auth_service.py # JWT签发/验证
|
||||||
|
- workspace_service.py # workspace权限校验
|
||||||
|
- product_service.py # 产品档案业务逻辑
|
||||||
|
- task_service.py # 任务状态机流转
|
||||||
|
- review_service.py # 审核流转+飞轮信号写入
|
||||||
|
- preference_aggregator.py # 飞轮实时聚合(最近50条→prompt片段)
|
||||||
|
- preference_collector.py # 三信号入口写入 preference_events
|
||||||
|
- banned_word_checker.py # 违禁词三级扫描(🟢改写/🟡提示/🔴拦截)
|
||||||
|
- package_exporter.py # 生成达人素材交付包
|
||||||
|
- image_postprocessor.py # 去水印后处理(重编码+削像素水印)
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
5
backend/app/services/ai_engine/__init__.py
Normal file
5
backend/app/services/ai_engine/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
AI 引擎包
|
||||||
|
扒自:上线版 worker/src/copy.js + image.js(2026-06-09)
|
||||||
|
重写为 Python,逻辑对照JS版防走样
|
||||||
|
"""
|
||||||
104
backend/app/services/ai_engine/_score_prompt.py
Normal file
104
backend/app/services/ai_engine/_score_prompt.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
_score_prompt.py — AI 评委 prompt(让模型真读文案,不机械找词)
|
||||||
|
评判标准忠于《富贵情绪营销理论》原文(口播一手来源),标实战补充出处。
|
||||||
|
6维满分分布(倩倩姐2026-06-15拍板,与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
|
||||||
|
痛点人群精准18 / 情绪张力18 / 买点转化18 / 开头钩子15 / 标题点击力13 / 真实感13 = 95
|
||||||
|
+ 合规5(机械硬拦,不进AI评委)= 100
|
||||||
|
"真实感"替换旧"产品聚焦一件事(16)":富贵"很少提产品/前70%干货后30%植入"独立升维。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ── 评委人设 ──────────────────────────────────────────────
|
||||||
|
SCORER_PERSONA = """你是一位资深小红书内容操盘手,深谙富贵情绪营销理论。
|
||||||
|
你的本事是:扫一眼就知道一条笔记能不能打动目标用户、会不会被划走。
|
||||||
|
你不数关键词、不看有没有出现某个固定词——你读的是【这条文案对真实用户有没有杀伤力】。
|
||||||
|
你按下面6个维度给文案打分,每一维都要给出【具体理由】,指出好在哪/差在哪/怎么改,
|
||||||
|
理由必须针对这条文案的真实内容,不准说放之四海皆准的空话。"""
|
||||||
|
|
||||||
|
# ── 6 维评判标准(按权重降序;合规第7维由代码机械硬拦,不在此)────
|
||||||
|
# 标准依据:括号内标注[富贵原文]或[实战补充],前者来自口播一手来源,权威最高。
|
||||||
|
SCORING_DIMENSIONS = """
|
||||||
|
【维度1·痛点人群精准(满分18)】[富贵原文]
|
||||||
|
判断:"说的就是我"——文案描述的处境/困扰,目标用户读了会不会对号入座。
|
||||||
|
好:具体到某类人的某个真实生活瞬间,让人觉得被看穿,落在"我的大问题/我的处境/我的渴望"上。
|
||||||
|
差:泛泛而谈谁都能套(如"适合所有想变美的女生");或PUA用户、拿别人的惨状吓唬人。
|
||||||
|
依据:富贵"我的大问题→处境→渴望"内容骨架;人群越具体穿透力越强;"用户被宠成爹,你PUA他不好使"。
|
||||||
|
|
||||||
|
【维度2·情绪张力(满分18)】[富贵原文·第一性原理]
|
||||||
|
判断:有没有"成果/后果"双向情绪,而不是平铺直叙报卖点。
|
||||||
|
· 后果=过去没用它,产生了什么糟糕处境(勾起恐惧/懊悔)
|
||||||
|
· 成果=用了它之后,会变成什么样(给出期待/乐观)
|
||||||
|
好:同时有"后果路径(过去的痛)+成果路径(未来的好)",有一句能戳中人、读完有情绪余温。
|
||||||
|
差:全程客观介绍产品、无情绪、像说明书;或只单向吓唬、或只空喊美好。
|
||||||
|
依据:富贵"营销第一性原理就是情绪,没有情绪什么内容都不转化";"成果是未来、后果是过去,要做这两种情绪"。
|
||||||
|
|
||||||
|
【维度3·买点转化(满分18)】[富贵原文]
|
||||||
|
判断:产品卖点有没有翻译成用户能感知的场景化利益(人话),而不是品牌视角的功效/参数。
|
||||||
|
好:用户能想象到的使用场景和结果(如"出门前最后一步、同事问我今天气色真好")。
|
||||||
|
差:品牌语言/参数罗列(如"采用XX技术""含XX成分"),用户无感。
|
||||||
|
依据:富贵"卖点是品牌视角、买点是用户视角";"用户买的不是产品,是使用场景背后被解决的问题"。
|
||||||
|
|
||||||
|
【维度4·开头钩子(满分15)】[富贵原文]
|
||||||
|
判断:开头能不能让人停下来、想继续读。
|
||||||
|
好:开头第一句就直击用户的大问题/痛处/具体场景代入,每句都打要害。
|
||||||
|
差:开头是套话或铺垫半天不进正题,没有任何抓人的点。
|
||||||
|
依据:富贵"内容就是锋利的刀,一定要插用户的心窝子"。
|
||||||
|
|
||||||
|
【维度5·标题点击力(满分13)】[富贵原文+实战补充]
|
||||||
|
判断:标题有没有让目标用户想点的诱因。
|
||||||
|
好:标题带具体人群/场景/痛点/情绪钩子,一眼觉得"和我有关、我想看",最好来自用户真实说法。
|
||||||
|
差:只有产品名、平淡无钩子、或太像广告。
|
||||||
|
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文];标题善用痛点/人群/效果/情绪词[实战补充]。
|
||||||
|
|
||||||
|
【维度6·真实感(满分13)】[富贵原文]
|
||||||
|
判断:整条文案是不是像真人分享,而不是品牌广告或功效说明书。
|
||||||
|
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格。
|
||||||
|
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板而非真实人设。
|
||||||
|
依据:富贵"我们很少提产品";"前70%是价值/场景,后30%才是产品";用户能感受到"这不像广告"。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
# ── 真实感已升为维度6(满分13),此处保留空字符串避免调用方引用报错 ──
|
||||||
|
REALNESS_NOTE = "" # 升为 SCORING_DIMENSIONS 维度6,不再重复附加
|
||||||
|
|
||||||
|
# ── 输出格式约束 ──────────────────────────────────────────
|
||||||
|
SCORER_OUTPUT_FORMAT = """
|
||||||
|
读完这条文案后,严格返回纯JSON对象(不要markdown代码块、不要多余文字),格式:
|
||||||
|
{
|
||||||
|
"dims": [
|
||||||
|
{"item":"痛点人群精准","score":<0-18整数>,"reason":"<针对本条的具体理由,30字内>"},
|
||||||
|
{"item":"情绪张力","score":<0-18>,"reason":"..."},
|
||||||
|
{"item":"买点转化","score":<0-18>,"reason":"..."},
|
||||||
|
{"item":"开头钩子","score":<0-15>,"reason":"..."},
|
||||||
|
{"item":"标题点击力","score":<0-13>,"reason":"..."},
|
||||||
|
{"item":"真实感","score":<0-13>,"reason":"..."}
|
||||||
|
],
|
||||||
|
"verdict":"<优秀|合格|不合格>",
|
||||||
|
"summary":"<一句话总评,说清这条最大的优点和最该改的点>"
|
||||||
|
}
|
||||||
|
打分要敢拉开差距:平庸文案该给中低分,不要清一色高分;优秀的地方也别吝啬给高分。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
# 合规维度满分(机械硬拦,不进 AI 评委)
|
||||||
|
COMPLIANCE_MAX = 5
|
||||||
|
|
||||||
|
# AI 评委 6 维满分合计(用于把 0-95 折算/校验;+合规5=100)
|
||||||
|
AI_DIMS_MAX = 95
|
||||||
|
|
||||||
|
|
||||||
|
def build_score_prompt(copy: dict, product: dict | None = None) -> str:
|
||||||
|
"""组装单条文案的评委 prompt。copy={title,content,...},product 提供品牌/品类语境。"""
|
||||||
|
title = str(copy.get("title", "")).strip()
|
||||||
|
content = str(copy.get("content", "")).strip()
|
||||||
|
ctx = ""
|
||||||
|
if product:
|
||||||
|
name = product.get("name") or product.get("title") or ""
|
||||||
|
brand = product.get("brand_keyword") or product.get("brand") or ""
|
||||||
|
cat = product.get("category") or ""
|
||||||
|
bits = [b for b in (f"产品:{name}", f"品牌词:{brand}", f"品类:{cat}") if b.split(":", 1)[1]]
|
||||||
|
if bits:
|
||||||
|
ctx = "【产品语境】\n" + "\n".join(bits) + "\n\n"
|
||||||
|
return (
|
||||||
|
f"{SCORING_DIMENSIONS}\n\n"
|
||||||
|
f"{ctx}【待评文案】\n标题:{title}\n正文:{content}\n\n"
|
||||||
|
f"{SCORER_OUTPUT_FORMAT}"
|
||||||
|
)
|
||||||
92
backend/app/services/ai_engine/_scoring_dims.py
Normal file
92
backend/app/services/ai_engine/_scoring_dims.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
_scoring_dims.py — 五维打分逻辑(单一职责:计算层)
|
||||||
|
词表常量 + 每维打分函数,由 text_scoring.score_copy 调用
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .constants import SCORE_WEIGHTS, BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, INTERNAL_COPY_HINTS
|
||||||
|
|
||||||
|
_EMOTION_WORDS = ["谁懂", "绝了", "姐妹", "宝子", "挖到", "救星", "离不开",
|
||||||
|
"闭眼入", "冲", "不踩雷", "后悔没早买"]
|
||||||
|
_SCENE_WORDS = ["通勤", "上班", "办公室", "工位", "宿舍", "出门", "旅行",
|
||||||
|
"居家", "运动", "带娃", "约会", "熬夜", "换季", "饭后",
|
||||||
|
"早餐", "下午", "加班", "外食", "日常", "早八", "学生党", "新手", "宝妈"]
|
||||||
|
_CATEGORY_WORDS: dict[str, list[str]] = {
|
||||||
|
"美妆护肤": ["肤", "妆", "质地", "服帖", "清透", "水润", "自然", "上脸", "底妆"],
|
||||||
|
"个护护理": ["手", "护理", "干", "黏", "吸收", "滋润", "粗糙", "倒刺", "随身", "质地"],
|
||||||
|
"食品饮品": ["口感", "入口", "味道", "冲泡", "杯", "工位", "囤", "不腻", "清爽"],
|
||||||
|
"营养健康": ["成分", "日常", "坚持", "习惯", "补给", "安心", "配方"],
|
||||||
|
"家居生活": ["收纳", "省事", "清洁", "桌面", "厨房", "租房", "细节", "高频"],
|
||||||
|
"服饰穿搭": ["上身", "版型", "面料", "通勤", "显瘦", "搭配", "质感"],
|
||||||
|
"通用好物": ["实用", "场景", "细节", "省事", "日常", "高频"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_any(text: str, words: list[str]) -> bool:
|
||||||
|
return any(w.lower() in text.lower() for w in words)
|
||||||
|
|
||||||
|
|
||||||
|
def _cat_words(category: str) -> list[str]:
|
||||||
|
return _CATEGORY_WORDS.get(category, _CATEGORY_WORDS["通用好物"])
|
||||||
|
|
||||||
|
|
||||||
|
def score_title(title: str, cat_words: list[str], w: dict) -> dict:
|
||||||
|
ts = 0
|
||||||
|
if 6 <= len(title) <= 34: ts += 8
|
||||||
|
if re.search(r"[0-9一二三四五六七八九十几]|最近|这支|这个|每天|早八|学生党|新手|宝妈|懒人|伪素颜", title): ts += 5
|
||||||
|
if _has_any(title, _SCENE_WORDS): ts += 6
|
||||||
|
if _has_any(title, cat_words): ts += 5
|
||||||
|
if re.search(r"[!!??]|救星|不踩雷|闭眼入|会回购|被问|惊喜|加分|常备|省心|实用|别乱选|放心|有气色", title): ts += 6
|
||||||
|
if re.search(r"救星|绝了|挖到|偷偷|被问|离不开|后悔|拿捏|香|包里|回购|常备|工位|换季", title): ts += 4
|
||||||
|
ts = min(ts, w["title"])
|
||||||
|
return {"item": "标题吸引力", "score": ts, "max": w["title"],
|
||||||
|
"note": "标题有明确人群/场景钩子" if ts >= 18 else "建议补充人群、场景或强钩子"}
|
||||||
|
|
||||||
|
|
||||||
|
def score_emotion(full: str, w: dict) -> dict:
|
||||||
|
es = 0
|
||||||
|
if _has_any(full, _EMOTION_WORDS): es += 10
|
||||||
|
if _has_any(full, _SCENE_WORDS): es += 8
|
||||||
|
if re.search(r"不假白|不卡|不搓泥|没底气|纠结|救|不黏|不腻|踩雷|麻烦|翻车", full): es += 7
|
||||||
|
if re.search(r"姐妹|宝子|室友|同事|我自己|实测|亲测|上脸|出门|工位|宿舍|家里", full): es += 5
|
||||||
|
if re.search(r"[✅✨🌿💧📦🔍🧡🪞🧴🍃🥹😭👍]", full): es += 3
|
||||||
|
es = min(es, w["emotion"])
|
||||||
|
return {"item": "情绪共鸣", "score": es, "max": w["emotion"],
|
||||||
|
"note": "口语感和痛点表达较充分" if es >= 18 else "建议增加真实痛点和口语化表达"}
|
||||||
|
|
||||||
|
|
||||||
|
def score_selling(copy: dict, full: str, selling_points: list, w: dict) -> dict:
|
||||||
|
bs = 0
|
||||||
|
matched = [pt for pt in selling_points if any(kw in full for kw in str(pt).split()[:3])]
|
||||||
|
if len(matched) >= 1: bs += 7
|
||||||
|
if len(matched) >= 2: bs += 4
|
||||||
|
if re.search(r"方便|自然|清爽|质地|口感|成分|实测|亲测|场景|随身|省事|高频|性价比|好用|适合|推荐", full): bs += 8
|
||||||
|
if copy.get("buyingPoint") or re.search(r"分钟|出门|通勤|办公室|宿舍|居家|换季|工位|旅行", full): bs += 9
|
||||||
|
if copy.get("coverTitle") or copy.get("imageBrief"): bs += 4
|
||||||
|
bs = min(bs, w["selling"])
|
||||||
|
return {"item": "买点表达", "score": bs, "max": w["selling"],
|
||||||
|
"note": "卖点已转成用户可感知买点" if bs >= 18 else "建议把功能卖点翻译成使用场景和结果"}
|
||||||
|
|
||||||
|
|
||||||
|
def score_keyword(copy: dict, tags: str, keywords: list, w: dict) -> dict:
|
||||||
|
ks = 0
|
||||||
|
matched = [k for k in keywords if str(k).replace("#", "") in tags + str(copy.get("content",""))]
|
||||||
|
if len(matched) >= 1: ks += 6
|
||||||
|
if len(matched) >= 2: ks += 5
|
||||||
|
if len(copy.get("tags", [])) >= 3: ks += 5
|
||||||
|
if "#" in tags: ks += 4
|
||||||
|
if len(copy.get("tags", [])) >= 5: ks += 4
|
||||||
|
ks = min(ks, w["keyword"])
|
||||||
|
note = f"覆盖:{'、'.join(matched)}" if matched else "建议补充品类词和长尾词"
|
||||||
|
return {"item": "关键词覆盖", "score": ks, "max": w["keyword"], "note": note}
|
||||||
|
|
||||||
|
|
||||||
|
def score_compliance(full: str, bwords: list[str], w: dict) -> tuple[dict, list[str]]:
|
||||||
|
found_banned = [bw for bw in bwords if bw.lower() in full.lower()]
|
||||||
|
found_hints = [hw for hw in INTERNAL_COPY_HINTS if hw in full]
|
||||||
|
cs = 0 if (found_banned or found_hints) else w["compliance"]
|
||||||
|
note = (f"含禁用词:{'、'.join(found_banned)}" if found_banned
|
||||||
|
else (f"正文混入内部提示:{'、'.join(found_hints)}" if found_hints else "未发现禁用词"))
|
||||||
|
return {"item": "合规性", "score": cs, "max": w["compliance"], "note": note}, found_banned + found_hints
|
||||||
64
backend/app/services/ai_engine/_storyboard_data.py
Normal file
64
backend/app/services/ai_engine/_storyboard_data.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
_storyboard_data.py — 品类证明策略数据(纯数据,不含逻辑)
|
||||||
|
品类来自 product.category,不枚举,兜底用"通用好物"
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# 视觉违禁词替换规则(扒 sanitizeImagePlanText)
|
||||||
|
SANITIZE_RULES: list[tuple[str, str]] = [
|
||||||
|
(r"before\s*&\s*after", "质地与肤感说明"),
|
||||||
|
(r"before\s*/?\s*after", "质地与肤感说明"),
|
||||||
|
(r"\bbefore\b", "质地状态"),
|
||||||
|
(r"\bafter\b", "上脸肤感"),
|
||||||
|
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"),
|
||||||
|
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
|
||||||
|
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
|
||||||
|
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
|
||||||
|
(r"治疗前后|治疗后|医美前后|治愈|修复受损", "日常使用场景说明"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 品类证明策略(不写死枚举,product.category 匹配,兜底通用)
|
||||||
|
PROOF_STRATEGIES: dict[str, dict] = {
|
||||||
|
"个护护理": {
|
||||||
|
"overlay_tpl": "{point}看得见",
|
||||||
|
"visual": "手部/身体局部使用证明:少量点涂、推开后吸收状态、真实纹理和自然光",
|
||||||
|
"asset_use": "优先使用实拍/参考图中的手部、干纹、涂抹、随身场景",
|
||||||
|
"forbidden": "不要变白、祛斑、医学效果、before/after字样;不要和封面同构图",
|
||||||
|
},
|
||||||
|
"美妆护肤": {
|
||||||
|
"overlay_tpl": "{point}看得见",
|
||||||
|
"visual": "肤感/质地证明:手背、脸颊局部或质地微距,展示推开前后真实状态",
|
||||||
|
"asset_use": "优先使用实拍/参考图中的手背、上脸、质地素材",
|
||||||
|
"forbidden": "不要变白、祛斑、医学效果、before/after字样",
|
||||||
|
},
|
||||||
|
"食品饮品": {
|
||||||
|
"overlay_tpl": "{point}一眼懂",
|
||||||
|
"visual": "冲泡/开袋/入口证明:展示包装、杯中状态、质地颜色,真实桌面光线",
|
||||||
|
"asset_use": "产品图保证包装准确,参考图用于杯子、开袋、冲泡、居家场景",
|
||||||
|
"forbidden": "不要涂抹、不要护肤肤感、不要医疗健康承诺",
|
||||||
|
},
|
||||||
|
"营养健康": {
|
||||||
|
"overlay_tpl": "看清{point}",
|
||||||
|
"visual": "理性证明页:包装、成分表、使用场景和每日习惯卡片",
|
||||||
|
"asset_use": "产品图和说明图用于成分/包装准确,参考图用于日常使用场景",
|
||||||
|
"forbidden": "不要治疗、改善疾病、速效、医生背书、前后对比",
|
||||||
|
},
|
||||||
|
"家居生活": {
|
||||||
|
"overlay_tpl": "{point}真省事",
|
||||||
|
"visual": "使用过程证明:展示痛点场景、产品介入和使用过程细节",
|
||||||
|
"asset_use": "参考图用于真实家居环境,产品图保证外观准确",
|
||||||
|
"forbidden": "不要护肤涂抹,不要虚假夸大结果",
|
||||||
|
},
|
||||||
|
"服饰穿搭": {
|
||||||
|
"overlay_tpl": "{point}有细节",
|
||||||
|
"visual": "上身/材质证明:展示面料纹理、版型细节或普通身材上身局部",
|
||||||
|
"asset_use": "参考图用于上身/搭配/材质,产品图保证款式颜色准确",
|
||||||
|
"forbidden": "不要护肤涂抹,不要过度精修模特感",
|
||||||
|
},
|
||||||
|
"通用好物": {
|
||||||
|
"overlay_tpl": "{point}清晰可见",
|
||||||
|
"visual": "产品使用场景证明:真实道具/场景,展示产品细节和使用过程",
|
||||||
|
"asset_use": "产品图保证准确,参考图用于场景辅助",
|
||||||
|
"forbidden": "不要夸大效果,不要硬广式价格牌",
|
||||||
|
},
|
||||||
|
}
|
||||||
209
backend/app/services/ai_engine/_text_prompt.py
Normal file
209
backend/app/services/ai_engine/_text_prompt.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
_text_prompt.py — 文案 prompt 组装 / JSON 解析 / 本地模板兜底
|
||||||
|
方法层(全品类共用):人设/变量池/5步框架/四段结构/反AI味规则
|
||||||
|
数据层(每产品各异):由 product dict 动态注入,代码不出现具体品牌/成分名
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ── Q4 佛系反推销人设(全品类共用,数据层产品名从product动态注入)──────────
|
||||||
|
_PERSONA = """你是一个日常生活分享博主,不是品牌推广号。
|
||||||
|
核心人设:佛系、不推销、真实记录日常。
|
||||||
|
内核:不主动劝买,靠真实体验让读者自己动心;写的是生活,产品只是生活的一部分。
|
||||||
|
比例:70% 写生活场景和使用感受,30% 提产品,绝不颠倒。
|
||||||
|
收尾铁律:每条结尾方式必须不同,禁止使用"东西放这了/买不买跟我没关系"这类被用滥的固定句式。"""
|
||||||
|
|
||||||
|
# ── Q1 随机变量池 ABC(反同质化核心,每次随机抽组合,N条不撞)──────────────
|
||||||
|
# 方法层:框架固定,内容可扩展,绝不写死
|
||||||
|
_POOL_A_IDENTITY: list[str] = [
|
||||||
|
"上班族早八妆前随手抹",
|
||||||
|
"宿舍懒人护肤三分钟搞定",
|
||||||
|
"敏感肌妈妈哄完娃才有五分钟",
|
||||||
|
"学生党第一次用高价护肤品",
|
||||||
|
"素颜出门前最后一步",
|
||||||
|
]
|
||||||
|
|
||||||
|
_POOL_B_EMOTION: list[str] = [
|
||||||
|
"看到镜子里发现气色暗了一周",
|
||||||
|
"闺蜜问你最近皮肤怎么这么好",
|
||||||
|
"出门被催快点根本来不及叠瓶",
|
||||||
|
]
|
||||||
|
|
||||||
|
_POOL_C_FLAW: list[str] = [
|
||||||
|
"第一次用量太少了没啥感觉",
|
||||||
|
"包装简单到以为是山寨",
|
||||||
|
"价格摆在那以为会很油很厚",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_combo() -> dict[str, str]:
|
||||||
|
"""随机抽一组 ABC 变量(每次生成调用一次,N条各不相同)"""
|
||||||
|
return {
|
||||||
|
"identity": random.choice(_POOL_A_IDENTITY),
|
||||||
|
"emotion": random.choice(_POOL_B_EMOTION),
|
||||||
|
"flaw": random.choice(_POOL_C_FLAW),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Q5 negative词:prompt级别负向约束,不让AI写进正文 ─────────────────────
|
||||||
|
_NEGATIVE_WORDS = (
|
||||||
|
"神器、福音、救急单品、遮羞布、日常维稳、精简底妆、"
|
||||||
|
"不仅而且、焕发、守护、尽享、日常维稳、"
|
||||||
|
"按头安利、绝绝子、闭眼冲、杀疯了、YYDS"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── emoji 规则(适度有表情,倩倩姐2026-06-08拍板:像真人发的小红书)──────────
|
||||||
|
_EMOJI_RULES = """
|
||||||
|
【emoji表情(适度有表情,必须遵守)】
|
||||||
|
- 卖点小标题前加 emoji:✅ 卖点1 / ✨ 卖点2 / 🌿 卖点3(每条卖点1个,符合语义)
|
||||||
|
- 正文段落可点缀少量 emoji 烘托情绪(如 🥹 😭 🤍 💛),每段最多1-2个,不堆砌
|
||||||
|
- 结尾话题标签前后带表情,如 "#好物分享 🛒"
|
||||||
|
- emoji 服务情绪和分点,不要每句都加;整条正文 emoji 总量控制在 6-12 个
|
||||||
|
- 常用小红书 emoji 池:✅✨🌿💧🪞🧴📦🔍💛🤍🥹😭🛒(按语义选,不乱用)
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
# ── Q2 5步框架 + Q3 四段结构 ─────────────────────────────────────────────
|
||||||
|
_STRUCTURE_RULES = """
|
||||||
|
【5步框架(必须严格遵循)】
|
||||||
|
① Hook暴击低价/痛点:第一句戳中场景或价格锚点,吊足读者好奇
|
||||||
|
② 痛点共鸣:2-3句描写使用前的真实困境(用上面抽到的起因情绪A·B)
|
||||||
|
③ 救星登场:自然带出产品,口吻是"碰巧发现/朋友安利/囤货时顺手",不是"推荐给你"
|
||||||
|
④ 卖点罗列(每条加✅小标题):3条以内,卖点翻译成使用感受,不是功效列表
|
||||||
|
⑤ 收尾(每条必须从下方策略池随机选一种,同批次不得重复同一种,禁止"东西放这了/买不买跟我没关系"此类固定句式):
|
||||||
|
【收尾策略池·每条选不同策略】
|
||||||
|
A·留白式感受:只说自己现在的状态,不提买不买,如"反正现在素颜出门我不慌了"
|
||||||
|
B·反问读者:把感受抛回给读者,如"你们护肤有没有那种一用就回不去的东西?"
|
||||||
|
C·场景延续:把故事延伸到未来某个细节,如"下次同事再问我皮肤的事我就知道说啥了"
|
||||||
|
D·克制回购暗示:轻描淡写说自己行为,如"第一罐用完了,已经在备第二罐"
|
||||||
|
E·纯记录收笔:像日记最后一句,不引导不评价,如"大概就这样,记录一下"
|
||||||
|
F·引导搜索(仅在有品牌词时使用):自然提一句,如"感兴趣可以搜搜『{品牌词}』",不催单
|
||||||
|
|
||||||
|
【正文四段结构(必须)】
|
||||||
|
段1·痛点引入:描写使用前的困境/触发场景(身份场景A·起因情绪B)
|
||||||
|
段2·实测记录:真实使用过程,带上小缺点C(真实感来源)
|
||||||
|
段3·种草核心:产品带来的变化,用感受描述而非功效声称
|
||||||
|
段4·引导收尾:从收尾策略池随机选一种,佛系口吻,不强推,末尾带1-2个相关话题标签
|
||||||
|
|
||||||
|
【字数】正文350-400字(不含标题tags),3-5处段落空行增强可读性
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
# ── Q8 标题公式(5类结构,每批次覆盖不同类型,不重复)──────────────────────
|
||||||
|
_TITLE_FORMULA = """
|
||||||
|
【标题公式(5类,每条用不同类型,禁止同批重复)】
|
||||||
|
肤质型:「{肤质}+用了{产品}+{感受}」例→"油皮用了素颜霜整个夏天不脱妆"
|
||||||
|
价格型:「{价格锚点}+{产品}+{功效感受}」例→"三位数买到大牌平替,用完第一罐回购第二罐"
|
||||||
|
功效型:「{使用场景}+{产品}+{可感知变化}」例→"早起素颜出门靠这罐,同事问我最近皮肤怎么了"
|
||||||
|
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神奇产品,涂上去感觉素颜也能出门了"
|
||||||
|
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"被人说皮肤变好了,没说的是我用了它一个月"
|
||||||
|
硬性约束:标题≤20字;禁止出现"绝绝子/YYDS/杀疯了";不直接写功效词(美白/祛斑等)。
|
||||||
|
""".strip()
|
||||||
|
_ANTI_AI = f"""
|
||||||
|
【反AI味必须遵守】
|
||||||
|
- 禁止固定开篇套话:不许"姐妹们/宝子们/今天给大家分享"开头
|
||||||
|
- 禁止以下AI味词出现在正文:{_NEGATIVE_WORDS}
|
||||||
|
- 禁止人群重叠:{'{count}'}条文案中身份场景不能重复(靠变量池ABC保证)
|
||||||
|
- 禁止场景重复:同批次文案不能都是"早上上班"或都是"学生宿舍"
|
||||||
|
- 避免生硬推销词:按头安利/绝绝子/闭眼冲 不能出现
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
# ── 系统 prompt(合并Q2/Q3/Q4/Q7/Q8,数据层走build_prompt动态注入)──────────
|
||||||
|
COPY_SYSTEM = f"""{_PERSONA}
|
||||||
|
|
||||||
|
合规红线(全品类通用):
|
||||||
|
- 禁用"美白、祛斑、速效、医用、药妆",不得暗示治疗或改善疾病
|
||||||
|
- 不得承诺效果,不得出现before/after变白对比暗示
|
||||||
|
- 图文合规:避免社交App界面、点赞评论等截图元素
|
||||||
|
|
||||||
|
{_ANTI_AI.format(count="N")}
|
||||||
|
|
||||||
|
{_TITLE_FORMULA}
|
||||||
|
|
||||||
|
{_EMOJI_RULES}
|
||||||
|
|
||||||
|
{_STRUCTURE_RULES}
|
||||||
|
|
||||||
|
返回纯JSON数组,每条字段:
|
||||||
|
title(标题≤20字)/ content(正文,严格350-400字,按emoji规则适度带表情)/ tags(list,3-5个#话题)
|
||||||
|
angle(本条角度标签)/ coverTitle(封面大字≤10字)/ imageBrief(配图方向)
|
||||||
|
|
||||||
|
硬性格式:只输出JSON,不要markdown代码块,字符串内用中文引号「」。"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
|
||||||
|
"""
|
||||||
|
组装文案生成 user_prompt。
|
||||||
|
数据层:product 动态注入(name/selling_points/style_tone/text_angles/custom_prompt)
|
||||||
|
方法层:已在 COPY_SYSTEM 固定,这里只注入产品数据+随机变量
|
||||||
|
"""
|
||||||
|
name = product.get("name", "产品")
|
||||||
|
selling = "、".join(product.get("selling_points") or ["核心卖点待录入"])
|
||||||
|
style = product.get("style_tone", "素人日常分享风")
|
||||||
|
angles = product.get("text_angles") or []
|
||||||
|
custom = (product.get("custom_prompt") or "").strip()
|
||||||
|
brand_kw = (product.get("brand_keyword") or "").strip()
|
||||||
|
|
||||||
|
# Q1:每条抽一组随机变量,传给模型作角色约束
|
||||||
|
combos = [_pick_combo() for _ in range(count)]
|
||||||
|
combos_text = "\n".join(
|
||||||
|
f" 第{i+1}条:身份场景=「{c['identity']}」·起因情绪=「{c['emotion']}」·小缺点=「{c['flaw']}」"
|
||||||
|
for i, c in enumerate(combos)
|
||||||
|
)
|
||||||
|
|
||||||
|
angle_hint = f"文案角度要覆盖:{'、'.join(angles)}(每条用不同角度)。" if angles else ""
|
||||||
|
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else ""
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"产品:{name}",
|
||||||
|
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效词;翻译范例:'烟酰胺'→'熬夜后第二天脸不那么黄了','高保湿'→'涂上去一整天都没搓泥拔干'):{selling}",
|
||||||
|
f"风格调性:{style}",
|
||||||
|
angle_hint,
|
||||||
|
brand_rule,
|
||||||
|
custom,
|
||||||
|
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】",
|
||||||
|
combos_text,
|
||||||
|
extra_rules,
|
||||||
|
f"\n请严格按5步框架+四段结构生成 {count} 条,每条350-400字,返回纯JSON数组。",
|
||||||
|
]
|
||||||
|
return "\n".join(l for l in lines if l)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_array(raw: str) -> list[dict]:
|
||||||
|
"""从模型输出提取 JSON 数组(容错 markdown 包裹)"""
|
||||||
|
text = re.sub(r"```(?:json)?", "", raw).strip()
|
||||||
|
start, end = text.find("["), text.rfind("]")
|
||||||
|
if start == -1 or end == -1:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(text[start:end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def build_local_drafts(product: dict, count: int) -> list[dict]:
|
||||||
|
"""本地模板兜底(保证永不空手,遵循四段结构)
|
||||||
|
角度从 text_angles 循环取(保证N条角度各不同,不被 dedupe_copies 吞掉)
|
||||||
|
"""
|
||||||
|
name = product.get("name", "产品")
|
||||||
|
points = product.get("selling_points") or ["使用方便", "真实可感知"]
|
||||||
|
# 从产品档案取角度池,不够就用通用角度兜底,保证每条都不同
|
||||||
|
_fallback_angles = ["生活场景型", "成分分析型", "使用感受型", "性价比型", "痛点切入型"]
|
||||||
|
angles_pool = product.get("text_angles") or _fallback_angles
|
||||||
|
for i in range(count):
|
||||||
|
c = _pick_combo()
|
||||||
|
# 循环取不同角度(角度相同的两条会被 dedupe_copies 过滤掉,所以必须不重复)
|
||||||
|
angle = angles_pool[i % len(angles_pool)]
|
||||||
|
yield {
|
||||||
|
"title": f"发现一个{name}的{angle}用法",
|
||||||
|
"content": (
|
||||||
|
f"{c['identity']},{c['emotion']}。\n\n"
|
||||||
|
f"用了一段时间,{points[i % len(points)]}这点最让我意外。\n\n"
|
||||||
|
f"说个小缺点:{c['flaw']},后来才摸到感觉。\n\n"
|
||||||
|
f"反正现在用顺手了。✅"
|
||||||
|
),
|
||||||
|
"tags": [f"#{name}", "#真实测评", "#好物分享"],
|
||||||
|
"angle": angle,
|
||||||
|
"coverTitle": f"{name}{angle}",
|
||||||
|
"imageBrief": "封面产品近景,内页核心卖点+真实使用场景。",
|
||||||
|
}
|
||||||
125
backend/app/services/ai_engine/banned_word_checker.py
Normal file
125
backend/app/services/ai_engine/banned_word_checker.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
违禁词三级处理(扒 copy.js sanitizePlanningText 扩展为三级)
|
||||||
|
🟢 auto_fix = 自动改写(replacement 字段给出替换词)
|
||||||
|
🟡 soft_warn = 软提示(返回建议词,不阻塞)
|
||||||
|
🔴 hard_block= 硬拦截(直接返回 None,拦住发布)
|
||||||
|
|
||||||
|
词库来自数据库 banned_words 表(level + replacement 字段),
|
||||||
|
DB 未配时用本模块内置默认词库作冷启动。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
BannedLevel = Literal["auto_fix", "soft_warn", "hard_block"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BannedWordEntry:
|
||||||
|
word: str
|
||||||
|
level: BannedLevel
|
||||||
|
replacement: str | None = None # auto_fix 时提供替换词
|
||||||
|
|
||||||
|
|
||||||
|
# ── 默认词库(北哥回填解读与落点 §4.3,数据库未配时使用)─
|
||||||
|
DEFAULT_BANNED_WORDS: list[BannedWordEntry] = [
|
||||||
|
# 功效违禁(auto_fix:改写成合规表达,对应北哥"提亮肤色感/改善暗沉观感")
|
||||||
|
BannedWordEntry("美白", "auto_fix", "提亮肤色感"),
|
||||||
|
BannedWordEntry("祛斑", "auto_fix", "改善暗沉观感"),
|
||||||
|
# 功效违禁(hard_block:无法合规改写,直接拦截)
|
||||||
|
BannedWordEntry("速效", "hard_block"),
|
||||||
|
BannedWordEntry("医用", "hard_block"),
|
||||||
|
BannedWordEntry("药妆", "hard_block"),
|
||||||
|
BannedWordEntry("强效焕白", "hard_block"),
|
||||||
|
# 保证性词(soft_warn)
|
||||||
|
BannedWordEntry("绝对", "soft_warn"),
|
||||||
|
BannedWordEntry("第一名", "soft_warn"),
|
||||||
|
BannedWordEntry("再也不", "soft_warn"),
|
||||||
|
# 夸张词(soft_warn)
|
||||||
|
BannedWordEntry("杀疯了", "soft_warn"),
|
||||||
|
BannedWordEntry("秒杀", "soft_warn"),
|
||||||
|
BannedWordEntry("震撼", "soft_warn"),
|
||||||
|
# AI 味词(auto_fix,置换为口语表达;同时在 _NEGATIVE_WORDS prompt负向约束里已禁止AI写进正文)
|
||||||
|
BannedWordEntry("神器", "auto_fix", "好用的"),
|
||||||
|
BannedWordEntry("福音", "auto_fix", "适合的"),
|
||||||
|
BannedWordEntry("救急单品", "auto_fix", "随手备用的"),
|
||||||
|
BannedWordEntry("遮羞布", "auto_fix", "底妆感"), # 北哥原文补录
|
||||||
|
BannedWordEntry("不仅而且", "auto_fix", ",另外"),
|
||||||
|
BannedWordEntry("焕发", "auto_fix", "呈现"),
|
||||||
|
BannedWordEntry("守护", "auto_fix", ""),
|
||||||
|
BannedWordEntry("尽享", "auto_fix", "使用"),
|
||||||
|
BannedWordEntry("日常维稳", "auto_fix", "日常保养"),
|
||||||
|
BannedWordEntry("精简底妆", "auto_fix", "轻便底妆"),
|
||||||
|
# 视觉违禁(hard_block,文案含这些词不许过)
|
||||||
|
BannedWordEntry("前后对比", "hard_block"),
|
||||||
|
BannedWordEntry("使用前后", "hard_block"),
|
||||||
|
BannedWordEntry("变白", "auto_fix", "自然光泽感"),
|
||||||
|
BannedWordEntry("瑕疵消失", "auto_fix", "妆感更服帖"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CheckResult:
|
||||||
|
text: str # 原文(soft_warn/hard_block 场景下保持原文)
|
||||||
|
fixed_text: str | None # auto_fix 后的文本;其他级别为 None
|
||||||
|
status: Literal["pass", "auto_fixed", "soft_warn", "hard_block"]
|
||||||
|
found: list[dict] = field(default_factory=list)
|
||||||
|
# found 每项: {"word": str, "level": BannedLevel, "replacement": str|None}
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_fix(
|
||||||
|
text: str,
|
||||||
|
entries: list[BannedWordEntry] | None = None,
|
||||||
|
) -> CheckResult:
|
||||||
|
"""
|
||||||
|
对一段文本做三级违禁词扫描。
|
||||||
|
entries:优先用 DB 词条,为 None 时用默认词库。
|
||||||
|
"""
|
||||||
|
word_list = entries if entries is not None else DEFAULT_BANNED_WORDS
|
||||||
|
found: list[dict] = []
|
||||||
|
working = text
|
||||||
|
|
||||||
|
# 先扫描所有命中
|
||||||
|
for entry in word_list:
|
||||||
|
if entry.word.lower() in working.lower():
|
||||||
|
found.append({
|
||||||
|
"word": entry.word,
|
||||||
|
"level": entry.level,
|
||||||
|
"replacement": entry.replacement,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
return CheckResult(text=text, fixed_text=None, status="pass", found=[])
|
||||||
|
|
||||||
|
# 有 hard_block → 直接拦截
|
||||||
|
if any(f["level"] == "hard_block" for f in found):
|
||||||
|
return CheckResult(text=text, fixed_text=None, status="hard_block", found=found)
|
||||||
|
|
||||||
|
# 只有 soft_warn → 软提示,不改文字
|
||||||
|
if any(f["level"] == "soft_warn" for f in found) and \
|
||||||
|
all(f["level"] in ("soft_warn", "auto_fix") for f in found):
|
||||||
|
# 仍执行 auto_fix 改写,但结果状态是 soft_warn(优先级高)
|
||||||
|
for f in found:
|
||||||
|
if f["level"] == "auto_fix" and f["replacement"] is not None:
|
||||||
|
working = re.sub(re.escape(f["word"]), f["replacement"], working, flags=re.IGNORECASE)
|
||||||
|
return CheckResult(text=text, fixed_text=working, status="soft_warn", found=found)
|
||||||
|
|
||||||
|
# 只有 auto_fix → 自动改写,返回 fixed_text
|
||||||
|
for f in found:
|
||||||
|
if f["level"] == "auto_fix" and f["replacement"] is not None:
|
||||||
|
working = re.sub(re.escape(f["word"]), f["replacement"], working, flags=re.IGNORECASE)
|
||||||
|
return CheckResult(text=text, fixed_text=working, status="auto_fixed", found=found)
|
||||||
|
|
||||||
|
|
||||||
|
def build_entries_from_db(rows: list[dict]) -> list[BannedWordEntry]:
|
||||||
|
"""把 DB banned_words 行转成 BannedWordEntry 列表"""
|
||||||
|
return [
|
||||||
|
BannedWordEntry(
|
||||||
|
word=r["word"],
|
||||||
|
level=r["level"],
|
||||||
|
replacement=r.get("replacement"),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
if r.get("word") and r.get("level") in ("auto_fix", "soft_warn", "hard_block")
|
||||||
|
]
|
||||||
139
backend/app/services/ai_engine/constants.py
Normal file
139
backend/app/services/ai_engine/constants.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
AI 引擎中心常量
|
||||||
|
扒自:worker/src/copy.js + image.js 上线版
|
||||||
|
业务参数不写死(基石A)——分值权重可由产品档案配置覆盖
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── 合规红线 ──────────────────────────────────────────────
|
||||||
|
# 初始默认词库(数据库 banned_words 表可覆盖,updatable=True)
|
||||||
|
BANNED_WORDS_DEFAULT = ["美白", "祛斑", "速效", "医用", "药妆"]
|
||||||
|
BANNED_VISUAL_WORDS = [
|
||||||
|
"前后对比", "使用前后", "用前用后",
|
||||||
|
"before", "after", "变白", "瑕疵消失", "治疗前后",
|
||||||
|
]
|
||||||
|
# 内部提示词(不能混入正文 content 字段)
|
||||||
|
INTERNAL_COPY_HINTS = [
|
||||||
|
"配图建议", "图片方向", "内页规划", "适合做成",
|
||||||
|
"不要做促销海报", "配图说明", "封面建议",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── 机械五维打分基线(仅轨B导入文案/降级回退用;轨A已切 AI 评委 llm_score_copy)─────
|
||||||
|
# 历史注:轨A原用此5维,2026-06-15切至7维AI评委(6维+合规)后此处只作轨B+回退占位。
|
||||||
|
# 关键词维度(keyword20)因 products 表无 keywords 字段导致 matched 恒空已知不准;
|
||||||
|
# 轨A不再依赖此权重,轨B展示参考可接受。不按此调分,真过关靠北哥抽检。
|
||||||
|
SCORE_WEIGHTS = {
|
||||||
|
"title": 25,
|
||||||
|
"emotion": 25,
|
||||||
|
"selling": 25,
|
||||||
|
"keyword": 20,
|
||||||
|
"compliance": 5,
|
||||||
|
}
|
||||||
|
# ── AI 评委 7 维满分分布(倩倩姐2026-06-15拍板·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
|
||||||
|
# 6维AI读分(痛点18+情绪18+买点18+钩子15+标题13+真实感13=95) + 合规5 = 100
|
||||||
|
# "真实感"=富贵"很少提产品/前70%干货后30%植入"原则,替换旧机械维度"产品聚焦一件事(16)"
|
||||||
|
AI_DIM_WEIGHTS = {
|
||||||
|
"痛点人群精准": 18,
|
||||||
|
"情绪张力": 18,
|
||||||
|
"买点转化": 18,
|
||||||
|
"开头钩子": 15,
|
||||||
|
"标题点击力": 13,
|
||||||
|
"真实感": 13,
|
||||||
|
"compliance": 5, # 机械硬拦,不进 AI 评委
|
||||||
|
}
|
||||||
|
# 过线分。倩倩姐2026-06-15拍板:80是临时观察值(AI评委给分克制,84文案实为合格)。
|
||||||
|
# 倩倩姐2026-06-15再次拍板:维持80临时线,不准擅自调85。方向=提生成质量顶分数,不降标准。
|
||||||
|
# 真过关靠北哥抽检;提质量方向=优化生成 prompt,不靠提高门槛凑数。
|
||||||
|
QUALITY_PASS_SCORE = 80
|
||||||
|
|
||||||
|
# ── 文案去重阈值 ──────────────────────────────────────────
|
||||||
|
DEDUP_TITLE_THRESHOLD = 0.82 # 标题相似度≥此值判重
|
||||||
|
DEDUP_TITLE_CONTENT_TITLE = 0.65 # 标题+正文联合判重时的标题阈值
|
||||||
|
DEDUP_TITLE_CONTENT_BODY = 0.72 # 标题+正文联合判重时的正文阈值
|
||||||
|
|
||||||
|
# ── 自动优化循环 ──────────────────────────────────────────
|
||||||
|
MAX_OPTIMIZE_ROUNDS = 2 # 最多重生成轮次
|
||||||
|
|
||||||
|
# ── storyboard 分镜角色(枚举不写死数量)────────────────
|
||||||
|
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
|
||||||
|
PAGE_ROLES = [
|
||||||
|
{"role": "hook", "name": "封面痛点大字", "focus": "负责点击:强情绪大字标题压痛点,产品露出,真实生活场景,像用户主动分享,不像广告海报"},
|
||||||
|
{"role": "product_closeup", "name": "单品特写", "focus": "负责种草锚点:单品高清特写+品牌词自然植入,第2/6张都带品牌词,强化记忆"},
|
||||||
|
{"role": "ingredient", "name": "成分拆解", "focus": "负责信任:核心成分信息、作用说明,避免医疗化和绝对化表达,信息清晰可信"},
|
||||||
|
{"role": "texture", "name": "质地展示", "focus": "负责种草:质地近景、涂抹过程、肤感说明,真实手部/桌面/日常光线"},
|
||||||
|
{"role": "applied_proof", "name": "上脸对比", "focus": "负责证明:可感知上脸效果,展示涂抹前后质地变化(不做肤色变白/瑕疵消失等违规暗示),第5张"},
|
||||||
|
{"role": "closer", "name": "促单收尾", "focus": "负责转化:转化句+品牌词,引导搜索品牌词成交,软性收尾不硬广,第6张再带一次品牌词"},
|
||||||
|
# 扩展角色(8张链路用)
|
||||||
|
{"role": "pain_scene", "name": "痛点共鸣", "focus": "负责共鸣:展示目标人群的真实困扰和使用前情境,但不做功效前后对比"},
|
||||||
|
{"role": "social_proof","name": "信任背书", "focus": "负责背书:多人反馈、囤货、复购等真实社交证据"},
|
||||||
|
{"role": "scenario", "name": "多场景演示", "focus": "负责代入:多场景使用展示,不做夸大效果承诺"},
|
||||||
|
{"role": "tutorial", "name": "使用教程", "focus": "负责降低门槛:简洁步骤、用量、注意事项"},
|
||||||
|
]
|
||||||
|
PAGE_ROLE_MAP = {r["role"]: r for r in PAGE_ROLES}
|
||||||
|
|
||||||
|
# ── 生图风格预设(扒 image.js STYLE_PROMPTS:26-29)──────────
|
||||||
|
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
|
||||||
|
STYLE_PROMPTS = {
|
||||||
|
"xiaohongshu_cover": "小红书种草风,独立3:4图文海报/素材图,1024×1536构图,明亮干净,真实实拍质感,醒目中文短标题,文字在安全区内",
|
||||||
|
"comparison": "小红书说明对比风,独立3:4图文海报/素材图,1024×1536构图,质地/场景/肤感左右或上下对比,信息层级清晰",
|
||||||
|
"ingredient": "小红书成分科普风,独立3:4图文海报/素材图,1024×1536构图,成分卡片布局,浅色商务美妆风,避免医疗化表达",
|
||||||
|
}
|
||||||
|
STYLE_DEFAULT = "xiaohongshu_cover"
|
||||||
|
|
||||||
|
# ── 叙事链路说明(扒 image.js planImageSet narrativeText:677-681)──
|
||||||
|
# 按图数告诉模型整组图的种草节奏,让每张各司其职不雷同
|
||||||
|
NARRATIVE_BY_COUNT = {
|
||||||
|
3: "3张极速链路:第1张负责点击,第2张是按品类变化的核心证明页,第3张负责软性转化。",
|
||||||
|
6: "6张标准种草链路:封面点击、单品特写带品牌词、成分信任、质地种草、上脸证明、促单转化,每张画面和文字各司其职不重复。",
|
||||||
|
8: "8张沉浸测评链路:点击、痛点共鸣、单品特写、成分、质地、上脸证明、背书、软性转化。",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3套正交叙事策略(倩倩姐2026-06-15起草,北哥过目版)──────────────
|
||||||
|
# A痛点先行/B场景先行/C成分背书先行,三套正交轴拉开差异
|
||||||
|
# 每套叙事链路注入 base_prompt 叙事链路段,替换 NARRATIVE_BY_COUNT 默认值
|
||||||
|
NARRATIVE_BY_STRATEGY = {
|
||||||
|
"A": (
|
||||||
|
'【套A·痛点先行】整组基调:紧迫感、强对比、情绪共鸣,文字短促带感叹号,直戳"脸黄显疲惫""素颜不敢出门"。'
|
||||||
|
'6张链路:①痛点暴击封面(强情绪大字直击暗黄/素颜焦虑)→ ②暗黄脸实拍对比(感叹号+对比词制造紧迫感)'
|
||||||
|
'→ ③单品特写+品牌词 → ④成分为什么能救暗黄(成分拆解+信任) → ⑤上脸提亮实证 → ⑥"别再顶着黄脸早八"软性促单。'
|
||||||
|
),
|
||||||
|
"B": (
|
||||||
|
'【套B·场景先行】整组基调:轻松、生活化、代入感,突出"快/省时/伪素颜自由",点到性价比不堆砌。'
|
||||||
|
'6张链路:①"早八来不及"场景封面(生活场景钩子) → ②手忙脚乱通勤场景(代入早八焦虑)'
|
||||||
|
'→ ③一抹搞定单品特写+品牌词 → ④养肤成分让你敢素颜 → ⑤30秒上脸效果 → ⑥"伪素颜自由+平价"软性促单。'
|
||||||
|
),
|
||||||
|
"C": (
|
||||||
|
'【套C·成分背书先行】整组基调:专业、可信、真实测评感,强调成分逻辑+前后对比+像有用户实证背书。'
|
||||||
|
'6张链路:①成分权威封面(核心成分信息锚定信任) → ②核心成分图解(作用说明+清晰可信)'
|
||||||
|
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实上脸细节 → ⑥"成分党闭眼入"软性促单。'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 生图通道 ──────────────────────────────────────────────
|
||||||
|
IMAGE_RETRY_ATTEMPTS = 3
|
||||||
|
IMAGE_RETRY_BACKOFF_BASE = 2.0 # 指数退避底数(秒)
|
||||||
|
IMAGE_SIZE_DEFAULT = "1024x1536"
|
||||||
|
|
||||||
|
# ── 生图合规负向约束(方法层常量,全品类共用,可扩展)───────────────────────
|
||||||
|
# 追加到每个 base_prompt 末尾,防模型脑补违禁词/真实品牌到包装
|
||||||
|
IMAGE_NEGATIVE_CONSTRAINTS = (
|
||||||
|
"【包装合规硬性禁止——必须严格遵守】"
|
||||||
|
"①包装/瓶身/标签上禁止出现任何违禁词:美白/whitening/祛斑/brightening/"
|
||||||
|
"医用/medical/drug/药妆/速效/instant,中英文全禁;"
|
||||||
|
"②禁止脑补任何真实品牌名或logo(如水密码/WETCODE/兰蔻/SK-II等),"
|
||||||
|
"产品包装只允许出现用户传入的指定品牌词,未传则画无字素瓶;"
|
||||||
|
"③英文功效词(ANTI-AGING/TONE-UP/BRIGHTENING/FIRMING等)禁止印在包装;"
|
||||||
|
"④如果提供了产品参考图,包装文字以参考图为准,不得自行添加或修改任何文字。"
|
||||||
|
"⑤背景纯净:禁止出现电子设备/笔记本电脑/键盘/手机/桌面杂物等无关物体(参考图若含此类背景一律不沿用),"
|
||||||
|
"只保留浅色简洁台面或产品定制场景,主体聚焦产品本身。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 飞轮信号权重(初始默认,北哥可校准)────────────────
|
||||||
|
FLYWHEEL_WEIGHTS = {
|
||||||
|
"text_select": 3,
|
||||||
|
"image_select": 3,
|
||||||
|
"approve": 5,
|
||||||
|
"reject_with_reason": -3,
|
||||||
|
"regenerate": -1,
|
||||||
|
}
|
||||||
|
FLYWHEEL_LOOKBACK = 50 # 聚合最近N条事件
|
||||||
|
FLYWHEEL_COLD_START = 5 # 信号不足N条时用产品档案冷启动
|
||||||
225
backend/app/services/ai_engine/gemini_factory.py
Normal file
225
backend/app/services/ai_engine/gemini_factory.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""
|
||||||
|
gemini_factory.py — 每任务构建独立的 AI client 实例
|
||||||
|
解决全局单例问题(扒 banana gemini_service.py __init__,改造为每任务局部实例)
|
||||||
|
|
||||||
|
铁律(基石B):
|
||||||
|
- 调用方只传 task_id,不传 key
|
||||||
|
- 本模块在 worker 内部查库 → Fernet 解密 → 构建 client
|
||||||
|
- 解密结果只活在局部变量,函数返回后即销毁
|
||||||
|
- 绝不打印 / 记录 / 传递明文 key
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIClients:
|
||||||
|
"""
|
||||||
|
一个任务专用的 AI client 集合。
|
||||||
|
worker 在任务开始时构建,任务结束后释放(局部变量,不存 Redis/DB)。
|
||||||
|
"""
|
||||||
|
# httpx AsyncClient 懒加载 + 按事件循环缓存:Celery 每任务多次 asyncio.run,
|
||||||
|
# 持久 client 会绑死到首个已关闭的 loop → "Event loop is closed"。
|
||||||
|
# 故只存 token/base,按当前运行 loop 缓存 client,loop 变了就重建。
|
||||||
|
_gpt_token: str | None = field(default=None, repr=False)
|
||||||
|
_gpt_base: str | None = field(default=None, repr=False)
|
||||||
|
_gpt_client: httpx.AsyncClient | None = field(default=None, repr=False)
|
||||||
|
_gpt_client_loop_id: int | None = field(default=None, repr=False)
|
||||||
|
# 备用 OpenAI 兼容中转站(codeproxy):apiports 503 时真正切过去(独立 base+key)
|
||||||
|
_alt_token: str | None = field(default=None, repr=False)
|
||||||
|
_alt_base: str | None = field(default=None, repr=False)
|
||||||
|
# 多 base/token 的 client 池:key=(base,token的id),按 loop 失效重建
|
||||||
|
_client_pool: dict = field(default_factory=dict, repr=False)
|
||||||
|
_pool_loop_id: int | None = field(default=None, repr=False)
|
||||||
|
_gemini_key: str | None = field(default=None, repr=False) # 局部变量不打印
|
||||||
|
_model_image: str = "gpt-image-2"
|
||||||
|
_model_text: str = "claude-sonnet-4-5" # apiports无gpt-4o-mini,文案用claude中文质量好
|
||||||
|
|
||||||
|
def _client(self) -> httpx.AsyncClient:
|
||||||
|
"""主通道(apiports) client,按当前事件循环缓存"""
|
||||||
|
return self._client_for(self._gpt_base, self._gpt_token)
|
||||||
|
|
||||||
|
def _client_for(self, base: str | None, token: str | None) -> httpx.AsyncClient:
|
||||||
|
"""按 (base, token) 返回可用 client;loop 变化则整池重建(避免跨 loop 复用)"""
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("GPT client 未初始化(缺 token)")
|
||||||
|
loop_id = id(asyncio.get_running_loop())
|
||||||
|
if self._pool_loop_id != loop_id:
|
||||||
|
self._client_pool = {}
|
||||||
|
self._pool_loop_id = loop_id
|
||||||
|
ck = (base or "", token)
|
||||||
|
if ck not in self._client_pool:
|
||||||
|
self._client_pool[ck] = httpx.AsyncClient(
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
base_url=base or None,
|
||||||
|
timeout=120.0,
|
||||||
|
)
|
||||||
|
return self._client_pool[ck]
|
||||||
|
|
||||||
|
# ── ImageClient 协议实现(供 image_gen.py 使用)────────
|
||||||
|
|
||||||
|
def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]:
|
||||||
|
"""按 provider 选 (base, token, client);codeproxy 走备用站独立 base+key"""
|
||||||
|
if provider == "codeproxy" and self._alt_token:
|
||||||
|
base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
|
||||||
|
return base, self._alt_token, self._client_for(base, self._alt_token)
|
||||||
|
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
|
||||||
|
return base, self._gpt_token, self._client_for(self._gpt_base, self._gpt_token)
|
||||||
|
|
||||||
|
async def gpt_edits(self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None) -> bytes:
|
||||||
|
"""GPT edits endpoint(带产品参考图,禁纯文生图)"""
|
||||||
|
import io
|
||||||
|
files: list[tuple] = [("prompt", (None, prompt))]
|
||||||
|
for i, img in enumerate(reference_images):
|
||||||
|
files.append(("image[]", (f"ref_{i}.png", io.BytesIO(img), "image/png")))
|
||||||
|
files.append(("size", (None, size)))
|
||||||
|
files.append(("model", (None, self._model_image)))
|
||||||
|
base, _, client = self._gpt_target(provider)
|
||||||
|
resp = await client.post(f"{base}/images/edits", files=files, timeout=120.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return _extract_image_bytes(resp.json())
|
||||||
|
|
||||||
|
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes:
|
||||||
|
"""GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)"""
|
||||||
|
base, _, client = self._gpt_target(provider)
|
||||||
|
payload = {"model": self._model_image, "prompt": prompt, "n": 1, "size": size}
|
||||||
|
resp = await client.post(f"{base}/images/generations", json=payload, timeout=120.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return _extract_image_bytes(resp.json())
|
||||||
|
|
||||||
|
async def gemini_generate(self, prompt: str, reference_images: list[bytes], model: str) -> bytes:
|
||||||
|
"""Gemini 生图(备用通道)"""
|
||||||
|
if not self._gemini_key:
|
||||||
|
raise RuntimeError("Gemini key 未初始化")
|
||||||
|
gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta")
|
||||||
|
url = f"{gemini_base}/models/{model}:generateContent?key={self._gemini_key}"
|
||||||
|
parts: list[dict] = [{"text": prompt}]
|
||||||
|
for img in reference_images:
|
||||||
|
import base64
|
||||||
|
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}})
|
||||||
|
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(url, json=payload, timeout=120.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return _extract_gemini_image(resp.json())
|
||||||
|
|
||||||
|
async def chat_complete(self, messages: list[dict], model: str | None = None, max_tokens: int = 4096, temperature: float = 0.75) -> str:
|
||||||
|
"""文字生成(文案生成用)"""
|
||||||
|
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
|
||||||
|
payload = {"model": model or self._model_text, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}
|
||||||
|
# 单批≤4条文案正常 40-55s 返回;apiports 网关 ~60s 上限。客户端超时设 75s:
|
||||||
|
# 略高于网关上限即可,过长(如180s)会在 apiports 卡顿时干等,拖慢整体。
|
||||||
|
timeout = float(os.environ.get("TEXT_LLM_TIMEOUT", "75"))
|
||||||
|
resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()["choices"][0]["message"]["content"] or ""
|
||||||
|
|
||||||
|
async def gpt_vision_analyze(self, prompt: str, images: list[bytes], model: str | None = None) -> str:
|
||||||
|
"""
|
||||||
|
GPT/Claude vision 读产品图,返回 JSON 字符串。
|
||||||
|
messages content 混合 text + image_url(base64),OpenAI vision 格式。
|
||||||
|
model 默认最强档(claude-opus-4-8),绝不偷降级。
|
||||||
|
最多传 4 张图,避免超 token。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
content: list[dict] = [{"type": "text", "text": prompt}]
|
||||||
|
for img in images[:4]:
|
||||||
|
b64 = base64.b64encode(img).decode()
|
||||||
|
content.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
||||||
|
})
|
||||||
|
used_model = model or os.environ.get("MODEL_TEXT", "claude-opus-4-8")
|
||||||
|
messages = [{"role": "user", "content": content}]
|
||||||
|
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
|
||||||
|
payload = {
|
||||||
|
"model": used_model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"temperature": 0.2,
|
||||||
|
}
|
||||||
|
resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=90.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()["choices"][0]["message"]["content"] or ""
|
||||||
|
|
||||||
|
# duck-type: text_variants._call_llm 用的属性
|
||||||
|
@property
|
||||||
|
def _model(self) -> str:
|
||||||
|
return self._model_text
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
# client 可能绑在已关闭的 loop(Celery 多次 asyncio.run),aclose 也可能报
|
||||||
|
# "Event loop is closed",吞掉即可——进程级连接随 loop 关闭自然释放。
|
||||||
|
for c in list(self._client_pool.values()):
|
||||||
|
try:
|
||||||
|
await c.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._client_pool = {}
|
||||||
|
self._pool_loop_id = None
|
||||||
|
self._gpt_client = None
|
||||||
|
self._gpt_client_loop_id = None
|
||||||
|
|
||||||
|
|
||||||
|
def build_ai_clients(plain_key: str, gemini_key: str | None = None) -> AIClients:
|
||||||
|
"""
|
||||||
|
用解密后的明文 key 构建 AIClients。
|
||||||
|
只在 Celery worker 函数体内调用,plain_key 是局部变量。
|
||||||
|
httpx client 不在此预创建(避免绑死到调用方 loop),首次 await 时按 loop 懒建。
|
||||||
|
调用完成后 caller 负责 await clients.aclose()。
|
||||||
|
"""
|
||||||
|
gpt_base = (
|
||||||
|
os.environ.get("IMAGE_API_BASE") # 旧变量名
|
||||||
|
or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名
|
||||||
|
or ""
|
||||||
|
).rstrip("/")
|
||||||
|
# 备用站 codeproxy:系统级 key(非用户录入),apiports 503 时切过去保生图成功
|
||||||
|
alt_base = (os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
|
||||||
|
alt_token = os.environ.get("CODEPROXY_KEY") or None
|
||||||
|
return AIClients(
|
||||||
|
_gpt_token=plain_key,
|
||||||
|
_gpt_base=gpt_base or None,
|
||||||
|
_alt_base=alt_base or None,
|
||||||
|
_alt_token=alt_token,
|
||||||
|
_gemini_key=gemini_key,
|
||||||
|
_model_image=os.environ.get("IMAGE_MODEL") or os.environ.get("MODEL_IMAGE", "gpt-image-2"),
|
||||||
|
_model_text=os.environ.get("MODEL_TEXT", "claude-opus-4-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 图片响应解析工具 ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _extract_image_bytes(resp_json: dict) -> bytes:
|
||||||
|
"""从 OpenAI images API 响应提取图片 bytes(b64 或 url)"""
|
||||||
|
import base64
|
||||||
|
data = resp_json.get("data", [{}])
|
||||||
|
if not data:
|
||||||
|
raise ValueError("图片 API 返回空 data")
|
||||||
|
item = data[0]
|
||||||
|
if "b64_json" in item:
|
||||||
|
return base64.b64decode(item["b64_json"])
|
||||||
|
if "url" in item:
|
||||||
|
resp = httpx.get(item["url"], timeout=30.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
raise ValueError(f"无法解析图片响应:{list(item.keys())}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_gemini_image(resp_json: dict) -> bytes:
|
||||||
|
"""从 Gemini generateContent 响应提取图片 bytes"""
|
||||||
|
import base64
|
||||||
|
candidates = resp_json.get("candidates", [])
|
||||||
|
for cand in candidates:
|
||||||
|
parts = cand.get("content", {}).get("parts", [])
|
||||||
|
for part in parts:
|
||||||
|
if "inlineData" in part:
|
||||||
|
return base64.b64decode(part["inlineData"]["data"])
|
||||||
|
raise ValueError("Gemini 响应中未找到图片数据")
|
||||||
175
backend/app/services/ai_engine/image_gen.py
Normal file
175
backend/app/services/ai_engine/image_gen.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""
|
||||||
|
生图通道 — gpt-image-2 主(edits 带产品图) / Gemini 备 + 重试退避
|
||||||
|
扒自:worker/src/image.js generateOneImage / requestProviderImage / imageProviderOrder
|
||||||
|
新增:asyncio 重试退避(上线版缺的,banana 有 _retry 思路)
|
||||||
|
|
||||||
|
铁律:
|
||||||
|
- IMAGE_PROVIDER_PRIMARY/FALLBACK 走环境变量,不写死
|
||||||
|
- GPT 主通道必须有产品参考图,无图报错(禁纯文生图防产品跑偏)
|
||||||
|
- key 不在本模块,由 worker 传入构造好的 async HTTP client
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT
|
||||||
|
from .storyboard import plan_image_set, sanitize_text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageClient(Protocol):
|
||||||
|
"""worker 注入的图片生成客户端协议(隔离 key 细节)"""
|
||||||
|
async def gpt_edits(
|
||||||
|
self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None
|
||||||
|
) -> bytes: ...
|
||||||
|
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes: ...
|
||||||
|
async def gemini_generate(
|
||||||
|
self, prompt: str, reference_images: list[bytes], model: str
|
||||||
|
) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _image_provider_order() -> list[str]:
|
||||||
|
"""从环境变量读主备顺序(扒 imageProviderOrder)"""
|
||||||
|
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower()
|
||||||
|
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower()
|
||||||
|
seen: list[str] = []
|
||||||
|
for p in [primary, fallback]:
|
||||||
|
if p and p not in seen:
|
||||||
|
seen.append(p)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _gemini_models() -> list[str]:
|
||||||
|
"""Gemini fallback 模型列表(多模型依次重试)"""
|
||||||
|
env_val = os.environ.get("GEMINI_IMAGE_MODELS", "gemini-2.0-flash-preview-image-generation,imagen-3.0-generate-002")
|
||||||
|
return [m.strip() for m in env_val.split(",") if m.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
async def _retry(coro_fn, attempts: int = IMAGE_RETRY_ATTEMPTS, backoff: float = IMAGE_RETRY_BACKOFF_BASE) -> Any:
|
||||||
|
"""指数退避重试(扒 banana _retry 思路)"""
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for i in range(attempts):
|
||||||
|
try:
|
||||||
|
return await coro_fn()
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if i < attempts - 1:
|
||||||
|
wait = backoff ** i
|
||||||
|
logger.warning("生图失败第%d次,%.1fs后重试:%s", i + 1, wait, exc)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
raise RuntimeError(f"重试{attempts}次均失败") from last_exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_gpt(client: ImageClient, prompt: str, reference_images: list[bytes], provider: str | None = None) -> bytes:
|
||||||
|
if reference_images:
|
||||||
|
return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider)
|
||||||
|
# 无产品参考图时降级为纯文生图(需 ALLOW_TEXT_ONLY_IMAGE=true 或 M2阶段)
|
||||||
|
allow_text_only = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "true").lower() == "true"
|
||||||
|
if allow_text_only:
|
||||||
|
logger.warning("无产品参考图,降级为纯文生图(可能产品跑偏,建议前端上传参考图)")
|
||||||
|
return await client.gpt_generate(prompt, IMAGE_SIZE_DEFAULT, provider)
|
||||||
|
raise ValueError("GPT 主通道缺产品图:禁止纯文生图以免产品跑偏(设 ALLOW_TEXT_ONLY_IMAGE=true 可解锁)")
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_gemini(client: ImageClient, prompt: str, reference_images: list[bytes]) -> bytes:
|
||||||
|
errors: list[str] = []
|
||||||
|
for model in _gemini_models():
|
||||||
|
try:
|
||||||
|
return await client.gemini_generate(prompt, reference_images, model)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"{model}: {exc}")
|
||||||
|
raise RuntimeError("Gemini 全部模型失败:" + ";".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_one_image(
|
||||||
|
client: ImageClient,
|
||||||
|
prompt: str,
|
||||||
|
reference_images: list[bytes] | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
主入口:按主备顺序依次尝试,每个 provider 内部有重试退避。
|
||||||
|
返回图片 bytes(PNG/JPEG)。
|
||||||
|
"""
|
||||||
|
refs = reference_images or []
|
||||||
|
providers = _image_provider_order()
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for provider in providers:
|
||||||
|
try:
|
||||||
|
# apiports/codeproxy/openai 都是 OpenAI 兼容中转站,走 gpt 协议,
|
||||||
|
# 但传 provider 进去 → client 按 provider 切到对应中转站的 base+key。
|
||||||
|
# 这才是真主备:apiports 503 → codeproxy 用独立 base+key 顶上。
|
||||||
|
if provider in ("apiports", "codeproxy", "openai"):
|
||||||
|
img = await _retry(lambda p=provider: _request_gpt(client, prompt, refs, p))
|
||||||
|
elif provider == "gpt":
|
||||||
|
img = await _retry(lambda: _request_gpt(client, prompt, refs, None))
|
||||||
|
elif provider == "gemini":
|
||||||
|
img = await _retry(lambda: _request_gemini(client, prompt, refs))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"未知图片通道:{provider}")
|
||||||
|
return img
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"{provider}: {exc}")
|
||||||
|
logger.warning("图片通道 %s 失败,尝试下一个:%s", provider, exc)
|
||||||
|
|
||||||
|
raise RuntimeError("所有图片通道均失败:" + ";".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_storyboard_images(
|
||||||
|
client: ImageClient,
|
||||||
|
note: dict,
|
||||||
|
product: dict,
|
||||||
|
image_count: int = 3,
|
||||||
|
reference_images: list[bytes] | None = None,
|
||||||
|
analysis: dict | None = None,
|
||||||
|
strategy: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
按 storyboard 逐张生图(asyncio.gather 并发),返回每张结果列表。
|
||||||
|
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
|
||||||
|
每项:{role, name, image_bytes, error}
|
||||||
|
"""
|
||||||
|
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
|
||||||
|
storyboard = plan["storyboard"]
|
||||||
|
base_prompt = plan["base_prompt"]
|
||||||
|
|
||||||
|
async def _gen_one(item: dict) -> dict:
|
||||||
|
# 逐图 prompt 9 字段(扒 promptFromStoryboard:323-334),每张差异化
|
||||||
|
brand_line = ""
|
||||||
|
if item.get("brand_keyword"):
|
||||||
|
brand_line = f"品牌词=「{item['brand_keyword']}」,{item.get('brand_keyword_rule','自然植入')}。"
|
||||||
|
per_prompt = (
|
||||||
|
f"{base_prompt}\n"
|
||||||
|
f"本张名称={item['name']}。"
|
||||||
|
f"本张目标={item.get('goal') or item['focus']}。"
|
||||||
|
f"图上主文字=「{sanitize_text(item.get('overlay_text',''), 20)}」。"
|
||||||
|
f"使用卖点={item.get('selling_point','')}。"
|
||||||
|
f"文案依据={item.get('source_basis','')}。"
|
||||||
|
f"画面主体={item.get('visual_strategy','')}。"
|
||||||
|
f"素材使用={item.get('asset_use','')}。"
|
||||||
|
f"{brand_line}"
|
||||||
|
f"禁止事项={item.get('forbidden','')}。"
|
||||||
|
"排版要求:独立小红书3:4图文海报,画面完整;标题只出现一次,不与其他页重复;"
|
||||||
|
"中文文字少而清晰,主标题+最多3个短点位;可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌;"
|
||||||
|
"不要生成App截图或笔记详情页界面。"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
img_bytes = await generate_one_image(client, per_prompt, reference_images)
|
||||||
|
return {"role": item["role"], "name": item["name"], "image_bytes": img_bytes, "error": None}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("分镜 %s 生图失败: %s", item["role"], exc)
|
||||||
|
return {"role": item["role"], "name": item["name"], "image_bytes": None, "error": str(exc)}
|
||||||
|
|
||||||
|
# 限并发:apiports 图片接口有 QPS 限制,6 张全并发会撞 429/503
|
||||||
|
concurrency = int(os.environ.get("IMAGE_CONCURRENCY", "2"))
|
||||||
|
sem = asyncio.Semaphore(max(1, concurrency))
|
||||||
|
|
||||||
|
async def _gen_guarded(item: dict) -> dict:
|
||||||
|
async with sem:
|
||||||
|
return await _gen_one(item)
|
||||||
|
|
||||||
|
results = await asyncio.gather(*(_gen_guarded(item) for item in storyboard))
|
||||||
|
return list(results)
|
||||||
177
backend/app/services/ai_engine/image_postprocessor.py
Normal file
177
backend/app/services/ai_engine/image_postprocessor.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""
|
||||||
|
图片后处理(去AI化主路)
|
||||||
|
对齐大卫 xhs-tool/backend/infrastructure/imagePostProcess.js(运营实测去AI化版)。
|
||||||
|
|
||||||
|
主路 = 尺寸可选(±2%容差内不resize) + SynthID破除(可选) + 高保真重编码去元数据。
|
||||||
|
|
||||||
|
诚实声明:C2PA 元数据可去除;私有像素水印(如 SynthID)只能削弱,不保证 100% 清除。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageEnhance, ImageOps
|
||||||
|
_PILLOW_OK = True
|
||||||
|
except ImportError:
|
||||||
|
_PILLOW_OK = False
|
||||||
|
logger.warning("Pillow 未安装,image_postprocessor 不可用")
|
||||||
|
|
||||||
|
# 比例映射表,对齐大卫 RATIO_MAP。key 为字符串如 '3:4'
|
||||||
|
RATIO_MAP: dict[str, tuple[int, int]] = {
|
||||||
|
"1:1": (1024, 1024),
|
||||||
|
"3:4": (1024, 1536), # gpt-image-2 原生尺寸,默认
|
||||||
|
"4:3": (1536, 1024),
|
||||||
|
"9:16": (864, 1536),
|
||||||
|
"16:9": (1536, 864),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ±2% 容差内不做 resize,避免无谓重采样(对齐大卫 diff > 0.02 才 resize)
|
||||||
|
_RATIO_TOLERANCE = 0.02
|
||||||
|
|
||||||
|
|
||||||
|
def _need_resize(actual_w: int, actual_h: int, target_w: int, target_h: int) -> bool:
|
||||||
|
"""判断实际比例与目标比例差距是否超出容差。"""
|
||||||
|
actual_ratio = actual_w / actual_h
|
||||||
|
target_ratio = target_w / target_h
|
||||||
|
diff = abs(actual_ratio - target_ratio) / target_ratio
|
||||||
|
return diff > _RATIO_TOLERANCE
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(
|
||||||
|
image_bytes: bytes,
|
||||||
|
aspect_ratio: str = "3:4",
|
||||||
|
resample_strength: int = 1, # 0=不重采样, 1=轻采样(默认), 2=重采样
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
处理单张图片。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
image_bytes — 原始图片 bytes(PNG/JPEG/WebP 等)
|
||||||
|
aspect_ratio — 目标比例,取 RATIO_MAP 的 key,默认 '3:4'=1024×1536
|
||||||
|
resample_strength — 轻重采样削像素水印(0/1/2),默认 1=轻采样
|
||||||
|
|
||||||
|
返回 JPEG bytes(无 EXIF/C2PA/XMP 元数据)。
|
||||||
|
失败时降级返回原图 bytes,不抛异常(对齐大卫 catch 返回原图)。
|
||||||
|
"""
|
||||||
|
if not _PILLOW_OK:
|
||||||
|
logger.error("Pillow 未安装,跳过后处理,返回原图")
|
||||||
|
return image_bytes
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||||
|
actual_w, actual_h = img.size
|
||||||
|
target = RATIO_MAP.get(aspect_ratio)
|
||||||
|
|
||||||
|
# --- Step1: 尺寸对齐(±2% 容差内跳过 resize)---
|
||||||
|
if target:
|
||||||
|
tw, th = target
|
||||||
|
if _need_resize(actual_w, actual_h, tw, th):
|
||||||
|
img = ImageOps.fit(img, (tw, th), method=Image.LANCZOS)
|
||||||
|
logger.debug("resize %dx%d → %dx%d (ratio=%s)", actual_w, actual_h, tw, th, aspect_ratio)
|
||||||
|
|
||||||
|
# --- Step2: resample_strength 削像素水印(可选,默认轻采样)---
|
||||||
|
img = _apply_resample(img, resample_strength)
|
||||||
|
|
||||||
|
# --- Step3: SynthID 破除(SYNTHID_HARD_MODE=1 才开,默认关)---
|
||||||
|
if os.environ.get("SYNTHID_HARD_MODE") == "1" and target:
|
||||||
|
img = _apply_synthid_break(img, target)
|
||||||
|
|
||||||
|
# --- Step4: 高保真 JPEG 重编码,去所有元数据 ---
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(
|
||||||
|
buf,
|
||||||
|
format="JPEG",
|
||||||
|
quality=100,
|
||||||
|
subsampling=0, # 4:4:4 chroma
|
||||||
|
optimize=True,
|
||||||
|
# 不传 exif/icc_profile/xmp = 不写入任何元数据
|
||||||
|
)
|
||||||
|
result = buf.getvalue()
|
||||||
|
logger.debug("后处理完成 %d B → %d B (ratio=%s)", len(image_bytes), len(result), aspect_ratio)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("图片后处理失败,降级返回原图: %s", exc)
|
||||||
|
return image_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_resample(img: "Image.Image", strength: int) -> "Image.Image":
|
||||||
|
"""
|
||||||
|
轻/重采样削像素级水印(resample_strength 控制)。
|
||||||
|
0 — 不采样,仅靠重编码去元数据。
|
||||||
|
1 — 轻采样:缩98%再回原尺寸,保视觉质量,削弱像素水印(对齐旧逻辑)。
|
||||||
|
2 — 重采样:两次缩放,削弱更多,轻微质量损失。
|
||||||
|
"""
|
||||||
|
if strength < 1:
|
||||||
|
return img
|
||||||
|
w, h = img.size
|
||||||
|
img = img.resize((int(w * 0.98), int(h * 0.98)), Image.LANCZOS)
|
||||||
|
img = img.resize((w, h), Image.LANCZOS)
|
||||||
|
if strength >= 2:
|
||||||
|
img = img.resize((int(w * 0.96), int(h * 0.96)), Image.LANCZOS)
|
||||||
|
img = img.resize((w, h), Image.LANCZOS)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_synthid_break(img: "Image.Image", target: tuple[int, int]) -> "Image.Image":
|
||||||
|
"""
|
||||||
|
SynthID 破除(SYNTHID_HARD_MODE=1 时调用):
|
||||||
|
对齐大卫逻辑 — 缩到(w-2,h-2)再裁掉1px边 + 亮度*1.005/饱和*0.998。
|
||||||
|
诚实声明:只能削弱 SynthID,不保证 100% 清除。
|
||||||
|
"""
|
||||||
|
tw, th = target
|
||||||
|
img = ImageOps.fit(img, (tw - 2, th - 2), method=Image.LANCZOS)
|
||||||
|
# 裁掉1px边,消除边缘水印残留
|
||||||
|
img = img.crop((1, 1, tw - 3, th - 3))
|
||||||
|
# 微调亮度/饱和(对齐大卫 modulate brightness/saturation)
|
||||||
|
img = ImageEnhance.Brightness(img).enhance(1.005)
|
||||||
|
img = ImageEnhance.Color(img).enhance(0.998)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def batch_process(
|
||||||
|
images: list[bytes],
|
||||||
|
aspect_ratio: str = "3:4",
|
||||||
|
resample_strength: int = 1,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
批量后处理。返回 [{index, data, error}],单张失败不阻塞其余。
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for i, img_bytes in enumerate(images):
|
||||||
|
try:
|
||||||
|
processed = process_image(img_bytes, aspect_ratio=aspect_ratio,
|
||||||
|
resample_strength=resample_strength)
|
||||||
|
results.append({"index": i, "data": processed, "error": None})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("图片[%d]后处理失败: %s", i, exc)
|
||||||
|
results.append({"index": i, "data": img_bytes, "error": str(exc)})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def gemini_rewatermark_fallback(
|
||||||
|
client: "Any", # GeminiClient,由 worker 注入
|
||||||
|
image_bytes: bytes,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
备选路:Gemini 重绘去水印。
|
||||||
|
⚠️ 对海报中文大字有改字风险,仅特殊场景启用。
|
||||||
|
"""
|
||||||
|
prompt = (
|
||||||
|
"Remove all watermarks, text overlays, and digital signatures from this image. "
|
||||||
|
"Reconstruct any covered areas naturally to match the surrounding content. "
|
||||||
|
"Return a clean version of the same image without any watermarks."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await client.gemini_generate(
|
||||||
|
prompt, [image_bytes], "gemini-2.0-flash-preview-image-generation"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Gemini 去水印失败,降级返回原图: %s", exc)
|
||||||
|
return image_bytes
|
||||||
96
backend/app/services/ai_engine/llm_scorer.py
Normal file
96
backend/app/services/ai_engine/llm_scorer.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
llm_scorer.py — AI 评委打分入口(让模型真读文案,替代机械找词)
|
||||||
|
合规第7维仍走机械硬拦(score_compliance);AI 读前6维给分+理由。
|
||||||
|
任何异常/解析失败 → 回退旧机械 score_copy,绝不卡链路。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .constants import BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, QUALITY_PASS_SCORE
|
||||||
|
from ._scoring_dims import score_compliance
|
||||||
|
from .text_scoring import score_copy
|
||||||
|
from ._score_prompt import SCORER_PERSONA, build_score_prompt, COMPLIANCE_MAX
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 6 个 AI 维度满分(倩倩姐2026-06-15拍板·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
|
||||||
|
# 痛点18+情绪18+买点18+钩子15+标题13+真实感13=95,+合规5=100
|
||||||
|
# "真实感"替换旧"产品聚焦一件事",对齐富贵"很少提产品/前70%干货后30%植入"原则
|
||||||
|
_DIM_MAX = {
|
||||||
|
"痛点人群精准": 18, "情绪张力": 18, "买点转化": 18,
|
||||||
|
"开头钩子": 15, "标题点击力": 13, "真实感": 13,
|
||||||
|
}
|
||||||
|
# 评委合规相关默认权重(仅供 score_compliance 复用其内部硬拦逻辑)
|
||||||
|
_COMPLIANCE_W = {"compliance": COMPLIANCE_MAX}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_verdict(raw: str) -> dict | None:
|
||||||
|
"""从模型输出里抠出 JSON 对象,失败返 None。"""
|
||||||
|
s = raw.strip()
|
||||||
|
m = re.search(r"\{.*\}", s, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
obj = json.loads(m.group(0))
|
||||||
|
return obj if isinstance(obj, dict) and isinstance(obj.get("dims"), list) else None
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def llm_score_copy(
|
||||||
|
client: Any,
|
||||||
|
copy: dict[str, Any],
|
||||||
|
source: dict[str, Any],
|
||||||
|
banned_words: list[str] | None = None,
|
||||||
|
pass_score: int = QUALITY_PASS_SCORE,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""AI 评委读 1 条文案 → 6维分+理由,合规机械硬拦。返回与 score_copy 同结构。"""
|
||||||
|
bwords = list(set((banned_words or []) + BANNED_WORDS_DEFAULT + BANNED_VISUAL_WORDS))
|
||||||
|
full = f"{copy.get('title','')}\n{copy.get('content','')}\n{' '.join(str(t) for t in copy.get('tags',[]))}"
|
||||||
|
dim_comp, found_all = score_compliance(full, bwords, _COMPLIANCE_W)
|
||||||
|
|
||||||
|
prompt = build_score_prompt(copy, source)
|
||||||
|
raw = ""
|
||||||
|
backoff = [5, 10, 20]
|
||||||
|
for attempt in range(4):
|
||||||
|
try:
|
||||||
|
raw = await client.chat_complete(
|
||||||
|
messages=[{"role": "system", "content": SCORER_PERSONA},
|
||||||
|
{"role": "user", "content": prompt}],
|
||||||
|
model=client._model, max_tokens=1500, temperature=0.3,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception as exc: # noqa: BLE001 — 含 httpx.HTTPStatusError 503/429
|
||||||
|
status = getattr(getattr(exc, "response", None), "status_code", 0)
|
||||||
|
if status in (503, 429) and attempt < 3:
|
||||||
|
await asyncio.sleep(backoff[min(attempt, 2)])
|
||||||
|
continue
|
||||||
|
logger.warning("AI评委调用失败,回退机械打分: %s", exc)
|
||||||
|
return score_copy(copy, source, banned_words, pass_score=pass_score)
|
||||||
|
|
||||||
|
verdict = _parse_verdict(raw)
|
||||||
|
if not verdict:
|
||||||
|
logger.warning("AI评委输出解析失败,回退机械打分。raw[:120]=%s", raw[:120])
|
||||||
|
return score_copy(copy, source, banned_words, pass_score=pass_score)
|
||||||
|
|
||||||
|
details: list[dict] = []
|
||||||
|
for d in verdict["dims"]:
|
||||||
|
item = str(d.get("item", "")).strip()
|
||||||
|
if item not in _DIM_MAX: # 只收白名单6维,模型偶尔多吐"总分"等噪声项,丢弃
|
||||||
|
continue
|
||||||
|
mx = _DIM_MAX[item]
|
||||||
|
sc = max(0, min(mx, int(round(float(d.get("score", 0))))))
|
||||||
|
details.append({"item": item, "score": sc, "max": mx, "note": str(d.get("reason", ""))[:60]})
|
||||||
|
details.append(dim_comp)
|
||||||
|
|
||||||
|
total = max(0, min(100, sum(d["score"] for d in details)))
|
||||||
|
passed = (total >= pass_score) and not found_all
|
||||||
|
return {
|
||||||
|
"score": total, "score_detail": details, "passed": passed,
|
||||||
|
"banned_words_found": found_all,
|
||||||
|
"verdict": verdict.get("verdict", ""), "summary": str(verdict.get("summary", ""))[:120],
|
||||||
|
}
|
||||||
132
backend/app/services/ai_engine/package_exporter.py
Normal file
132
backend/app/services/ai_engine/package_exporter.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
package_exporter.py — 达人素材交付包生成
|
||||||
|
架构方案§五 1A步骤5:按笔记分文件夹 + 图(01/02/03) + 文案.txt + 发布清单 + 合规说明
|
||||||
|
路径规则:uploads/packages/{workspace_id}/{task_id}/note_{n}/
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 文件夹结构
|
||||||
|
# uploads/packages/{workspace_id}/{task_id}/
|
||||||
|
# note_01/
|
||||||
|
# 01_hook.jpg # 按 seq 序号命名防传错序
|
||||||
|
# 02_proof.jpg
|
||||||
|
# 文案.txt # 标题 + 正文 + 标签
|
||||||
|
# note_02/
|
||||||
|
# ...
|
||||||
|
# 📋发布清单.txt
|
||||||
|
# ✅合规说明.txt
|
||||||
|
# package.zip # 最终打包文件
|
||||||
|
|
||||||
|
|
||||||
|
def build_delivery_package(
|
||||||
|
workspace_id: int,
|
||||||
|
task_id: int,
|
||||||
|
notes: list[dict], # 每条笔记,含 text_candidate + image_candidates
|
||||||
|
base_path: str = "uploads/packages",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
打包交付,返回 zip 文件的本地路径。
|
||||||
|
notes 格式:[{
|
||||||
|
"title": str, "content": str, "tags": list[str],
|
||||||
|
"images": [{"seq": int, "role": str, "data": bytes}],
|
||||||
|
"banned_word_status": str, # 合规说明用
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
package_dir = Path(base_path) / str(workspace_id) / str(task_id)
|
||||||
|
package_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
note_dirs: list[Path] = []
|
||||||
|
for idx, note in enumerate(notes, start=1):
|
||||||
|
note_dir = package_dir / f"note_{idx:02d}"
|
||||||
|
note_dir.mkdir(exist_ok=True)
|
||||||
|
note_dirs.append(note_dir)
|
||||||
|
|
||||||
|
# ── 图片文件(按 seq 序号命名)
|
||||||
|
for img in sorted(note.get("images", []), key=lambda x: x.get("seq", 0)):
|
||||||
|
seq = img.get("seq", idx)
|
||||||
|
role = img.get("role", "img")
|
||||||
|
fname = f"{seq:02d}_{role}.jpg"
|
||||||
|
img_data = img.get("data", b"")
|
||||||
|
if img_data:
|
||||||
|
(note_dir / fname).write_bytes(img_data)
|
||||||
|
|
||||||
|
# ── 文案.txt(标题 + 正文 + 标签,达人可直接复制)
|
||||||
|
tags = note.get("tags") or []
|
||||||
|
body = note.get("content", "")
|
||||||
|
# 正文末尾如果 LLM 已写入 #话题 标签,不再重复追加(避免重复)
|
||||||
|
body_has_tags = bool(tags) and any(
|
||||||
|
t.strip("#") in body for t in tags if t
|
||||||
|
)
|
||||||
|
copy_lines = [
|
||||||
|
f"【标题】{note.get('title', '')}",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
]
|
||||||
|
if tags and not body_has_tags:
|
||||||
|
copy_lines += ["", " ".join(tags)]
|
||||||
|
(note_dir / "文案.txt").write_text("\n".join(copy_lines), encoding="utf-8")
|
||||||
|
|
||||||
|
# ── 发布清单.txt
|
||||||
|
checklist_lines = [
|
||||||
|
"📋 发布清单",
|
||||||
|
f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||||
|
f"任务ID:{task_id}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for idx, note in enumerate(notes, start=1):
|
||||||
|
title = note.get("title", f"笔记{idx}")
|
||||||
|
n_images = len(note.get("images", []))
|
||||||
|
checklist_lines.append(f"note_{idx:02d} 标题:{title[:30]} 图片数:{n_images}")
|
||||||
|
checklist_lines += [
|
||||||
|
"",
|
||||||
|
"发布注意事项:",
|
||||||
|
"- 每条笔记图片按 01/02/03 顺序上传,避免传错序",
|
||||||
|
"- 文案.txt 中标题/正文/标签已区分,复制对应部分",
|
||||||
|
"- 品牌词已植入,请勿删除",
|
||||||
|
"- 不要添加链接(种品牌词,引导天猫搜索成交)",
|
||||||
|
]
|
||||||
|
(package_dir / "📋发布清单.txt").write_text("\n".join(checklist_lines), encoding="utf-8")
|
||||||
|
|
||||||
|
# ── 合规说明.txt
|
||||||
|
compliance_lines = [
|
||||||
|
"✅ 合规说明",
|
||||||
|
f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||||
|
"",
|
||||||
|
"本批次内容已完成以下合规处理:",
|
||||||
|
"1. 违禁词扫描(美白/祛斑/速效/医用/药妆等)",
|
||||||
|
"2. 视觉违禁词处理(前后对比/变白等)",
|
||||||
|
"3. 图片去水印处理(C2PA元数据已清除)",
|
||||||
|
"",
|
||||||
|
"各笔记合规状态:",
|
||||||
|
]
|
||||||
|
for idx, note in enumerate(notes, start=1):
|
||||||
|
status = note.get("banned_word_status", "pass")
|
||||||
|
status_label = {"pass": "✅通过", "auto_fixed": "✅自动改写", "soft_warn": "⚠️软提示", "hard_block": "❌硬拦截"}.get(status, status)
|
||||||
|
compliance_lines.append(f"note_{idx:02d}:{status_label}")
|
||||||
|
compliance_lines += [
|
||||||
|
"",
|
||||||
|
"注:C2PA元数据可去除;私有像素水印只能削弱,不保证100%清除。",
|
||||||
|
"如有合规疑问,请联系运营团队。",
|
||||||
|
]
|
||||||
|
(package_dir / "✅合规说明.txt").write_text("\n".join(compliance_lines), encoding="utf-8")
|
||||||
|
|
||||||
|
# ── 打 zip
|
||||||
|
zip_path = package_dir / "package.zip"
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for note_dir in note_dirs:
|
||||||
|
for fpath in sorted(note_dir.iterdir()):
|
||||||
|
zf.write(fpath, arcname=f"{note_dir.name}/{fpath.name}")
|
||||||
|
zf.write(package_dir / "📋发布清单.txt", arcname="📋发布清单.txt")
|
||||||
|
zf.write(package_dir / "✅合规说明.txt", arcname="✅合规说明.txt")
|
||||||
|
|
||||||
|
logger.info("delivery package built: %s (notes=%d)", zip_path, len(notes))
|
||||||
|
return str(zip_path)
|
||||||
145
backend/app/services/ai_engine/preference_aggregator.py
Normal file
145
backend/app/services/ai_engine/preference_aggregator.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
偏好飞轮聚合(preference_aggregator)
|
||||||
|
扒自:Clover架构方案.md §偏好飞轮怎么转 + PRD §8
|
||||||
|
三层继承:L1 公司品牌基线 > L2 矩阵号人设(二期)> L3 个人手感
|
||||||
|
聚合最近 FLYWHEEL_LOOKBACK 条 events → prompt 片段注入文案生成
|
||||||
|
|
||||||
|
关键:
|
||||||
|
- 按 product_id 分开学(素颜霜偏好不串精华)
|
||||||
|
- 信号不足 FLYWHEEL_COLD_START 条时,用产品档案冷启动
|
||||||
|
- 返回结构对齐 API契约 GET /tasks/{id}/preference/context
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .constants import FLYWHEEL_LOOKBACK, FLYWHEEL_COLD_START
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_preference_context(
|
||||||
|
events: list[dict],
|
||||||
|
product: dict,
|
||||||
|
workspace_id: int,
|
||||||
|
product_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
输入:最近 preference_events 行(已按 workspace_id+product_id 过滤)
|
||||||
|
输出:{recent_preference, reject_reasons, injected_count, prompt_fragment}
|
||||||
|
prompt_fragment 直接注入文案生成 prompt
|
||||||
|
"""
|
||||||
|
# 按 product_id 过滤(防串货)
|
||||||
|
relevant = [
|
||||||
|
e for e in events
|
||||||
|
if e.get("workspace_id") == workspace_id and e.get("product_id") == product_id
|
||||||
|
][:FLYWHEEL_LOOKBACK]
|
||||||
|
|
||||||
|
injected_count = len(relevant)
|
||||||
|
|
||||||
|
if injected_count < FLYWHEEL_COLD_START:
|
||||||
|
# 冷启动:用产品档案静态基线
|
||||||
|
return _cold_start(product, injected_count)
|
||||||
|
|
||||||
|
# ── 统计最常选角度(text_select + approve 信号)
|
||||||
|
angle_counts: Counter = Counter()
|
||||||
|
reject_reasons: list[str] = []
|
||||||
|
|
||||||
|
for e in relevant:
|
||||||
|
sig_type = e.get("signal_type", "")
|
||||||
|
angle = str(e.get("angle_label", "")).strip()
|
||||||
|
weight = int(e.get("signal_weight", 1))
|
||||||
|
|
||||||
|
if sig_type in ("text_select", "approve") and angle:
|
||||||
|
angle_counts[angle] += weight
|
||||||
|
elif sig_type == "reject_with_reason":
|
||||||
|
reason = str(e.get("reason", "")).strip()
|
||||||
|
if reason:
|
||||||
|
reject_reasons.append(reason)
|
||||||
|
|
||||||
|
# 取权重最高的角度
|
||||||
|
top_angles = [a for a, _ in angle_counts.most_common(3)]
|
||||||
|
# 取最近3条打回原因
|
||||||
|
recent_rejects = reject_reasons[-3:] if reject_reasons else []
|
||||||
|
|
||||||
|
# ── 拼 prompt 片段(三层继承:L1>L2>L3,一期只跑L1+L3)
|
||||||
|
prompt_fragment = _build_prompt_fragment(top_angles, recent_rejects, product)
|
||||||
|
|
||||||
|
# ── 人类可读摘要(前端"本次已注入"显示)
|
||||||
|
if top_angles:
|
||||||
|
pref_summary = f"最近偏好角度:{'、'.join(top_angles)}(已选{injected_count}次信号)"
|
||||||
|
else:
|
||||||
|
pref_summary = f"已注入{injected_count}条偏好信号"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"recent_preference": pref_summary,
|
||||||
|
"reject_reasons": recent_rejects,
|
||||||
|
"injected_count": injected_count,
|
||||||
|
"prompt_fragment": prompt_fragment, # 注入 generate_text_variants extra_rules
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cold_start(product: dict, injected_count: int) -> dict:
|
||||||
|
"""信号不足时用产品档案基线"""
|
||||||
|
angles = product.get("text_angles") or []
|
||||||
|
style = product.get("style_tone", "素人分享风")
|
||||||
|
fragment = ""
|
||||||
|
if angles:
|
||||||
|
fragment = f"优先覆盖以下文案角度:{'、'.join(angles[:3])}。风格调性:{style}。"
|
||||||
|
return {
|
||||||
|
"recent_preference": f"冷启动(历史信号{injected_count}条,不足{FLYWHEEL_COLD_START}条),使用产品档案基线",
|
||||||
|
"reject_reasons": [],
|
||||||
|
"injected_count": injected_count,
|
||||||
|
"prompt_fragment": fragment,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prompt_fragment(
|
||||||
|
top_angles: list[str],
|
||||||
|
reject_reasons: list[str],
|
||||||
|
product: dict,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
组装注入文案 prompt 的片段
|
||||||
|
越积累越精准:1次=全靠基线;10次=知道偏好角度;30次=措辞从"供参考"升为明确指令
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
if top_angles:
|
||||||
|
lines.append(f"【偏好角度参考】历史选择偏好:{'、'.join(top_angles)},请优先采用这些角度方向。")
|
||||||
|
if reject_reasons:
|
||||||
|
formatted = ";".join(f"「{r}」" for r in reject_reasons)
|
||||||
|
lines.append(f"【打回原因参考】以下问题请主动规避:{formatted}。")
|
||||||
|
# L1 品牌基线(产品档案 custom_prompt)
|
||||||
|
custom = (product.get("custom_prompt") or "").strip()
|
||||||
|
if custom:
|
||||||
|
lines.append(f"【品牌基线】{custom}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_preference_event(
|
||||||
|
signal_type: str,
|
||||||
|
user_id: int,
|
||||||
|
workspace_id: int,
|
||||||
|
product_id: int,
|
||||||
|
angle_label: str = "",
|
||||||
|
reason: str = "",
|
||||||
|
weights: dict[str, int] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
构造 preference_event 行(由业务接口内部调用,不暴露给前端)
|
||||||
|
返回待插 DB 的字段 dict
|
||||||
|
"""
|
||||||
|
from .constants import FLYWHEEL_WEIGHTS
|
||||||
|
w_map = weights or FLYWHEEL_WEIGHTS
|
||||||
|
weight = w_map.get(signal_type, 0)
|
||||||
|
return {
|
||||||
|
"signal_type": signal_type,
|
||||||
|
"signal_weight": weight,
|
||||||
|
"user_id": user_id,
|
||||||
|
"workspace_id": workspace_id,
|
||||||
|
"product_id": product_id,
|
||||||
|
"angle_label": angle_label,
|
||||||
|
"reason": reason,
|
||||||
|
"data_ownership": "client_data", # 原始行为信号归客户(PRD §3 data_ownership)
|
||||||
|
}
|
||||||
109
backend/app/services/ai_engine/prompt_composer.py
Normal file
109
backend/app/services/ai_engine/prompt_composer.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
prompt_composer.py — 统一 prompt 组装入口(≤100行)
|
||||||
|
扒自:banana prompts/service.py + worker/src/copy.js prompt 逻辑
|
||||||
|
Lead 指名接口:compose_variants / compose_preference_context
|
||||||
|
|
||||||
|
组装逻辑委托:
|
||||||
|
_text_prompt.py → build_prompt (文案 prompt 主体)
|
||||||
|
preference_aggregator.py → aggregate_preference_context (飞轮上下文)
|
||||||
|
|
||||||
|
原则:prompt 组装从这里进,不散落在 text_variants / generate_text_variants 里。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ._text_prompt import build_prompt, COPY_SYSTEM
|
||||||
|
from .preference_aggregator import aggregate_preference_context
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 主接口 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def compose_variants(
|
||||||
|
product: dict,
|
||||||
|
count: int,
|
||||||
|
flywheel_context: str = "",
|
||||||
|
extra_rules: str = "",
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
一次出 count 角度文案的完整 prompt。
|
||||||
|
|
||||||
|
返回 (system_prompt, user_prompt)。
|
||||||
|
飞轮片段追加到 user_prompt 末尾(不改 system,避免覆盖质量红线)。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
product — 产品档案 dict(name/selling_points/text_angles/custom_prompt 等)
|
||||||
|
count — 需要几条
|
||||||
|
flywheel_context— 由 compose_preference_context 返回的 prompt_fragment
|
||||||
|
extra_rules — 额外规则(优化循环重生成时传 hint)
|
||||||
|
"""
|
||||||
|
combined_extra = "\n".join(filter(None, [flywheel_context, extra_rules]))
|
||||||
|
user_prompt = build_prompt(product, count, extra_rules=combined_extra)
|
||||||
|
logger.debug(
|
||||||
|
"compose_variants: product=%s count=%d flywheel_len=%d",
|
||||||
|
product.get("name", "?"), count, len(flywheel_context),
|
||||||
|
)
|
||||||
|
return COPY_SYSTEM, user_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def compose_preference_context(
|
||||||
|
events: list[dict],
|
||||||
|
product: dict,
|
||||||
|
workspace_id: int,
|
||||||
|
product_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
聚合偏好事件 → 可注入 prompt 的飞轮上下文。
|
||||||
|
|
||||||
|
返回结构(对齐 API契约 GET /tasks/{id}/preference/context):
|
||||||
|
{
|
||||||
|
recent_preference: str, # 人类可读摘要(前端"本次已注入"显示)
|
||||||
|
reject_reasons: list, # 最近打回原因
|
||||||
|
injected_count: int, # 有效信号数
|
||||||
|
prompt_fragment: str, # 注入 compose_variants flywheel_context 的字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
信号不足 FLYWHEEL_COLD_START 条时用产品档案冷启动。
|
||||||
|
按 workspace_id + product_id 双维过滤(素颜霜偏好不串精华)。
|
||||||
|
"""
|
||||||
|
return aggregate_preference_context(events, product, workspace_id, product_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 辅助:解析模型返回的 JSON(给 text_variants 调用,集中不散) ──────────────
|
||||||
|
|
||||||
|
def parse_model_output(raw: str) -> list[dict]:
|
||||||
|
"""从 LLM 原始输出提取 JSON 数组(容错 markdown 包裹)"""
|
||||||
|
from ._text_prompt import parse_json_array
|
||||||
|
return parse_json_array(raw)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 辅助:图片 prompt 组装入口(预留,联调时填充)─────────────────────────────
|
||||||
|
|
||||||
|
def compose_image_prompt(
|
||||||
|
role_name: str,
|
||||||
|
visual_system: dict,
|
||||||
|
product: dict,
|
||||||
|
extra: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
单张分镜 prompt 组装(供 image_gen.generate_one_image 调用)。
|
||||||
|
TODO: 联调后从 storyboard.plan_image_set 取 base_prompt 注入。
|
||||||
|
|
||||||
|
role_name — 分镜角色(hook / pain_scene / closer 等)
|
||||||
|
visual_system— build_visual_system 返回的视觉系统 dict
|
||||||
|
extra — 追加约束(飞轮图片偏好片段,二期接入)
|
||||||
|
"""
|
||||||
|
name = product.get("name", "产品")
|
||||||
|
style = visual_system.get("style", "")
|
||||||
|
palette = visual_system.get("color_palette", "")
|
||||||
|
base = visual_system.get("base_prompt", "")
|
||||||
|
lines = [
|
||||||
|
f"[{role_name}] 为产品「{name}」生成种草图。",
|
||||||
|
base and f"视觉基调:{base}",
|
||||||
|
style and f"摄影风格:{style}",
|
||||||
|
palette and f"色调:{palette}",
|
||||||
|
extra,
|
||||||
|
]
|
||||||
|
return "\n".join(l for l in lines if l)
|
||||||
197
backend/app/services/ai_engine/storyboard.py
Normal file
197
backend/app/services/ai_engine/storyboard.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""
|
||||||
|
storyboard 分镜引擎
|
||||||
|
扒自:worker/src/image.js
|
||||||
|
- getNarrativeRoles:按图数取分镜角色
|
||||||
|
- proof_strategy:按品类定证明页策略(品类不写死,走数据驱动)
|
||||||
|
- build_visual_system:成组视觉统一
|
||||||
|
- plan_image_set:组装最终分镜计划
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import re
|
||||||
|
from .constants import (
|
||||||
|
PAGE_ROLE_MAP, IMAGE_NEGATIVE_CONSTRAINTS,
|
||||||
|
STYLE_PROMPTS, STYLE_DEFAULT, NARRATIVE_BY_COUNT, NARRATIVE_BY_STRATEGY,
|
||||||
|
)
|
||||||
|
# sanitize_text 移至 templates(腾行数),此处 re-export 供 image_gen 沿用 import
|
||||||
|
from .storyboard_templates import role_template, proof_strategy, sanitize_text # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_count(value: int, fallback: int = 6, lo: int = 1, hi: int = 8) -> int:
|
||||||
|
try:
|
||||||
|
return max(lo, min(hi, int(value)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def short_selling_points(points, fallback: str = "") -> str:
|
||||||
|
"""3个短卖点拼成 a / b / c(扒 shortSellingPoints:112-120)"""
|
||||||
|
src = points if isinstance(points, list) else str(points or "").split("、")
|
||||||
|
clean = [sanitize_text(p, 18) for p in src if sanitize_text(p, 18)][:3]
|
||||||
|
return " / ".join(clean) if clean else sanitize_text(fallback, 28)
|
||||||
|
|
||||||
|
|
||||||
|
def short_tags(tags, keywords=None) -> str:
|
||||||
|
"""标签去#截断拼成 #a #b(扒 shortTags:48-54)"""
|
||||||
|
merged = list(tags or []) + list(keywords or [])
|
||||||
|
out = []
|
||||||
|
for t in merged:
|
||||||
|
c = sanitize_text(str(t), 12).lstrip("#")
|
||||||
|
if c:
|
||||||
|
out.append(f"#{c}")
|
||||||
|
return " ".join(out[:5])
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_copy_for_image(note: dict, product: dict) -> dict:
|
||||||
|
"""
|
||||||
|
从文案+产品提取生图锚点(扒 analyzeCopyForImage:129-148)
|
||||||
|
给每张图填 audience/pain/scene/hook,让画面有真实代入而非空泛。
|
||||||
|
"""
|
||||||
|
text = f"{note.get('title','')}。{note.get('coverTitle','')}。{note.get('content','')}"
|
||||||
|
tags = [sanitize_text(str(t).lstrip('#'), 12) for t in (note.get("tags") or [])]
|
||||||
|
audience = sanitize_text(
|
||||||
|
product.get("target_audience")
|
||||||
|
or next((t for t in tags if re.search(r"党|人|妈妈|女生|学生|通勤|上班|办公室", t)), "")
|
||||||
|
or "目标用户", 18)
|
||||||
|
scene = sanitize_text(
|
||||||
|
next((t for t in tags if re.search(r"通勤|宿舍|上课|约会|出门|办公室|旅行|居家|工位", t)), "")
|
||||||
|
or "日常自然光场景", 18)
|
||||||
|
pain = sanitize_text(
|
||||||
|
next((w for w in re.split(r"[、,,。;;!!??\n]", text)
|
||||||
|
if re.search(r"暗沉|没气色|假白|卡粉|搓泥|油|干|赶时间|预算|麻烦", w)), "")
|
||||||
|
or "日常使用痛点", 18)
|
||||||
|
hook = sanitize_text(note.get("coverTitle") or note.get("title") or f"{audience}{scene}", 18)
|
||||||
|
return {"audience": audience, "scene": scene, "pain": pain, "hook": hook}
|
||||||
|
|
||||||
|
|
||||||
|
def get_narrative_roles(image_count: int = 6) -> list[dict]:
|
||||||
|
"""
|
||||||
|
按图数返回分镜角色列表(扒 getNarrativeRoles,Q6对齐北哥6张套路)
|
||||||
|
≤3 张:极速链路 hook / applied_proof / closer
|
||||||
|
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
|
||||||
|
>6 张:沉浸链路 + pain_scene / scenario / social_proof
|
||||||
|
"""
|
||||||
|
count = clamp_count(image_count)
|
||||||
|
m = PAGE_ROLE_MAP
|
||||||
|
if count <= 3:
|
||||||
|
sequence = ["hook", "applied_proof", "closer"]
|
||||||
|
elif count <= 6:
|
||||||
|
# Q6:北哥6张标准顺序——品牌词在②(product_closeup)和⑥(closer)两次出现
|
||||||
|
sequence = ["hook", "product_closeup", "ingredient", "texture", "applied_proof", "closer"]
|
||||||
|
else:
|
||||||
|
# 8张沉浸链路:在北哥6张基础上插入 pain_scene / social_proof
|
||||||
|
sequence = ["hook", "pain_scene", "product_closeup", "ingredient", "texture", "applied_proof", "social_proof", "closer"]
|
||||||
|
return [m[r] for r in sequence[:count] if r in m]
|
||||||
|
|
||||||
|
|
||||||
|
# ── proofStrategy 已移至 storyboard_templates.proof_strategy(腾行数,超200拆)
|
||||||
|
|
||||||
|
|
||||||
|
def build_visual_system(product: dict, analysis: dict | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
成组视觉统一(扒 buildVisualSystem)
|
||||||
|
analysis 来自 product.js 分析结果(visualIdentity),可空
|
||||||
|
"""
|
||||||
|
identity = (analysis or {}).get("visualIdentity", {})
|
||||||
|
palette = (
|
||||||
|
"、".join(identity["colorPalette"][:5])
|
||||||
|
if isinstance(identity.get("colorPalette"), list) and identity["colorPalette"]
|
||||||
|
else "提取产品包装主色,搭配浅色真实生活背景"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"palette": palette,
|
||||||
|
"typography": identity.get("typographyStyle", "主标题清晰黑体或手写感标题,辅助文字便签/勾选标注,字重颜色保持同一体系"),
|
||||||
|
"sticker": identity.get("stickerLanguage", "少量箭头、放大镜、勾选、小表情、便签,不使用促销按钮"),
|
||||||
|
"layout": identity.get("layoutStyle", "同一组图片保持色调、光线、产品露出方式一致,每张图承担不同叙事角色"),
|
||||||
|
"texture": identity.get("materialTexture", "产品包装、质地、手背/上脸肤感要真实自然"),
|
||||||
|
"package_details": identity.get("packageDetails", "如果提供产品图,必须还原包装颜色、瓶身形状、标签方向和主视觉"),
|
||||||
|
"xhs_style_preset": identity.get("xhsStylePreset", "真实测评风/手写安利风/清单便签风"),
|
||||||
|
"symbol_system": identity.get("symbolSystem", "中等密度小红书种草符号:✅ ✨ 🌿 💧 🪞 🧴 📦 🔍 💛,每张最多2-4个"),
|
||||||
|
"quality_rules": [
|
||||||
|
"同组图片字体体系相对一致,但不要像固定模板",
|
||||||
|
"每张压图文字必须服务当前叙事角色,不能重复封面标题",
|
||||||
|
"护肤品优先出现手背涂抹、质地微距、自然上脸局部或真实生活场景",
|
||||||
|
"人物真实自然,有轻微皮肤纹理和生活感,不要AI精修美女",
|
||||||
|
"禁止乱码、错别字、App底栏、Like评论分享、硬广价格牌、虚假功效before/after",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def plan_image_set(note: dict, product: dict, image_count: int = 3, analysis: dict | None = None, strategy: str | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
组装分镜计划(主入口)
|
||||||
|
strategy: None=默认按图数叙事,'A'/'B'/'C'=三套正交叙事策略
|
||||||
|
返回:{requested_count, storyboard, visual_system, base_prompt}
|
||||||
|
storyboard 每项:{role, name, focus, overlay_text, prompt_for_item}
|
||||||
|
"""
|
||||||
|
count = clamp_count(image_count)
|
||||||
|
roles = get_narrative_roles(count)
|
||||||
|
visual = build_visual_system(product, analysis)
|
||||||
|
category = product.get("category", "通用好物")
|
||||||
|
points = product.get("selling_points") or ["核心买点"]
|
||||||
|
src = analyze_copy_for_image(note, product) # 文案锚点:audience/pain/scene/hook
|
||||||
|
|
||||||
|
storyboard = []
|
||||||
|
brand_kw = sanitize_text(product.get("brand_keyword") or "", 12)
|
||||||
|
brand_roles = {"product_closeup", "closer"} # Q6:第2/6张带品牌词
|
||||||
|
|
||||||
|
for i, role in enumerate(roles):
|
||||||
|
point = sanitize_text(points[i % len(points)], 18)
|
||||||
|
tpl = role_template(role["role"])
|
||||||
|
proof = proof_strategy(category, point) # 仅 applied_proof 用品类证明
|
||||||
|
# 填模板占位:每角色画面/文字各不同(修缩水根因——不再全角色共用proof)
|
||||||
|
fill = {
|
||||||
|
"audience": src["audience"], "pain": src["pain"], "scene": src["scene"],
|
||||||
|
"hook": src["hook"], "point": point, "brand": brand_kw or product.get("name", "产品"),
|
||||||
|
"proof_overlay": proof.get("overlay", point),
|
||||||
|
"proof_visual": proof.get("visual", ""),
|
||||||
|
"proof_forbidden": proof.get("forbidden", ""),
|
||||||
|
}
|
||||||
|
item = {
|
||||||
|
"role": role["role"],
|
||||||
|
"name": role["name"],
|
||||||
|
"focus": role["focus"],
|
||||||
|
"goal": sanitize_text(tpl["goal"].format(**fill), 40),
|
||||||
|
"overlay_text": sanitize_text(tpl["overlay"].format(**fill), 20),
|
||||||
|
"visual_strategy": sanitize_text(tpl["visual"].format(**fill), 120),
|
||||||
|
"source_basis": sanitize_text(tpl["basis"].format(**fill), 60),
|
||||||
|
"selling_point": point,
|
||||||
|
"asset_use": proof.get("asset_use", "产品图保证包装准确,参考图用于真实场景"),
|
||||||
|
"forbidden": sanitize_text(tpl["forbidden"].format(**fill), 60),
|
||||||
|
}
|
||||||
|
if brand_kw and role["role"] in brand_roles:
|
||||||
|
item["brand_keyword"] = brand_kw
|
||||||
|
item["brand_keyword_rule"] = "品牌词自然融入画面文字或产品露出,不做广告牌感"
|
||||||
|
storyboard.append(item)
|
||||||
|
|
||||||
|
# base_prompt 全局规则(扒 planImageSet basePrompt:682-690)
|
||||||
|
product_name = product.get("name", "产品")
|
||||||
|
cover_title = sanitize_text(note.get("coverTitle") or note.get("title") or "", 18)
|
||||||
|
style_text = STYLE_PROMPTS.get(product.get("style_tone") or STYLE_DEFAULT, STYLE_PROMPTS[STYLE_DEFAULT])
|
||||||
|
# strategy非空时取对应正交叙事,否则按图数取默认链路
|
||||||
|
narrative = NARRATIVE_BY_STRATEGY.get(strategy) if strategy else None
|
||||||
|
if not narrative:
|
||||||
|
narrative = NARRATIVE_BY_COUNT.get(count, NARRATIVE_BY_COUNT[6])
|
||||||
|
sp_text = short_selling_points(points, cover_title)
|
||||||
|
tag_text = short_tags(note.get("tags") or [], product.get("keywords") or [])
|
||||||
|
base_prompt = (
|
||||||
|
f"生成一张小红书可上传的独立3:4图文海报/素材图,目标比例1024×1536,可直接上传的独立图片,不是提示词,不是App截图。"
|
||||||
|
f"产品:{product_name}。短标题:{cover_title}。"
|
||||||
|
f"短卖点:{sp_text}。短标签:{tag_text}。"
|
||||||
|
f"叙事链路:{narrative}"
|
||||||
|
f"视觉风格:{style_text}。"
|
||||||
|
f"成组视觉:主色={visual['palette']};字体={visual['typography']};贴纸={visual['sticker']};"
|
||||||
|
f"符号系统={visual['symbol_system']};产品还原={visual['package_details']}。"
|
||||||
|
"重要限制:中文文字少而清晰,每张只允许一个主标题,同一句话禁止重复出现,正文点位最多3条;"
|
||||||
|
"四周留安全边距,文字不贴边不被裁切;真实自然像实拍素材后排版,降低AI味;"
|
||||||
|
"禁止生成小红书App界面截图、Like/评论/分享/底栏/头像等社交元素;"
|
||||||
|
"禁止肤色变白、瑕疵消失、治疗前后等视觉暗示,允许安全的未推开/推开后质地状态对比;"
|
||||||
|
"如果提供产品图,产品是不可修改的真实商品锚点,禁止改名、换包装、混入其他产品。"
|
||||||
|
f"\n{IMAGE_NEGATIVE_CONSTRAINTS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"requested_count": count,
|
||||||
|
"storyboard": storyboard,
|
||||||
|
"visual_system": visual,
|
||||||
|
"base_prompt": base_prompt,
|
||||||
|
}
|
||||||
174
backend/app/services/ai_engine/storyboard_templates.py
Normal file
174
backend/app/services/ai_engine/storyboard_templates.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
角色差异化分镜模板
|
||||||
|
扒自:worker/src/image.js buildImageStoryboard storyboardByRole(222-321)
|
||||||
|
|
||||||
|
每个分镜角色有【各自不同】的画面/文字/构图策略,这是"小红书风格不雷同"的根因。
|
||||||
|
之前缩水:6张共用同一个品类 proof 策略 → 图全长一样。
|
||||||
|
模板里 {占位} 在 storyboard.py 运行时按文案/卖点填充。
|
||||||
|
|
||||||
|
字段含义(对齐 promptFromStoryboard 9 字段):
|
||||||
|
goal 本张目标(这张图要让用户产生什么动作/情绪)
|
||||||
|
overlay 图上主文字模板(每张不同,不重复封面标题)
|
||||||
|
visual 画面主体(构图、景别、道具、光线——这是不雷同的关键)
|
||||||
|
basis 文案依据(这张图从文案哪里来,给模型锚点)
|
||||||
|
forbidden 本张禁止事项
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ── sanitize(扒 sanitizeImagePlanText,防违禁视觉词进 prompt)
|
||||||
|
_SANITIZE_RULES: list[tuple[str, str]] = [
|
||||||
|
(r"before\s*&\s*after", "质地与肤感说明"),
|
||||||
|
(r"before\s*/?\s*after", "质地与肤感说明"),
|
||||||
|
(r"\bbefore\b", "质地状态"),
|
||||||
|
(r"\bafter\b", "上脸肤感"),
|
||||||
|
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"),
|
||||||
|
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
|
||||||
|
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
|
||||||
|
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
|
||||||
|
(r"治疗前后|治疗后|医美前后|治愈|修复受损", "日常使用场景说明"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_text(value: str, max_len: int = 56) -> str:
|
||||||
|
s = str(value)
|
||||||
|
for pattern, repl in _SANITIZE_RULES:
|
||||||
|
s = re.sub(pattern, repl, s, flags=re.IGNORECASE)
|
||||||
|
return re.sub(r"\s+", " ", s).strip()[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
# 北哥6张标准套 + 8张扩展角色,每角色独立画面策略
|
||||||
|
ROLE_STORYBOARD_TPL: dict[str, dict] = {
|
||||||
|
"hook": {
|
||||||
|
"goal": "让{audience}因为{pain}停下划走,产生点开欲",
|
||||||
|
"overlay": "{hook}",
|
||||||
|
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实肤感/手部细节,像iPhone随手实拍的封面,不是海报",
|
||||||
|
"basis": "来自选中文案标题、人群{audience}、痛点{pain}",
|
||||||
|
"forbidden": "不要价格、不要重复后续卖点、不要App界面、不要广告海报感",
|
||||||
|
},
|
||||||
|
"product_closeup": {
|
||||||
|
"goal": "建立单品记忆锚点,让用户记住是哪个产品",
|
||||||
|
"overlay": "{brand}",
|
||||||
|
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读,品牌词自然出现在画面或瓶身",
|
||||||
|
"basis": "来自产品名和品牌词,第2张和第6张都要带品牌词强化记忆",
|
||||||
|
"forbidden": "不要堆多个产品、不要花哨背景抢主体、不要改包装文字",
|
||||||
|
},
|
||||||
|
"ingredient": {
|
||||||
|
"goal": "用成分/配方信息建立信任,但不医疗化",
|
||||||
|
"overlay": "看清{point}",
|
||||||
|
"visual": "成分卡片式布局,产品+成分图标/短说明,浅色商务美妆风,信息层级清楚",
|
||||||
|
"basis": "来自卖点里的成分/功效点,理性表达不夸大",
|
||||||
|
"forbidden": "不要治疗/改善疾病承诺、不要医生背书、不要绝对化",
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"goal": "让用户看到{point}的真实质感证据",
|
||||||
|
"overlay": "{point}看得见",
|
||||||
|
"visual": "手背或指尖涂抹质地微距,产品放在旁边,自然光,保留真实皮肤纹理,能看清延展和肤感",
|
||||||
|
"basis": "来自卖点里的质地/肤感描述",
|
||||||
|
"forbidden": "不要生成变白效果、不要医疗化对比、不要和封面同构图",
|
||||||
|
},
|
||||||
|
"applied_proof": {
|
||||||
|
"goal": "用可感知的上脸/使用证据证明{point}",
|
||||||
|
"overlay": "{proof_overlay}",
|
||||||
|
"visual": "{proof_visual}",
|
||||||
|
"basis": "来自核心卖点{point}和用户对效果的关注",
|
||||||
|
"forbidden": "{proof_forbidden}",
|
||||||
|
},
|
||||||
|
"closer": {
|
||||||
|
"goal": "用囤货/省钱情报/搜索暗号完成软性转化",
|
||||||
|
"overlay": "这波真的会囤 {brand}",
|
||||||
|
"visual": "拆箱、囤货角或产品放在日常物品旁,真实分享氛围,轻量搜索/品牌词暗号提示,再带一次品牌词引导成交",
|
||||||
|
"basis": "来自价格心智/选择理由,但不做硬广",
|
||||||
|
"forbidden": "不要大促价格牌、不要购买按钮、不要红黄电商风",
|
||||||
|
},
|
||||||
|
# ── 8张扩展角色 ──
|
||||||
|
"pain_scene": {
|
||||||
|
"goal": "让用户共鸣{pain}",
|
||||||
|
"overlay": "{pain}真的懂",
|
||||||
|
"visual": "{scene}里的真实困扰场景,产品作为解决方案线索出现,不做使用前后对比",
|
||||||
|
"basis": "来自文案痛点和目标人群",
|
||||||
|
"forbidden": "不要夸大焦虑、不要before/after",
|
||||||
|
},
|
||||||
|
"social_proof": {
|
||||||
|
"goal": "补足信任背书,让内容不像单方面推销",
|
||||||
|
"overlay": "身边人都在问",
|
||||||
|
"visual": "产品在包里/桌面/宿舍囤货角,配简短手写感反馈气泡,真实随手拍",
|
||||||
|
"basis": "来自评论区语言/选择理由,缺评论时用低调口碑表达",
|
||||||
|
"forbidden": "不要假造大量头像评论、不要App评论区截图",
|
||||||
|
},
|
||||||
|
"scenario": {
|
||||||
|
"goal": "展示{scene}以外的多场景使用代入",
|
||||||
|
"overlay": "这些场景都能用",
|
||||||
|
"visual": "2-3个生活小场景拼贴:宿舍/通勤包/办公桌,产品贯穿其中,统一光线",
|
||||||
|
"basis": "来自目标人群的多场景使用需求",
|
||||||
|
"forbidden": "不要电商详情页拼贴、不要夸大效果",
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"goal": "降低使用门槛,告诉用户怎么用",
|
||||||
|
"overlay": "三步就上手",
|
||||||
|
"visual": "三步手势教程:取量、点涂/使用、收尾,干净背景,产品在画面内",
|
||||||
|
"basis": "来自文案里的快速/懒人使用场景",
|
||||||
|
"forbidden": "不要复杂说明书、不要过多文字",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def role_template(role: str) -> dict:
|
||||||
|
"""取角色模板,未知角色用 applied_proof 兜底(和源头一致)"""
|
||||||
|
return ROLE_STORYBOARD_TPL.get(role, ROLE_STORYBOARD_TPL["applied_proof"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── proofStrategy(按品类定 applied_proof 证明页策略,扒 image.js:163-208)
|
||||||
|
# 品类来自 product.category,不硬编码枚举;无匹配走"通用好物"兜底
|
||||||
|
PROOF_STRATEGIES: dict[str, dict] = {
|
||||||
|
"个护护理": {
|
||||||
|
"overlay_tpl": "{point}看得见",
|
||||||
|
"visual": "手部/身体局部使用证明:少量点涂、推开后吸收状态、真实纹理和自然光;产品只做辅助露出",
|
||||||
|
"asset_use": "优先使用实拍/参考图中的手部、干纹、涂抹、随身场景;产品图保证包装准确",
|
||||||
|
"forbidden": "不要变白、祛斑、医学效果、before/after字样;不要和封面同构图",
|
||||||
|
},
|
||||||
|
"美妆护肤": {
|
||||||
|
"overlay_tpl": "{point}看得见",
|
||||||
|
"visual": "肤感/质地证明:手背、脸颊局部或质地微距,展示推开前后真实状态,保留皮肤纹理和自然光",
|
||||||
|
"asset_use": "优先使用实拍/参考图中的手背、上脸、质地素材;产品图辅助露出",
|
||||||
|
"forbidden": "不要变白、祛斑、医学效果、before/after字样;不要和封面同构图",
|
||||||
|
},
|
||||||
|
"食品饮品": {
|
||||||
|
"overlay_tpl": "{point}一眼懂",
|
||||||
|
"visual": "冲泡/开袋/入口证明:展示包装、杯中状态、质地颜色或一口口感,真实桌面光线",
|
||||||
|
"asset_use": "产品图保证包装准确,参考图用于杯子、开袋、冲泡、办公室/居家场景",
|
||||||
|
"forbidden": "不要涂抹、不要护肤肤感、不要医疗健康承诺",
|
||||||
|
},
|
||||||
|
"营养健康": {
|
||||||
|
"overlay_tpl": "看清{point}",
|
||||||
|
"visual": "理性证明页:包装、成分表、使用场景和每日习惯卡片,信息清晰但不做治疗承诺",
|
||||||
|
"asset_use": "产品图和说明图用于成分/包装准确,参考图用于日常使用场景",
|
||||||
|
"forbidden": "不要治疗、改善疾病、速效、医生背书、前后对比",
|
||||||
|
},
|
||||||
|
"家居生活": {
|
||||||
|
"overlay_tpl": "{point}真省事",
|
||||||
|
"visual": "使用过程证明:展示痛点场景、产品介入和使用过程细节,强调顺手/收纳/效率",
|
||||||
|
"asset_use": "参考图用于真实家居环境,产品图保证外观准确",
|
||||||
|
"forbidden": "不要护肤涂抹,不要虚假夸大结果",
|
||||||
|
},
|
||||||
|
"服饰穿搭": {
|
||||||
|
"overlay_tpl": "{point}有细节",
|
||||||
|
"visual": "上身/材质证明:展示面料纹理、版型细节或普通身材上身局部,真实自然",
|
||||||
|
"asset_use": "参考图用于上身/搭配/材质,产品图保证款式颜色准确",
|
||||||
|
"forbidden": "不要护肤涂抹,不要过度精修模特感",
|
||||||
|
},
|
||||||
|
"通用好物": {
|
||||||
|
"overlay_tpl": "{point}清晰可见",
|
||||||
|
"visual": "产品使用场景证明:真实道具/场景,展示产品细节和使用过程",
|
||||||
|
"asset_use": "产品图保证准确,参考图用于场景辅助",
|
||||||
|
"forbidden": "不要夸大效果,不要硬广式价格牌",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def proof_strategy(category: str, point: str) -> dict:
|
||||||
|
"""取品类证明策略,无匹配用通用兜底(扒 proofStrategy)"""
|
||||||
|
s = PROOF_STRATEGIES.get(category, PROOF_STRATEGIES["通用好物"]).copy()
|
||||||
|
s["overlay"] = s.pop("overlay_tpl", "{point}").format(point=point)
|
||||||
|
return s
|
||||||
|
|
||||||
93
backend/app/services/ai_engine/text_scoring.py
Normal file
93
backend/app/services/ai_engine/text_scoring.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
text_scoring.py — 五维打分接口 + 去重(≤100行)
|
||||||
|
打分维度逻辑见 _scoring_dims.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import re
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS,
|
||||||
|
SCORE_WEIGHTS, QUALITY_PASS_SCORE,
|
||||||
|
DEDUP_TITLE_THRESHOLD, DEDUP_TITLE_CONTENT_TITLE, DEDUP_TITLE_CONTENT_BODY,
|
||||||
|
)
|
||||||
|
from ._scoring_dims import (
|
||||||
|
_cat_words, score_title, score_emotion, score_selling,
|
||||||
|
score_keyword, score_compliance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def score_copy(
|
||||||
|
copy: dict[str, Any],
|
||||||
|
source: dict[str, Any],
|
||||||
|
banned_words: list[str] | None = None,
|
||||||
|
weights: dict[str, int] | None = None,
|
||||||
|
pass_score: int = QUALITY_PASS_SCORE,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
五维打分(标题25 / 情绪25 / 买点25 / 关键词20 / 合规5)
|
||||||
|
返回:{score, score_detail, passed, banned_words_found}
|
||||||
|
"""
|
||||||
|
w = weights or SCORE_WEIGHTS
|
||||||
|
bwords = list(set((banned_words or []) + BANNED_WORDS_DEFAULT + BANNED_VISUAL_WORDS))
|
||||||
|
|
||||||
|
title = str(copy.get("title", ""))
|
||||||
|
content = str(copy.get("content", ""))
|
||||||
|
tags = " ".join(str(t) for t in copy.get("tags", []))
|
||||||
|
full = f"{title}\n{content}\n{tags}\n{copy.get('imageBrief','')}"
|
||||||
|
|
||||||
|
selling_points = source.get("selling_points", []) or []
|
||||||
|
keywords = source.get("keywords", []) or []
|
||||||
|
category = source.get("category", "通用好物")
|
||||||
|
cat_w = _cat_words(category)
|
||||||
|
|
||||||
|
dim_title = score_title(title, cat_w, w)
|
||||||
|
dim_emotion = score_emotion(full, w)
|
||||||
|
dim_selling = score_selling(copy, full, selling_points, w)
|
||||||
|
dim_keyword = score_keyword(copy, tags, keywords, w)
|
||||||
|
dim_compliance, found_all = score_compliance(full, bwords, w)
|
||||||
|
|
||||||
|
details = [dim_title, dim_emotion, dim_selling, dim_keyword, dim_compliance]
|
||||||
|
total = max(0, min(100, sum(d["score"] for d in details)))
|
||||||
|
passed = (total >= pass_score) and not found_all
|
||||||
|
|
||||||
|
return {"score": total, "score_detail": details, "passed": passed, "banned_words_found": found_all}
|
||||||
|
|
||||||
|
|
||||||
|
def _sim(a: str, b: str) -> float:
|
||||||
|
return SequenceMatcher(None, a, b).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_signature(copy: dict) -> str:
|
||||||
|
content = str(copy.get("content", ""))
|
||||||
|
opening = re.sub(r"\s+", "", content[:30])
|
||||||
|
return f"{copy.get('title', '')}|{opening}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_similar_copy(a: dict, b: dict) -> bool:
|
||||||
|
"""同质化判重(标题≥0.82 OR 标题≥0.65且正文≥0.72)"""
|
||||||
|
t = _sim(str(a.get("title", "")), str(b.get("title", "")))
|
||||||
|
if t >= DEDUP_TITLE_THRESHOLD:
|
||||||
|
return True
|
||||||
|
if t >= DEDUP_TITLE_CONTENT_TITLE:
|
||||||
|
if _sim(str(a.get("content",""))[:200], str(b.get("content",""))[:200]) >= DEDUP_TITLE_CONTENT_BODY:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_copies(copies: list[dict], previous: list[dict] | None = None) -> list[dict]:
|
||||||
|
"""本轮内互去重 + 与历史去重 + angle 去重"""
|
||||||
|
history = previous or []
|
||||||
|
kept: list[dict] = []
|
||||||
|
used_angles: set[str] = set()
|
||||||
|
for c in copies:
|
||||||
|
sig = _copy_signature(c)
|
||||||
|
if any(_copy_signature(h) == sig for h in history): continue
|
||||||
|
if any(is_similar_copy(c, h) for h in history): continue
|
||||||
|
if any(is_similar_copy(c, k) for k in kept): continue
|
||||||
|
angle = str(c.get("angle", "")).strip()
|
||||||
|
if angle and angle in used_angles: continue
|
||||||
|
if angle: used_angles.add(angle)
|
||||||
|
kept.append(c)
|
||||||
|
return kept
|
||||||
186
backend/app/services/ai_engine/text_variants.py
Normal file
186
backend/app/services/ai_engine/text_variants.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
text_variants.py — 文案双轨主入口(≤100行)
|
||||||
|
轨A: generate_text_variants — 调 LLM 出 N 角度 JSON
|
||||||
|
轨B: text_import_handler — 导入外部文案进候选池
|
||||||
|
|
||||||
|
prompt 组装/解析见 _text_prompt.py;评分/去重见 text_scoring.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .constants import MAX_OPTIMIZE_ROUNDS
|
||||||
|
from ._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array, build_local_drafts
|
||||||
|
from .text_scoring import score_copy, dedupe_copies
|
||||||
|
from .llm_scorer import llm_score_copy
|
||||||
|
from .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_llm(client: Any, prompt: str, max_tokens: int = 8192) -> str:
|
||||||
|
"""统一 LLM 调用,client 由 worker 注入,隔离 key。
|
||||||
|
G1坑修复:AIClients 没有 .chat.completions,正确方法是 .chat_complete()
|
||||||
|
S8: 503/429 指数退避重试(最多3次,2^attempt 秒),其他异常直接降级返 ''。
|
||||||
|
max_tokens 由调用方按批量缩放:opus 会尽量填满输出空间,8192 token 的生成
|
||||||
|
单批 >60s 必撞 apiports 网关上限返 503(task46 实测每请求恰挂 ~61s)。实测单条
|
||||||
|
max_tokens=1500~2500 仅 16~18s。故按条数动态收,墙钟压进 60s 网关窗口内。
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# 倩倩姐2026-06-13拍板"加大重试+拉长退避":apiports负载波动时单条opus也会被
|
||||||
|
# 拖过60s返503,短退避(1/2/4s)赶不开高负载窗口。故重试5次、退避拉长到最长30s,
|
||||||
|
# 给中转站负载回落留时间。墙钟换稳定(MVP免费阶段可接受)。
|
||||||
|
max_attempts = 5
|
||||||
|
backoff = [5, 10, 20, 30] # 第1~4次重试前等待秒数,拉长跨过apiports高负载窗口
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
return await client.chat_complete(
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": COPY_SYSTEM},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
model=client._model,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=0.75,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status = exc.response.status_code if exc.response is not None else 0
|
||||||
|
if status in (503, 429) and attempt < max_attempts - 1:
|
||||||
|
wait = backoff[min(attempt, len(backoff) - 1)]
|
||||||
|
logger.warning(
|
||||||
|
"LLM 返回 %s,第%d/%d次重试,等待 %ds: %s",
|
||||||
|
status, attempt + 1, max_attempts - 1, wait, exc,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
logger.error("LLM HTTP错误(不可重试或已达上限): %s: %s", type(exc).__name__, exc)
|
||||||
|
return ""
|
||||||
|
except Exception as exc:
|
||||||
|
# 其他异常(超时/网络断开等)不重试,直接降级
|
||||||
|
logger.error("LLM 调用失败: %s: %s", type(exc).__name__, exc)
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# apiports 网关单次响应有 ~60s 上限,claude 一次生成 >4 条长文案会超时返 503。
|
||||||
|
# 故分批:每批最多 4 条,串行调用合并。批大小可经 TEXT_BATCH_SIZE 调。
|
||||||
|
TEXT_BATCH_SIZE = int(os.environ.get("TEXT_BATCH_SIZE", "4"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_one_batch(llm_client: Any, product: dict, batch_n: int, extra: str) -> list[dict]:
|
||||||
|
"""生成一批 batch_n 条,含解析重试(最多2次)。失败返回空列表。
|
||||||
|
max_tokens 按条数缩放(每条约 1800 token,封顶 8192),压进 apiports 60s 网关窗口。"""
|
||||||
|
batch_max_tokens = min(8192, max(1800, batch_n * 1800))
|
||||||
|
for attempt in range(2):
|
||||||
|
raw = await _call_llm(llm_client, build_prompt(product, batch_n, extra_rules=extra), batch_max_tokens)
|
||||||
|
parsed = parse_json_array(raw)
|
||||||
|
if parsed:
|
||||||
|
return parsed
|
||||||
|
logger.warning("文案批(%d条)第%d次解析失败%s", batch_n, attempt + 1,
|
||||||
|
",重试" if attempt == 0 else ",放弃本批")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_in_batches(llm_client: Any, product: dict, count: int, extra: str) -> list[dict]:
|
||||||
|
"""把 count 条按 TEXT_BATCH_SIZE 分批,串行调用合并。
|
||||||
|
串行而非并发:opus 单批就慢(~300s)且 apiports 限并发,多批 gather 会触发
|
||||||
|
大面积 503 雪崩(task45 实测)。故改串行,墙钟换稳定。"""
|
||||||
|
sizes: list[int] = []
|
||||||
|
remaining = count
|
||||||
|
while remaining > 0:
|
||||||
|
n = min(TEXT_BATCH_SIZE, remaining)
|
||||||
|
sizes.append(n)
|
||||||
|
remaining -= n
|
||||||
|
collected: list[dict] = []
|
||||||
|
for n in sizes:
|
||||||
|
r = await _generate_one_batch(llm_client, product, n, extra)
|
||||||
|
collected.extend(r)
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_text_variants(
|
||||||
|
llm_client: Any,
|
||||||
|
product: dict,
|
||||||
|
count: int,
|
||||||
|
previous_copies: list[dict] | None = None,
|
||||||
|
banned_word_rows: list[dict] | None = None,
|
||||||
|
flywheel_context: str = "",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""轨A:一次出 count 条不同角度文案,三层兜底,自动优化循环"""
|
||||||
|
banned_entries = build_entries_from_db(banned_word_rows or [])
|
||||||
|
extra = flywheel_context
|
||||||
|
|
||||||
|
copies: list[dict] = await _generate_in_batches(llm_client, product, count, extra)
|
||||||
|
if not copies:
|
||||||
|
copies = list(build_local_drafts(product, count)) # generator → list
|
||||||
|
|
||||||
|
candidates: list[dict] = []
|
||||||
|
for c in copies:
|
||||||
|
ban: CheckResult = check_and_fix(
|
||||||
|
f"{c.get('title','')} {c.get('content','')}",
|
||||||
|
banned_entries or None,
|
||||||
|
)
|
||||||
|
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
|
||||||
|
c.update({"source": "ai", "score": scored["score"], "score_detail": scored["score_detail"],
|
||||||
|
"passed": scored["passed"], "banned_word_status": ban.status,
|
||||||
|
"verdict": scored.get("verdict", ""), "summary": scored.get("summary", "")})
|
||||||
|
if ban.status == "auto_fixed" and ban.fixed_text:
|
||||||
|
c["content"] = ban.fixed_text
|
||||||
|
candidates.append(c)
|
||||||
|
|
||||||
|
failed = [c for c in candidates if not c["passed"] and c["banned_word_status"] != "hard_block"]
|
||||||
|
# 优化轮默认关闭:apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503,
|
||||||
|
# 严重拖慢出文案(实测 +100s+)。质量优化等北哥 prompt 方案到位再开(架构已留位)。
|
||||||
|
optimize_enabled = os.environ.get("TEXT_OPTIMIZE_ENABLED", "false").lower() == "true"
|
||||||
|
rounds = MAX_OPTIMIZE_ROUNDS if optimize_enabled else 0
|
||||||
|
for _ in range(rounds):
|
||||||
|
if not failed:
|
||||||
|
break
|
||||||
|
# 优化轮也受 60s 网关上限约束:一次最多重生成 TEXT_BATCH_SIZE 条
|
||||||
|
batch_failed = failed[:TEXT_BATCH_SIZE]
|
||||||
|
hint = "\n".join(
|
||||||
|
f"标题「{c['title']}」{c['score']}分,需改进:" +
|
||||||
|
";".join(d["note"] for d in c.get("score_detail", []) if d["score"] < d["max"] * 0.72)
|
||||||
|
for c in batch_failed
|
||||||
|
)
|
||||||
|
raw2 = await _call_llm(llm_client, build_prompt(
|
||||||
|
product, len(batch_failed),
|
||||||
|
extra_rules=f"以下文案未达标,请重新生成并改进:\n{hint}\n不要重复已有标题和角度。",
|
||||||
|
), min(8192, max(1800, len(batch_failed) * 1800)))
|
||||||
|
if not raw2:
|
||||||
|
# LLM 失败(如 503/超时):优化是锦上添花,原始候选已够用,不再耗时重试
|
||||||
|
logger.warning("文案优化轮 LLM 失败,沿用原始候选不再重试")
|
||||||
|
break
|
||||||
|
for nc in parse_json_array(raw2):
|
||||||
|
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
|
||||||
|
nc.update({"source": "ai", "score": sc2["score"], "score_detail": sc2["score_detail"],
|
||||||
|
"passed": sc2["passed"], "banned_word_status": "pass",
|
||||||
|
"verdict": sc2.get("verdict", ""), "summary": sc2.get("summary", "")})
|
||||||
|
candidates.append(nc)
|
||||||
|
failed = [c for c in candidates if not c["passed"]]
|
||||||
|
|
||||||
|
return dedupe_copies(candidates, previous_copies or [])[:count]
|
||||||
|
|
||||||
|
|
||||||
|
def text_import_handler(
|
||||||
|
raw_text: str,
|
||||||
|
product: dict,
|
||||||
|
banned_word_rows: list[dict] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""轨B:导入外部文案(豆包等)直接进候选池,source=import"""
|
||||||
|
banned_entries = build_entries_from_db(banned_word_rows or [])
|
||||||
|
lines = raw_text.strip().splitlines()
|
||||||
|
title = lines[0].strip() if lines else ""
|
||||||
|
content = "\n".join(lines[1:]).strip() if len(lines) > 1 else raw_text.strip()
|
||||||
|
candidate: dict = {"title": title, "content": content, "tags": [], "angle": "import",
|
||||||
|
"buyingPoint": "", "coverTitle": title, "imageBrief": "", "source": "import"}
|
||||||
|
ban = check_and_fix(f"{title} {content}", banned_entries or None)
|
||||||
|
# 轨B(导入外部文案)走机械 score_copy 而非 AI 评委:导入的是用户自带成品,评分仅作
|
||||||
|
# 参考展示不卡发布;且本函数同步、改 await 会扩大到调用方。AI 评委只用于轨A生成链路。
|
||||||
|
scored = score_copy(candidate, product, [e.word for e in banned_entries])
|
||||||
|
candidate.update({"score": scored["score"], "score_detail": scored["score_detail"],
|
||||||
|
"passed": scored["passed"], "banned_word_status": ban.status})
|
||||||
|
return candidate
|
||||||
71
backend/app/services/auth_service.py
Normal file
71
backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
app/services/auth_service.py — 认证 service
|
||||||
|
密码哈希校验、用户查找、响应格式化。
|
||||||
|
路由层不含业务逻辑,全在此。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.response import raise_unauthorized
|
||||||
|
from app.middleware.workspace_guard import CurrentUser
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.workspace import WorkspaceMember
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
return pwd_context.hash(plain)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(
|
||||||
|
db: Session, username: str, password: str
|
||||||
|
) -> tuple[User, int, str]:
|
||||||
|
"""
|
||||||
|
验证用户名+密码,返回 (user, workspace_id, role)。
|
||||||
|
失败抛 CloverHTTPException 40101。
|
||||||
|
"""
|
||||||
|
user = db.query(User).filter(
|
||||||
|
User.username == username, User.is_active == True
|
||||||
|
).first()
|
||||||
|
if not user or not verify_password(password, user.hashed_password):
|
||||||
|
raise_unauthorized("用户名或密码错误")
|
||||||
|
|
||||||
|
# 取用户所在的第一个 workspace(手动建账号场景只有一个)
|
||||||
|
member = (
|
||||||
|
db.query(WorkspaceMember)
|
||||||
|
.filter(WorkspaceMember.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not member:
|
||||||
|
raise_unauthorized("用户未加入任何 workspace,请联系管理员")
|
||||||
|
|
||||||
|
# 记录登录
|
||||||
|
try:
|
||||||
|
from app.models.user import LoginRecord
|
||||||
|
db.add(LoginRecord(user_id=user.id))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to write login_record for user=%s", user.id)
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
return user, member.workspace_id, member.role
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_response(user: User, workspace_id: int, role: str) -> dict:
|
||||||
|
"""格式化用户响应体(契约§4 DTO)。"""
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"current_workspace_id": workspace_id,
|
||||||
|
"role": role,
|
||||||
|
}
|
||||||
121
backend/app/services/flywheel_service.py
Normal file
121
backend/app/services/flywheel_service.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
app/services/flywheel_service.py — 飞轮信号写入 + 偏好上下文聚合
|
||||||
|
preference_collector:三信号入口(选文案/选图/审核)写入 preference_events。
|
||||||
|
preference_aggregator:查最近50条 → 最常选角度 + 打回原因近3条原文拼 prompt。
|
||||||
|
飞轮不暴露独立埋点端点,只由业务接口内部调用(契约红线)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import desc, func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership, SignalType
|
||||||
|
from app.middleware.workspace_guard import CurrentUser
|
||||||
|
from app.models.flywheel import PreferenceEvent
|
||||||
|
from app.models.task import GenerationTask
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 实时聚合窗口:最近50条事件
|
||||||
|
_AGGREGATION_WINDOW = 50
|
||||||
|
# 冷启动阈值:不足5条信号用产品档案冷启动
|
||||||
|
_COLD_START_THRESHOLD = 5
|
||||||
|
|
||||||
|
|
||||||
|
def record_signal(
|
||||||
|
db: Session,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
task: GenerationTask,
|
||||||
|
signal_type: str,
|
||||||
|
candidate_id: int | None = None,
|
||||||
|
angle_label: str | None = None,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
写入飞轮信号。
|
||||||
|
workspace_id + product_id 都必须有(基石C + 按产品分开学)。
|
||||||
|
signal_weight 用枚举默认值,北哥可校准。
|
||||||
|
data_ownership 默认 client_data(选择行为归客户)。
|
||||||
|
"""
|
||||||
|
weight = SIGNAL_WEIGHTS.get(signal_type, 0)
|
||||||
|
event = PreferenceEvent(
|
||||||
|
workspace_id=current_user.workspace_id,
|
||||||
|
product_id=task.product_id,
|
||||||
|
task_id=task.id,
|
||||||
|
user_id=current_user.user_id,
|
||||||
|
signal_type=signal_type,
|
||||||
|
signal_weight=weight,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
angle_label=angle_label,
|
||||||
|
reason=reason,
|
||||||
|
data_ownership=DataOwnership.CLIENT_DATA,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.add(event)
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"Flywheel signal: type=%s weight=%s user=%s product=%s",
|
||||||
|
signal_type, weight, current_user.user_id, task.product_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(
|
||||||
|
"Failed to write preference_event: type=%s user=%s",
|
||||||
|
signal_type, current_user.user_id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def get_preference_context(
|
||||||
|
db: Session, workspace_id: int, product_id: int
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
实时聚合偏好上下文(最近50条 events)。
|
||||||
|
返回:recent_preference摘要 + reject_reasons近3条 + injected_count。
|
||||||
|
不足5条 → 冷启动提示(产品档案兜底,由 AIE prompt 层读 products.custom_prompt)。
|
||||||
|
按 workspace_id + product_id 严格过滤(不串数据,基石C)。
|
||||||
|
"""
|
||||||
|
recent = (
|
||||||
|
db.query(PreferenceEvent)
|
||||||
|
.filter(
|
||||||
|
PreferenceEvent.workspace_id == workspace_id,
|
||||||
|
PreferenceEvent.product_id == product_id,
|
||||||
|
)
|
||||||
|
.order_by(desc(PreferenceEvent.created_at))
|
||||||
|
.limit(_AGGREGATION_WINDOW)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(recent) < _COLD_START_THRESHOLD:
|
||||||
|
return {
|
||||||
|
"recent_preference": "信号不足,使用产品档案基线(冷启动)",
|
||||||
|
"reject_reasons": [],
|
||||||
|
"injected_count": len(recent),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 统计最常被选中的角度
|
||||||
|
angle_counts: dict[str, int] = {}
|
||||||
|
for ev in recent:
|
||||||
|
if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE) and ev.angle_label:
|
||||||
|
angle_counts[ev.angle_label] = angle_counts.get(ev.angle_label, 0) + 1
|
||||||
|
|
||||||
|
top_angles = sorted(angle_counts.items(), key=lambda x: x[1], reverse=True)[:3]
|
||||||
|
if top_angles:
|
||||||
|
pref_desc = ";".join(f"{a}(已选{c}次)" for a, c in top_angles)
|
||||||
|
preference_summary = f"最近偏好:{pref_desc}"
|
||||||
|
else:
|
||||||
|
preference_summary = "暂无明显角度偏好"
|
||||||
|
|
||||||
|
# 取最近3条打回原因原文(不做 AI 归纳,契约§3)
|
||||||
|
reject_reasons = [
|
||||||
|
ev.reason for ev in recent
|
||||||
|
if ev.signal_type == SignalType.REJECT_WITH_REASON and ev.reason
|
||||||
|
][:3]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"recent_preference": preference_summary,
|
||||||
|
"reject_reasons": reject_reasons,
|
||||||
|
"injected_count": len(recent),
|
||||||
|
}
|
||||||
86
backend/app/services/task_service.py
Normal file
86
backend/app/services/task_service.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
app/services/task_service.py — 任务创建 service
|
||||||
|
校验有无 key → 建 GenerationTask → 只推 task_id 入队,绝不传 key(基石B)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.response import raise_business
|
||||||
|
from app.middleware.workspace_guard import CurrentUser
|
||||||
|
from app.models.task import GenerationTask
|
||||||
|
from app.models.workspace import UserApiKey
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_user_has_key(db: Session, user_id: int, workspace_id: int) -> None:
|
||||||
|
"""校验用户在此 workspace 是否有可用 API Key(openai/apiports均可),没有则引导去配置。"""
|
||||||
|
key = (
|
||||||
|
db.query(UserApiKey)
|
||||||
|
.filter(
|
||||||
|
UserApiKey.user_id == user_id,
|
||||||
|
UserApiKey.workspace_id == workspace_id,
|
||||||
|
UserApiKey.provider.in_(["openai", "apiports"]), # G6坑修复:接受主备通道名
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not key:
|
||||||
|
raise_business("尚未配置 API Key,请先在设置中录入")
|
||||||
|
|
||||||
|
|
||||||
|
def create_generation_task(
|
||||||
|
db: Session,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
body, # CreateTaskRequest
|
||||||
|
) -> GenerationTask:
|
||||||
|
"""
|
||||||
|
建 GenerationTask 并推 Celery 队列。
|
||||||
|
只传 task_id,绝不传 key(基石B)。
|
||||||
|
"""
|
||||||
|
if body.track == "ai":
|
||||||
|
# 轨A:先检查有没有 key
|
||||||
|
_check_user_has_key(db, current_user.user_id, current_user.workspace_id)
|
||||||
|
|
||||||
|
# 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
|
||||||
|
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
|
||||||
|
need_img = getattr(body, "need_product_image", True)
|
||||||
|
if need_img:
|
||||||
|
from app.models.product import Product
|
||||||
|
product = db.query(Product).filter(
|
||||||
|
Product.id == body.product_id,
|
||||||
|
Product.workspace_id == current_user.workspace_id,
|
||||||
|
).first()
|
||||||
|
if not product:
|
||||||
|
raise_business("产品不存在")
|
||||||
|
if not (product.image_path or "").strip():
|
||||||
|
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关")
|
||||||
|
|
||||||
|
task = GenerationTask(
|
||||||
|
workspace_id=current_user.workspace_id,
|
||||||
|
product_id=body.product_id,
|
||||||
|
operator_id=current_user.user_id,
|
||||||
|
theme=body.theme,
|
||||||
|
text_count=body.text_count,
|
||||||
|
image_count=body.image_count,
|
||||||
|
track=body.track,
|
||||||
|
need_product_image=need_img,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
logger.info("GenerationTask created: id=%s ws=%s", task.id, current_user.workspace_id)
|
||||||
|
|
||||||
|
if body.track == "ai":
|
||||||
|
enqueue_generation(task.id)
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_generation(task_id: int) -> None:
|
||||||
|
"""只推 task_id 入队,绝不推 key(基石B)。"""
|
||||||
|
from app.workers.tasks import run_generation_pipeline
|
||||||
|
run_generation_pipeline.delay(task_id)
|
||||||
|
logger.info("Enqueued task_id=%s", task_id)
|
||||||
7
backend/app/utils/README.md
Normal file
7
backend/app/utils/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# app/utils/
|
||||||
|
|
||||||
|
工具层占位:
|
||||||
|
- fernet_utils.py # Fernet加密/解密(FERNET_KEY走环境变量,绝不进代码库)
|
||||||
|
- sse_utils.py # SSE推送工具(补发历史事件,前端按event_seq去重)
|
||||||
|
- pagination.py # 统一分页工具
|
||||||
|
- ai_usage_logger.py # AI调用用量记录(每次调用记usage,归因到个人key)
|
||||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
49
backend/app/utils/fernet_utils.py
Normal file
49
backend/app/utils/fernet_utils.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
app/utils/fernet_utils.py — Fernet 加解密工具(按 Lead 规范路径)
|
||||||
|
FERNET_KEY 走环境变量,绝不进代码库(基石B)。
|
||||||
|
此模块是 app/core/security.py 中 Fernet 功能的独立导出,
|
||||||
|
供 AIE / worker 层直接 import,无需依赖 FastAPI 上下文。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_fernet_instance: Fernet | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
global _fernet_instance
|
||||||
|
if _fernet_instance is None:
|
||||||
|
settings = get_settings()
|
||||||
|
_fernet_instance = Fernet(settings.FERNET_KEY.encode())
|
||||||
|
return _fernet_instance
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_key(plain_key: str) -> str:
|
||||||
|
"""
|
||||||
|
加密 API Key,返回密文字符串。
|
||||||
|
调用方绝不打印 plain_key(基石B)。
|
||||||
|
"""
|
||||||
|
return _get_fernet().encrypt(plain_key.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_key(encrypted_key: str) -> str:
|
||||||
|
"""
|
||||||
|
解密 API Key。只在 Celery worker 内部调用。
|
||||||
|
解密结果只活在调用函数的局部变量,不落盘、不打日志。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _get_fernet().decrypt(encrypted_key.encode()).decode()
|
||||||
|
except InvalidToken:
|
||||||
|
logger.error("Fernet decryption failed: token invalid or key rotated")
|
||||||
|
raise ValueError("API key decryption failed")
|
||||||
|
|
||||||
|
|
||||||
|
def mask_key(plain_key: str) -> str:
|
||||||
|
"""只返回后4位(展示用,不暴露完整 key)。"""
|
||||||
|
return plain_key[-4:] if len(plain_key) >= 4 else "****"
|
||||||
122
backend/app/utils/sse_utils.py
Normal file
122
backend/app/utils/sse_utils.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
app/utils/sse_utils.py — SSE 工具函数(按 Lead 规范路径)
|
||||||
|
补发历史事件、event_seq 去重、Redis pub/sub 推送。
|
||||||
|
供 api/v1/stream.py 和 AIE worker 共用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# SSE 事件类型(契约§2全列)
|
||||||
|
SSE_EVENT_TYPES = frozenset({
|
||||||
|
"task_started", "analyze_done", "text_progress", "text_candidate",
|
||||||
|
"image_progress", "image_candidate", "flywheel_injected",
|
||||||
|
"batch_failed", "task_done", "error", "heartbeat",
|
||||||
|
})
|
||||||
|
|
||||||
|
_HISTORY_KEY_TPL = "sse:task:{task_id}:events"
|
||||||
|
_CHANNEL_TPL = "sse:task:{task_id}"
|
||||||
|
_HISTORY_TTL_SECONDS = 3600 # 历史事件保留 1h
|
||||||
|
|
||||||
|
|
||||||
|
def format_sse(event: str, data: dict, seq: int | None = None) -> str:
|
||||||
|
"""格式化单条 SSE 消息(text/event-stream 格式)。"""
|
||||||
|
payload = json.dumps(data, ensure_ascii=False)
|
||||||
|
lines = [f"event: {event}", f"data: {payload}"]
|
||||||
|
if seq is not None:
|
||||||
|
lines.append(f"id: {seq}")
|
||||||
|
return "\n".join(lines) + "\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def push_event(
|
||||||
|
redis_client,
|
||||||
|
task_id: int,
|
||||||
|
workspace_id: int,
|
||||||
|
event: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
seq: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
推送事件到 Redis:
|
||||||
|
1. 追加到历史 list(供断线重连补发)
|
||||||
|
2. Publish 到 channel(供在线客户端实时收)
|
||||||
|
"""
|
||||||
|
if event not in SSE_EVENT_TYPES:
|
||||||
|
logger.warning("Unknown SSE event type: %s", event)
|
||||||
|
|
||||||
|
record = {"event": event, "data": data, "seq": seq, "workspace_id": workspace_id}
|
||||||
|
payload = json.dumps(record, ensure_ascii=False)
|
||||||
|
|
||||||
|
hist_key = _HISTORY_KEY_TPL.format(task_id=task_id)
|
||||||
|
channel = _CHANNEL_TPL.format(task_id=task_id)
|
||||||
|
|
||||||
|
await redis_client.rpush(hist_key, payload)
|
||||||
|
await redis_client.expire(hist_key, _HISTORY_TTL_SECONDS)
|
||||||
|
await redis_client.publish(channel, payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history_events(
|
||||||
|
redis_client, task_id: int, after_seq: int
|
||||||
|
) -> list[dict]:
|
||||||
|
"""取 task 历史事件中 seq > after_seq 的部分(断线重连补发)。"""
|
||||||
|
hist_key = _HISTORY_KEY_TPL.format(task_id=task_id)
|
||||||
|
try:
|
||||||
|
raw_list = await redis_client.lrange(hist_key, 0, -1)
|
||||||
|
result = []
|
||||||
|
for raw in raw_list:
|
||||||
|
ev = json.loads(raw)
|
||||||
|
if ev.get("seq", 0) > after_seq:
|
||||||
|
result.append(ev)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("get_history_events failed task=%s: %s", task_id, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_events(
|
||||||
|
redis_client,
|
||||||
|
task_id: int,
|
||||||
|
workspace_id: int,
|
||||||
|
last_seq: int = 0,
|
||||||
|
heartbeat_interval: int = 25,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
主 SSE 生成器:
|
||||||
|
1. 先补发 last_seq 之后的历史事件
|
||||||
|
2. 再订阅 Redis channel 实时推新事件
|
||||||
|
3. 每 heartbeat_interval 秒推一次保活 heartbeat
|
||||||
|
"""
|
||||||
|
# 1. 补发历史
|
||||||
|
history = await get_history_events(redis_client, task_id, last_seq)
|
||||||
|
for ev in history:
|
||||||
|
yield format_sse(ev["event"], ev["data"], ev.get("seq"))
|
||||||
|
|
||||||
|
# 2. 实时订阅
|
||||||
|
pubsub = redis_client.pubsub()
|
||||||
|
channel = _CHANNEL_TPL.format(task_id=task_id)
|
||||||
|
await pubsub.subscribe(channel)
|
||||||
|
elapsed = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
||||||
|
if msg and msg["type"] == "message":
|
||||||
|
ev = json.loads(msg["data"])
|
||||||
|
if ev.get("workspace_id") != workspace_id:
|
||||||
|
continue # 防越权订阅他人任务
|
||||||
|
yield format_sse(ev["event"], ev["data"], ev.get("seq"))
|
||||||
|
if ev["event"] in ("task_done", "error"):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
elapsed += 1
|
||||||
|
if elapsed >= heartbeat_interval:
|
||||||
|
elapsed = 0
|
||||||
|
yield format_sse("heartbeat", {"ts": asyncio.get_event_loop().time()})
|
||||||
|
finally:
|
||||||
|
await pubsub.unsubscribe(channel)
|
||||||
11
backend/app/workers/README.md
Normal file
11
backend/app/workers/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# app/workers/
|
||||||
|
|
||||||
|
Celery worker 占位:
|
||||||
|
- celery_app.py # Celery实例配置(broker=Redis)
|
||||||
|
- task_runner.py # 主任务:只接收task_id → 查库→FERNET_KEY解密key → 调模型
|
||||||
|
# 铁律:明文key绝不进Celery参数,只在函数局部变量,不落盘不打日志
|
||||||
|
- subtasks/
|
||||||
|
analyze.py # 分析标杆笔记8特征
|
||||||
|
generate_text.py # 文案双轨(轨A一次5角度JSON/轨B跳过)
|
||||||
|
generate_image.py # 并发生图asyncio.gather(A/B/C三策略)
|
||||||
|
postprocess.py # 去水印后处理
|
||||||
0
backend/app/workers/__init__.py
Normal file
0
backend/app/workers/__init__.py
Normal file
36
backend/app/workers/celery_app.py
Normal file
36
backend/app/workers/celery_app.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
app/workers/celery_app.py — Celery 任务框架壳
|
||||||
|
铁律:只传 task_id,绝不传 key(基石B)。
|
||||||
|
worker 内查库 → Fernet 解密 → 局部变量,不落盘不打日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"clover",
|
||||||
|
broker=settings.celery_broker(),
|
||||||
|
backend=settings.celery_backend(),
|
||||||
|
include=["app.workers.tasks", "app.workers.replenish_task"],
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
result_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
enable_utc=True,
|
||||||
|
task_track_started=True,
|
||||||
|
task_acks_late=True, # 任务处理完才 ACK,防丢失
|
||||||
|
worker_prefetch_multiplier=1, # 一次只取1条,防长任务堆积
|
||||||
|
task_routes={
|
||||||
|
"app.workers.tasks.run_generation_pipeline": {"queue": "generation"},
|
||||||
|
"app.workers.tasks.build_delivery_package": {"queue": "packaging"},
|
||||||
|
},
|
||||||
|
)
|
||||||
104
backend/app/workers/packaging_task.py
Normal file
104
backend/app/workers/packaging_task.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
app/workers/packaging_task.py — 交付打包 Celery 任务
|
||||||
|
build_delivery_package:查已选文案+图片 → package_exporter → 存路径
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db():
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
return SessionLocal()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
bind=True,
|
||||||
|
name="app.workers.tasks.build_delivery_package",
|
||||||
|
max_retries=2,
|
||||||
|
default_retry_delay=10,
|
||||||
|
queue="packaging",
|
||||||
|
)
|
||||||
|
def build_delivery_package(self, package_id: int) -> dict:
|
||||||
|
"""打包交付任务。查 delivery_packages → 收集笔记 → package_exporter"""
|
||||||
|
logger.info("build_delivery_package start: package_id=%s", package_id)
|
||||||
|
db = _get_db()
|
||||||
|
try:
|
||||||
|
from app.models.task import DeliveryPackage, TextCandidate, ImageCandidate
|
||||||
|
from app.constants.enums import PackageStatus
|
||||||
|
|
||||||
|
pkg = db.query(DeliveryPackage).filter(DeliveryPackage.id == package_id).first()
|
||||||
|
if not pkg:
|
||||||
|
raise ValueError(f"package_id={package_id} not found")
|
||||||
|
|
||||||
|
workspace_id = pkg.workspace_id
|
||||||
|
task_id = pkg.task_id
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
|
||||||
|
|
||||||
|
selected_text = db.query(TextCandidate).filter(
|
||||||
|
TextCandidate.task_id == task_id, TextCandidate.is_selected == True,
|
||||||
|
).first()
|
||||||
|
# 整套全打(倩倩姐2026-06-08拍板):一条笔记的全部图按 seq 排序进包,
|
||||||
|
# 不再只打 is_selected 的封面。北哥6张标准套 seq=1 是 hook 封面,天然排第一。
|
||||||
|
selected_images = db.query(ImageCandidate).filter(
|
||||||
|
ImageCandidate.task_id == task_id,
|
||||||
|
).order_by(ImageCandidate.seq).all()
|
||||||
|
|
||||||
|
if not selected_text:
|
||||||
|
raise ValueError("无已选文案,请先选择文案")
|
||||||
|
|
||||||
|
text_data = json.loads(selected_text.content or "{}")
|
||||||
|
images_data = []
|
||||||
|
for ic in selected_images:
|
||||||
|
img_bytes = b""
|
||||||
|
if ic.url:
|
||||||
|
# url 形如 /uploads/ws/task/file.jpg,本身已含 uploads 前缀。
|
||||||
|
# 工作目录是 /app,直接 lstrip("/") 当相对路径读,不能再拼 upload_base(会重复 uploads/uploads)。
|
||||||
|
rel = ic.url.lstrip("/")
|
||||||
|
abs_path = rel
|
||||||
|
try:
|
||||||
|
with open(abs_path, "rb") as f:
|
||||||
|
img_bytes = f.read()
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("图片读取失败,跳过:%s %s", abs_path, e)
|
||||||
|
images_data.append({
|
||||||
|
"seq": ic.seq,
|
||||||
|
"role": ic.role.value if hasattr(ic.role, "value") else str(ic.role),
|
||||||
|
"data": img_bytes,
|
||||||
|
})
|
||||||
|
|
||||||
|
notes = [{
|
||||||
|
"title": text_data.get("title", ""),
|
||||||
|
"content": text_data.get("content", ""),
|
||||||
|
"tags": text_data.get("tags", []),
|
||||||
|
"images": images_data,
|
||||||
|
"banned_word_status": (selected_text.banned_word_status.value
|
||||||
|
if hasattr(selected_text.banned_word_status, "value")
|
||||||
|
else str(selected_text.banned_word_status)),
|
||||||
|
}]
|
||||||
|
|
||||||
|
from app.services.ai_engine.package_exporter import build_delivery_package as do_build
|
||||||
|
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开
|
||||||
|
packages_base = f"{upload_base}/packages"
|
||||||
|
zip_path = do_build(workspace_id, task_id, notes, base_path=packages_base)
|
||||||
|
|
||||||
|
pkg.package_path = zip_path
|
||||||
|
pkg.download_url = f"/api/v1/delivery-packages/{package_id}/download-file"
|
||||||
|
pkg.status = PackageStatus.READY
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("delivery package ready: package_id=%s path=%s", package_id, zip_path)
|
||||||
|
return {"package_id": package_id, "status": "ready", "path": zip_path}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("build_delivery_package failed: package_id=%s err=%s", package_id, exc)
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user