上线版: 产品表单统一+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:
@@ -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 Session(Celery 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))
|
||||
|
||||
Reference in New Issue
Block a user