baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

15
backend/Dockerfile Normal file
View 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
View 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_idworker内解密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
View 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
View 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
View 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()

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

View 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")

View 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")

View 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 无需清理

View 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 无需清理

View 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")

View 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",
)

View 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")

View 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")

View 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")

View 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")

View File

@@ -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: 裂变来源任务IDNULL=普通任务非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 IDNULL=普通任务非NULL=裂变产出第11环",
),
)
def downgrade():
op.drop_column("generation_tasks", "source_fission_id")
op.drop_column("generation_tasks", "benchmark_ids")

View 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
View File

View File

View 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

View File

View 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 KeyFernet 加密存储只保存后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})

View 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,
})

View 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})

View 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,
)

View 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 解密基石Bplain_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)

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

View 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 → 拿 ticket60s有效一次性
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 ticket60s 有效,仅对该 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",
},
)

View 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
View 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],
})

View 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,
})

View 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提供商枚举开放接口不硬编码

View File

View 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

View File

@@ -0,0 +1,3 @@
"""
app/core/__init__.py
"""

View 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()

View 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"]

View 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)

View 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 "****"

View File

@@ -0,0 +1,43 @@
"""
app/core/sse_ticket.py — SSE 一次性短票 (ticket) 签发与验证
倩倩姐2026-06-08拍板SSE 认证不传 JWT改传 ticket。
ticket 存 RedisTTL 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写入 RedisTTL 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

View File

@@ -0,0 +1,5 @@
# app/middleware/
中间件占位:
- workspace_guard.py # 所有接口强制注入workspace_id防串数据所有查询强制带此条件
- auth_middleware.py # JWT验签HS256每请求验签

View File

View 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

View 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_preferencesUI设置偏好
## Alembic 002 — 多租户基础(全新)
- workspace.py # workspaces
- workspace_member.py # workspace_membersuser+workspace+角色)
- user_api_key.py # user_api_keysFernet加密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_candidatessource=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

View 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",
]

View 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"),
)

View File

@@ -0,0 +1,97 @@
"""
app/models/flywheel.py — preference_events / ai_call_logs
Alembic 003ai_call_logs+ Alembic 004preference_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"),
)

View 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: 目标人群(客户输入,透传进文案/生图 promptstoryboard.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特征分析结果JSONAI解析后写入")
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
View 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 IDNULL=普通任务")
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 不用 enumstoryboard 角色名仍在演进,约束放应用层 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
)

View 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()

View 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 KeyFernet 加密)。
encrypted_key 只存密文,不存 urltoken站固定自家站
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"),
)

View File

@@ -0,0 +1,3 @@
"""
app/repositories/__init__.py
"""

View 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

View 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 # 去水印后处理(重编码+削像素水印)

View File

View File

@@ -0,0 +1,5 @@
"""
AI 引擎包
扒自:上线版 worker/src/copy.js + image.js2026-06-09
重写为 Python逻辑对照JS版防走样
"""

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

View 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

View 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": "不要夸大效果,不要硬广式价格牌",
},
}

View 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字不含标题tags3-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规则适度带表情/ tagslist3-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": "封面产品近景,内页核心卖点+真实使用场景。",
}

View 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")
]

View 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条时用产品档案冷启动

View 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 缓存 clientloop 变了就重建。
_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 兼容中转站codeproxyapiports 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) 返回可用 clientloop 变化则整池重建(避免跨 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 可能绑在已关闭的 loopCelery 多次 asyncio.runaclose 也可能报
# "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 响应提取图片 bytesb64 或 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 响应中未找到图片数据")

View 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 内部有重试退避。
返回图片 bytesPNG/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)

View 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 — 原始图片 bytesPNG/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

View 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],
}

View 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)

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

View 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 — 产品档案 dictname/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)

