上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事

- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段);
  修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品
- dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开;
  settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch
- 新增用户管理(users API/admin service/改密页) + alembic 022/023/024
- 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example
- A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -27,6 +27,26 @@ def _json_loads_safe(raw: str | None) -> dict:
return {"content": raw}
def _load_notes_by_strategy(db, task_id: int, target_strategy: str | None = None) -> dict[str, dict]:
"""按 A/B/C 套别取已入库文案,优先已选,其次本套最早一条。"""
from app.models.task import TextCandidate
q = db.query(TextCandidate).filter(TextCandidate.task_id == task_id)
if target_strategy:
q = q.filter(TextCandidate.strategy == target_strategy)
rows = q.order_by(
TextCandidate.strategy.asc(),
TextCandidate.is_selected.desc(),
TextCandidate.id.asc(),
).all()
notes: dict[str, dict] = {}
for row in rows:
strategy = row.strategy or "_"
if strategy not in notes:
notes[strategy] = _json_loads_safe(row.content)
return notes
def _get_db():
"""获取同步 DB SessionCelery worker 环境用同步)"""
from app.core.database import SessionLocal
@@ -79,7 +99,8 @@ def _release_run_lock(task_id: int) -> None:
queue="generation",
)
def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = None,
regen_role: str | None = None, custom_prompt: str | None = None) -> dict:
regen_role: str | None = None, custom_prompt: str | None = None,
reuse_text: bool = False) -> dict:
"""
生产链主任务。只接收 task_id绝不接收 key。
子步骤委托给 pipeline_steps查库/解密/飞轮上下文)
@@ -88,6 +109,10 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
R2 重生参数(均 None=常规全量生成)
regen_strategy='A'/'B'/'C' → 只重生该套regen_role 配合 → 只重生该套的该张;
custom_prompt → 人工追加提示词。重生模式跳过文案生成,复用已有合格文案。
reuse_text=True导入轨「去生图」首次出图——状态照常进 GENERATING(非重生)
但跳过文案生成、复用库内导入文案作生图依据。与重生区别:重生改图(状态不变)
导入是首次出图(进 GENERATING),信号语义不同,故单独标志位不复用 regen 参数。
"""
logger.info("run_generation_pipeline start: task_id=%s", task_id)
# 幂等双保险:抢不到锁说明已有 worker 在跑同一 task直接跳过(防重复烧钱)。
@@ -103,6 +128,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
from app.workers.pipeline_steps import (
load_task_and_product,
decrypt_user_key,
decrypt_codeproxy_key,
build_clients_and_clear_key,
build_product_dict,
load_flywheel_context,
@@ -120,13 +146,17 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
# Step2: Fernet 解密(局部变量,不传出)
plain_key = decrypt_user_key(db, task.operator_id, workspace_id)
alt_key = decrypt_codeproxy_key(db, task.operator_id, workspace_id)
# Step3: 构建 AIClients
clients = build_clients_and_clear_key(plain_key)
clients = build_clients_and_clear_key(plain_key, alt_key)
plain_key = None # 用完即清除基石B
alt_key = None
task.status = TaskStatus.GENERATING
db.commit()
_is_regen = bool(regen_strategy or regen_role)
if not _is_regen:
task.status = TaskStatus.GENERATING
db.commit()
# Step4: 推 task_started + 飞轮上下文
seq += 1
@@ -145,20 +175,31 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
_push_event_sync(task_id, workspace_id, "flywheel_injected", {
"recent_preference": flywheel_ctx.get("recent_preference", ""),
"reject_reasons": flywheel_ctx.get("reject_reasons", []),
"injected_count": flywheel_ctx.get("injected_count", 0),
"signal_count": flywheel_ctx.get("signal_count", 0),
}, seq)
# Step5: 文案生成S1: 返回 needs_replenish=合格数<目标数,触发后台补充)
# R2 重生模式:跳过文案重生,复用库内已有合格文案作生图依据(重生只针对图片)
_is_regen = bool(regen_strategy or regen_role)
if _is_regen:
from app.models.task import TextCandidate as _TC
_existing = db.query(_TC).filter(
_TC.task_id == task_id
).order_by(_TC.id.asc()).first()
if not _existing:
# reuse_text(导入轨):同样跳过文案生成,复用库内导入文案;与重生共用复用分支。
if _is_regen or reuse_text:
notes_by_strategy = _load_notes_by_strategy(db, task_id, regen_strategy)
if regen_strategy and regen_strategy not in notes_by_strategy:
# 重生依赖已有文案作生图语境;一条都没有时硬失败,绝不以空文案降级生图(质量崩)
from app.constants.enums import TaskStatus as _TS
logger.warning("重生无可复用文案,拒绝空语境生图: task_id=%s", task_id)
logger.warning(
"重生无可复用文案,拒绝空语境生图: task_id=%s strategy=%s",
task_id, regen_strategy,
)
task.status = _TS.PENDING_SELECTION
db.commit()
_push_event_sync(task_id, workspace_id, "error", {
"code": 40002,
"message": f"{regen_strategy}无可复用文案,请先完成该套文案生成再重生图片",
}, seq + 1)
return {"task_id": task_id, "status": "regen_no_text"}
if not notes_by_strategy:
from app.constants.enums import TaskStatus as _TS
task.status = _TS.PENDING_SELECTION
db.commit()
_push_event_sync(task_id, workspace_id, "error", {
@@ -166,13 +207,15 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
"message": "无可复用文案,请先完成文案生成再重生图片",
}, seq + 1)
return {"task_id": task_id, "status": "regen_no_text"}
candidates_raw = [_json_loads_safe(_existing.content)]
candidates_raw = list(notes_by_strategy.values())
needs_replenish = False
text_fail_reason = None # 重生不生成新文案,无文案失败原因
else:
candidates_raw, seq, needs_replenish = run_text_generation(
candidates_raw, seq, needs_replenish, text_fail_reason, _saved = run_text_generation(
db, clients, task, product_dict, flywheel_fragment,
_push_event_sync, workspace_id, seq,
)
notes_by_strategy = _load_notes_by_strategy(db, task_id)
if needs_replenish:
# 合格文案不足用户目标条数:先展示已合格的,后台异步补充(先展示后台补铁律)
# 注candidates_raw 是本轮原始生成数(含被过滤的低分条),非合格入库数;
@@ -187,24 +230,31 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
logger.error("触发后台补充失败: task_id=%s err=%s", task_id, _re)
# Step6+7+8: 图片生成 + 后处理 + 存盘
first_copy = candidates_raw[0] if candidates_raw else {}
# A/B/C 三套必须用各自 strategy 的合格文案,不再全套复用第一条文案。
settings = get_settings()
seq = run_image_generation(
db, clients, task, product_dict,
_push_event_sync, workspace_id, seq,
first_copy, settings.UPLOAD_BASE_PATH,
notes_by_strategy, settings.UPLOAD_ABS_ROOT,
regen_strategy=regen_strategy, regen_role=regen_role,
custom_prompt=custom_prompt,
flywheel_fragment=flywheel_fragment,
reuse_text=reuse_text,
)
# 最终状态 + task_done
task.status = TaskStatus.PENDING_SELECTION
db.commit()
# B1透传文案结果原因 + 实际合格数,前端据此区分"评分不可用/质量不合格/补充中/正常"
from app.models.task import TextCandidate as _TCcount
_text_saved = db.query(_TCcount).filter(_TCcount.task_id == task_id).count()
seq += 1
_push_event_sync(task_id, workspace_id, "task_done", {
"task_id": task_id, "status": "pending_selection"
"task_id": task_id, "status": "pending_selection",
"text_saved": _text_saved, "text_target": task.text_count,
"text_reason": text_fail_reason,
}, seq)
logger.info("run_generation_pipeline done: task_id=%s", task_id)
@@ -221,6 +271,11 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
t = db.query(GT).filter(GT.id == task_id).first()
if t:
t.status = TS.FAILED if exhausted else TS.GENERATING
# B6+#11:失败终态时落原因,供历史页/确认页查看(Redis error历史TTL仅1h会过期)。
# 复用 reject_reason 列(Text),前端按 status 区分展示"失败原因"/"打回原因",
# 物理不冲突(failed 不命中 rejected 展示分支),免加 Alembic 迁移。
if exhausted:
t.reject_reason = str(exc)[:2000]
db.commit()
except Exception:
pass
@@ -262,7 +317,7 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
db = _get_db()
try:
from app.models.task import GenerationTask
from app.workers.pipeline_steps import decrypt_user_key, build_clients_and_clear_key
from app.workers.pipeline_steps import decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key
from app.services.fission_pipeline import execute_fission_pipeline
src = db.query(GenerationTask).filter(GenerationTask.id == source_task_id).first()
@@ -270,8 +325,10 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
return {"fission_id": fission_id, "status": "not_found"}
plain_key = decrypt_user_key(db, src.operator_id, src.workspace_id)
clients = build_clients_and_clear_key(plain_key)
alt_key = decrypt_codeproxy_key(db, src.operator_id, src.workspace_id)
clients = build_clients_and_clear_key(plain_key, alt_key)
plain_key = None # 用完即清基石B
alt_key = None
return execute_fission_pipeline(db, clients, fission_id, source_task_id)
except Exception as exc:
@@ -320,7 +377,7 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict:
from app.models.task import GenerationTask
from app.models.product import Product
from app.workers.pipeline_steps import (
decrypt_user_key, build_clients_and_clear_key, build_product_dict,
decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key, build_product_dict,
)
from app.services.fission_image_retry import retry_fission_note_images
@@ -340,8 +397,10 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict:
# key 归属源任务 operator与首轮生图一致基石B不接收明文 key
plain_key = decrypt_user_key(db, src.operator_id, ft.workspace_id)
clients = build_clients_and_clear_key(plain_key)
alt_key = decrypt_codeproxy_key(db, src.operator_id, ft.workspace_id)
clients = build_clients_and_clear_key(plain_key, alt_key)
plain_key = None # 用完即清基石B
alt_key = None
product = build_product_dict(product_row)
image_count = max(1, (src.image_count or 3))