View 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]:
"""
按图数返回分镜角色列表(扒 getNarrativeRolesQ6对齐北哥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,
}

View 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

View 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

View 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

View 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,
}

View 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),
}

View 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 Keyopenai/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)

View 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

View File

View 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 "****"

View 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)

View 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.gatherA/B/C三策略
postprocess.py # 去水印后处理

View File

View 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"},
},
)

View 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()

View File

@@ -0,0 +1,298 @@
"""
app/workers/pipeline_io.py — 生产链 Step5-8
Step5: 文案生成generate_text_variants
Step6: 图片生成generate_storyboard_imagesasyncio.gather
Step7: 图片后处理image_postprocessor
Step8: 存 text_candidates / image_candidates → 更新状态 → 推 task_done
"""
import asyncio
import json
import logging
import os
logger = logging.getLogger(__name__)
def _resolve_image_path(img_path: str) -> str:
"""
解析产品参考图路径,兼容绝对路径(新)与历史相对路径(旧)。
新数据存绝对路径(/app/uploads/...)直接返回;
旧数据存相对路径(uploads/packages/...)锚定到 UPLOAD_ABS_ROOT 的父级,
避免 worker(cwd=/) 解析失败。
"""
if not img_path:
return ""
if os.path.isabs(img_path):
return img_path
from app.core.config import get_settings
# UPLOAD_ABS_ROOT=/app/uploads其父级 /app 是相对路径(uploads/...)的锚点
root_parent = os.path.dirname(get_settings().UPLOAD_ABS_ROOT.rstrip("/"))
return os.path.join(root_parent, img_path)
def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment: str,
push_fn, workspace_id: int, seq_start: int) -> tuple[list, int, bool]:
"""
Step5: 调 generate_text_variants → 存 TextCandidate → 推 SSE → 写 ai_call_logs。
S1: 存库前过滤——只存 passed且score>=90且banned_word_status!='hard_block' 的文案。
合格数 < task.text_count 时 needs_replenish=True由主任务发起后台补充子任务
返回 (candidates_raw, next_seq, needs_replenish)。
"""
import time
from app.services.ai_engine.text_variants import generate_text_variants
from app.models.product import BannedWord
from app.models.task import TextCandidate
from app.models.flywheel import AiCallLog
from app.models.workspace import UserApiKey
from app.constants.enums import CandidateSource, BannedWordStatus
from app.services.ai_engine.constants import QUALITY_PASS_SCORE
banned_rows = db.query(BannedWord).filter(
BannedWord.workspace_id == workspace_id
).all()
banned_dicts = [{"word": b.word, "level": b.level, "replacement": b.replacement}
for b in banned_rows]
# 查 key_id只取 id不解密不违反基石B
key_row = db.query(UserApiKey).filter(
UserApiKey.user_id == task.operator_id,
UserApiKey.workspace_id == workspace_id,
).first()
key_id = key_row.id if key_row else None
t0 = time.monotonic()
llm_success = True
try:
candidates_raw = asyncio.run(generate_text_variants(
llm_client=clients,
product=product_dict,
count=task.text_count,
banned_word_rows=banned_dicts,
flywheel_context=flywheel_fragment,
))
except Exception as exc:
llm_success = False
logger.error("generate_text_variants 失败: %s", exc)
candidates_raw = []
latency_ms = int((time.monotonic() - t0) * 1000)
# 写 ai_call_logs留痕不含明文key
try:
log = AiCallLog(
workspace_id=workspace_id,
user_id=task.operator_id,
key_id=key_id,
task_id=task.id,
provider="apiports",
model=clients._model,
call_type="text",
success=llm_success,
latency_ms=latency_ms,
)
db.add(log)
db.flush()
except Exception as log_exc:
logger.warning("ai_call_logs 写入失败(非阻断): %s", log_exc)
# S1: 存库前过滤——只存合格文案passed + score>=90 + 非hard_block
seq = seq_start
saved_count = 0
for i, c in enumerate(candidates_raw):
score = c.get("score", 0)
passed = c.get("passed", False)
bw_status = c.get("banned_word_status", "pass")
if not (passed and score >= QUALITY_PASS_SCORE and bw_status != "hard_block"):
logger.info(
"文案[%d] 过滤丢弃: passed=%s score=%s banned=%s",
i, passed, score, bw_status,
)
continue
tc = TextCandidate(
workspace_id=workspace_id,
task_id=task.id,
source=CandidateSource.AI,
angle_label=c.get("angle_label") or c.get("angle", ""),
content=json.dumps(c, ensure_ascii=False),
score_json=json.dumps(c.get("score_detail", []), ensure_ascii=False),
banned_word_status=BannedWordStatus(bw_status),
)
db.add(tc)
db.flush()
saved_count += 1
seq += 1
push_fn(task.id, workspace_id, "text_candidate", {
"candidate_id": tc.id, "angle_label": tc.angle_label,
"content": c.get("content", ""), "score": score,
}, seq)
seq += 1
push_fn(task.id, workspace_id, "text_progress", {
"done": saved_count, "total": task.text_count
}, seq)
db.commit()
# S1: 合格数不足时标记需要后台补充
needs_replenish = saved_count < task.text_count
if needs_replenish:
logger.warning(
"文案合格数不足: task_id=%s 目标=%s 实得=%s,将后台异步补充",
task.id, task.text_count, saved_count,
)
return candidates_raw, seq, needs_replenish
def run_image_generation(db, clients, task, product_dict: dict,
push_fn, workspace_id: int, seq_start: int,
first_copy: dict, upload_base_path: str) -> int:
"""
Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。
返回 next_seq。
"""
import time
from app.services.ai_engine.image_gen import generate_storyboard_images
from app.services.ai_engine.image_postprocessor import process_image
from app.models.task import ImageCandidate
from app.models.flywheel import AiCallLog
from app.models.workspace import UserApiKey
from app.constants.enums import ImageRole as IR
# 取 key_id不解密不记录明文 key
key_row = db.query(UserApiKey).filter(
UserApiKey.user_id == task.operator_id,
UserApiKey.workspace_id == workspace_id,
).first()
key_id = key_row.id if key_row else None
# TODO: 尺寸字段后续加产品级配置products 表现无 aspect_ratio 字段)
# 本轮固定 '3:4'=1024×1536与 gpt-image-2 原生尺寸一致,免后处理二次拉伸
aspect_ratio = "3:4"
# image_count=0 直接跳过(纯文案任务/测试),不空跑生图通道触发无谓失败日志。
if not task.image_count or task.image_count <= 0:
logger.info("image_count=0跳过生图: task_id=%s", task.id)
return seq_start
reference_images: list[bytes] = []
_img_path = _resolve_image_path(product_dict.get("image_path", ""))
if _img_path and os.path.isfile(_img_path):
try:
with open(_img_path, "rb") as _f:
reference_images = [_f.read()]
logger.info("产品参考图已加载:%s (%d bytes)", _img_path, len(reference_images[0]))
except Exception as _e:
logger.warning("产品参考图读取失败,退化为空列表:%s %s", _img_path, _e)
else:
logger.warning(
"product.image_path 未设置或文件不存在(%r),生图将以无参考图模式运行,"
"可能导致产品包装跑偏。", _img_path
)
# 禁降级兜底:本次产品入镜但无参考图 → 硬失败,绝不降级纯文生图(建任务已拦一道,这是防绕过)
if getattr(task, "need_product_image", True) and not reference_images:
raise ValueError(
"本次产品入镜(need_product_image=True)但未获取到产品参考图,"
"拒绝降级纯文生图。请确认产品已上传参考图。"
)
seq = seq_start
# 3套正交叙事 A/B/C每套各 image_count 张独立生图
for strategy in ("A", "B", "C"):
t0 = time.monotonic()
img_success = True
img_error_code = None
try:
image_results = asyncio.run(generate_storyboard_images(
client=clients,
note=first_copy,
product=product_dict,
image_count=task.image_count,
reference_images=reference_images or None,
strategy=strategy,
))
except Exception as exc:
img_success = False
img_error_code = type(exc).__name__
logger.error("generate_storyboard_images 套%s 失败: %s", strategy, exc)
image_results = []
latency_ms = int((time.monotonic() - t0) * 1000)
fail_count = 0
first_img_error: str | None = None
for i, img_result in enumerate(image_results):
if img_result.get("error"):
fail_count += 1
if first_img_error is None:
first_img_error = str(img_result["error"])[:32]
seq += 1
push_fn(task.id, workspace_id, "batch_failed", {
"batch": img_result["role"], "reason": img_result["error"],
"strategy": strategy, "retryable": True,
}, seq)
continue
raw_bytes = img_result["image_bytes"]
try:
processed = process_image(raw_bytes, aspect_ratio=aspect_ratio, resample_strength=1)
except Exception as e:
logger.warning("图片后处理失败,使用原图: %s", e)
processed = raw_bytes
img_dir = os.path.join(upload_base_path, str(workspace_id), str(task.id))
os.makedirs(img_dir, exist_ok=True)
filename = f"{strategy}_{i+1:02d}_{img_result['role']}.jpg"
img_path = os.path.join(img_dir, filename)
with open(img_path, "wb") as f:
f.write(processed)
img_url = f"/uploads/{workspace_id}/{task.id}/{filename}"
role_enum = IR.MAIN
try:
role_enum = IR(img_result["role"])
except ValueError:
pass
ic = ImageCandidate(
workspace_id=workspace_id,
task_id=task.id,
role=role_enum,
url=img_url,
seq=i + 1,
strategy=strategy, # 写入 A/B/C非 hardcode
)
db.add(ic)
db.flush()
seq += 1
push_fn(task.id, workspace_id, "image_candidate", {
"candidate_id": ic.id, "strategy": strategy,
"url": img_url, "role": img_result["role"],
}, seq)
seq += 1
push_fn(task.id, workspace_id, "image_progress", {
"done": i + 1, "total": task.image_count, "strategy": strategy,
}, seq)
# 写 ai_call_logs每套一条失败不阻断
actual_provider = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt")
final_error_code = first_img_error or img_error_code
try:
img_log = AiCallLog(
workspace_id=workspace_id,
user_id=task.operator_id,
key_id=key_id,
task_id=task.id,
provider=actual_provider,
call_type="image",
success=(img_success and fail_count == 0),
latency_ms=latency_ms,
error_code=final_error_code,
)
db.add(img_log)
db.flush()
except Exception as log_exc:
logger.warning("ai_call_logs(image) 套%s 写入失败(非阻断): %s", strategy, log_exc)
db.commit()
return seq

View File

@@ -0,0 +1,93 @@
"""
app/workers/pipeline_steps.py — 生产链 Step1-4
Step1: 查 DBtask/product
Step2: 查 key → Fernet 解密局部变量不传出基石B
Step3: 构建 AI clients
Step4: 推 task_started SSE + 飞轮上下文
"""
import json
import logging
logger = logging.getLogger(__name__)
def load_task_and_product(db, task_id: int):
"""Step1: 查任务 + 产品,失败返 None 或抛异常。"""
from app.models.task import GenerationTask
from app.models.product import Product
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
if not task:
logger.error("task_id=%s not found", task_id)
return None, None
product = db.query(Product).filter(Product.id == task.product_id).first()
if not product:
raise ValueError(f"product_id={task.product_id} not found")
return task, product
def decrypt_user_key(db, operator_id: int, workspace_id: int) -> str:
"""
Step2: 查 key → Fernet 解密,返回 plain_key只活在调用方局部变量
绝不打印、不持久化 plain_key基石B
"""
from app.models.workspace import UserApiKey
from app.utils.fernet_utils import decrypt_key
api_key_row = db.query(UserApiKey).filter(
UserApiKey.user_id == operator_id,
UserApiKey.workspace_id == workspace_id,
UserApiKey.provider.in_(["openai", "apiports"]), # G6坑修复接受主备通道名
).first()
if not api_key_row:
raise ValueError("用户未配置 API Key请先录入")
return decrypt_key(api_key_row.encrypted_key)
def build_clients_and_clear_key(plain_key: str):
"""
Step3: 构建 AIClientsplain_key 传入后立即由调用方置 None。
返回 clients 对象。
"""
from app.services.ai_engine.gemini_factory import build_ai_clients
return build_ai_clients(plain_key)
def build_product_dict(product) -> dict:
"""把 ORM product 转成 AI 引擎所需的 dict不含任何 key"""
return {
"name": product.name,
"category": product.category or "通用好物",
"selling_points": json.loads(product.selling_points or "[]"),
"style_tone": product.style_tone or "素人分享风",
"text_angles": json.loads(product.text_angles or "[]"),
"custom_prompt": product.custom_prompt or "",
"brand_keyword": product.brand_keyword or "", # S3: 品牌词透传进生成prompt(每条植入)
"target_audience": product.target_audience or "", # 012: 人群透传进storyboard/文案prompt
"image_path": product.image_path or "", # 产品参考图路径(前端上传后填入)
}
def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict: dict) -> tuple[str, dict]:
"""
查最近50条飞轮事件聚合偏好上下文。
返回 (prompt_fragment, full_ctx)。
"""
from app.models.flywheel import PreferenceEvent
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
recent = db.query(PreferenceEvent).filter(
PreferenceEvent.workspace_id == workspace_id,
PreferenceEvent.product_id == product_id,
).order_by(PreferenceEvent.id.desc()).limit(50).all()
events_dicts = [
{"signal_type": e.signal_type, "workspace_id": e.workspace_id,
"product_id": e.product_id, "angle_label": e.angle_label or "",
"signal_weight": e.signal_weight, "reason": e.reason or ""}
for e in recent
]
ctx = aggregate_preference_context(events_dicts, product_dict, workspace_id, product_id)
return ctx.get("prompt_fragment", ""), ctx

View File

@@ -0,0 +1,100 @@
"""
app/workers/replenish_task.py — 文案后台补充任务S1: 先展示后台补铁律)
合格文案(passed且score>=90且非hard_block)不足用户目标条数时,
run_generation_pipeline 已先展示合格的; 此任务异步补齐到目标数或达上限。
只接收 task_id(基石B)。复用 pipeline_io.run_text_generation 的生成+过滤。
"""
import logging
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# 补充上限:避免合格率低时无限补。最多补 MAX_REPLENISH_ROUNDS 轮。
MAX_REPLENISH_ROUNDS = 3
@celery_app.task(
bind=True,
name="app.workers.tasks.replenish_text_candidates",
max_retries=2,
default_retry_delay=30,
queue="generation",
)
def replenish_text_candidates(self, task_id: int) -> dict:
"""后台补充合格文案到目标条数(或达补充上限)。
每轮调用 run_text_generation 续生成,过滤后累加入库;
已合格数 >= text_count 即停; 达 MAX_REPLENISH_ROUNDS 仍不足也停(不卡用户)。
"""
from app.workers.tasks import _get_db, _push_event_sync
from app.workers.pipeline_io import run_text_generation
from app.workers.pipeline_steps import (
load_task_and_product,
decrypt_user_key,
build_clients_and_clear_key,
build_product_dict,
load_flywheel_context,
)
from app.models.task import TextCandidate
db = _get_db()
try:
task, product = load_task_and_product(db, task_id)
if not task:
logger.warning("补充任务找不到 task_id=%s", task_id)
return {"task_id": task_id, "replenished": 0, "reason": "task_not_found"}
# 已合格数(按入库的合格候选计)
existing = db.query(TextCandidate).filter(
TextCandidate.task_id == task_id
).count()
target = task.text_count
if existing >= target:
return {"task_id": task_id, "replenished": 0, "reason": "already_enough"}
workspace_id = task.workspace_id
plain_key = decrypt_user_key(db, task.operator_id, workspace_id)
clients = build_clients_and_clear_key(plain_key)
plain_key = None # 用完即清除基石B
product_dict = build_product_dict(product)
flywheel_fragment, _ = load_flywheel_context(
db, workspace_id, task.product_id, product_dict
)
seq = 9000 # 补充事件 seq 段,避开主流程
rounds = 0
# 真实入库合格数以 DB count 为准,绝不用 len(raw)(那是原始生成数含被过滤的低分条)。
# task47 实测踩坑len(raw)=2 但 saved=0(都没过90),误判达标提前停,库里仍缺。
current = existing
while current < target and rounds < MAX_REPLENISH_ROUNDS:
rounds += 1
run_text_generation(
db, clients, task, product_dict, flywheel_fragment,
_push_event_sync, workspace_id, seq,
)
# run_text_generation 内部已把本轮合格候选写库,这里重查真实库内条数
db.expire_all()
current = db.query(TextCandidate).filter(
TextCandidate.task_id == task_id
).count()
seq += 100
logger.info(
"补充轮 %d: task_id=%s 库内合格=%d/%d",
rounds, task_id, current, target,
)
return {
"task_id": task_id,
"replenished": current - existing,
"rounds": rounds,
"final": current,
"target": target,
}
except Exception as exc:
logger.error("replenish_text_candidates failed: task_id=%s err=%s", task_id, exc)
raise self.retry(exc=exc)
finally:
db.close()

View File

@@ -0,0 +1,155 @@
"""
app/workers/tasks.py — Celery 任务入口(编排层)
只接收 task_id基石B。具体步骤在 pipeline_steps / pipeline_io。
打包任务在 packaging_task.py此处 re-export 保持旧引用不变。
"""
import logging
from app.workers.celery_app import celery_app
# re-export 保持旧代码 `from app.workers.tasks import build_delivery_package` 不变
from app.workers.packaging_task import build_delivery_package # noqa: F401
from app.workers.replenish_task import replenish_text_candidates # noqa: F401
logger = logging.getLogger(__name__)
def _get_db():
"""获取同步 DB SessionCelery worker 环境用同步)"""
from app.core.database import SessionLocal
return SessionLocal()
def _push_event_sync(task_id: int, workspace_id: int, event: str, data: dict, seq: int):
"""同步推 SSE 事件worker 内部用)"""
import json as _json
import redis as redis_lib
from app.core.config import get_settings
s = get_settings()
r = redis_lib.from_url(s.REDIS_URL, decode_responses=True)
hist_key = f"sse:task:{task_id}:events"
channel = f"sse:task:{task_id}"
record = _json.dumps({"event": event, "data": data, "seq": seq,
"workspace_id": workspace_id}, ensure_ascii=False)
r.rpush(hist_key, record)
r.expire(hist_key, 3600)
r.publish(channel, record)
@celery_app.task(
bind=True,
name="app.workers.tasks.run_generation_pipeline",
max_retries=3,
default_retry_delay=30,
queue="generation",
)
def run_generation_pipeline(self, task_id: int) -> dict:
"""
生产链主任务。只接收 task_id绝不接收 key。
子步骤委托给 pipeline_steps查库/解密/飞轮上下文)
和 pipeline_io文案/图片生成+存储)。
"""
logger.info("run_generation_pipeline start: task_id=%s", task_id)
db = _get_db()
seq = 0
workspace_id = 0 # G3坑修复确保 except 里能用真实 workspace_id
try:
from app.constants.enums import TaskStatus
from app.workers.pipeline_steps import (
load_task_and_product,
decrypt_user_key,
build_clients_and_clear_key,
build_product_dict,
load_flywheel_context,
)
from app.workers.pipeline_io import run_text_generation, run_image_generation
from app.core.config import get_settings
# Step1: 查任务 + 产品
task, product = load_task_and_product(db, task_id)
if not task:
return {"task_id": task_id, "status": "not_found"}
workspace_id = task.workspace_id
# Step2: Fernet 解密(局部变量,不传出)
plain_key = decrypt_user_key(db, task.operator_id, workspace_id)
# Step3: 构建 AIClients
clients = build_clients_and_clear_key(plain_key)
plain_key = None # 用完即清除基石B
task.status = TaskStatus.GENERATING
db.commit()
# Step4: 推 task_started + 飞轮上下文
seq += 1
_push_event_sync(task_id, workspace_id, "task_started", {
"task_id": task_id, "total_text": task.text_count, "total_image": task.image_count,
}, seq)
product_dict = build_product_dict(product)
flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict)
if flywheel_fragment:
seq += 1
_push_event_sync(task_id, workspace_id, "flywheel_injected", {
"recent_preference": flywheel_ctx.get("recent_preference", ""),
"reject_reasons": flywheel_ctx.get("reject_reasons", []),
}, seq)
# Step5: 文案生成S1: 返回 needs_replenish=合格数<目标数,触发后台补充)
candidates_raw, seq, needs_replenish = run_text_generation(
db, clients, task, product_dict, flywheel_fragment,
_push_event_sync, workspace_id, seq,
)
if needs_replenish:
# 合格文案不足用户目标条数:先展示已合格的,后台异步补充(先展示后台补铁律)
# 注candidates_raw 是本轮原始生成数(含被过滤的低分条),非合格入库数;
# 真实合格数以库内 saved_count 为准,补充子任务会按库内实际缺口补到够。
logger.warning(
"文案合格数不足将后台补充: task_id=%s 本轮生成=%d 目标=%d",
task_id, len(candidates_raw), task.text_count,
)
try:
replenish_text_candidates.delay(task_id)
except Exception as _re:
logger.error("触发后台补充失败: task_id=%s err=%s", task_id, _re)
# Step6+7+8: 图片生成 + 后处理 + 存盘
first_copy = candidates_raw[0] if candidates_raw else {}
settings = get_settings()
seq = run_image_generation(
db, clients, task, product_dict,
_push_event_sync, workspace_id, seq,
first_copy, settings.UPLOAD_BASE_PATH,
)
# 最终状态 + task_done
task.status = TaskStatus.PENDING_SELECTION
db.commit()
seq += 1
_push_event_sync(task_id, workspace_id, "task_done", {
"task_id": task_id, "status": "pending_selection"
}, seq)
logger.info("run_generation_pipeline done: task_id=%s", task_id)
return {"task_id": task_id, "status": "pending_selection"}
except Exception as exc:
logger.error("run_generation_pipeline failed: task_id=%s err=%s", task_id, exc)
try:
from app.models.task import GenerationTask as GT
from app.constants.enums import TaskStatus as TS
t = db.query(GT).filter(GT.id == task_id).first()
if t:
t.status = TS.PENDING
db.commit()
except Exception:
pass
_push_event_sync(task_id, workspace_id, "error", {"code": 50001, "message": str(exc)}, seq + 1)
raise self.retry(exc=exc)
finally:
db.close()

100
backend/docker-compose.yml Normal file
View File

@@ -0,0 +1,100 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: clover_api
ports:
- "8000:8000"
env_file:
- ./.env
environment:
- DATABASE_URL=mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DB}
- MONGO_URI=mongodb://mongo:27017/clover_trace
- REDIS_URL=redis://redis:6379/0
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- .:/app
- uploads:/app/uploads
command: sh -c "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
worker:
build:
context: .
dockerfile: Dockerfile
container_name: clover_worker
env_file:
- ./.env
environment:
- DATABASE_URL=mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DB}
- MONGO_URI=mongodb://mongo:27017/clover_trace
- REDIS_URL=redis://redis:6379/0
depends_on:
- mysql
- redis
volumes:
- .:/app
- uploads:/app/uploads
command: celery -A app.workers.celery_app worker -Q generation,packaging -c 2 -l info
mysql:
image: mysql:8.0
container_name: clover_mysql
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DB}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
mongo:
image: mongo:7.0
container_name: clover_mongo
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
redis:
image: redis:7.4-alpine
container_name: clover_redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
nginx:
image: nginx:1.27-alpine
container_name: clover_nginx
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
volumes:
mysql_data:
mongo_data:
uploads:

16
backend/fire_real.py Normal file
View File

@@ -0,0 +1,16 @@
"""真实数据端到端验证:倍分子素颜霜(用完即删)"""
from app.core.database import SessionLocal
from app.models.task import GenerationTask
from app.workers.tasks import run_generation_pipeline
db = SessionLocal()
task = GenerationTask(
workspace_id=3, product_id=1, operator_id=4,
theme="倍分子素颜霜真实数据端到端", text_count=1, image_count=6,
track="ai", need_product_image=True, status="pending",
)
db.add(task); db.commit(); db.refresh(task)
tid = task.id; db.close()
print("NEW_TASK_ID=", tid)
run_generation_pipeline.delay(tid)
print("ENQUEUED")

95
backend/main.py Normal file
View File

@@ -0,0 +1,95 @@
"""
main.py — FastAPI 应用入口
统一响应包络 / 全局异常处理 / 健康检查 / 路由注册
"""
import logging
import os
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from app.core.config import get_settings
from app.core.response import CloverHTTPException, ErrorCode
logger = logging.getLogger(__name__)
settings = get_settings()
def create_app() -> FastAPI:
app = FastAPI(
title="Clover API",
version="0.1.0",
docs_url="/docs" if settings.APP_ENV != "production" else None,
redoc_url=None,
)
# ── CORS ──────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境收窄到前端域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── 全局异常处理(统一包络)──────────────────────
@app.exception_handler(CloverHTTPException)
async def clover_http_exception_handler(request: Request, exc: CloverHTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"code": exc.biz_code, "message": exc.biz_message},
)
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
logger.error("Unhandled exception: %s %s%s", request.method, request.url, exc)
return JSONResponse(
status_code=500,
content={"code": ErrorCode.SERVER_ERROR, "message": "服务器内部错误"},
)
# ── 健康检查Docker 健康检查用)──────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
# ── 路由注册(每个模块路由独立文件,这里汇总注册)─
_register_routers(app)
# ── 静态文件G4坑修复图片 /uploads 路由)──────
uploads_dir = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(uploads_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
return app
def _register_routers(app: FastAPI) -> None:
from app.api.v1.auth import router as auth_router
from app.api.v1.api_keys import router as api_keys_router
from app.api.v1.products import router as products_router
from app.api.v1.benchmarks import router as benchmarks_router
from app.api.v1.tasks import router as tasks_router
from app.api.v1.task_actions import router as task_actions_router
from app.api.v1.review import router as review_router
from app.api.v1.delivery import router as delivery_router
from app.api.v1.stream import router as stream_router
from app.api.v1.workspaces import router as workspaces_router
prefix = "/api/v1"
app.include_router(auth_router, prefix=prefix)
app.include_router(api_keys_router, prefix=prefix)
app.include_router(products_router, prefix=prefix)
app.include_router(benchmarks_router, prefix=prefix)
app.include_router(tasks_router, prefix=prefix)
app.include_router(task_actions_router, prefix=prefix)
app.include_router(review_router, prefix=prefix)
app.include_router(delivery_router, prefix=prefix)
app.include_router(stream_router, prefix=prefix)
app.include_router(workspaces_router, prefix=prefix)
app = create_app()

36
backend/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,36 @@
# nginx/nginx.conf — Clover 反向代理配置
# SSE 端点需关闭 proxy_buffering 才能实时透传
server {
listen 80;
server_name _;
# ── SSE 端点:关闭缓冲,实时透传 ──────────────────
location ~* /api/v1/tasks/[^/]+/stream {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Authorization $http_authorization;
}
# ── 普通 API 端点 ──────────────────────────────────
location /api/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization $http_authorization;
client_max_body_size 20m;
}
# ── 健康检查 ───────────────────────────────────────
location /health {
proxy_pass http://api:8000/health;
}
}

View File

@@ -0,0 +1,35 @@
# 生图/文案 Prompt 缩水诊断 + 补齐方案
> 倩倩姐 2026-06-08 提出:图质量还行但没用小红书风格 prompt怀疑缩水。
> 经逐函数对照产品包上线版(`万牛会L1准备/worker` src/copy.js + image.js确认**确实缩水**。
> banana 未使用(符合铁律:扒上线版 worker
## 一、结论先行
我之前扒的是**分镜骨架**(哪张图什么角色),漏了源头让内容"像小红书"的 prompt 内核。
文案侧、生图侧**都缩水了**。源头 image.js 871行 / copy.js 690行我的对应文件只有 203 / 201 行。
## 二、生图侧缩水清单(对照 image.js
| # | 源头有 | 位置 | 我的现状 | 影响 |
|---|--------|------|---------|------|
| G1 | STYLE_PROMPTS 风格预设(种草风/对比风/成分科普风) | image.js:26-29 | ❌ 完全没有 | 图不分风格,全一个样 |
| G2 | 叙事链路描述("6张标准种草链路点击/痛点/核心证明…" | image.js:678-682 | ❌ 没注入 | 模型不知整组叙事逻辑 |
| G3 | 短卖点 + 短标签注入图 prompt | image.js:684-686 | ❌ 没注入 | 图与卖点脱节 |
| G4 | promptFromStoryboard 逐图完整字段(卖点/文案依据/画面主体/素材使用/禁止/排版要求) | image.js:323-334 | ⚠️ 只拼了角色+目标+主文字 | 每张图约束大幅缩水 |
| G5 | buildImageStoryboard 每角色细粒度 goal/overlay/assetUse/forbidden | image.js:222-322 | ⚠️ 部分proof_strategy有逐角色细节缺 | 角色指导粗糙 |
| G6 | compose_image_prompt 仍是 TODO 占位 | 我方 prompt_composer.py:84-109 | ❌ 死代码 | 入口没接通 |
## 三、文案侧缩水清单(对照 copy.js
| # | 源头有 | 位置 | 我的现状 | 影响 |
|---|--------|------|---------|------|
| C1 | COPY_SYSTEM "小红书7步SOP+标题公式+卖点翻买点" | copy.js:10-30 | ⚠️ 我有自写版(佛系人设),非源头方法论 | 方法论不同源 |
| C2 | buildGenerationPrompt 候选池超量生成(targetCount*4)再筛 | copy.js:520-560 | ❌ 没有候选池超量机制 | 出稿多样性弱 |
| C3 | angleSlots 角度槽位按品类分配强制N条不同策略 | copy.js | ⚠️ 我有变量池ABC非角度槽位 | 角度覆盖机制不同 |
| C4 | 避开历史标题/角度previousCopies 注入) | copy.js:543-548 | ❌ 没有(飞轮有偏好但无"避重" | 重生成可能撞稿 |
| C5 | imageBrief/content 分离硬约束(配图建议不进正文) | copy.js:18 | ⚠️ 字段有,无强约束 | 正文可能混入执行提示 |
## 四、待补范围(倩倩姐拍板)
见下方 ExitPlanMode。

38
backend/requirements.txt Normal file
View File

@@ -0,0 +1,38 @@
# requirements.txt — Clover 后端依赖pinned versions
# ── Web 框架 ──────────────────────────────────────────
fastapi==0.115.5
uvicorn[standard]==0.32.1
pydantic==2.10.3
pydantic-settings==2.6.1
# ── 数据库 ───────────────────────────────────────────
sqlalchemy==2.0.36
alembic==1.14.0
pymysql==1.1.1
cryptography==44.0.0
# ── MongoDB ──────────────────────────────────────────
motor==3.6.0
# ── Redis + Celery ───────────────────────────────────
redis==5.2.1
celery==5.4.0
# ── JWT + 安全 ───────────────────────────────────────
pyjwt==2.10.1
# passlib 不带 [bcrypt] extra该 extra 会拉最新 bcrypt(5.x),覆盖下面的 pin
# 而 passlib 1.7.4 与 bcrypt 5.x 不兼容password cannot be longer than 72 bytes
# 显式锁 bcrypt 4.0.1。
passlib==1.7.4
bcrypt==4.0.1
# ── HTTP 客户端AI 调用)────────────────────────────
httpx==0.28.1
aiohttp==3.11.10
# ── 图像处理(去水印)──────────────────────────────
pillow==11.0.0
# ── 工具 ─────────────────────────────────────────────
python-multipart==0.0.19
python-dotenv==1.0.1

View File

@@ -0,0 +1,103 @@
"""真实出图验证正式留存版不带_tmp勿当临时文件清理
用法: cd Clover/backend && python3 scripts/real_image_check.py
key 只从 ../.env 读,不硬编码不打印明文。
"""
from __future__ import annotations
import asyncio, base64, io, logging, os, sys, time
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parents[2]
_env = ROOT / ".env"
if _env.exists():
for _l in _env.read_text().splitlines():
_l = _l.strip()
if _l and not _l.startswith("#") and "=" in _l:
k, _, v = _l.partition("=")
os.environ.setdefault(k.strip(), v.strip())
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("img")
TEST_PROMPT = "\n".join([
"生成一张小红书可上传的独立竖版3:4图文海报目标1024x1536不是App截图。",
"产品:倍分子素颜霜(美妆护肤·素颜霜)。",
"画面角色:封面-钩子。画面主体自然光生活场景手持素颜霜或产品在桌面前景像iPhone实拍封面。",
"图上文字:主标题「黄黑皮逆袭|伪素颜天花板」,点位「黄黑皮也能拥有的妈生好皮」。",
"核心卖点:黄黑皮提亮、伪素颜自然、不假白不卡纹、洗面奶就能卸、懒人必备。",
"成分:烟酰胺(改善暗沉)、水解珍珠(锁水保湿)、角鲨烷(不卡纹)。",
"视觉风格小红书种草风明亮干净暖色调模拟iPhone主摄浅景深。",
"重要限制禁止生成App截图/界面元素;禁止肤色变白/功效前后对比;"
"避免美白/祛斑/速效/医用等违规词;必须保持产品包装与参考图一致。",
])
_REF = ROOT / "test/output/01_连通测试.png"
async def _apiports(key, url, ref, model, size):
payload = {"model": model, "prompt": TEST_PROMPT, "n": 1, "size": size,
"image": base64.b64encode(ref).decode()}
async with httpx.AsyncClient(timeout=300.0) as c:
r = await c.post(url, json=payload, headers={"Authorization": f"Bearer {key}"})
if r.status_code != 200:
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:200]}")
return _parse(r.json())
async def _codeproxy(key, base, ref, model, size):
files = [("model", (None, model)), ("prompt", (None, TEST_PROMPT)),
("size", (None, size)), ("n", (None, "1")),
("image[]", ("ref.png", io.BytesIO(ref), "image/png"))]
async with httpx.AsyncClient(timeout=300.0) as c:
r = await c.post(f"{base}/images/edits", files=files,
headers={"Authorization": f"Bearer {key}"})
if r.status_code != 200:
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:200]}")
return _parse(r.json())
def _parse(j):
item = (j.get("data") or [{}])[0]
if "b64_json" in item:
return base64.b64decode(item["b64_json"])
if "url" in item:
rr = httpx.get(item["url"], timeout=30.0); rr.raise_for_status()
return rr.content
raise ValueError(f"无法解析: {list(item.keys())}")
async def run():
model = os.environ.get("MODEL_IMAGE", "gpt-image-2")
size = os.environ.get("IMAGE_SIZE", "1024x1536")
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "apiports").lower()
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "codeproxy").lower()
out_dir = Path(__file__).parent.parent / "verify_output"
out_dir.mkdir(parents=True, exist_ok=True)
ref = _REF.read_bytes()
t0 = time.time(); img = None; used = None
for prov in [primary, fallback]:
key = os.environ.get(f"{prov.upper()}_KEY", "")
url = os.environ.get(f"{prov.upper()}_BASE_URL", "")
if not key or not url:
continue
logger.info("[%s] 出图中 model=%s size=%s", prov, model, size)
try:
img = await (_apiports(key, url, ref, model, size) if prov == "apiports"
else _codeproxy(key, url, ref, model, size))
used = prov; break
except Exception as e:
logger.error("[%s] 失败: %s", prov, e)
dt = time.time() - t0
print("\n===== 出图结果 =====")
if img:
out = out_dir / "素颜霜_封面hook.png"
out.write_bytes(img)
print(f" provider: {used}\n 耗时: {dt:.1f}s\n 大小: {len(img)//1024}KB\n 路径: {out}")
else:
print(f" 耗时: {dt:.1f}s\n 状态: 全失败")
print("====================\n")
return img is not None
if __name__ == "__main__":
sys.exit(0 if asyncio.run(run()) else 1)

View File

@@ -0,0 +1,13 @@
2026-06-09 11:39:33,251 INFO [apiports] 出图中 model=gpt-image-2 size=1024x1536
2026-06-09 11:41:04,233 INFO HTTP Request: POST https://www.apiports.com/v1/api/generate "HTTP/1.1 200 OK"
2026-06-09 11:41:04,236 ERROR [apiports] 失败: 无法解析: []
2026-06-09 11:41:04,236 INFO [codeproxy] 出图中 model=gpt-image-2 size=1024x1536
2026-06-09 11:42:33,485 INFO HTTP Request: POST https://codeproxy.dev/v1/images/edits "HTTP/1.1 200 OK"
===== 出图结果 =====
provider: codeproxy
耗时: 181.2s
大小: 2009KB
路径: /Users/qiyu/Documents/企业培训项目/万牛会L1准备/北哥小红书产品/Clover/backend/verify_output/素颜霜_封面hook.png
====================

View File

@@ -0,0 +1,156 @@
"""
scripts/seed_data.py — 初始化北哥 workspace 种子数据
建3账号(admin/supervisor/operator) + workspace_members + 预置素颜霜 + 5条违禁词
幂等:已存在则跳过,可重复跑。
用法cd backend && python3 scripts/seed_data.py
"""
import json
import os
import sys
# 把 backend/ 加入 path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core.config import get_settings # noqa: E402 — path must be set first
from app.core.database import SessionLocal # noqa: E402
from app.models.user import User # noqa: E402
from app.models.workspace import Workspace, WorkspaceMember # noqa: E402
from app.models.product import Product, BannedWord # noqa: E402
from app.services.auth_service import hash_password # noqa: E402
get_settings() # 触发 .env 校验,缺变量早报错
WORKSPACE_NAME = "北哥小红书车间"
WORKSPACE_SLUG = "beige-xhs"
USERS = [
{"username": "admin", "email": "admin@clover.local", "password": "Clover2026!", "role": "admin"},
{"username": "supervisor", "email": "supervisor@clover.local", "password": "Clover2026!", "role": "supervisor"},
{"username": "operator", "email": "operator@clover.local", "password": "Clover2026!", "role": "operator"},
]
PRODUCT = {
"name": "素颜霜(预置样本)",
"category": "素颜霜",
"source": "preset",
"selling_points": json.dumps(["遮瑕提亮", "持妆", "养肤成分"], ensure_ascii=False),
"style_tone": "素人分享、真实不浮夸",
"text_angles": json.dumps(["痛点切入", "成分党", "使用场景", "质地肤感", "平价替代"], ensure_ascii=False),
"custom_prompt": None,
"is_active": True,
}
BANNED_WORDS = [
{"word": "美白", "level": "auto_fix", "replacement": "提亮肤色"},
{"word": "祛斑", "level": "auto_fix", "replacement": "改善暗沉"},
{"word": "速效", "level": "soft_warn", "replacement": "温和渐进"},
{"word": "医用", "level": "hard_block", "replacement": None},
{"word": "药妆", "level": "hard_block", "replacement": None},
]
def run():
db = SessionLocal()
try:
# ── workspace ──────────────────────────────────────
ws = db.query(Workspace).filter(Workspace.slug == WORKSPACE_SLUG).first()
if not ws:
ws = Workspace(name=WORKSPACE_NAME, slug=WORKSPACE_SLUG, is_active=True)
db.add(ws)
db.flush()
print(f"[+] workspace 已建: {ws.name} (id={ws.id})")
else:
print(f"[=] workspace 已存在: id={ws.id}")
# ── users + members ────────────────────────────────
for u_spec in USERS:
user = db.query(User).filter(User.username == u_spec["username"]).first()
if not user:
user = User(
username=u_spec["username"],
email=u_spec["email"],
hashed_password=hash_password(u_spec["password"]),
is_active=True,
)
db.add(user)
db.flush()
print(f"[+] user: {user.username}")
else:
print(f"[=] user 已存在: {user.username}")
mem = db.query(WorkspaceMember).filter(
WorkspaceMember.workspace_id == ws.id,
WorkspaceMember.user_id == user.id,
).first()
if not mem:
db.add(WorkspaceMember(workspace_id=ws.id, user_id=user.id, role=u_spec["role"]))
print(f" -> 绑定 role={u_spec['role']}")
# ── 预置素颜霜 ─────────────────────────────────────
prod = db.query(Product).filter(
Product.workspace_id == ws.id, Product.name == PRODUCT["name"]
).first()
if not prod:
prod = Product(workspace_id=ws.id, **PRODUCT)
db.add(prod)
print(f"[+] product: {prod.name}")
else:
print(f"[=] product 已存在: {prod.name}")
# ── 违禁词 ─────────────────────────────────────────
for bw_spec in BANNED_WORDS:
existing = db.query(BannedWord).filter(
BannedWord.workspace_id == ws.id, BannedWord.word == bw_spec["word"]
).first()
if not existing:
db.add(BannedWord(workspace_id=ws.id, **bw_spec))
print(f"[+] banned_word: {bw_spec['word']} ({bw_spec['level']})")
else:
print(f"[=] banned_word 已存在: {bw_spec['word']}")
# ── user_api_keysR3明文key只在加密瞬间用不打印不落明文──
apiports_key = os.environ.get("APIPORTS_KEY", "").strip()
if apiports_key:
from app.models.workspace import UserApiKey
from app.utils.fernet_utils import encrypt_key
for u_spec in USERS:
u = db.query(User).filter(User.username == u_spec["username"]).first()
if not u:
continue
existing_key = db.query(UserApiKey).filter(
UserApiKey.user_id == u.id,
UserApiKey.workspace_id == ws.id,
UserApiKey.provider == "apiports",
).first()
encrypted = encrypt_key(apiports_key)
last4 = apiports_key[-4:] if len(apiports_key) >= 4 else "****"
if existing_key:
existing_key.encrypted_key = encrypted
existing_key.key_last4 = last4
print(f"[=] api_key updated: {u.username} apiports ***{last4}")
else:
db.add(UserApiKey(
user_id=u.id,
workspace_id=ws.id,
provider="apiports",
encrypted_key=encrypted,
key_last4=last4,
))
print(f"[+] api_key: {u.username} apiports ***{last4}")
apiports_key = None # 明文用完即清基石B
else:
print("[!] APIPORTS_KEY 未设置,跳过 user_api_keys 录入(部署时需手动录入或重跑种子)")
db.commit()
print("\n种子数据初始化完成。")
except Exception as e:
db.rollback()
print(f"[ERROR] {e}")
raise
finally:
db.close()
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,93 @@
"""
scripts/verify_real_text_gen.py — 真实文案生成验收脚本
调 LLM 用素颜霜产品数据生成3条原文打印给北哥人工核查。
用法cd backend && python3 scripts/verify_real_text_gen.py
读取 .env 里的 CODEPROXY_BASE_URL + CODEPROXY_KEY中转站
"""
import json
import os
import sys
import httpx
from pathlib import Path
from dotenv import load_dotenv
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 加载 .env
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(env_path)
from app.services.ai_engine._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array
# 素颜霜产品数据(方法验证用,不依赖 DB
PRODUCT = {
"name": "素颜霜",
"category": "美妆护肤",
"selling_points": ["遮瑕提亮", "持妆全天", "养肤成分温和"],
"style_tone": "素人分享、真实不浮夸",
"text_angles": ["痛点切入", "成分党", "使用场景", "质地肤感", "平价替代"],
"brand_keyword": "XX素颜霜",
"custom_prompt": "",
}
COUNT = 3
def main():
# 主通道 apiports实测真实可用模型见 /v1/models
base_url = (os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
api_key = os.environ.get("APIPORTS_KEY", "")
# apiports 无 gpt-4o-mini文案用 claude-sonnet-4-5中文质量好
model = os.environ.get("MODEL_TEXT", "claude-sonnet-4-5")
if not base_url or not api_key:
print("[ERROR] .env 缺少 APIPORTS_BASE_URL 或 APIPORTS_KEY")
sys.exit(1)
user_prompt = build_prompt(PRODUCT, COUNT)
print("=" * 60)
print("SYSTEM PROMPT前300字")
print(COPY_SYSTEM[:300], "...")
print("=" * 60)
print("USER PROMPT完整")
print(user_prompt)
print("=" * 60)
print(f"调用 {base_url} 模型={model} 生成 {COUNT} 条文案...")
resp = httpx.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": model,
"messages": [
{"role": "system", "content": COPY_SYSTEM},
{"role": "user", "content": user_prompt},
],
"temperature": 0.9,
"max_tokens": 3000,
},
timeout=120,
)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"]
print("\n===== 原始输出(供北哥核查)=====")
print(raw)
copies = parse_json_array(raw)
if copies:
print(f"\n===== 解析成功,共 {len(copies)} 条 =====")
for i, c in enumerate(copies):
print(f"\n--- 第{i+1}条 [{c.get('angle','')}] ---")
print(f"标题:{c.get('title')}")
print(f"正文({len(c.get('content',''))}字):\n{c.get('content')}")
print(f"标签:{c.get('tags')}")
print(f"封面大字:{c.get('coverTitle')}")
else:
print("\n[WARN] JSON 解析失败,请人工看原始输出")
if __name__ == "__main__":
main()

26
backend/test_3sets_e2e.py Normal file
View File

@@ -0,0 +1,26 @@
"""Task#6 端到端真跑3套正交配图。建真实task(product_id=1倍分子素颜霜)。
image_count=2 → 每套2张(hook+product_closeup特写) ×3套 = 6张。
验:①strategy=A/B/C各2张 ②尺寸1024×1536 ③品牌词进特写图 ④三套图真不同。"""
import sys
sys.path.insert(0, "/app")
from app.core.database import SessionLocal
from app.models.task import GenerationTask
from app.constants.enums import TaskStatus
db = SessionLocal()
t = GenerationTask(
workspace_id=3, product_id=1, operator_id=3,
theme="伪素颜·黄黑皮提亮·3套正交",
text_count=1, image_count=2, track="ai",
need_product_image=True, status=TaskStatus.PENDING,
)
db.add(t); db.commit(); db.refresh(t)
task_id = t.id
print(f"建task成功 task_id={task_id} image_count=2 (期望3套×2=6张)")
db.close()
from app.workers.tasks import run_generation_pipeline
print(f"=== 同步执行 run_generation_pipeline({task_id}) ===")
result = run_generation_pipeline.apply(args=[task_id]).get()
print(f"=== 结果: {result} ===")

Some files were not shown because too many files have changed in this diff Show More