baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 系统依赖:curl 供 healthcheck;无需 libmysqlclient-dev(用 pymysql 纯 Python 驱动)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
30
backend/README.md
Normal file
30
backend/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Clover Backend
|
||||
|
||||
FastAPI + Celery + MySQL + MongoDB + Redis
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
backend/
|
||||
app/
|
||||
api/v1/ # 路由层(auth/products/tasks/review/delivery)
|
||||
services/ # 业务逻辑层(按模块拆分)
|
||||
models/ # SQLAlchemy ORM 模型(14张表)
|
||||
middleware/ # workspace_guard 等中间件
|
||||
workers/ # Celery 任务(只传task_id,worker内解密key)
|
||||
constants/ # 策略命名中心文件,消除打架
|
||||
utils/ # Fernet加密、SSE工具等
|
||||
alembic/
|
||||
versions/ # 001-004 migration 脚本
|
||||
tests/ # TDD 测试用例
|
||||
```
|
||||
|
||||
## 依赖(需装)
|
||||
见 requirements.txt(待 BE agent 填)
|
||||
|
||||
## 启动
|
||||
见 docker-compose.yml(待 BE agent 填)
|
||||
|
||||
## 铁律提醒
|
||||
- API Key 绝不进 Celery 参数,只传 task_id
|
||||
- FERNET_KEY 走环境变量
|
||||
- 所有查询强制带 workspace_id
|
||||
45
backend/alembic.ini
Normal file
45
backend/alembic.ini
Normal file
@@ -0,0 +1,45 @@
|
||||
# Alembic 配置文件
|
||||
# Clover 项目使用 alembic.ini 在 backend/ 目录
|
||||
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
|
||||
# 从环境变量读取,不硬编码(基石B)
|
||||
sqlalchemy.url = %(DATABASE_URL)s
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
16
backend/alembic/README.md
Normal file
16
backend/alembic/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# alembic/
|
||||
|
||||
Alembic 数据库迁移脚本占位:
|
||||
|
||||
## 执行顺序
|
||||
001 → 002 → 003 → 004(有外键依赖,必须按顺序)
|
||||
|
||||
## 版本规划
|
||||
- 001_from_banana.py # users/login_records/user_preferences(从banana搬3张,改造)
|
||||
- 002_multi_tenant_base.py # workspaces/workspace_members/user_api_keys
|
||||
- 003_business_core.py # 业务主体7张(products等)
|
||||
- 004_flywheel.py # preference_events
|
||||
|
||||
## 铁律
|
||||
- matrix_accounts / preference_profile 一期不建(二期预留)
|
||||
- Alembic 版本号统一管理,不允许手动改表
|
||||
62
backend/alembic/env.py
Normal file
62
backend/alembic/env.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
alembic/env.py — Alembic 运行环境配置
|
||||
从环境变量读取 DATABASE_URL,不硬编码。
|
||||
"""
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# ── 确保 app 包可导入 ────────────────────────────────
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401 — 触发所有模型注册
|
||||
|
||||
config = context.config
|
||||
|
||||
# 从环境变量覆盖 DATABASE_URL
|
||||
db_url = os.environ.get("DATABASE_URL")
|
||||
if db_url:
|
||||
config.set_main_option("sqlalchemy.url", db_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
29
backend/alembic/script.py.mako
Normal file
29
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
alembic/script.py.mako — 迁移文件模板
|
||||
"""
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
64
backend/alembic/versions/001_banana_users_tables.py
Normal file
64
backend/alembic/versions/001_banana_users_tables.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""001_banana_users_tables
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-06-09
|
||||
Alembic 001: 从 banana 搬 3 张表(users/login_records/user_preferences)
|
||||
删除 credits 字段,其余无改。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("username", sa.String(64), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(255), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("username"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"login_records",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("ip_address", sa.String(64), nullable=True),
|
||||
sa.Column("user_agent", sa.String(512), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_login_records_user_id", "login_records", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("preferences_json", sa.Text(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("user_preferences")
|
||||
op.drop_index("idx_login_records_user_id", "login_records")
|
||||
op.drop_table("login_records")
|
||||
op.drop_table("users")
|
||||
71
backend/alembic/versions/002_multitenant_workspace.py
Normal file
71
backend/alembic/versions/002_multitenant_workspace.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""002_multitenant_workspace
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-06-09
|
||||
Alembic 002: 多租户基础(workspaces/workspace_members/user_api_keys)
|
||||
matrix_accounts 二期预留,一期不建。
|
||||
user_api_keys 不存 url 字段(token站固定自家站,基石B)。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workspaces",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("slug", sa.String(64), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workspace_members",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("role", sa.Enum("admin", "supervisor", "operator", name="userrole"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),
|
||||
)
|
||||
op.create_index("idx_workspace_members_workspace_id", "workspace_members", ["workspace_id"])
|
||||
|
||||
op.create_table(
|
||||
"user_api_keys",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("provider", sa.String(32), nullable=False),
|
||||
sa.Column("encrypted_key", sa.String(512), nullable=False),
|
||||
sa.Column("key_last4", sa.String(4), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", "workspace_id", "provider", name="uq_user_workspace_provider"),
|
||||
)
|
||||
op.create_index("idx_user_api_keys_workspace_id", "user_api_keys", ["workspace_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_user_api_keys_workspace_id", "user_api_keys")
|
||||
op.drop_table("user_api_keys")
|
||||
op.drop_index("idx_workspace_members_workspace_id", "workspace_members")
|
||||
op.drop_table("workspace_members")
|
||||
op.drop_table("workspaces")
|
||||
op.execute("DROP TYPE IF EXISTS userrole")
|
||||
205
backend/alembic/versions/003_business_tables.py
Normal file
205
backend/alembic/versions/003_business_tables.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""003_business_tables
|
||||
|
||||
Revision ID: 003
|
||||
Revises: 002
|
||||
Create Date: 2026-06-09
|
||||
Alembic 003: 业务主体7张表 + ai_call_logs
|
||||
products / benchmark_notes / banned_words /
|
||||
generation_tasks / text_candidates / image_candidates /
|
||||
delivery_packages / ai_call_logs
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "003"
|
||||
down_revision: Union[str, None] = "002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_TASK_STATUS = sa.Enum(
|
||||
"pending", "generating", "pending_selection",
|
||||
"pending_review", "approved", "rejected", "archived",
|
||||
name="taskstatus",
|
||||
)
|
||||
_REVIEW_STATUS = sa.Enum("pending", "approved", "rejected", name="reviewstatus")
|
||||
_CANDIDATE_SOURCE = sa.Enum("ai", "import", name="candidatesource")
|
||||
_BANNED_LEVEL = sa.Enum("auto_fix", "soft_warn", "hard_block", name="bannedwordlevel")
|
||||
_BANNED_STATUS = sa.Enum("pass", "auto_fixed", "soft_warn", "hard_block", name="bannedwordstatus")
|
||||
_IMAGE_ROLE = sa.Enum("hook", "pain", "proof", "quality", "credit", "convert", "main", name="imagerole")
|
||||
_PKG_STATUS = sa.Enum("pending", "ready", "downloaded", name="packagestatus")
|
||||
_PRODUCT_SOURCE = sa.Enum("preset", "custom", name="productsource")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"products",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("category", sa.String(64), nullable=True),
|
||||
sa.Column("source", _PRODUCT_SOURCE, nullable=False, server_default="custom"),
|
||||
sa.Column("selling_points", sa.Text(), nullable=True),
|
||||
sa.Column("style_tone", sa.String(128), nullable=True),
|
||||
sa.Column("text_angles", sa.Text(), nullable=True),
|
||||
sa.Column("custom_prompt", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_products_workspace_id", "products", ["workspace_id"])
|
||||
|
||||
op.create_table(
|
||||
"benchmark_notes",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("product_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("screenshot_url", sa.String(512), nullable=True),
|
||||
sa.Column("highlights", sa.Text(), nullable=True),
|
||||
sa.Column("link_url", sa.String(512), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_benchmark_notes_product_id", "benchmark_notes", ["product_id"])
|
||||
|
||||
op.create_table(
|
||||
"banned_words",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("word", sa.String(64), nullable=False),
|
||||
sa.Column("level", _BANNED_LEVEL, nullable=False),
|
||||
sa.Column("replacement", sa.String(128), nullable=True),
|
||||
sa.Column("updatable", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_banned_words_workspace_id", "banned_words", ["workspace_id"])
|
||||
|
||||
op.create_table(
|
||||
"generation_tasks",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("product_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("operator_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("theme", sa.String(256), nullable=True),
|
||||
sa.Column("text_count", sa.Integer(), nullable=False, server_default="5"),
|
||||
sa.Column("image_count", sa.Integer(), nullable=False, server_default="3"),
|
||||
sa.Column("track", sa.String(16), nullable=False, server_default="ai"),
|
||||
sa.Column("status", _TASK_STATUS, nullable=False, server_default="pending"),
|
||||
sa.Column("mongo_trace_id", sa.String(24), nullable=True),
|
||||
sa.Column("review_status", _REVIEW_STATUS, nullable=True),
|
||||
sa.Column("reviewer_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("reject_reason", sa.Text(), nullable=True),
|
||||
sa.Column("approved_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("archived_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
|
||||
sa.ForeignKeyConstraint(["operator_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["reviewer_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_generation_tasks_workspace_status", "generation_tasks", ["workspace_id", "status"])
|
||||
|
||||
op.create_table(
|
||||
"text_candidates",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("source", _CANDIDATE_SOURCE, nullable=False, server_default="ai"),
|
||||
sa.Column("angle_label", sa.String(64), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("score_json", sa.Text(), nullable=True),
|
||||
sa.Column("banned_word_status", _BANNED_STATUS, nullable=False, server_default="pass"),
|
||||
sa.Column("eval_score", sa.Float(), nullable=True),
|
||||
sa.Column("is_selected", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_text_candidates_task_id", "text_candidates", ["task_id"])
|
||||
|
||||
op.create_table(
|
||||
"image_candidates",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("role", _IMAGE_ROLE, nullable=False, server_default="main"),
|
||||
sa.Column("url", sa.String(512), nullable=True),
|
||||
sa.Column("strategy", sa.String(4), nullable=True),
|
||||
sa.Column("seq", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("is_selected", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("eval_score", sa.Float(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_image_candidates_task_id", "image_candidates", ["task_id"])
|
||||
|
||||
op.create_table(
|
||||
"delivery_packages",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("status", _PKG_STATUS, nullable=False, server_default="pending"),
|
||||
sa.Column("package_path", sa.String(512), nullable=True),
|
||||
sa.Column("download_url", sa.String(512), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ai_call_logs",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("key_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("provider", sa.String(32), nullable=True),
|
||||
sa.Column("model", sa.String(64), nullable=True),
|
||||
sa.Column("call_type", sa.String(32), nullable=True),
|
||||
sa.Column("prompt_tokens", sa.Integer(), nullable=True),
|
||||
sa.Column("completion_tokens", sa.Integer(), nullable=True),
|
||||
sa.Column("success", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("error_code", sa.String(32), nullable=True),
|
||||
sa.Column("latency_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["key_id"], ["user_api_keys.id"]),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_ai_call_logs_workspace_user", "ai_call_logs", ["workspace_id", "user_id"])
|
||||
op.create_index("idx_ai_call_logs_task_id", "ai_call_logs", ["task_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_ai_call_logs_task_id", "ai_call_logs")
|
||||
op.drop_index("idx_ai_call_logs_workspace_user", "ai_call_logs")
|
||||
op.drop_table("ai_call_logs")
|
||||
op.drop_table("delivery_packages")
|
||||
op.drop_index("idx_image_candidates_task_id", "image_candidates")
|
||||
op.drop_table("image_candidates")
|
||||
op.drop_index("idx_text_candidates_task_id", "text_candidates")
|
||||
op.drop_table("text_candidates")
|
||||
op.drop_index("idx_generation_tasks_workspace_status", "generation_tasks")
|
||||
op.drop_table("generation_tasks")
|
||||
op.drop_index("idx_banned_words_workspace_id", "banned_words")
|
||||
op.drop_table("banned_words")
|
||||
op.drop_index("idx_benchmark_notes_product_id", "benchmark_notes")
|
||||
op.drop_table("benchmark_notes")
|
||||
op.drop_index("idx_products_workspace_id", "products")
|
||||
op.drop_table("products")
|
||||
# MySQL 不支持 DROP TYPE,枚举存 VARCHAR 无需清理
|
||||
67
backend/alembic/versions/004_flywheel_preference_events.py
Normal file
67
backend/alembic/versions/004_flywheel_preference_events.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""004_flywheel_preference_events
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-06-09
|
||||
Alembic 004: 飞轮信号表
|
||||
preference_events 一期建表写入
|
||||
preference_profile 二期预留,一期不建
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "004"
|
||||
down_revision: Union[str, None] = "003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_SIGNAL_TYPE = sa.Enum(
|
||||
"text_select", "image_select", "approve",
|
||||
"reject_with_reason", "regenerate",
|
||||
name="signaltype",
|
||||
)
|
||||
_DATA_OWNERSHIP = sa.Enum("client_data", "platform_asset", name="dataownership")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"preference_events",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("product_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("signal_type", _SIGNAL_TYPE, nullable=False),
|
||||
sa.Column("signal_weight", sa.Integer(), nullable=False),
|
||||
sa.Column("candidate_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("angle_label", sa.String(64), nullable=True),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("signal_meta", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"data_ownership", _DATA_OWNERSHIP,
|
||||
nullable=False, server_default="client_data",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(),
|
||||
server_default=sa.text("now()"), nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
|
||||
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_preference_events_ws_product_created",
|
||||
"preference_events",
|
||||
["workspace_id", "product_id", "created_at"],
|
||||
)
|
||||
# preference_profile 二期预留,一期不建
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_preference_events_ws_product_created", "preference_events")
|
||||
op.drop_table("preference_events")
|
||||
# MySQL 不支持 DROP TYPE,枚举存 VARCHAR 无需清理
|
||||
27
backend/alembic/versions/005_product_image_path.py
Normal file
27
backend/alembic/versions/005_product_image_path.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""005_product_image_path — products 表加 image_path 列(产品参考图本地路径)
|
||||
前端图片上传完成后,BE 接口写入此字段;pipeline_io 读取用于生图参考。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "005"
|
||||
down_revision = "004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column(
|
||||
"image_path",
|
||||
sa.String(512),
|
||||
nullable=True,
|
||||
comment="产品参考图本地文件路径(前端上传后写入,生图时作 reference_images 注入)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("products", "image_path")
|
||||
45
backend/alembic/versions/006_image_role_varchar.py
Normal file
45
backend/alembic/versions/006_image_role_varchar.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""006_image_role_varchar — image_candidates.role 从 enum 改 varchar(32)
|
||||
|
||||
根因:storyboard 角色名仍在演进(hook/product_closeup/ingredient/texture/
|
||||
applied_proof/closer/pain_scene/social_proof/scenario/tutorial…),固定 enum
|
||||
每加一个新角色就要迁一次,且旧 enum 漏了 product_closeup/ingredient 直接导致
|
||||
落库 "Data truncated for column 'role'" → 生图任务无限重试卡死。
|
||||
改 varchar 后落库不再受 enum 约束,角色名由 ImageRole 枚举在应用层定义。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "006"
|
||||
down_revision = "005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"image_candidates",
|
||||
"role",
|
||||
existing_type=sa.Enum(
|
||||
"hook", "pain", "proof", "quality", "credit", "convert", "main",
|
||||
name="role",
|
||||
),
|
||||
type_=sa.String(32),
|
||||
existing_nullable=False,
|
||||
server_default="hook",
|
||||
comment="分镜角色名(storyboard 输出,应用层 ImageRole 定义,不在 DB 约束)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"image_candidates",
|
||||
"role",
|
||||
existing_type=sa.String(32),
|
||||
type_=sa.Enum(
|
||||
"hook", "pain", "proof", "quality", "credit", "convert", "main",
|
||||
name="role",
|
||||
),
|
||||
existing_nullable=False,
|
||||
server_default="main",
|
||||
)
|
||||
33
backend/alembic/versions/007_need_product_image.py
Normal file
33
backend/alembic/versions/007_need_product_image.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""add need_product_image to generation_tasks
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-06-08
|
||||
|
||||
本次产品是否入镜开关:True=必须产品参考图(无图禁生成,不降级),False=允许纯文生图。
|
||||
默认 True(铁律:生图优先带产品参考图)。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "007"
|
||||
down_revision = "006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column(
|
||||
"need_product_image",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
comment="本次产品是否入镜:True需产品图(无图禁生成不降级)/False允许纯文生图",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("generation_tasks", "need_product_image")
|
||||
32
backend/alembic/versions/008_brand_keyword.py
Normal file
32
backend/alembic/versions/008_brand_keyword.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""add brand_keyword to products
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2026-06-13
|
||||
|
||||
第5环品牌词字段:客户输入,植入文案每条+生图特写图(第2/6张)。
|
||||
brand_keyword 是产品级字段,随产品固定,由客户录入(非AI生成)。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "008"
|
||||
down_revision = "007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column(
|
||||
"brand_keyword",
|
||||
sa.String(64),
|
||||
nullable=True,
|
||||
comment="品牌词(客户输入,植入文案每条+生图特写图第2/6张)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("products", "brand_keyword")
|
||||
44
backend/alembic/versions/009_benchmark_analyze_fields.py
Normal file
44
backend/alembic/versions/009_benchmark_analyze_fields.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""add features_json and analyze_status to benchmark_notes
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-06-13
|
||||
|
||||
第2环标杆分析字段:
|
||||
- features_json: 存储8特征结构化分析结果(TEXT JSON)
|
||||
- analyze_status: AI分析状态机 pending/analyzing/done/failed
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "009"
|
||||
down_revision = "008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"benchmark_notes",
|
||||
sa.Column(
|
||||
"features_json",
|
||||
sa.Text,
|
||||
nullable=True,
|
||||
comment="爆款8特征分析结果(JSON),第2环AI解析后写入",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"benchmark_notes",
|
||||
sa.Column(
|
||||
"analyze_status",
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
comment="AI分析状态: pending/analyzing/done/failed",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("benchmark_notes", "analyze_status")
|
||||
op.drop_column("benchmark_notes", "features_json")
|
||||
70
backend/alembic/versions/010_fission_tasks_table.py
Normal file
70
backend/alembic/versions/010_fission_tasks_table.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""create fission_tasks table
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2026-06-13
|
||||
|
||||
第11环裂变引擎:1爆款→N套完整笔记包(非只换文案)。
|
||||
fission_tasks 是裂变任务主表,挂载在工作台内呈现。
|
||||
reference_level: low/mid/high(参考程度)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "010"
|
||||
down_revision = "009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"fission_tasks",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"workspace_id",
|
||||
sa.BigInteger,
|
||||
sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="多租户隔离",
|
||||
),
|
||||
sa.Column(
|
||||
"source_note",
|
||||
sa.Text,
|
||||
nullable=True,
|
||||
comment="爆款源笔记内容(文案+图描述,JSON存储)",
|
||||
),
|
||||
sa.Column(
|
||||
"reference_level",
|
||||
sa.String(10),
|
||||
nullable=False,
|
||||
server_default="mid",
|
||||
comment="参考程度: low/mid/high",
|
||||
),
|
||||
sa.Column(
|
||||
"fanout_count",
|
||||
sa.Integer,
|
||||
nullable=False,
|
||||
server_default="3",
|
||||
comment="裂变套数(默认3套)",
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
comment="状态: pending/generating/done/failed",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime,
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("idx_fission_tasks_workspace_id", "fission_tasks", ["workspace_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("idx_fission_tasks_workspace_id", table_name="fission_tasks")
|
||||
op.drop_table("fission_tasks")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add benchmark_ids and source_fission_id to generation_tasks
|
||||
|
||||
Revision ID: 011
|
||||
Revises: 010
|
||||
Create Date: 2026-06-13
|
||||
|
||||
合并第2环S12+第11环两个需求:
|
||||
- benchmark_ids: 关联标杆笔记ID列表(TEXT存JSON list,第2环标杆分析引用)
|
||||
- source_fission_id: 裂变来源任务ID(NULL=普通任务,非NULL=裂变产出,第11环)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "011"
|
||||
down_revision = "010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column(
|
||||
"benchmark_ids",
|
||||
sa.Text,
|
||||
nullable=True,
|
||||
comment="关联标杆笔记ID列表(JSON list),第2环标杆分析引用",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column(
|
||||
"source_fission_id",
|
||||
sa.Integer,
|
||||
nullable=True,
|
||||
comment="裂变来源fission_task ID;NULL=普通任务,非NULL=裂变产出(第11环)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("generation_tasks", "source_fission_id")
|
||||
op.drop_column("generation_tasks", "benchmark_ids")
|
||||
29
backend/alembic/versions/012_product_target_audience.py
Normal file
29
backend/alembic/versions/012_product_target_audience.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""012 products 表加 target_audience 字段
|
||||
|
||||
Revision ID: 012_product_target_audience
|
||||
Revises: 011_task_benchmark_fission_fields
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "012"
|
||||
down_revision = "011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column(
|
||||
"target_audience",
|
||||
sa.String(128),
|
||||
nullable=True,
|
||||
comment="目标人群,客户输入,透传进文案/生图prompt",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("products", "target_audience")
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
11
backend/app/api/v1/README.md
Normal file
11
backend/app/api/v1/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# app/api/v1/
|
||||
|
||||
路由层占位,BE agent 按模块创建:
|
||||
- auth.py # 登录/me/切workspace
|
||||
- api_keys.py # API Key录入/列表/删除
|
||||
- products.py # 产品档案CRUD
|
||||
- benchmarks.py # 标杆笔记CRUD(挂在products下)
|
||||
- tasks.py # 生产任务(发起/列表/详情/SSE/选候选)
|
||||
- review.py # 组长审核(队列/通过/打回)
|
||||
- delivery.py # 交付包生成+下载
|
||||
- banned_words.py # 违禁词库CRUD
|
||||
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
130
backend/app/api/v1/api_keys.py
Normal file
130
backend/app/api/v1/api_keys.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
app/api/v1/api_keys.py — API Key 路由
|
||||
只录不显余额(CLAUDE.md 红线)。
|
||||
只返回 provider + key_last4,不返回 encrypted_key。
|
||||
url 字段不接收,token站固定自家站(架构方案§二)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_not_found
|
||||
from app.core.security import encrypt_api_key, mask_api_key
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.workspace import UserApiKey
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
|
||||
|
||||
|
||||
# ── DTO ────────────────────────────────────────────────────
|
||||
class CreateApiKeyRequest(BaseModel):
|
||||
provider: str
|
||||
api_key: str
|
||||
|
||||
@field_validator("provider")
|
||||
@classmethod
|
||||
def provider_not_empty(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("provider 不能为空")
|
||||
return v.strip().lower()
|
||||
|
||||
@field_validator("api_key")
|
||||
@classmethod
|
||||
def key_not_empty(cls, v: str) -> str:
|
||||
if not v or len(v) < 8:
|
||||
raise ValueError("api_key 无效")
|
||||
return v
|
||||
# 注:不接收 url 字段(token站固定自家站,基石B)
|
||||
|
||||
|
||||
def _format_key(k: UserApiKey) -> dict:
|
||||
"""只显 provider + key_last4,不暴露余额/用量/encrypted_key(红线)。"""
|
||||
return {
|
||||
"id": k.id,
|
||||
"provider": k.provider,
|
||||
"key_last4": k.key_last4,
|
||||
"created_at": k.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── 路由 ───────────────────────────────────────────────────
|
||||
@router.get("")
|
||||
def list_api_keys(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出当前用户在此 workspace 的 API Key(只显后4位)。"""
|
||||
keys = (
|
||||
db.query(UserApiKey)
|
||||
.filter(
|
||||
UserApiKey.user_id == current_user.user_id,
|
||||
UserApiKey.workspace_id == current_user.workspace_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return ok({"items": [_format_key(k) for k in keys]})
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_api_key(
|
||||
body: CreateApiKeyRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""录入 API Key(Fernet 加密存储,只保存后4位明文用于展示)。"""
|
||||
from app.core.response import raise_business
|
||||
# 检查同 user+workspace+provider 是否已有
|
||||
existing = (
|
||||
db.query(UserApiKey)
|
||||
.filter(
|
||||
UserApiKey.user_id == current_user.user_id,
|
||||
UserApiKey.workspace_id == current_user.workspace_id,
|
||||
UserApiKey.provider == body.provider,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise_business(f"已存在 {body.provider} 的 API Key,请先删除再录入")
|
||||
|
||||
encrypted = encrypt_api_key(body.api_key)
|
||||
key_obj = UserApiKey(
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=current_user.workspace_id,
|
||||
provider=body.provider,
|
||||
encrypted_key=encrypted,
|
||||
key_last4=mask_api_key(body.api_key),
|
||||
)
|
||||
db.add(key_obj)
|
||||
db.commit()
|
||||
db.refresh(key_obj)
|
||||
logger.info("API key created: user=%s provider=%s", current_user.user_id, body.provider)
|
||||
return ok(_format_key(key_obj))
|
||||
|
||||
|
||||
@router.delete("/{key_id}")
|
||||
def delete_api_key(
|
||||
key_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除 API Key(只能删自己的)。"""
|
||||
key_obj = (
|
||||
db.query(UserApiKey)
|
||||
.filter(
|
||||
UserApiKey.id == key_id,
|
||||
UserApiKey.user_id == current_user.user_id,
|
||||
UserApiKey.workspace_id == current_user.workspace_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not key_obj:
|
||||
raise_not_found("API Key 不存在")
|
||||
db.delete(key_obj)
|
||||
db.commit()
|
||||
return ok({"deleted": key_id})
|
||||
69
backend/app/api/v1/auth.py
Normal file
69
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
app/api/v1/auth.py — 认证路由
|
||||
路由层只做:参数校验 → 调 service → 格式化响应(不含业务逻辑)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_unauthorized
|
||||
from app.core.security import create_access_token, decode_access_token
|
||||
from app.middleware.workspace_guard import CurrentUser, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.workspace import Workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
# ── DTO ────────────────────────────────────────────────────
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@field_validator("username", "password")
|
||||
@classmethod
|
||||
def not_empty(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("不能为空")
|
||||
return v
|
||||
|
||||
|
||||
# ── 路由 ───────────────────────────────────────────────────
|
||||
@router.post("/login")
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""登录,返回 JWT。密码校验在 service 层(此处调用)。"""
|
||||
from app.services.auth_service import authenticate_user, build_user_response
|
||||
user, workspace_id, role = authenticate_user(db, body.username, body.password)
|
||||
token = create_access_token(user.id, workspace_id, role)
|
||||
return ok({
|
||||
"token": token,
|
||||
"user": build_user_response(user, workspace_id, role),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_me(
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""当前用户信息 + workspace + role。"""
|
||||
user = db.query(User).filter(User.id == current_user.user_id).first()
|
||||
if not user:
|
||||
raise_unauthorized("用户不存在")
|
||||
ws = db.query(Workspace).filter(Workspace.id == current_user.workspace_id).first()
|
||||
return ok({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"current_workspace_id": current_user.workspace_id,
|
||||
"role": current_user.role,
|
||||
"workspace": {
|
||||
"id": ws.id, "name": ws.name, "slug": ws.slug,
|
||||
} if ws else None,
|
||||
})
|
||||
143
backend/app/api/v1/benchmarks.py
Normal file
143
backend/app/api/v1/benchmarks.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
app/api/v1/benchmarks.py — 标杆笔记 + 违禁词路由(管理员)
|
||||
主通道:截图+手填亮点(不做原始抓取)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, paginate, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
||||
from app.models.product import BannedWord, BenchmarkNote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["products"])
|
||||
|
||||
|
||||
class BenchmarkCreate(BaseModel):
|
||||
screenshot_url: str | None = None
|
||||
highlights: str | None = None # 手填亮点(主通道)
|
||||
link_url: str | None = None # 可选,不做自动抓取
|
||||
|
||||
|
||||
class BannedWordCreate(BaseModel):
|
||||
word: str
|
||||
level: str # auto_fix | soft_warn | hard_block
|
||||
replacement: str | None = None
|
||||
updatable: bool = True
|
||||
|
||||
|
||||
def _fmt_benchmark(b: BenchmarkNote) -> dict:
|
||||
return {
|
||||
"id": b.id, "product_id": b.product_id,
|
||||
"screenshot_url": b.screenshot_url,
|
||||
"highlights": b.highlights, "link_url": b.link_url,
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _fmt_banned(bw: BannedWord) -> dict:
|
||||
return {
|
||||
"id": bw.id, "word": bw.word, "level": bw.level,
|
||||
"replacement": bw.replacement, "updatable": bw.updatable,
|
||||
"workspace_id": bw.workspace_id,
|
||||
}
|
||||
|
||||
|
||||
# ── 标杆笔记 ────────────────────────────────────────────────
|
||||
@router.get("/products/{product_id}/benchmarks")
|
||||
def list_benchmarks(
|
||||
product_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
items = (
|
||||
db.query(BenchmarkNote)
|
||||
.filter(
|
||||
BenchmarkNote.product_id == product_id,
|
||||
BenchmarkNote.workspace_id == current_user.workspace_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return ok({"items": [_fmt_benchmark(b) for b in items]})
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/benchmarks")
|
||||
def create_benchmark(
|
||||
product_id: int, body: BenchmarkCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
b = BenchmarkNote(
|
||||
workspace_id=current_user.workspace_id,
|
||||
product_id=product_id,
|
||||
screenshot_url=body.screenshot_url,
|
||||
highlights=body.highlights,
|
||||
link_url=body.link_url,
|
||||
)
|
||||
db.add(b); db.commit(); db.refresh(b)
|
||||
return ok(_fmt_benchmark(b))
|
||||
|
||||
|
||||
# ── 违禁词库 ────────────────────────────────────────────────
|
||||
@router.get("/banned-words")
|
||||
def list_banned_words(
|
||||
page: int = 1, page_size: int = 50,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = db.query(BannedWord).filter(BannedWord.workspace_id == current_user.workspace_id)
|
||||
total = q.count()
|
||||
items = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return ok(paginate([_fmt_banned(bw) for bw in items], total, page, page_size))
|
||||
|
||||
|
||||
@router.post("/banned-words")
|
||||
def create_banned_word(
|
||||
body: BannedWordCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bw = BannedWord(
|
||||
workspace_id=current_user.workspace_id,
|
||||
word=body.word, level=body.level,
|
||||
replacement=body.replacement, updatable=body.updatable,
|
||||
)
|
||||
db.add(bw); db.commit(); db.refresh(bw)
|
||||
return ok(_fmt_banned(bw))
|
||||
|
||||
|
||||
@router.put("/banned-words/{word_id}")
|
||||
def update_banned_word(
|
||||
word_id: int, body: BannedWordCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
|
||||
if not bw:
|
||||
raise_not_found("违禁词不存在")
|
||||
if not bw.updatable:
|
||||
from app.core.response import raise_forbidden
|
||||
raise_forbidden("该违禁词不可修改")
|
||||
bw.word = body.word; bw.level = body.level
|
||||
bw.replacement = body.replacement; bw.updatable = body.updatable
|
||||
db.commit(); db.refresh(bw)
|
||||
return ok(_fmt_banned(bw))
|
||||
|
||||
|
||||
@router.delete("/banned-words/{word_id}")
|
||||
def delete_banned_word(
|
||||
word_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
|
||||
if not bw:
|
||||
raise_not_found("违禁词不存在")
|
||||
db.delete(bw); db.commit()
|
||||
return ok({"deleted": word_id})
|
||||
184
backend/app/api/v1/delivery.py
Normal file
184
backend/app/api/v1/delivery.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
app/api/v1/delivery.py — 交付包路由
|
||||
POST /tasks/{id}/package — 生成达人素材交付包
|
||||
GET /delivery-packages/{id}/download — 下载(status: pending/ready/downloaded)
|
||||
POST /delivery-packages/{id}/download-token — 签发60s一次性下载令牌(C1坑修复)
|
||||
GET /delivery-packages/{id}/download-file?token= — 带令牌下载,前端可用 window.open
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.task import DeliveryPackage, GenerationTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["delivery"])
|
||||
|
||||
|
||||
class PackageRequest(BaseModel):
|
||||
note_ids: list[int] = [] # 可选,指定要打包的内容
|
||||
|
||||
|
||||
def _fmt_package(pkg: DeliveryPackage) -> dict:
|
||||
return {
|
||||
"id": pkg.id,
|
||||
"status": pkg.status,
|
||||
"download_url": pkg.download_url,
|
||||
"expires_at": pkg.expires_at.isoformat() if pkg.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/package")
|
||||
def create_package(
|
||||
task_id: int, body: PackageRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
生成达人素材交付包。
|
||||
任务推入 Celery build_delivery_package 队列(只传 package_id)。
|
||||
"""
|
||||
task = db.query(GenerationTask).filter(
|
||||
GenerationTask.id == task_id,
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not task:
|
||||
raise_not_found("任务不存在")
|
||||
if task.status not in ("approved", "pending_selection"):
|
||||
from app.core.response import raise_business
|
||||
raise_business("任务尚未生成完成,无法打包")
|
||||
|
||||
pkg = DeliveryPackage(
|
||||
workspace_id=current_user.workspace_id,
|
||||
task_id=task_id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(pkg)
|
||||
db.commit()
|
||||
db.refresh(pkg)
|
||||
|
||||
# 推 Celery 队列,只传 package_id(基石B思路:不传任何敏感信息)
|
||||
from app.workers.tasks import build_delivery_package
|
||||
build_delivery_package.delay(pkg.id)
|
||||
|
||||
return ok({"delivery_package_id": pkg.id, "status": "pending"})
|
||||
|
||||
|
||||
@router.get("/delivery-packages/{package_id}/download")
|
||||
def get_package_download(
|
||||
package_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""下载交付包(status: pending/ready/downloaded)。"""
|
||||
pkg = db.query(DeliveryPackage).filter(
|
||||
DeliveryPackage.id == package_id,
|
||||
DeliveryPackage.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not pkg:
|
||||
raise_not_found("交付包不存在")
|
||||
|
||||
if pkg.status == "ready" and pkg.download_url:
|
||||
# 标记为 downloaded(只能下一次,防重复公开 URL)
|
||||
pkg.status = "downloaded"
|
||||
db.commit()
|
||||
|
||||
return ok(_fmt_package(pkg))
|
||||
|
||||
|
||||
@router.post("/delivery-packages/{package_id}/download-token")
|
||||
def create_download_token(
|
||||
package_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
C1坑修复:签发一次性下载令牌(60s有效)。
|
||||
前端先用 JWT 调此接口,再用 token 直接 window.open/fetch,无需在URL里传JWT。
|
||||
"""
|
||||
import secrets
|
||||
import redis as sync_redis
|
||||
from app.core.config import get_settings
|
||||
|
||||
pkg = db.query(DeliveryPackage).filter(
|
||||
DeliveryPackage.id == package_id,
|
||||
DeliveryPackage.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not pkg:
|
||||
raise_not_found("交付包不存在")
|
||||
if pkg.status not in ("ready", "downloaded"):
|
||||
from app.core.response import raise_business
|
||||
raise_business("交付包尚未准备好")
|
||||
|
||||
token = secrets.token_hex(32)
|
||||
r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True)
|
||||
r.setex(f"dl_token:{token}", 60, str(package_id))
|
||||
return ok({"token": token, "expires_in": 60})
|
||||
|
||||
|
||||
@router.get("/delivery-packages/{package_id}/download-file")
|
||||
def download_package_file(
|
||||
package_id: int,
|
||||
token: str = Query(default=""),
|
||||
authorization: str = Header(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
直接下载交付包 zip 文件(FileResponse)。
|
||||
支持两种认证:
|
||||
1. ?token=<download-token>(前端 window.open 用,C1坑修复)
|
||||
2. Authorization: Bearer <JWT>(API 调用用)
|
||||
"""
|
||||
import os
|
||||
import redis as sync_redis
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import decode_access_token
|
||||
import jwt as pyjwt
|
||||
|
||||
# token 认证(一次性,60s有效,跳过JWT)
|
||||
if token:
|
||||
r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True)
|
||||
stored = r.getdel(f"dl_token:{token}")
|
||||
if not stored or int(stored) != package_id:
|
||||
from app.core.response import raise_business
|
||||
raise_business("下载令牌无效或已过期")
|
||||
pkg = db.query(DeliveryPackage).filter(DeliveryPackage.id == package_id).first()
|
||||
else:
|
||||
# JWT 认证
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
from app.core.response import raise_unauthorized
|
||||
raise_unauthorized("缺少认证信息")
|
||||
try:
|
||||
payload = decode_access_token(authorization.split(" ", 1)[1])
|
||||
except (pyjwt.PyJWTError, Exception):
|
||||
from app.core.response import raise_unauthorized
|
||||
raise_unauthorized("Token 无效")
|
||||
workspace_id = int(payload["current_workspace_id"])
|
||||
pkg = db.query(DeliveryPackage).filter(
|
||||
DeliveryPackage.id == package_id,
|
||||
DeliveryPackage.workspace_id == workspace_id,
|
||||
).first()
|
||||
|
||||
if not pkg:
|
||||
raise_not_found("交付包不存在")
|
||||
if pkg.status not in ("ready", "downloaded"):
|
||||
from app.core.response import raise_business
|
||||
raise_business("交付包尚未准备好,请稍后重试")
|
||||
if not pkg.package_path or not os.path.exists(pkg.package_path):
|
||||
from app.core.response import raise_business
|
||||
raise_business("交付包文件不存在,请重新打包")
|
||||
|
||||
filename = os.path.basename(pkg.package_path)
|
||||
return FileResponse(
|
||||
path=pkg.package_path,
|
||||
media_type="application/zip",
|
||||
filename=filename,
|
||||
)
|
||||
308
backend/app/api/v1/products.py
Normal file
308
backend/app/api/v1/products.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
app/api/v1/products.py — 品牌库路由(管理员)
|
||||
products / benchmark_notes / banned_words
|
||||
category 是纯数据字段,不在代码里做枚举(基石A)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, paginate, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["products"])
|
||||
|
||||
|
||||
# ── DTO ────────────────────────────────────────────────────
|
||||
class ProductCreate(BaseModel):
|
||||
name: str
|
||||
category: str | None = None # 纯数据字段,不做枚举(基石A)
|
||||
source: str = "custom" # preset | custom
|
||||
selling_points: list[str] = []
|
||||
style_tone: str | None = None
|
||||
text_angles: list[str] = [] # 用户设定,不写死(基石A)
|
||||
custom_prompt: str | None = None # 等北哥方案注入
|
||||
banned_word_ids: list[int] = []
|
||||
image_path: str | None = None # 产品参考图(可建档即带;通常走 upload-image 接口)
|
||||
brand_keyword: str | None = None # 品牌词,客户录入,012:套2字段暴露
|
||||
target_audience: str | None = None # 目标人群,客户录入,012:套2字段暴露
|
||||
|
||||
|
||||
class BenchmarkCreate(BaseModel):
|
||||
screenshot_url: str | None = None
|
||||
highlights: str | None = None
|
||||
link_url: str | None = None
|
||||
|
||||
|
||||
class BannedWordCreate(BaseModel):
|
||||
word: str
|
||||
level: str # auto_fix | soft_warn | hard_block
|
||||
replacement: str | None = None
|
||||
updatable: bool = True
|
||||
|
||||
|
||||
def _fmt_product(p: Product) -> dict:
|
||||
import json
|
||||
return {
|
||||
"id": p.id, "name": p.name, "category": p.category,
|
||||
"source": p.source, "is_active": p.is_active,
|
||||
"selling_points": json.loads(p.selling_points) if p.selling_points else [],
|
||||
"style_tone": p.style_tone,
|
||||
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
|
||||
"custom_prompt": p.custom_prompt,
|
||||
"image_path": p.image_path,
|
||||
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
|
||||
"target_audience": p.target_audience, # 012: 套2字段暴露
|
||||
"created_at": p.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── 产品档案 ────────────────────────────────────────────────
|
||||
@router.get("/products")
|
||||
def list_products(
|
||||
page: int = 1, page_size: int = 20, source: str | None = None,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = db.query(Product).filter(Product.workspace_id == current_user.workspace_id, Product.is_active == True)
|
||||
if source in ("preset", "custom"):
|
||||
q = q.filter(Product.source == source)
|
||||
total = q.count()
|
||||
items = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return ok(paginate([_fmt_product(p) for p in items], total, page, page_size))
|
||||
|
||||
|
||||
@router.post("/products")
|
||||
def create_product(
|
||||
body: ProductCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
p = Product(
|
||||
workspace_id=current_user.workspace_id,
|
||||
name=body.name, category=body.category, source=body.source,
|
||||
selling_points=json.dumps(body.selling_points, ensure_ascii=False),
|
||||
style_tone=body.style_tone,
|
||||
text_angles=json.dumps(body.text_angles, ensure_ascii=False),
|
||||
custom_prompt=body.custom_prompt,
|
||||
image_path=body.image_path or None,
|
||||
brand_keyword=body.brand_keyword or None, # 012: 套2字段
|
||||
target_audience=body.target_audience or None, # 012: 套2字段
|
||||
)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return ok(_fmt_product(p))
|
||||
|
||||
|
||||
@router.get("/products/{product_id}")
|
||||
def get_product(
|
||||
product_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
return ok(_fmt_product(p))
|
||||
|
||||
|
||||
@router.put("/products/{product_id}")
|
||||
def update_product(
|
||||
product_id: int, body: ProductCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
p.name = body.name; p.category = body.category; p.source = body.source
|
||||
p.selling_points = json.dumps(body.selling_points, ensure_ascii=False)
|
||||
p.style_tone = body.style_tone
|
||||
p.text_angles = json.dumps(body.text_angles, ensure_ascii=False)
|
||||
p.custom_prompt = body.custom_prompt
|
||||
p.brand_keyword = body.brand_keyword or None # 012: 套2字段
|
||||
p.target_audience = body.target_audience or None # 012: 套2字段
|
||||
db.commit(); db.refresh(p)
|
||||
return ok(_fmt_product(p))
|
||||
|
||||
|
||||
@router.delete("/products/{product_id}")
|
||||
def delete_product(
|
||||
product_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
p.is_active = False # 软删
|
||||
db.commit()
|
||||
return ok({"deleted": product_id})
|
||||
|
||||
|
||||
# ── 产品参考图上传 ──────────────────────────────────────────
|
||||
_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/upload-image")
|
||||
async def upload_product_image(
|
||||
product_id: int,
|
||||
file: UploadFile = File(...),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
上传产品参考图(铁律:生图必须带产品图)。
|
||||
文件类型:JPEG/PNG/WebP;大小上限 10 MB。
|
||||
存储路径:uploads/products/{workspace_id}/{product_id}/{filename}
|
||||
写回 product.image_path,生图管道读此字段构建 reference_images。
|
||||
"""
|
||||
from app.core.response import raise_business
|
||||
from app.core.config import get_settings
|
||||
import os, uuid
|
||||
|
||||
p = db.query(Product).filter(
|
||||
Product.id == product_id,
|
||||
Product.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
|
||||
if file.content_type not in _ALLOWED_CONTENT_TYPES:
|
||||
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_SIZE_BYTES:
|
||||
raise_business("文件超过 10 MB 限制")
|
||||
|
||||
settings = get_settings()
|
||||
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
|
||||
# 存绝对路径:锚定 StaticFiles 根 /app/uploads,避免 worker(cwd=/) 读不到。
|
||||
abs_dir = os.path.join(settings.UPLOAD_ABS_ROOT, "products",
|
||||
str(current_user.workspace_id), str(product_id))
|
||||
os.makedirs(abs_dir, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
save_path = os.path.join(abs_dir, filename)
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
p.image_path = save_path # 绝对路径,worker 直接 open 可读
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path)
|
||||
return ok(_fmt_product(p))
|
||||
|
||||
|
||||
# ── 套1 产品图视觉分析 ────────────────────────────────────────
|
||||
_VISION_PROMPT = """你是小红书产品种草策划。请根据产品图分析卖点与人群。
|
||||
硬性规则:只分析本次上传图片,不沿用历史案例,不默认护肤品。
|
||||
返回JSON(仅JSON,无其他文字):
|
||||
{
|
||||
"productName": "从包装识别,识别不到填空",
|
||||
"category": "美妆护肤/个护护理/食品饮品/营养健康/家居生活/服饰穿搭/电商产品之一",
|
||||
"sellingPoints": ["转成用户买点,不要品牌空话,3-6个"],
|
||||
"targetAudience": "一句话描述核心人群",
|
||||
"scenarios": ["使用场景,2-4个"],
|
||||
"keywords": ["小红书搜索关键词,3-5个"],
|
||||
"bannedWords": ["合规禁用词"],
|
||||
"imageDirection": "这组图如何拍/排版,一句话",
|
||||
"confidence": 0.9,
|
||||
"source": "vision"
|
||||
}"""
|
||||
|
||||
|
||||
def _parse_vision_json(raw: str) -> dict:
|
||||
"""从模型返回文本中提取JSON(容错 markdown ```json 代码块)"""
|
||||
import json, re
|
||||
# 去掉 markdown 代码块包裹
|
||||
cleaned = re.sub(r"```json\s*", "", raw)
|
||||
cleaned = re.sub(r"```\s*", "", cleaned)
|
||||
# 提取第一个 {...} 块
|
||||
m = re.search(r"\{[\s\S]*\}", cleaned)
|
||||
if not m:
|
||||
raise ValueError("vision 返回内容中未找到 JSON")
|
||||
return json.loads(m.group())
|
||||
|
||||
|
||||
def _fallback_analysis(product_name: str = "") -> dict:
|
||||
"""vision 失败时的文字兜底,保证接口不空手返回"""
|
||||
return {
|
||||
"productName": product_name or "产品",
|
||||
"category": "电商产品",
|
||||
"sellingPoints": ["使用方便", "适合日常场景"],
|
||||
"targetAudience": "有明确使用场景和效率诉求的人群",
|
||||
"scenarios": ["日常使用", "通勤出门"],
|
||||
"keywords": [product_name or "好物", "真实测评", "种草分享"],
|
||||
"bannedWords": ["美白", "祛斑", "速效", "医用", "药妆"],
|
||||
"imageDirection": "产品白底图保证准确,真实场景图突出使用体验",
|
||||
"confidence": 0.45,
|
||||
"source": "fallback",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/analyze-image")
|
||||
async def analyze_product_image(
|
||||
product_id: int,
|
||||
file: UploadFile = File(...),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
套1:上传产品图 → GPT vision 读图 → 返回结构化卖点/人群分析。
|
||||
key 链路:从当前用户 API key 解密(基石B);plain_key 只活在局部变量。
|
||||
"""
|
||||
from app.core.response import raise_business
|
||||
from app.models.workspace import UserApiKey
|
||||
from app.utils.fernet_utils import decrypt_key
|
||||
from app.services.ai_engine.gemini_factory import build_ai_clients
|
||||
|
||||
# 文件校验(复用 upload_product_image 同款规则)
|
||||
if file.content_type not in _ALLOWED_CONTENT_TYPES:
|
||||
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_SIZE_BYTES:
|
||||
raise_business("文件超过 10 MB 限制")
|
||||
|
||||
# key 解密(照抄 pipeline_steps.decrypt_user_key,基石B)
|
||||
api_key_row = db.query(UserApiKey).filter(
|
||||
UserApiKey.user_id == current_user.user_id,
|
||||
UserApiKey.workspace_id == current_user.workspace_id,
|
||||
UserApiKey.provider.in_(["openai", "apiports"]),
|
||||
).first()
|
||||
if not api_key_row:
|
||||
raise_business("未配置 API Key,请先在设置中录入")
|
||||
plain_key = decrypt_key(api_key_row.encrypted_key)
|
||||
|
||||
clients = build_ai_clients(plain_key)
|
||||
plain_key = None # 立即清零,不传出(基石B)
|
||||
|
||||
try:
|
||||
raw = await clients.gpt_vision_analyze(_VISION_PROMPT, [data])
|
||||
try:
|
||||
result = _parse_vision_json(raw)
|
||||
result["source"] = "vision"
|
||||
except Exception as parse_err:
|
||||
logger.warning("vision JSON parse failed, fallback: %s", parse_err)
|
||||
result = _fallback_analysis()
|
||||
result["warning"] = f"视觉分析解析失败,已使用文字兜底:{parse_err}"
|
||||
except Exception as e:
|
||||
logger.warning("vision_analyze failed, fallback: %s", e)
|
||||
result = _fallback_analysis()
|
||||
result["warning"] = f"视觉分析失败,已使用文字兜底:{e}"
|
||||
finally:
|
||||
await clients.aclose()
|
||||
|
||||
logger.info("analyze_product_image done: product_id=%s source=%s conf=%.2f",
|
||||
product_id, result.get("source"), result.get("confidence", 0))
|
||||
return ok(result)
|
||||
140
backend/app/api/v1/review.py
Normal file
140
backend/app/api/v1/review.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
app/api/v1/review.py — 审核路由(组长)
|
||||
通过(+5,最重) / 打回(写原因,-3,回 pending_selection)
|
||||
打回原因存 preference_events.reason,不做 AI 归纳。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, paginate, raise_not_found, raise_state_invalid
|
||||
from app.middleware.workspace_guard import CurrentUser, require_supervisor_or_above
|
||||
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/review", tags=["review"])
|
||||
|
||||
|
||||
class RejectRequest(BaseModel):
|
||||
reason: str # 原文存入 preference_events,不做 AI 归纳(契约§3)
|
||||
|
||||
|
||||
@router.get("/queue")
|
||||
def review_queue(
|
||||
page: int = 1, page_size: int = 20,
|
||||
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""待审队列(pending_review 状态)。"""
|
||||
q = (
|
||||
db.query(GenerationTask)
|
||||
.filter(
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
GenerationTask.status == "pending_review",
|
||||
)
|
||||
.order_by(GenerationTask.updated_at.asc())
|
||||
)
|
||||
total = q.count()
|
||||
tasks = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
items = [_fmt_queue_item(t, db) for t in tasks]
|
||||
return ok(paginate(items, total, page, page_size))
|
||||
|
||||
|
||||
def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
|
||||
import json
|
||||
from app.models.product import Product
|
||||
product = db.query(Product).filter(Product.id == t.product_id).first()
|
||||
operator = db.query(User).filter(User.id == t.operator_id).first()
|
||||
selected_text = db.query(TextCandidate).filter(
|
||||
TextCandidate.task_id == t.id, TextCandidate.is_selected == True
|
||||
).first()
|
||||
selected_image = db.query(ImageCandidate).filter(
|
||||
ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True
|
||||
).first()
|
||||
|
||||
# content 列存 JSON dict,解包取 title/content 分字段返回
|
||||
text_parsed: dict = {}
|
||||
if selected_text and selected_text.content:
|
||||
try:
|
||||
text_parsed = json.loads(selected_text.content)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
text_parsed = {"content": selected_text.content}
|
||||
|
||||
return {
|
||||
"task_id": t.id,
|
||||
"product_name": product.name if product else None,
|
||||
"theme": t.theme,
|
||||
"submitted_at": t.updated_at.isoformat(),
|
||||
"operator_name": operator.username if operator else None,
|
||||
"selected_text": {
|
||||
"candidate_id": selected_text.id,
|
||||
"angle_label": selected_text.angle_label,
|
||||
"title": text_parsed.get("title", ""),
|
||||
"content": text_parsed.get("content", ""), # 纯正文
|
||||
"tags": text_parsed.get("tags", []),
|
||||
} if selected_text else None,
|
||||
"selected_image": {
|
||||
"candidate_id": selected_image.id,
|
||||
"strategy": selected_image.strategy,
|
||||
"url": selected_image.url,
|
||||
} if selected_image else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{task_id}/approve")
|
||||
def approve_task(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""通过(飞轮 +5,最重,状态 → approved)。"""
|
||||
from app.services.flywheel_service import record_signal
|
||||
task = db.query(GenerationTask).filter(
|
||||
GenerationTask.id == task_id,
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not task:
|
||||
raise_not_found("任务不存在")
|
||||
if task.status != "pending_review":
|
||||
raise_state_invalid("只有 pending_review 状态可审核")
|
||||
task.status = "approved"
|
||||
task.review_status = "approved"
|
||||
task.reviewer_id = current_user.user_id
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
task.approved_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "approve")
|
||||
return ok({"task_id": task_id, "status": "approved"})
|
||||
|
||||
|
||||
@router.post("/{task_id}/reject")
|
||||
def reject_task(
|
||||
task_id: int, body: RejectRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""打回(飞轮 -3,回 pending_selection,原因下次注入 prompt)。"""
|
||||
from app.services.flywheel_service import record_signal
|
||||
task = db.query(GenerationTask).filter(
|
||||
GenerationTask.id == task_id,
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not task:
|
||||
raise_not_found("任务不存在")
|
||||
if task.status != "pending_review":
|
||||
raise_state_invalid("只有 pending_review 状态可打回")
|
||||
task.status = "pending_selection" # 打回回 pending_selection
|
||||
task.review_status = "rejected"
|
||||
task.reviewer_id = current_user.user_id
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
task.reject_reason = body.reason
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "reject_with_reason", reason=body.reason)
|
||||
return ok({"task_id": task_id, "status": "pending_selection"})
|
||||
97
backend/app/api/v1/stream.py
Normal file
97
backend/app/api/v1/stream.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
app/api/v1/stream.py — SSE ticket 签发 + SSE 流路由
|
||||
安全红线(倩倩姐2026-06-08拍板):SSE 认证用一次性短票 ticket,禁止 ?token= 直传 JWT。
|
||||
|
||||
流程:
|
||||
1. FE 带 JWT 调 POST /tasks/{id}/sse-ticket → 拿 ticket(60s有效,一次性)
|
||||
2. FE 用 ticket 建 EventSource: GET /tasks/{id}/stream?ticket=<ticket>
|
||||
3. BE 验 ticket(原子 getdel),用后即失效,换不了其他接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
import redis as sync_redis
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_not_found, raise_unauthorized
|
||||
from app.core.sse_ticket import consume_ticket, issue_ticket
|
||||
from app.middleware.workspace_guard import CurrentUser, get_current_user
|
||||
from app.models.task import GenerationTask
|
||||
from app.utils.sse_utils import stream_events
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# ── 签发接口(用 JWT 换 ticket)────────────────────────────
|
||||
@router.post("/tasks/{task_id}/sse-ticket")
|
||||
def create_sse_ticket(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
签发一次性 SSE ticket(60s 有效,仅对该 task 有效)。
|
||||
FE 拿到 ticket 后立即建 EventSource,不要存起来复用。
|
||||
"""
|
||||
task = (
|
||||
db.query(GenerationTask)
|
||||
.filter(
|
||||
GenerationTask.id == task_id,
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not task:
|
||||
raise_not_found("任务不存在")
|
||||
|
||||
r = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
ticket = issue_ticket(r, task_id, current_user.workspace_id)
|
||||
return ok({"ticket": ticket, "expires_in": 60})
|
||||
|
||||
|
||||
# ── SSE 流(ticket 认证,绝不传 JWT)─────────────────────
|
||||
@router.get("/tasks/{task_id}/stream")
|
||||
async def task_stream(
|
||||
task_id: int,
|
||||
ticket: str = Query(...),
|
||||
last_seq: int = Query(default=0),
|
||||
):
|
||||
"""
|
||||
SSE 实时进度推送。
|
||||
ticket 验证:原子 getdel,用后即失效,无法重放、无法访问其他接口。
|
||||
支持断线重连补发(?last_seq=<上次收到的seq>)。
|
||||
断线重连时 FE 须重新调 sse-ticket 换新 ticket。
|
||||
"""
|
||||
r_sync = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
result = consume_ticket(r_sync, ticket)
|
||||
if not result:
|
||||
raise_unauthorized("ticket 无效或已过期")
|
||||
|
||||
verified_task_id, workspace_id = result
|
||||
if verified_task_id != task_id:
|
||||
raise_unauthorized("ticket 与任务不匹配")
|
||||
|
||||
redis_async = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
|
||||
return StreamingResponse(
|
||||
stream_events(
|
||||
redis_client=redis_async,
|
||||
task_id=task_id,
|
||||
workspace_id=workspace_id,
|
||||
last_seq=last_seq,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
146
backend/app/api/v1/task_actions.py
Normal file
146
backend/app/api/v1/task_actions.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
app/api/v1/task_actions.py — 任务操作端点
|
||||
选文案 / 选图 / 导入文案 / 重新生成 / 提交审核 / 偏好上下文
|
||||
DTOs 和辅助函数从 tasks.py 导入。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_not_found, raise_state_invalid
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
|
||||
from app.api.v1.tasks import (
|
||||
SelectCandidateRequest,
|
||||
ImportTextRequest,
|
||||
_fmt_text,
|
||||
_fmt_image,
|
||||
_check_task_ownership,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.post("/{task_id}/text-candidates/select")
|
||||
def select_text(
|
||||
task_id: int, body: SelectCandidateRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""选文案(飞轮信号 text_select +3)。"""
|
||||
from app.services.flywheel_service import record_signal
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
tc = db.query(TextCandidate).filter(
|
||||
TextCandidate.id == body.candidate_id, TextCandidate.task_id == task_id
|
||||
).first()
|
||||
if not tc:
|
||||
raise_not_found("文案候选不存在")
|
||||
tc.is_selected = True
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "text_select", candidate_id=tc.id, angle_label=tc.angle_label)
|
||||
return ok({"selected": body.candidate_id})
|
||||
|
||||
|
||||
@router.post("/{task_id}/image-candidates/select")
|
||||
def select_image(
|
||||
task_id: int, body: SelectCandidateRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""选图(飞轮信号 image_select +3)。"""
|
||||
from app.services.flywheel_service import record_signal
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
ic = db.query(ImageCandidate).filter(
|
||||
ImageCandidate.id == body.candidate_id, ImageCandidate.task_id == task_id
|
||||
).first()
|
||||
if not ic:
|
||||
raise_not_found("图片候选不存在")
|
||||
ic.is_selected = True
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "image_select", candidate_id=ic.id)
|
||||
return ok({"selected": body.candidate_id})
|
||||
|
||||
|
||||
@router.post("/{task_id}/import-text")
|
||||
def import_text(
|
||||
task_id: int, body: ImportTextRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""轨B:导入外部文案直接进候选池,跳过 AI 生成(洞2)。"""
|
||||
_check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
tc = TextCandidate(
|
||||
workspace_id=current_user.workspace_id, task_id=task_id,
|
||||
source="import", content=body.content, angle_label=body.angle_label,
|
||||
)
|
||||
db.add(tc)
|
||||
db.commit()
|
||||
db.refresh(tc)
|
||||
return ok(_fmt_text(tc))
|
||||
|
||||
|
||||
@router.post("/{task_id}/regenerate")
|
||||
def regenerate(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""重新生成(飞轮信号 regenerate -1)。"""
|
||||
from app.services.flywheel_service import record_signal
|
||||
from app.services.task_service import enqueue_generation
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
record_signal(db, current_user, task, "regenerate")
|
||||
enqueue_generation(task.id)
|
||||
return ok({"task_id": task_id, "status": "regenerating"})
|
||||
|
||||
|
||||
@router.post("/{task_id}/submit-review")
|
||||
def submit_review(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""提交审核(状态 pending_selection → pending_review)。"""
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
if task.status != "pending_selection":
|
||||
raise_state_invalid("只有 pending_selection 状态可提审")
|
||||
task.status = "pending_review"
|
||||
db.commit()
|
||||
return ok({"task_id": task_id, "status": "pending_review"})
|
||||
|
||||
|
||||
@router.get("/{task_id}/preference/context")
|
||||
def get_preference_context(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""取本任务飞轮偏好上下文(最近选中样本+打回原因)。"""
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
from app.services.flywheel_service import get_preference_context
|
||||
ctx = get_preference_context(db, current_user.workspace_id, task.product_id)
|
||||
return ok(ctx)
|
||||
191
backend/app/api/v1/tasks.py
Normal file
191
backend/app/api/v1/tasks.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
app/api/v1/tasks.py — 任务路由(查询层)
|
||||
发起任务 / 任务列表 / 任务详情
|
||||
操作类端点(选文案/选图/导入/重生成/提审/偏好上下文)在 task_actions.py。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, paginate, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
# ── DTO ────────────────────────────────────────────────────
|
||||
class CreateTaskRequest(BaseModel):
|
||||
product_id: int
|
||||
benchmark_ids: list[int] = []
|
||||
theme: str | None = None
|
||||
text_count: int = 5 # 不写死(基石A),用户设定
|
||||
image_count: int = 3 # 不写死(基石A),用户设定
|
||||
track: str = "ai" # ai | import
|
||||
need_product_image: bool = True # 本次产品是否入镜:True=无图禁生成不降级
|
||||
|
||||
@field_validator("text_count", "image_count")
|
||||
@classmethod
|
||||
def positive(cls, v: int) -> int:
|
||||
if v < 1 or v > 20:
|
||||
raise ValueError("数量范围 1-20")
|
||||
return v
|
||||
|
||||
@field_validator("track")
|
||||
@classmethod
|
||||
def valid_track(cls, v: str) -> str:
|
||||
if v not in ("ai", "import"):
|
||||
raise ValueError("track 必须是 ai 或 import")
|
||||
return v
|
||||
|
||||
|
||||
class SelectCandidateRequest(BaseModel):
|
||||
candidate_id: int
|
||||
|
||||
|
||||
class ImportTextRequest(BaseModel):
|
||||
content: str
|
||||
angle_label: str | None = None
|
||||
|
||||
|
||||
# ── 格式化辅助 ──────────────────────────────────────────────
|
||||
def _fmt_task(t: GenerationTask) -> dict:
|
||||
return {
|
||||
"id": t.id, "product_id": t.product_id, "theme": t.theme,
|
||||
"status": t.status, "text_count": t.text_count, "image_count": t.image_count,
|
||||
"track": t.track, "need_product_image": t.need_product_image,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _score_array_to_obj(arr) -> dict:
|
||||
"""score_json 是评分明细数组 [{item,score,max,note}]。
|
||||
AI评委7维(2026-06-15新版:痛点18/情绪18/买点18/钩子15/标题13/真实感13+合规5)
|
||||
或旧机械5维均可处理。
|
||||
total 直接对明细求和(不依赖固定key),透传 dims 给前端 ScoreDimBars 数据驱动渲染。
|
||||
同时填旧5维key保持兼容(能映射的填,映射不上的为0)。"""
|
||||
# 旧5维 + 新7维维度名 → 旧key 的映射表(兼容新旧两套维度名)
|
||||
legacy = {
|
||||
# 旧机械维度
|
||||
"标题吸引力": "title", "标题点击力": "title",
|
||||
"情绪共鸣": "emotion", "情绪张力": "emotion",
|
||||
"买点表达": "selling", "买点转化": "selling",
|
||||
"关键词覆盖": "keyword", "合规性": "compliance",
|
||||
# 新AI评委维度(2026-06-15)→ 最近似旧key
|
||||
"痛点人群精准": "keyword", # 痛点人群精准语义最接近旧关键词(均为"内容精准度")
|
||||
"开头钩子": "emotion", # 钩子即情绪抓点,归入 emotion
|
||||
"真实感": "selling", # 真实感/买点转化同属"用户感知"维度
|
||||
# "产品聚焦一件事" 已被"真实感"替换,保留映射避免旧存量数据报 KeyError
|
||||
"产品聚焦一件事": "selling",
|
||||
}
|
||||
obj = {"title": 0, "emotion": 0, "selling": 0, "keyword": 0, "compliance": 0,
|
||||
"total": 0, "dims": []}
|
||||
if isinstance(arr, list):
|
||||
total = 0
|
||||
for d in arr:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
item = d.get("item", "")
|
||||
sc = d.get("score", 0) or 0
|
||||
total += sc
|
||||
obj["dims"].append({"item": item, "score": sc,
|
||||
"max": d.get("max", 0), "note": d.get("note", "")})
|
||||
key = legacy.get(item)
|
||||
if key:
|
||||
obj[key] = sc
|
||||
obj["total"] = max(0, min(100, total))
|
||||
return obj
|
||||
|
||||
|
||||
def _fmt_text(tc: TextCandidate) -> dict:
|
||||
import json
|
||||
# content 列存整条 JSON dict(含 title/content/tags/angle 等),解包后分字段返回
|
||||
parsed: dict = {}
|
||||
if tc.content:
|
||||
try:
|
||||
parsed = json.loads(tc.content)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# 兼容旧数据:如果 content 已经是纯正文则原样返回
|
||||
parsed = {"content": tc.content}
|
||||
score_raw = json.loads(tc.score_json) if tc.score_json else None
|
||||
return {
|
||||
"candidate_id": tc.id,
|
||||
"angle_label": tc.angle_label,
|
||||
"title": parsed.get("title", ""),
|
||||
"content": parsed.get("content", ""), # 纯正文,前端直接展示
|
||||
"tags": parsed.get("tags", []),
|
||||
"cover_title": parsed.get("coverTitle", ""),
|
||||
"image_brief": parsed.get("imageBrief", ""),
|
||||
"source": tc.source,
|
||||
"score": _score_array_to_obj(score_raw), # 转成 {title,emotion,...,total,dims} 对象
|
||||
"banned_word_status": tc.banned_word_status,
|
||||
"is_selected": tc.is_selected,
|
||||
# AI 评委总评(verdict/summary 存在 content 列,新数据有,旧数据为空字符串降级)
|
||||
"verdict": parsed.get("verdict", ""), # "优秀"|"合格"|"不合格"
|
||||
"summary": parsed.get("summary", ""), # 一句话总评含改进点
|
||||
}
|
||||
|
||||
|
||||
def _fmt_image(ic: ImageCandidate) -> dict:
|
||||
return {
|
||||
"candidate_id": ic.id, "role": ic.role, "url": ic.url,
|
||||
"strategy": ic.strategy, "seq": ic.seq, "is_selected": ic.is_selected,
|
||||
}
|
||||
|
||||
|
||||
def _check_task_ownership(task: GenerationTask | None, workspace_id: int) -> GenerationTask:
|
||||
if not task or task.workspace_id != workspace_id:
|
||||
raise_not_found("任务不存在")
|
||||
return task
|
||||
|
||||
|
||||
# ── 路由 ───────────────────────────────────────────────────
|
||||
@router.post("")
|
||||
def create_task(
|
||||
body: CreateTaskRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""发起任务:校验有无 key → 只推 task_id 入队,绝不传 key。"""
|
||||
from app.services.task_service import create_generation_task
|
||||
task = create_generation_task(db, current_user, body)
|
||||
return ok(_fmt_task(task))
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tasks(
|
||||
page: int = 1, page_size: int = 20,
|
||||
status: list[str] | None = Query(default=None),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
|
||||
if status:
|
||||
# 支持多状态(?status=approved&status=rejected),单值也兼容
|
||||
q = q.filter(GenerationTask.status.in_(status))
|
||||
total = q.count()
|
||||
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return ok(paginate([_fmt_task(t) for t in items], total, page, page_size))
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
def get_task(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
|
||||
task = _check_task_ownership(task, current_user.workspace_id)
|
||||
texts = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).all()
|
||||
images = db.query(ImageCandidate).filter(ImageCandidate.task_id == task_id).all()
|
||||
return ok({
|
||||
**_fmt_task(task),
|
||||
"text_candidates": [_fmt_text(tc) for tc in texts],
|
||||
"image_candidates": [_fmt_image(ic) for ic in images],
|
||||
})
|
||||
43
backend/app/api/v1/workspaces.py
Normal file
43
backend/app/api/v1/workspaces.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
app/api/v1/workspaces.py — workspace 切换路由
|
||||
POST /workspaces/switch → /api/v1/workspaces/switch(契约路径)
|
||||
从 auth.py 独立出来,避免 /auth 前缀错误。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import create_access_token
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_forbidden
|
||||
from app.middleware.workspace_guard import CurrentUser, get_current_user
|
||||
from app.models.workspace import WorkspaceMember
|
||||
|
||||
router = APIRouter(tags=["workspaces"])
|
||||
|
||||
|
||||
class SwitchWorkspaceRequest(BaseModel):
|
||||
workspace_id: int
|
||||
|
||||
|
||||
@router.post("/workspaces/switch")
|
||||
def switch_workspace(
|
||||
body: SwitchWorkspaceRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""切换当前 workspace(必须查 membership 校验)。"""
|
||||
member = db.query(WorkspaceMember).filter(
|
||||
WorkspaceMember.user_id == current_user.user_id,
|
||||
WorkspaceMember.workspace_id == body.workspace_id,
|
||||
).first()
|
||||
if not member:
|
||||
raise_forbidden("无权访问目标 workspace")
|
||||
token = create_access_token(current_user.user_id, body.workspace_id, member.role)
|
||||
return ok({
|
||||
"current_workspace_id": body.workspace_id,
|
||||
"token": token,
|
||||
})
|
||||
8
backend/app/constants/README.md
Normal file
8
backend/app/constants/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# app/constants/
|
||||
|
||||
中心常量文件,消除三套命名打架(架构方案§二决策):
|
||||
- strategies.py # 图片策略命名:对外API用A/B/C,内部method用minimal_edit系
|
||||
- signal_weights.py # 飞轮信号默认权重(可调,不写死)
|
||||
- status.py # 任务状态机枚举
|
||||
- roles.py # 角色枚举:admin/supervisor/operator
|
||||
- providers.py # AI提供商枚举(开放接口,不硬编码)
|
||||
0
backend/app/constants/__init__.py
Normal file
0
backend/app/constants/__init__.py
Normal file
132
backend/app/constants/enums.py
Normal file
132
backend/app/constants/enums.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
constants/enums.py — Clover 命名中心
|
||||
DB枚举约束在此定义,消除三套命名打架。
|
||||
业务参数(品类/数量/角度)不在此定义(基石A)。
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ── 用户角色 ────────────────────────────────────────────
|
||||
class UserRole(str, Enum):
|
||||
ADMIN = "admin"
|
||||
SUPERVISOR = "supervisor"
|
||||
OPERATOR = "operator"
|
||||
|
||||
|
||||
# ── 任务状态机 ──────────────────────────────────────────
|
||||
class TaskStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
GENERATING = "generating"
|
||||
PENDING_SELECTION = "pending_selection"
|
||||
PENDING_REVIEW = "pending_review"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
# ── 审核状态(generation_tasks 平铺字段)──────────────────
|
||||
class ReviewStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
# ── 文案候选来源 ───────────────────────────────────────
|
||||
class CandidateSource(str, Enum):
|
||||
AI = "ai"
|
||||
IMPORT = "import"
|
||||
|
||||
|
||||
# ── 违禁词等级 ────────────────────────────────────────
|
||||
class BannedWordLevel(str, Enum):
|
||||
AUTO_FIX = "auto_fix"
|
||||
SOFT_WARN = "soft_warn"
|
||||
HARD_BLOCK = "hard_block"
|
||||
|
||||
|
||||
# ── 违禁词扫描结果 ───────────────────────────────────
|
||||
class BannedWordStatus(str, Enum):
|
||||
PASS = "pass"
|
||||
AUTO_FIXED = "auto_fixed"
|
||||
SOFT_WARN = "soft_warn"
|
||||
HARD_BLOCK = "hard_block"
|
||||
|
||||
|
||||
# ── 飞轮信号类型 ─────────────────────────────────────
|
||||
class SignalType(str, Enum):
|
||||
TEXT_SELECT = "text_select"
|
||||
IMAGE_SELECT = "image_select"
|
||||
APPROVE = "approve"
|
||||
REJECT_WITH_REASON = "reject_with_reason"
|
||||
REGENERATE = "regenerate"
|
||||
|
||||
|
||||
# ── 飞轮信号权重默认值(北哥可校准)─────────────────────
|
||||
SIGNAL_WEIGHTS: dict[str, int] = {
|
||||
SignalType.TEXT_SELECT: 3,
|
||||
SignalType.IMAGE_SELECT: 3,
|
||||
SignalType.APPROVE: 5,
|
||||
SignalType.REJECT_WITH_REASON: -3,
|
||||
SignalType.REGENERATE: -1,
|
||||
}
|
||||
|
||||
|
||||
# ── 数据归属 ──────────────────────────────────────────
|
||||
class DataOwnership(str, Enum):
|
||||
CLIENT_DATA = "client_data" # 原始输入产出,客户可导出
|
||||
PLATFORM_ASSET = "platform_asset" # 飞轮蒸馏成果,归平台
|
||||
|
||||
|
||||
# ── 交付包状态 ────────────────────────────────────────
|
||||
class PackageStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
READY = "ready"
|
||||
DOWNLOADED = "downloaded"
|
||||
|
||||
|
||||
# ── 图片分镜角色(G5坑修复:对齐 storyboard.py 实际角色名)────
|
||||
class ImageRole(str, Enum):
|
||||
# storyboard 实际输出角色名
|
||||
HOOK = "hook"
|
||||
PAIN_SCENE = "pain_scene"
|
||||
PRODUCT_CLOSEUP = "product_closeup"
|
||||
INGREDIENT = "ingredient"
|
||||
APPLIED_PROOF = "applied_proof"
|
||||
TEXTURE = "texture"
|
||||
SOCIAL_PROOF = "social_proof"
|
||||
CLOSER = "closer"
|
||||
SCENARIO = "scenario"
|
||||
TUTORIAL = "tutorial"
|
||||
# 旧值保留向后兼容
|
||||
PAIN = "pain"
|
||||
PROOF = "proof"
|
||||
QUALITY = "quality"
|
||||
CREDIT = "credit"
|
||||
CONVERT = "convert"
|
||||
MAIN = "main"
|
||||
|
||||
|
||||
# ── AI 图片提供商 ─────────────────────────────────────
|
||||
class ImageProvider(str, Enum):
|
||||
GPT = "gpt"
|
||||
GEMINI = "gemini"
|
||||
|
||||
|
||||
# ── 产品来源 ──────────────────────────────────────────
|
||||
class ProductSource(str, Enum):
|
||||
PRESET = "preset"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
# ── 错误码(契约§0七类)─────────────────────────────────
|
||||
class ErrorCode:
|
||||
SUCCESS = 0
|
||||
PARAM_INVALID = 40001
|
||||
UNAUTHORIZED = 40101
|
||||
FORBIDDEN = 40301
|
||||
NOT_FOUND = 40401
|
||||
STATE_INVALID = 40901
|
||||
BUSINESS_ERROR = 42201
|
||||
SERVER_ERROR = 50001
|
||||
AI_CALL_FAILED = 50002
|
||||
3
backend/app/core/__init__.py
Normal file
3
backend/app/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
app/core/__init__.py
|
||||
"""
|
||||
80
backend/app/core/config.py
Normal file
80
backend/app/core/config.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
app/core/config.py — 环境变量加载 + 启动校验
|
||||
必填项缺失则启动失败,不静默。
|
||||
"""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# ── 必填(缺失启动保护)───────────────────────────
|
||||
FERNET_KEY: str
|
||||
JWT_SECRET: str
|
||||
DATABASE_URL: str
|
||||
MONGO_URI: str
|
||||
REDIS_URL: str
|
||||
|
||||
# ── AI 提供商(不写死默认,可配置)──────────────────
|
||||
IMAGE_PROVIDER_PRIMARY: str = "gpt"
|
||||
IMAGE_PROVIDER_FALLBACK: str = "gemini"
|
||||
IMAGE_API_BASE: str = ""
|
||||
IMAGE_MODEL: str = "gpt-image-2"
|
||||
MODEL_IMAGE: str = "gpt-image-2" # 别名,与 IMAGE_MODEL 同义
|
||||
MODEL_TEXT: str = "claude-opus-4-8"
|
||||
MODEL_VISION: str = "" # 视觉模型(verify_real_image 脚本用)
|
||||
CLAUDE_API_URL: str = ""
|
||||
GEMINI_API_URL: str = ""
|
||||
|
||||
# ── 多 Provider Key(各人自录,测试/真实出图脚本用)──
|
||||
APIPORTS_BASE_URL: str = ""
|
||||
APIPORTS_KEY: str = ""
|
||||
CODEPROXY_BASE_URL: str = ""
|
||||
CODEPROXY_KEY: str = ""
|
||||
|
||||
# ── 应用配置 ──────────────────────────────────────
|
||||
APP_ENV: str = "development"
|
||||
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
|
||||
CELERY_BROKER_URL: str = ""
|
||||
CELERY_RESULT_BACKEND: str = ""
|
||||
|
||||
# ── 并发限制(可配置,不写死)─────────────────────
|
||||
MAX_CONCURRENT_TASKS_PER_USER: int = 2
|
||||
|
||||
# ── 文件存储路径 ──────────────────────────────────
|
||||
UPLOAD_BASE_PATH: str = "uploads/packages"
|
||||
# StaticFiles 实际服务的绝对目录(与 main.py app.mount("/uploads", .../uploads) 一致)。
|
||||
# 容器内 main.py 在 /app/main.py,故 uploads 绝对根 = /app/uploads。
|
||||
# 所有写盘/读盘统一锚定此根,避免 api(cwd=/app) 与 worker(cwd=/) 相对路径解析不一致。
|
||||
UPLOAD_ABS_ROOT: str = "/app/uploads"
|
||||
|
||||
model_config = {"env_file": ".env", "case_sensitive": True, "extra": "ignore"}
|
||||
|
||||
@field_validator("FERNET_KEY")
|
||||
@classmethod
|
||||
def fernet_key_not_empty(cls, v: str) -> str:
|
||||
if not v or len(v) < 32:
|
||||
raise ValueError("FERNET_KEY must be set and at least 32 chars")
|
||||
return v
|
||||
|
||||
@field_validator("JWT_SECRET")
|
||||
@classmethod
|
||||
def jwt_secret_not_empty(cls, v: str) -> str:
|
||||
if not v or len(v) < 32:
|
||||
raise ValueError("JWT_SECRET must be at least 32 characters")
|
||||
return v
|
||||
|
||||
def celery_broker(self) -> str:
|
||||
return self.CELERY_BROKER_URL or self.REDIS_URL
|
||||
|
||||
def celery_backend(self) -> str:
|
||||
return self.CELERY_RESULT_BACKEND or self.REDIS_URL
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""单例配置,启动时校验一次。"""
|
||||
return Settings()
|
||||
53
backend/app/core/database.py
Normal file
53
backend/app/core/database.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
app/core/database.py — SQLAlchemy 双库连接(MySQL + MongoDB)
|
||||
"""
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# ── MySQL(主业务库)──────────────────────────────────
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
echo=(settings.APP_ENV == "development"),
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有 ORM 模型的基类。"""
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI 依赖注入:获取 DB Session,用完自动关闭。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── MongoDB(只存 AI debug trace,业务不依赖)──────────
|
||||
_mongo_client: AsyncIOMotorClient | None = None
|
||||
|
||||
|
||||
def get_mongo_client() -> AsyncIOMotorClient:
|
||||
global _mongo_client
|
||||
if _mongo_client is None:
|
||||
_mongo_client = AsyncIOMotorClient(settings.MONGO_URI)
|
||||
return _mongo_client
|
||||
|
||||
|
||||
def get_mongo_db():
|
||||
"""获取 MongoDB 数据库实例(clover_trace)。"""
|
||||
client = get_mongo_client()
|
||||
return client["clover_trace"]
|
||||
63
backend/app/core/response.py
Normal file
63
backend/app/core/response.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
app/core/response.py — 统一响应包络
|
||||
成功:{ "code": 0, "data": {...} }
|
||||
失败:{ "code": <错误码>, "message": "用户可读信息" }
|
||||
HTTP 状态码与业务 code 分离(契约§0)。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.constants.enums import ErrorCode
|
||||
|
||||
|
||||
def ok(data: Any = None) -> dict:
|
||||
"""标准成功响应。"""
|
||||
return {"code": ErrorCode.SUCCESS, "data": data}
|
||||
|
||||
|
||||
def err(code: int, message: str) -> dict:
|
||||
"""标准失败响应体(不含 HTTP 状态,由调用方决定)。"""
|
||||
return {"code": code, "message": message}
|
||||
|
||||
|
||||
def paginate(items: list, total: int, page: int, page_size: int) -> dict:
|
||||
"""分页数据包装。"""
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
# ── 常用异常 ──────────────────────────────────────────────
|
||||
class CloverHTTPException(HTTPException):
|
||||
"""携带业务 code 的 HTTP 异常,供全局 handler 格式化。"""
|
||||
|
||||
def __init__(self, http_status: int, code: int, message: str):
|
||||
super().__init__(status_code=http_status, detail=message)
|
||||
self.biz_code = code
|
||||
self.biz_message = message
|
||||
|
||||
|
||||
def raise_not_found(message: str = "资源不存在") -> None:
|
||||
raise CloverHTTPException(404, ErrorCode.NOT_FOUND, message)
|
||||
|
||||
|
||||
def raise_forbidden(message: str = "无权限访问") -> None:
|
||||
raise CloverHTTPException(403, ErrorCode.FORBIDDEN, message)
|
||||
|
||||
|
||||
def raise_unauthorized(message: str = "未认证或 Token 失效") -> None:
|
||||
raise CloverHTTPException(401, ErrorCode.UNAUTHORIZED, message)
|
||||
|
||||
|
||||
def raise_business(message: str) -> None:
|
||||
raise CloverHTTPException(422, ErrorCode.BUSINESS_ERROR, message)
|
||||
|
||||
|
||||
def raise_state_invalid(message: str = "状态机非法流转") -> None:
|
||||
raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message)
|
||||
73
backend/app/core/security.py
Normal file
73
backend/app/core/security.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
app/core/security.py — JWT + Fernet 工具函数
|
||||
FERNET_KEY 走环境变量,绝不进代码库(基石B)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# ── Fernet 加密(API Key 存取)──────────────────────────
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
_fernet = Fernet(settings.FERNET_KEY.encode())
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt_api_key(plain_key: str) -> str:
|
||||
"""加密 API Key,返回密文字符串。绝不打印 plain_key。"""
|
||||
return _get_fernet().encrypt(plain_key.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_api_key(encrypted_key: str) -> str:
|
||||
"""
|
||||
解密 API Key。只在 Celery worker 内部调用。
|
||||
解密结果只活在调用函数的局部变量,不落盘、不打日志。
|
||||
"""
|
||||
try:
|
||||
return _get_fernet().decrypt(encrypted_key.encode()).decode()
|
||||
except InvalidToken:
|
||||
logger.error("Fernet decrypt failed: invalid token (key may be rotated)")
|
||||
raise ValueError("API key decryption failed")
|
||||
|
||||
|
||||
# ── JWT ──────────────────────────────────────────────────
|
||||
def create_access_token(
|
||||
user_id: int,
|
||||
workspace_id: int,
|
||||
role: str,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"user_id": user_id,
|
||||
"current_workspace_id": workspace_id,
|
||||
"role": role,
|
||||
"exp": expire,
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
"""验签并返回 payload。无效则抛 jwt.PyJWTError。"""
|
||||
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||
|
||||
|
||||
def mask_api_key(plain_key: str) -> str:
|
||||
"""只返回后4位(展示用),不暴露完整 key。"""
|
||||
return plain_key[-4:] if len(plain_key) >= 4 else "****"
|
||||
43
backend/app/core/sse_ticket.py
Normal file
43
backend/app/core/sse_ticket.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
app/core/sse_ticket.py — SSE 一次性短票 (ticket) 签发与验证
|
||||
倩倩姐2026-06-08拍板:SSE 认证不传 JWT,改传 ticket。
|
||||
ticket 存 Redis,TTL 60s,验一次即删,换不了其他接口。
|
||||
"""
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
TICKET_TTL_SECONDS = 60
|
||||
TICKET_KEY_PREFIX = "sse_ticket:"
|
||||
|
||||
|
||||
def _redis_key(ticket: str) -> str:
|
||||
return f"{TICKET_KEY_PREFIX}{ticket}"
|
||||
|
||||
|
||||
def issue_ticket(redis_client, task_id: int, workspace_id: int) -> str:
|
||||
"""
|
||||
签发一次性 SSE ticket,写入 Redis,TTL 60s。
|
||||
返回 ticket 字符串(32字节 hex,共64字符)。
|
||||
"""
|
||||
ticket = secrets.token_hex(32)
|
||||
value = f"{task_id}:{workspace_id}"
|
||||
redis_client.setex(_redis_key(ticket), TICKET_TTL_SECONDS, value)
|
||||
return ticket
|
||||
|
||||
|
||||
def consume_ticket(redis_client, ticket: str) -> Optional[tuple[int, int]]:
|
||||
"""
|
||||
验证并消费 ticket(用后即删,一次性)。
|
||||
成功返回 (task_id, workspace_id),失败返回 None。
|
||||
"""
|
||||
if not ticket:
|
||||
return None
|
||||
key = _redis_key(ticket)
|
||||
value = redis_client.getdel(key) # 原子取出并删除
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
task_id_str, ws_str = value.split(":", 1)
|
||||
return int(task_id_str), int(ws_str)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
5
backend/app/middleware/README.md
Normal file
5
backend/app/middleware/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# app/middleware/
|
||||
|
||||
中间件占位:
|
||||
- workspace_guard.py # 所有接口强制注入workspace_id,防串数据(所有查询强制带此条件)
|
||||
- auth_middleware.py # JWT验签(HS256,每请求验签)
|
||||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
106
backend/app/middleware/workspace_guard.py
Normal file
106
backend/app/middleware/workspace_guard.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
app/middleware/workspace_guard.py — 多租户隔离中间件
|
||||
所有业务接口强制注入 workspace_id(基石C)。
|
||||
读操作信任 JWT;写操作+切换 workspace 查 workspace_members 校验。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import raise_forbidden, raise_unauthorized
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.workspace import WorkspaceMember
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CurrentUser:
|
||||
"""JWT 解码后的请求上下文,注入到每个路由函数。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: int,
|
||||
workspace_id: int,
|
||||
role: str,
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.workspace_id = workspace_id
|
||||
self.role = role
|
||||
|
||||
|
||||
def _extract_token(authorization: str | None) -> str:
|
||||
"""从 Authorization: Bearer <token> 中提取 token。"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise_unauthorized("缺少 Authorization header")
|
||||
return authorization.split(" ", 1)[1]
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> CurrentUser:
|
||||
"""
|
||||
FastAPI 依赖:解码 JWT,返回 CurrentUser。
|
||||
所有业务路由 Depends(get_current_user)。
|
||||
"""
|
||||
token = _extract_token(authorization)
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise_unauthorized("Token 已过期")
|
||||
except jwt.PyJWTError:
|
||||
raise_unauthorized("Token 无效")
|
||||
|
||||
return CurrentUser(
|
||||
user_id=int(payload["user_id"]),
|
||||
workspace_id=int(payload["current_workspace_id"]),
|
||||
role=payload["role"],
|
||||
)
|
||||
|
||||
|
||||
def require_write_permission(
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> CurrentUser:
|
||||
"""
|
||||
写操作依赖:查 workspace_members 校验当前用户确实属于此 workspace。
|
||||
读操作用 get_current_user 即可(JWT 加速)。
|
||||
"""
|
||||
member = (
|
||||
db.query(WorkspaceMember)
|
||||
.filter(
|
||||
WorkspaceMember.workspace_id == current_user.workspace_id,
|
||||
WorkspaceMember.user_id == current_user.user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not member:
|
||||
logger.warning(
|
||||
"workspace permission denied: user=%s workspace=%s",
|
||||
current_user.user_id,
|
||||
current_user.workspace_id,
|
||||
)
|
||||
raise_forbidden("无权限访问此 workspace")
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
) -> CurrentUser:
|
||||
"""仅管理员可访问的路由依赖。"""
|
||||
if current_user.role != "admin":
|
||||
raise_forbidden("需要管理员权限")
|
||||
return current_user
|
||||
|
||||
|
||||
def require_supervisor_or_above(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
) -> CurrentUser:
|
||||
"""组长及以上(supervisor/admin)。"""
|
||||
if current_user.role not in ("supervisor", "admin"):
|
||||
raise_forbidden("需要组长或管理员权限")
|
||||
return current_user
|
||||
30
backend/app/models/README.md
Normal file
30
backend/app/models/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# app/models/
|
||||
|
||||
SQLAlchemy ORM 模型占位,Alembic 001-004 按顺序建:
|
||||
|
||||
## Alembic 001 — 从banana搬3张(改造)
|
||||
- user.py # users(删credits字段)
|
||||
- login_record.py # login_records
|
||||
- user_preference.py # user_preferences(UI设置偏好)
|
||||
|
||||
## Alembic 002 — 多租户基础(全新)
|
||||
- workspace.py # workspaces
|
||||
- workspace_member.py # workspace_members(user+workspace+角色)
|
||||
- user_api_key.py # user_api_keys(Fernet加密,UNIQUE user_id+workspace_id+provider)
|
||||
|
||||
## Alembic 003 — 业务主体7张(全新)
|
||||
- product.py # products(含text_angles/custom_prompt/source/workspace_id)
|
||||
- benchmark_note.py # benchmark_notes
|
||||
- generation_task.py # generation_tasks(状态机+审核字段平铺)
|
||||
- text_candidate.py # text_candidates(source=ai/import双轨)
|
||||
- image_candidate.py # image_candidates
|
||||
- delivery_package.py # delivery_packages
|
||||
- banned_word.py # banned_words(三级level)
|
||||
|
||||
## Alembic 004 — 飞轮(全新)
|
||||
- preference_event.py # preference_events(含data_ownership字段)
|
||||
|
||||
## 铁律
|
||||
- 所有业务表有 workspace_id 字段
|
||||
- generation_tasks 审核字段平铺:review_status/reviewer_id/reviewed_at/reject_reason/approved_at/archived_at
|
||||
- preference_events.signal_weight 初始默认值:选文案+3/选图+3/通过+5/打回-3/重生成-1
|
||||
24
backend/app/models/__init__.py
Normal file
24
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
app/models/__init__.py — 统一导出所有模型,Alembic autogenerate 能扫到
|
||||
"""
|
||||
|
||||
from app.models.user import LoginRecord, User, UserPreference
|
||||
from app.models.workspace import UserApiKey, Workspace, WorkspaceMember
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product
|
||||
from app.models.task import (
|
||||
DeliveryPackage,
|
||||
GenerationTask,
|
||||
ImageCandidate,
|
||||
TextCandidate,
|
||||
)
|
||||
from app.models.flywheel import AiCallLog, PreferenceEvent
|
||||
from app.models.fission import FissionTask
|
||||
|
||||
__all__ = [
|
||||
"User", "LoginRecord", "UserPreference",
|
||||
"Workspace", "WorkspaceMember", "UserApiKey",
|
||||
"Product", "BenchmarkNote", "BannedWord",
|
||||
"GenerationTask", "TextCandidate", "ImageCandidate",
|
||||
"DeliveryPackage", "PreferenceEvent", "AiCallLog",
|
||||
"FissionTask",
|
||||
]
|
||||
53
backend/app/models/fission.py
Normal file
53
backend/app/models/fission.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
app/models/fission.py — fission_tasks
|
||||
Alembic 010 第11环裂变引擎(1爆款→N套完整笔记包)
|
||||
|
||||
reference_level: low/mid/high(参考程度)
|
||||
status: pending/generating/done/failed
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, DateTime, ForeignKey,
|
||||
Index, Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class FissionTask(Base):
|
||||
"""裂变任务:1爆款→N套完整笔记包(第11环),在工作台内呈现。"""
|
||||
__tablename__ = "fission_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# 爆款源笔记内容(文案+图描述),JSON存储
|
||||
source_note: Mapped[str | None] = mapped_column(
|
||||
Text, comment="爆款源笔记内容(文案+图描述,JSON存储)"
|
||||
)
|
||||
# 参考程度:low/mid/high
|
||||
reference_level: Mapped[str] = mapped_column(
|
||||
String(10), default="mid", nullable=False,
|
||||
comment="参考程度: low/mid/high"
|
||||
)
|
||||
# 裂变套数(默认3套)
|
||||
fanout_count: Mapped[int] = mapped_column(
|
||||
Integer, default=3, nullable=False,
|
||||
comment="裂变套数(默认3套)"
|
||||
)
|
||||
# 状态机
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), default="pending", nullable=False,
|
||||
comment="状态: pending/generating/done/failed"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_fission_tasks_workspace_id", "workspace_id"),
|
||||
)
|
||||
97
backend/app/models/flywheel.py
Normal file
97
backend/app/models/flywheel.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
app/models/flywheel.py — preference_events / ai_call_logs
|
||||
Alembic 003(ai_call_logs)+ Alembic 004(preference_events)
|
||||
preference_profile 二期预留,一期不建。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, DateTime, Enum, ForeignKey,
|
||||
Index, Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.constants.enums import DataOwnership, SignalType
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PreferenceEvent(Base):
|
||||
"""
|
||||
飞轮信号日志。
|
||||
workspace_id + product_id 都必须有(跨公司隔离 + 按产品分开学)。
|
||||
angle_label 跟着产品的文案角度走,不写死(基石A)。
|
||||
"""
|
||||
__tablename__ = "preference_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("products.id"), nullable=False
|
||||
)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
signal_type: Mapped[str] = mapped_column(
|
||||
Enum(SignalType, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
)
|
||||
signal_weight: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
candidate_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
angle_label: Mapped[str | None] = mapped_column(String(64))
|
||||
reason: Mapped[str | None] = mapped_column(Text) # 打回原因原文
|
||||
signal_meta: Mapped[str | None] = mapped_column(Text) # JSON,扩展用
|
||||
data_ownership: Mapped[str] = mapped_column(
|
||||
Enum(DataOwnership, values_callable=lambda x: [e.value for e in x]),
|
||||
default=DataOwnership.CLIENT_DATA, nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"idx_preference_events_ws_product_created",
|
||||
"workspace_id", "product_id", "created_at",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AiCallLog(Base):
|
||||
"""
|
||||
AI 调用记录(usage + 排障基础)。
|
||||
调用失败归因到个人 key(错误码50002)。
|
||||
绝不记录明文 key,只记录 key_id。
|
||||
"""
|
||||
__tablename__ = "ai_call_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
key_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("user_api_keys.id")
|
||||
)
|
||||
task_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id")
|
||||
)
|
||||
provider: Mapped[str | None] = mapped_column(String(32))
|
||||
model: Mapped[str | None] = mapped_column(String(64))
|
||||
call_type: Mapped[str | None] = mapped_column(String(32)) # text/image/analyze
|
||||
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||
success: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
error_code: Mapped[str | None] = mapped_column(String(32))
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_ai_call_logs_workspace_user", "workspace_id", "user_id"),
|
||||
Index("idx_ai_call_logs_task_id", "task_id"),
|
||||
)
|
||||
110
backend/app/models/product.py
Normal file
110
backend/app/models/product.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
app/models/product.py — products / benchmark_notes / banned_words
|
||||
Alembic 003 业务主体(1/3)
|
||||
products.category 是纯数据字段,禁止任何品类枚举(基石A)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, DateTime, Enum, ForeignKey,
|
||||
Index, Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.constants.enums import BannedWordLevel, ProductSource
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""产品档案(卖点/违禁词/风格/调性/文案角度/可调prompt/source)"""
|
||||
__tablename__ = "products"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# category 是纯数据字段,不在代码里做枚举(基石A)
|
||||
category: Mapped[str | None] = mapped_column(String(64))
|
||||
source: Mapped[str] = mapped_column(
|
||||
Enum(ProductSource, values_callable=lambda x: [e.value for e in x]),
|
||||
default=ProductSource.CUSTOM, nullable=False,
|
||||
)
|
||||
selling_points: Mapped[str | None] = mapped_column(Text) # JSON数组
|
||||
style_tone: Mapped[str | None] = mapped_column(String(128))
|
||||
text_angles: Mapped[str | None] = mapped_column(Text) # JSON数组,用户设定
|
||||
custom_prompt: Mapped[str | None] = mapped_column(Text) # 等北哥方案注入
|
||||
image_path: Mapped[str | None] = mapped_column(String(512)) # 产品参考图路径(前端上传后写入)
|
||||
# 008: 品牌词(客户输入,植入文案每条+生图特写图第2/6张,第5环)
|
||||
brand_keyword: Mapped[str | None] = mapped_column(String(64), comment="品牌词,客户录入,随产品固定")
|
||||
# 012: 目标人群(客户输入,透传进文案/生图 prompt,storyboard.py:52 原恒空现可填)
|
||||
target_audience: Mapped[str | None] = mapped_column(String(128), comment="目标人群,客户输入,透传进文案/生图prompt")
|
||||
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship(
|
||||
back_populates="product", lazy="noload"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_products_workspace_id", "workspace_id"),
|
||||
)
|
||||
|
||||
|
||||
class BenchmarkNote(Base):
|
||||
"""标杆笔记(截图+手填亮点为主通道)"""
|
||||
__tablename__ = "benchmark_notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
screenshot_url: Mapped[str | None] = mapped_column(String(512))
|
||||
highlights: Mapped[str | None] = mapped_column(Text) # 手填亮点
|
||||
link_url: Mapped[str | None] = mapped_column(String(512))
|
||||
# 009: 第2环标杆分析字段
|
||||
features_json: Mapped[str | None] = mapped_column(Text, comment="爆款8特征分析结果(JSON),AI解析后写入")
|
||||
analyze_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False, comment="AI分析状态: pending/analyzing/done/failed")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
product: Mapped["Product"] = relationship(back_populates="benchmark_notes")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_benchmark_notes_product_id", "product_id"),
|
||||
)
|
||||
|
||||
|
||||
class BannedWord(Base):
|
||||
"""违禁词库(三级:auto_fix/soft_warn/hard_block)"""
|
||||
__tablename__ = "banned_words"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
word: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
level: Mapped[str] = mapped_column(
|
||||
Enum(BannedWordLevel, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
)
|
||||
replacement: Mapped[str | None] = mapped_column(String(128))
|
||||
updatable: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_banned_words_workspace_id", "workspace_id"),
|
||||
)
|
||||
151
backend/app/models/task.py
Normal file
151
backend/app/models/task.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
app/models/task.py — generation_tasks / text_candidates / image_candidates / delivery_packages
|
||||
Alembic 003 业务主体(2/3)
|
||||
任务主键:自增 BIGINT + mongo_trace_id VARCHAR(24)
|
||||
eval_score 留 NULL,不接 banana 假评分(基石)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, DateTime, Enum, Float, ForeignKey,
|
||||
Index, Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.constants.enums import (
|
||||
BannedWordStatus, CandidateSource, ImageRole,
|
||||
PackageStatus, ReviewStatus, TaskStatus,
|
||||
)
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class GenerationTask(Base):
|
||||
"""生产任务,审核字段平铺(不建独立审核表)。"""
|
||||
__tablename__ = "generation_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("products.id"), nullable=False
|
||||
)
|
||||
operator_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
theme: Mapped[str | None] = mapped_column(String(256))
|
||||
text_count: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||
image_count: Mapped[int] = mapped_column(Integer, default=3, nullable=False)
|
||||
track: Mapped[str] = mapped_column(String(16), default="ai", nullable=False)
|
||||
# 本次产品是否入镜:True=必须用产品参考图(无图禁生成,不降级纯文生图);False=允许纯文生图
|
||||
need_product_image: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(TaskStatus, values_callable=lambda x: [e.value for e in x]),
|
||||
default=TaskStatus.PENDING, nullable=False,
|
||||
)
|
||||
mongo_trace_id: Mapped[str | None] = mapped_column(String(24)) # MongoDB trace
|
||||
# ── 审核字段(平铺)──────────────────────────────
|
||||
review_status: Mapped[str | None] = mapped_column(
|
||||
Enum(ReviewStatus, values_callable=lambda x: [e.value for e in x])
|
||||
)
|
||||
reviewer_id: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("users.id"))
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
reject_reason: Mapped[str | None] = mapped_column(Text)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
# 011: 第2环S12+第11环裂变
|
||||
benchmark_ids: Mapped[str | None] = mapped_column(Text, comment="关联标杆笔记ID列表(JSON list),第2环引用")
|
||||
source_fission_id: Mapped[int | None] = mapped_column(Integer, comment="裂变来源fission_task ID;NULL=普通任务")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_generation_tasks_workspace_status", "workspace_id", "status"),
|
||||
)
|
||||
|
||||
|
||||
class TextCandidate(Base):
|
||||
"""文案候选(source=ai/import 区分双轨)。eval_score 留 NULL。"""
|
||||
__tablename__ = "text_candidates"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
source: Mapped[str] = mapped_column(
|
||||
Enum(CandidateSource, values_callable=lambda x: [e.value for e in x]),
|
||||
default=CandidateSource.AI, nullable=False,
|
||||
)
|
||||
angle_label: Mapped[str | None] = mapped_column(String(64))
|
||||
content: Mapped[str | None] = mapped_column(Text)
|
||||
score_json: Mapped[str | None] = mapped_column(Text) # 五维分 JSON
|
||||
banned_word_status: Mapped[str] = mapped_column(
|
||||
Enum(BannedWordStatus, values_callable=lambda x: [e.value for e in x]),
|
||||
default=BannedWordStatus.PASS, nullable=False,
|
||||
)
|
||||
eval_score: Mapped[float | None] = mapped_column(Float) # 一期留 NULL
|
||||
is_selected: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_text_candidates_task_id", "task_id"),
|
||||
)
|
||||
|
||||
|
||||
class ImageCandidate(Base):
|
||||
"""图片候选。eval_score 留 NULL。"""
|
||||
__tablename__ = "image_candidates"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# role 用 varchar 不用 enum:storyboard 角色名仍在演进,约束放应用层 ImageRole
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(32), default=ImageRole.HOOK.value, nullable=False,
|
||||
)
|
||||
url: Mapped[str | None] = mapped_column(String(512))
|
||||
strategy: Mapped[str | None] = mapped_column(String(4)) # A/B/C(二期)
|
||||
seq: Mapped[int] = mapped_column(Integer, default=1) # 分镜序号
|
||||
is_selected: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
eval_score: Mapped[float | None] = mapped_column(Float) # 一期留 NULL
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_image_candidates_task_id", "task_id"),
|
||||
)
|
||||
|
||||
|
||||
class DeliveryPackage(Base):
|
||||
"""达人素材交付包。"""
|
||||
__tablename__ = "delivery_packages"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("generation_tasks.id"), nullable=False
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(PackageStatus, values_callable=lambda x: [e.value for e in x]),
|
||||
default=PackageStatus.PENDING, nullable=False,
|
||||
)
|
||||
package_path: Mapped[str | None] = mapped_column(String(512))
|
||||
download_url: Mapped[str | None] = mapped_column(String(512))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
68
backend/app/models/user.py
Normal file
68
backend/app/models/user.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
app/models/user.py — users / login_records / user_preferences
|
||||
Alembic 001:从 banana 搬,删除 credits 字段(架构方案规定)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, DateTime, ForeignKey,
|
||||
Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
# 注:删除 banana 的 credits 字段(架构方案规定)
|
||||
|
||||
login_records: Mapped[list["LoginRecord"]] = relationship(
|
||||
back_populates="user", lazy="noload"
|
||||
)
|
||||
|
||||
|
||||
class LoginRecord(Base):
|
||||
__tablename__ = "login_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="login_records")
|
||||
|
||||
|
||||
class UserPreference(Base):
|
||||
"""UI 设置偏好(主题/语言等),不含 API Key。"""
|
||||
__tablename__ = "user_preferences"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"),
|
||||
unique=True, nullable=False,
|
||||
)
|
||||
preferences_json: Mapped[str | None] = mapped_column(Text) # JSON字符串
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
88
backend/app/models/workspace.py
Normal file
88
backend/app/models/workspace.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
app/models/workspace.py — workspaces / workspace_members / user_api_keys
|
||||
Alembic 002:多租户基础,全新建。
|
||||
matrix_accounts 二期预留,一期不建。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, DateTime, Enum, ForeignKey,
|
||||
Index, String, UniqueConstraint, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.constants.enums import UserRole
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Workspace(Base):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
members: Mapped[list["WorkspaceMember"]] = relationship(
|
||||
back_populates="workspace", lazy="noload"
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMember(Base):
|
||||
__tablename__ = "workspace_members"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
Enum(UserRole, values_callable=lambda x: [e.value for e in x]),
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
workspace: Mapped["Workspace"] = relationship(back_populates="members")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),
|
||||
)
|
||||
|
||||
|
||||
class UserApiKey(Base):
|
||||
"""
|
||||
个人 API Key(Fernet 加密)。
|
||||
encrypted_key 只存密文,不存 url(token站固定自家站)。
|
||||
UNIQUE(user_id, workspace_id, provider)。
|
||||
"""
|
||||
__tablename__ = "user_api_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
encrypted_key: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
key_last4: Mapped[str] = mapped_column(String(4), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id", "workspace_id", "provider",
|
||||
name="uq_user_workspace_provider",
|
||||
),
|
||||
Index("idx_user_api_keys_workspace_id", "workspace_id"),
|
||||
)
|
||||
3
backend/app/repositories/__init__.py
Normal file
3
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
app/repositories/__init__.py
|
||||
"""
|
||||
85
backend/app/repositories/base_workspace_repo.py
Normal file
85
backend/app/repositories/base_workspace_repo.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
app/repositories/base_workspace_repo.py — 多租户基础 Repo
|
||||
所有 workspace 相关 Repo 继承此类,强制过滤 workspace_id(基石C)。
|
||||
禁止 SELECT *,明确字段(db.md规范)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import raise_not_found
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class BaseWorkspaceRepo(Generic[T]):
|
||||
"""
|
||||
workspace 感知的基础 Repo。
|
||||
子类设置 model_class,所有查询自动注入 workspace_id 过滤。
|
||||
"""
|
||||
|
||||
model_class: type[T]
|
||||
|
||||
def __init__(self, db: Session, workspace_id: int):
|
||||
self.db = db
|
||||
self.workspace_id = workspace_id
|
||||
|
||||
def _base_query(self):
|
||||
"""所有查询的基础:强制带 workspace_id 过滤(多租户红线)。"""
|
||||
return self.db.query(self.model_class).filter(
|
||||
self.model_class.workspace_id == self.workspace_id # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
def get_by_id(self, record_id: int) -> T | None:
|
||||
"""按 ID 查单条,强制带 workspace_id 防越权读。"""
|
||||
return self._base_query().filter(
|
||||
self.model_class.id == record_id # type: ignore[attr-defined]
|
||||
).first()
|
||||
|
||||
def get_by_id_or_404(self, record_id: int) -> T:
|
||||
obj = self.get_by_id(record_id)
|
||||
if not obj:
|
||||
raise_not_found(f"{self.model_class.__name__} {record_id} 不存在")
|
||||
return obj # type: ignore[return-value]
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
filters: list[Any] | None = None,
|
||||
) -> tuple[list[T], int]:
|
||||
"""分页列表,返回 (items, total)。"""
|
||||
q = self._base_query()
|
||||
if filters:
|
||||
q = q.filter(*filters)
|
||||
total = q.count()
|
||||
items = q.offset(offset).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
def create(self, obj: T) -> T:
|
||||
"""插入一条记录,自动设置 workspace_id。"""
|
||||
obj.workspace_id = self.workspace_id # type: ignore[attr-defined]
|
||||
self.db.add(obj)
|
||||
self.db.flush()
|
||||
self.db.refresh(obj)
|
||||
logger.debug("Created %s id=%s ws=%s", self.model_class.__name__, obj.id, self.workspace_id) # type: ignore[attr-defined]
|
||||
return obj
|
||||
|
||||
def delete(self, obj: T) -> None:
|
||||
"""物理删除(软删各子类自己处理 archived 态)。"""
|
||||
self.db.delete(obj)
|
||||
self.db.flush()
|
||||
|
||||
def save(self) -> None:
|
||||
"""提交事务,错误不静默(官网V1坑3)。"""
|
||||
try:
|
||||
self.db.commit()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
logger.error("DB commit failed in %s ws=%s", self.model_class.__name__, self.workspace_id)
|
||||
raise
|
||||
13
backend/app/services/README.md
Normal file
13
backend/app/services/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# app/services/
|
||||
|
||||
业务逻辑层占位,按模块:
|
||||
- auth_service.py # JWT签发/验证
|
||||
- workspace_service.py # workspace权限校验
|
||||
- product_service.py # 产品档案业务逻辑
|
||||
- task_service.py # 任务状态机流转
|
||||
- review_service.py # 审核流转+飞轮信号写入
|
||||
- preference_aggregator.py # 飞轮实时聚合(最近50条→prompt片段)
|
||||
- preference_collector.py # 三信号入口写入 preference_events
|
||||
- banned_word_checker.py # 违禁词三级扫描(🟢改写/🟡提示/🔴拦截)
|
||||
- package_exporter.py # 生成达人素材交付包
|
||||
- image_postprocessor.py # 去水印后处理(重编码+削像素水印)
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
5
backend/app/services/ai_engine/__init__.py
Normal file
5
backend/app/services/ai_engine/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
AI 引擎包
|
||||
扒自:上线版 worker/src/copy.js + image.js(2026-06-09)
|
||||
重写为 Python,逻辑对照JS版防走样
|
||||
"""
|
||||
104
backend/app/services/ai_engine/_score_prompt.py
Normal file
104
backend/app/services/ai_engine/_score_prompt.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
_score_prompt.py — AI 评委 prompt(让模型真读文案,不机械找词)
|
||||
评判标准忠于《富贵情绪营销理论》原文(口播一手来源),标实战补充出处。
|
||||
6维满分分布(倩倩姐2026-06-15拍板,与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
|
||||
痛点人群精准18 / 情绪张力18 / 买点转化18 / 开头钩子15 / 标题点击力13 / 真实感13 = 95
|
||||
+ 合规5(机械硬拦,不进AI评委)= 100
|
||||
"真实感"替换旧"产品聚焦一件事(16)":富贵"很少提产品/前70%干货后30%植入"独立升维。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# ── 评委人设 ──────────────────────────────────────────────
|
||||
SCORER_PERSONA = """你是一位资深小红书内容操盘手,深谙富贵情绪营销理论。
|
||||
你的本事是:扫一眼就知道一条笔记能不能打动目标用户、会不会被划走。
|
||||
你不数关键词、不看有没有出现某个固定词——你读的是【这条文案对真实用户有没有杀伤力】。
|
||||
你按下面6个维度给文案打分,每一维都要给出【具体理由】,指出好在哪/差在哪/怎么改,
|
||||
理由必须针对这条文案的真实内容,不准说放之四海皆准的空话。"""
|
||||
|
||||
# ── 6 维评判标准(按权重降序;合规第7维由代码机械硬拦,不在此)────
|
||||
# 标准依据:括号内标注[富贵原文]或[实战补充],前者来自口播一手来源,权威最高。
|
||||
SCORING_DIMENSIONS = """
|
||||
【维度1·痛点人群精准(满分18)】[富贵原文]
|
||||
判断:"说的就是我"——文案描述的处境/困扰,目标用户读了会不会对号入座。
|
||||
好:具体到某类人的某个真实生活瞬间,让人觉得被看穿,落在"我的大问题/我的处境/我的渴望"上。
|
||||
差:泛泛而谈谁都能套(如"适合所有想变美的女生");或PUA用户、拿别人的惨状吓唬人。
|
||||
依据:富贵"我的大问题→处境→渴望"内容骨架;人群越具体穿透力越强;"用户被宠成爹,你PUA他不好使"。
|
||||
|
||||
【维度2·情绪张力(满分18)】[富贵原文·第一性原理]
|
||||
判断:有没有"成果/后果"双向情绪,而不是平铺直叙报卖点。
|
||||
· 后果=过去没用它,产生了什么糟糕处境(勾起恐惧/懊悔)
|
||||
· 成果=用了它之后,会变成什么样(给出期待/乐观)
|
||||
好:同时有"后果路径(过去的痛)+成果路径(未来的好)",有一句能戳中人、读完有情绪余温。
|
||||
差:全程客观介绍产品、无情绪、像说明书;或只单向吓唬、或只空喊美好。
|
||||
依据:富贵"营销第一性原理就是情绪,没有情绪什么内容都不转化";"成果是未来、后果是过去,要做这两种情绪"。
|
||||
|
||||
【维度3·买点转化(满分18)】[富贵原文]
|
||||
判断:产品卖点有没有翻译成用户能感知的场景化利益(人话),而不是品牌视角的功效/参数。
|
||||
好:用户能想象到的使用场景和结果(如"出门前最后一步、同事问我今天气色真好")。
|
||||
差:品牌语言/参数罗列(如"采用XX技术""含XX成分"),用户无感。
|
||||
依据:富贵"卖点是品牌视角、买点是用户视角";"用户买的不是产品,是使用场景背后被解决的问题"。
|
||||
|
||||
【维度4·开头钩子(满分15)】[富贵原文]
|
||||
判断:开头能不能让人停下来、想继续读。
|
||||
好:开头第一句就直击用户的大问题/痛处/具体场景代入,每句都打要害。
|
||||
差:开头是套话或铺垫半天不进正题,没有任何抓人的点。
|
||||
依据:富贵"内容就是锋利的刀,一定要插用户的心窝子"。
|
||||
|
||||
【维度5·标题点击力(满分13)】[富贵原文+实战补充]
|
||||
判断:标题有没有让目标用户想点的诱因。
|
||||
好:标题带具体人群/场景/痛点/情绪钩子,一眼觉得"和我有关、我想看",最好来自用户真实说法。
|
||||
差:只有产品名、平淡无钩子、或太像广告。
|
||||
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文];标题善用痛点/人群/效果/情绪词[实战补充]。
|
||||
|
||||
【维度6·真实感(满分13)】[富贵原文]
|
||||
判断:整条文案是不是像真人分享,而不是品牌广告或功效说明书。
|
||||
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格。
|
||||
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板而非真实人设。
|
||||
依据:富贵"我们很少提产品";"前70%是价值/场景,后30%才是产品";用户能感受到"这不像广告"。
|
||||
""".strip()
|
||||
|
||||
# ── 真实感已升为维度6(满分13),此处保留空字符串避免调用方引用报错 ──
|
||||
REALNESS_NOTE = "" # 升为 SCORING_DIMENSIONS 维度6,不再重复附加
|
||||
|
||||
# ── 输出格式约束 ──────────────────────────────────────────
|
||||
SCORER_OUTPUT_FORMAT = """
|
||||
读完这条文案后,严格返回纯JSON对象(不要markdown代码块、不要多余文字),格式:
|
||||
{
|
||||
"dims": [
|
||||
{"item":"痛点人群精准","score":<0-18整数>,"reason":"<针对本条的具体理由,30字内>"},
|
||||
{"item":"情绪张力","score":<0-18>,"reason":"..."},
|
||||
{"item":"买点转化","score":<0-18>,"reason":"..."},
|
||||
{"item":"开头钩子","score":<0-15>,"reason":"..."},
|
||||
{"item":"标题点击力","score":<0-13>,"reason":"..."},
|
||||
{"item":"真实感","score":<0-13>,"reason":"..."}
|
||||
],
|
||||
"verdict":"<优秀|合格|不合格>",
|
||||
"summary":"<一句话总评,说清这条最大的优点和最该改的点>"
|
||||
}
|
||||
打分要敢拉开差距:平庸文案该给中低分,不要清一色高分;优秀的地方也别吝啬给高分。
|
||||
""".strip()
|
||||
|
||||
# 合规维度满分(机械硬拦,不进 AI 评委)
|
||||
COMPLIANCE_MAX = 5
|
||||
|
||||
# AI 评委 6 维满分合计(用于把 0-95 折算/校验;+合规5=100)
|
||||
AI_DIMS_MAX = 95
|
||||
|
||||
|
||||
def build_score_prompt(copy: dict, product: dict | None = None) -> str:
|
||||
"""组装单条文案的评委 prompt。copy={title,content,...},product 提供品牌/品类语境。"""
|
||||
title = str(copy.get("title", "")).strip()
|
||||
content = str(copy.get("content", "")).strip()
|
||||
ctx = ""
|
||||
if product:
|
||||
name = product.get("name") or product.get("title") or ""
|
||||
brand = product.get("brand_keyword") or product.get("brand") or ""
|
||||
cat = product.get("category") or ""
|
||||
bits = [b for b in (f"产品:{name}", f"品牌词:{brand}", f"品类:{cat}") if b.split(":", 1)[1]]
|
||||
if bits:
|
||||
ctx = "【产品语境】\n" + "\n".join(bits) + "\n\n"
|
||||
return (
|
||||
f"{SCORING_DIMENSIONS}\n\n"
|
||||
f"{ctx}【待评文案】\n标题:{title}\n正文:{content}\n\n"
|
||||
f"{SCORER_OUTPUT_FORMAT}"
|
||||
)
|
||||
92
backend/app/services/ai_engine/_scoring_dims.py
Normal file
92
backend/app/services/ai_engine/_scoring_dims.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
_scoring_dims.py — 五维打分逻辑(单一职责:计算层)
|
||||
词表常量 + 每维打分函数,由 text_scoring.score_copy 调用
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .constants import SCORE_WEIGHTS, BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, INTERNAL_COPY_HINTS
|
||||
|
||||
_EMOTION_WORDS = ["谁懂", "绝了", "姐妹", "宝子", "挖到", "救星", "离不开",
|
||||
"闭眼入", "冲", "不踩雷", "后悔没早买"]
|
||||
_SCENE_WORDS = ["通勤", "上班", "办公室", "工位", "宿舍", "出门", "旅行",
|
||||
"居家", "运动", "带娃", "约会", "熬夜", "换季", "饭后",
|
||||
"早餐", "下午", "加班", "外食", "日常", "早八", "学生党", "新手", "宝妈"]
|
||||
_CATEGORY_WORDS: dict[str, list[str]] = {
|
||||
"美妆护肤": ["肤", "妆", "质地", "服帖", "清透", "水润", "自然", "上脸", "底妆"],
|
||||
"个护护理": ["手", "护理", "干", "黏", "吸收", "滋润", "粗糙", "倒刺", "随身", "质地"],
|
||||
"食品饮品": ["口感", "入口", "味道", "冲泡", "杯", "工位", "囤", "不腻", "清爽"],
|
||||
"营养健康": ["成分", "日常", "坚持", "习惯", "补给", "安心", "配方"],
|
||||
"家居生活": ["收纳", "省事", "清洁", "桌面", "厨房", "租房", "细节", "高频"],
|
||||
"服饰穿搭": ["上身", "版型", "面料", "通勤", "显瘦", "搭配", "质感"],
|
||||
"通用好物": ["实用", "场景", "细节", "省事", "日常", "高频"],
|
||||
}
|
||||
|
||||
|
||||
def _has_any(text: str, words: list[str]) -> bool:
|
||||
return any(w.lower() in text.lower() for w in words)
|
||||
|
||||
|
||||
def _cat_words(category: str) -> list[str]:
|
||||
return _CATEGORY_WORDS.get(category, _CATEGORY_WORDS["通用好物"])
|
||||
|
||||
|
||||
def score_title(title: str, cat_words: list[str], w: dict) -> dict:
|
||||
ts = 0
|
||||
if 6 <= len(title) <= 34: ts += 8
|
||||
if re.search(r"[0-9一二三四五六七八九十几]|最近|这支|这个|每天|早八|学生党|新手|宝妈|懒人|伪素颜", title): ts += 5
|
||||
if _has_any(title, _SCENE_WORDS): ts += 6
|
||||
if _has_any(title, cat_words): ts += 5
|
||||
if re.search(r"[!!??]|救星|不踩雷|闭眼入|会回购|被问|惊喜|加分|常备|省心|实用|别乱选|放心|有气色", title): ts += 6
|
||||
if re.search(r"救星|绝了|挖到|偷偷|被问|离不开|后悔|拿捏|香|包里|回购|常备|工位|换季", title): ts += 4
|
||||
ts = min(ts, w["title"])
|
||||
return {"item": "标题吸引力", "score": ts, "max": w["title"],
|
||||
"note": "标题有明确人群/场景钩子" if ts >= 18 else "建议补充人群、场景或强钩子"}
|
||||
|
||||
|
||||
def score_emotion(full: str, w: dict) -> dict:
|
||||
es = 0
|
||||
if _has_any(full, _EMOTION_WORDS): es += 10
|
||||
if _has_any(full, _SCENE_WORDS): es += 8
|
||||
if re.search(r"不假白|不卡|不搓泥|没底气|纠结|救|不黏|不腻|踩雷|麻烦|翻车", full): es += 7
|
||||
if re.search(r"姐妹|宝子|室友|同事|我自己|实测|亲测|上脸|出门|工位|宿舍|家里", full): es += 5
|
||||
if re.search(r"[✅✨🌿💧📦🔍🧡🪞🧴🍃🥹😭👍]", full): es += 3
|
||||
es = min(es, w["emotion"])
|
||||
return {"item": "情绪共鸣", "score": es, "max": w["emotion"],
|
||||
"note": "口语感和痛点表达较充分" if es >= 18 else "建议增加真实痛点和口语化表达"}
|
||||
|
||||
|
||||
def score_selling(copy: dict, full: str, selling_points: list, w: dict) -> dict:
|
||||
bs = 0
|
||||
matched = [pt for pt in selling_points if any(kw in full for kw in str(pt).split()[:3])]
|
||||
if len(matched) >= 1: bs += 7
|
||||
if len(matched) >= 2: bs += 4
|
||||
if re.search(r"方便|自然|清爽|质地|口感|成分|实测|亲测|场景|随身|省事|高频|性价比|好用|适合|推荐", full): bs += 8
|
||||
if copy.get("buyingPoint") or re.search(r"分钟|出门|通勤|办公室|宿舍|居家|换季|工位|旅行", full): bs += 9
|
||||
if copy.get("coverTitle") or copy.get("imageBrief"): bs += 4
|
||||
bs = min(bs, w["selling"])
|
||||
return {"item": "买点表达", "score": bs, "max": w["selling"],
|
||||
"note": "卖点已转成用户可感知买点" if bs >= 18 else "建议把功能卖点翻译成使用场景和结果"}
|
||||
|
||||
|
||||
def score_keyword(copy: dict, tags: str, keywords: list, w: dict) -> dict:
|
||||
ks = 0
|
||||
matched = [k for k in keywords if str(k).replace("#", "") in tags + str(copy.get("content",""))]
|
||||
if len(matched) >= 1: ks += 6
|
||||
if len(matched) >= 2: ks += 5
|
||||
if len(copy.get("tags", [])) >= 3: ks += 5
|
||||
if "#" in tags: ks += 4
|
||||
if len(copy.get("tags", [])) >= 5: ks += 4
|
||||
ks = min(ks, w["keyword"])
|
||||
note = f"覆盖:{'、'.join(matched)}" if matched else "建议补充品类词和长尾词"
|
||||
return {"item": "关键词覆盖", "score": ks, "max": w["keyword"], "note": note}
|
||||
|
||||
|
||||
def score_compliance(full: str, bwords: list[str], w: dict) -> tuple[dict, list[str]]:
|
||||
found_banned = [bw for bw in bwords if bw.lower() in full.lower()]
|
||||
found_hints = [hw for hw in INTERNAL_COPY_HINTS if hw in full]
|
||||
cs = 0 if (found_banned or found_hints) else w["compliance"]
|
||||
note = (f"含禁用词:{'、'.join(found_banned)}" if found_banned
|
||||
else (f"正文混入内部提示:{'、'.join(found_hints)}" if found_hints else "未发现禁用词"))
|
||||
return {"item": "合规性", "score": cs, "max": w["compliance"], "note": note}, found_banned + found_hints
|
||||
64
backend/app/services/ai_engine/_storyboard_data.py
Normal file
64
backend/app/services/ai_engine/_storyboard_data.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
_storyboard_data.py — 品类证明策略数据(纯数据,不含逻辑)
|
||||
品类来自 product.category,不枚举,兜底用"通用好物"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# 视觉违禁词替换规则(扒 sanitizeImagePlanText)
|
||||
SANITIZE_RULES: list[tuple[str, str]] = [
|
||||
(r"before\s*&\s*after", "质地与肤感说明"),
|
||||
(r"before\s*/?\s*after", "质地与肤感说明"),
|
||||
(r"\bbefore\b", "质地状态"),
|
||||
(r"\bafter\b", "上脸肤感"),
|
||||
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"),
|
||||
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
|
||||
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
|
||||
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
|
||||
(r"治疗前后|治疗后|医美前后|治愈|修复受损", "日常使用场景说明"),
|
||||
]
|
||||
|
||||
# 品类证明策略(不写死枚举,product.category 匹配,兜底通用)
|
||||
PROOF_STRATEGIES: dict[str, dict] = {
|
||||
"个护护理": {
|
||||
"overlay_tpl": "{point}看得见",
|
||||
"visual": "手部/身体局部使用证明:少量点涂、推开后吸收状态、真实纹理和自然光",
|
||||
"asset_use": "优先使用实拍/参考图中的手部、干纹、涂抹、随身场景",
|
||||
"forbidden": "不要变白、祛斑、医学效果、before/after字样;不要和封面同构图",
|
||||
},
|
||||
"美妆护肤": {
|
||||
"overlay_tpl": "{point}看得见",
|
||||
"visual": "肤感/质地证明:手背、脸颊局部或质地微距,展示推开前后真实状态",
|
||||
"asset_use": "优先使用实拍/参考图中的手背、上脸、质地素材",
|
||||
"forbidden": "不要变白、祛斑、医学效果、before/after字样",
|
||||
},
|
||||
"食品饮品": {
|
||||
"overlay_tpl": "{point}一眼懂",
|
||||
"visual": "冲泡/开袋/入口证明:展示包装、杯中状态、质地颜色,真实桌面光线",
|
||||
"asset_use": "产品图保证包装准确,参考图用于杯子、开袋、冲泡、居家场景",
|
||||
"forbidden": "不要涂抹、不要护肤肤感、不要医疗健康承诺",
|
||||
},
|
||||
"营养健康": {
|
||||
"overlay_tpl": "看清{point}",
|
||||
"visual": "理性证明页:包装、成分表、使用场景和每日习惯卡片",
|
||||
"asset_use": "产品图和说明图用于成分/包装准确,参考图用于日常使用场景",
|
||||
"forbidden": "不要治疗、改善疾病、速效、医生背书、前后对比",
|
||||
},
|
||||
"家居生活": {
|
||||
"overlay_tpl": "{point}真省事",
|
||||
"visual": "使用过程证明:展示痛点场景、产品介入和使用过程细节",
|
||||
"asset_use": "参考图用于真实家居环境,产品图保证外观准确",
|
||||
"forbidden": "不要护肤涂抹,不要虚假夸大结果",
|
||||
},
|
||||
"服饰穿搭": {
|
||||
"overlay_tpl": "{point}有细节",
|
||||
"visual": "上身/材质证明:展示面料纹理、版型细节或普通身材上身局部",
|
||||
"asset_use": "参考图用于上身/搭配/材质,产品图保证款式颜色准确",
|
||||
"forbidden": "不要护肤涂抹,不要过度精修模特感",
|
||||
},
|
||||
"通用好物": {
|
||||
"overlay_tpl": "{point}清晰可见",
|
||||
"visual": "产品使用场景证明:真实道具/场景,展示产品细节和使用过程",
|
||||
"asset_use": "产品图保证准确,参考图用于场景辅助",
|
||||
"forbidden": "不要夸大效果,不要硬广式价格牌",
|
||||
},
|
||||
}
|
||||
209
backend/app/services/ai_engine/_text_prompt.py
Normal file
209
backend/app/services/ai_engine/_text_prompt.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
_text_prompt.py — 文案 prompt 组装 / JSON 解析 / 本地模板兜底
|
||||
方法层(全品类共用):人设/变量池/5步框架/四段结构/反AI味规则
|
||||
数据层(每产品各异):由 product dict 动态注入,代码不出现具体品牌/成分名
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
|
||||
# ── Q4 佛系反推销人设(全品类共用,数据层产品名从product动态注入)──────────
|
||||
_PERSONA = """你是一个日常生活分享博主,不是品牌推广号。
|
||||
核心人设:佛系、不推销、真实记录日常。
|
||||
内核:不主动劝买,靠真实体验让读者自己动心;写的是生活,产品只是生活的一部分。
|
||||
比例:70% 写生活场景和使用感受,30% 提产品,绝不颠倒。
|
||||
收尾铁律:每条结尾方式必须不同,禁止使用"东西放这了/买不买跟我没关系"这类被用滥的固定句式。"""
|
||||
|
||||
# ── Q1 随机变量池 ABC(反同质化核心,每次随机抽组合,N条不撞)──────────────
|
||||
# 方法层:框架固定,内容可扩展,绝不写死
|
||||
_POOL_A_IDENTITY: list[str] = [
|
||||
"上班族早八妆前随手抹",
|
||||
"宿舍懒人护肤三分钟搞定",
|
||||
"敏感肌妈妈哄完娃才有五分钟",
|
||||
"学生党第一次用高价护肤品",
|
||||
"素颜出门前最后一步",
|
||||
]
|
||||
|
||||
_POOL_B_EMOTION: list[str] = [
|
||||
"看到镜子里发现气色暗了一周",
|
||||
"闺蜜问你最近皮肤怎么这么好",
|
||||
"出门被催快点根本来不及叠瓶",
|
||||
]
|
||||
|
||||
_POOL_C_FLAW: list[str] = [
|
||||
"第一次用量太少了没啥感觉",
|
||||
"包装简单到以为是山寨",
|
||||
"价格摆在那以为会很油很厚",
|
||||
]
|
||||
|
||||
|
||||
def _pick_combo() -> dict[str, str]:
|
||||
"""随机抽一组 ABC 变量(每次生成调用一次,N条各不相同)"""
|
||||
return {
|
||||
"identity": random.choice(_POOL_A_IDENTITY),
|
||||
"emotion": random.choice(_POOL_B_EMOTION),
|
||||
"flaw": random.choice(_POOL_C_FLAW),
|
||||
}
|
||||
|
||||
|
||||
# ── Q5 negative词:prompt级别负向约束,不让AI写进正文 ─────────────────────
|
||||
_NEGATIVE_WORDS = (
|
||||
"神器、福音、救急单品、遮羞布、日常维稳、精简底妆、"
|
||||
"不仅而且、焕发、守护、尽享、日常维稳、"
|
||||
"按头安利、绝绝子、闭眼冲、杀疯了、YYDS"
|
||||
)
|
||||
|
||||
# ── emoji 规则(适度有表情,倩倩姐2026-06-08拍板:像真人发的小红书)──────────
|
||||
_EMOJI_RULES = """
|
||||
【emoji表情(适度有表情,必须遵守)】
|
||||
- 卖点小标题前加 emoji:✅ 卖点1 / ✨ 卖点2 / 🌿 卖点3(每条卖点1个,符合语义)
|
||||
- 正文段落可点缀少量 emoji 烘托情绪(如 🥹 😭 🤍 💛),每段最多1-2个,不堆砌
|
||||
- 结尾话题标签前后带表情,如 "#好物分享 🛒"
|
||||
- emoji 服务情绪和分点,不要每句都加;整条正文 emoji 总量控制在 6-12 个
|
||||
- 常用小红书 emoji 池:✅✨🌿💧🪞🧴📦🔍💛🤍🥹😭🛒(按语义选,不乱用)
|
||||
""".strip()
|
||||
|
||||
# ── Q2 5步框架 + Q3 四段结构 ─────────────────────────────────────────────
|
||||
_STRUCTURE_RULES = """
|
||||
【5步框架(必须严格遵循)】
|
||||
① Hook暴击低价/痛点:第一句戳中场景或价格锚点,吊足读者好奇
|
||||
② 痛点共鸣:2-3句描写使用前的真实困境(用上面抽到的起因情绪A·B)
|
||||
③ 救星登场:自然带出产品,口吻是"碰巧发现/朋友安利/囤货时顺手",不是"推荐给你"
|
||||
④ 卖点罗列(每条加✅小标题):3条以内,卖点翻译成使用感受,不是功效列表
|
||||
⑤ 收尾(每条必须从下方策略池随机选一种,同批次不得重复同一种,禁止"东西放这了/买不买跟我没关系"此类固定句式):
|
||||
【收尾策略池·每条选不同策略】
|
||||
A·留白式感受:只说自己现在的状态,不提买不买,如"反正现在素颜出门我不慌了"
|
||||
B·反问读者:把感受抛回给读者,如"你们护肤有没有那种一用就回不去的东西?"
|
||||
C·场景延续:把故事延伸到未来某个细节,如"下次同事再问我皮肤的事我就知道说啥了"
|
||||
D·克制回购暗示:轻描淡写说自己行为,如"第一罐用完了,已经在备第二罐"
|
||||
E·纯记录收笔:像日记最后一句,不引导不评价,如"大概就这样,记录一下"
|
||||
F·引导搜索(仅在有品牌词时使用):自然提一句,如"感兴趣可以搜搜『{品牌词}』",不催单
|
||||
|
||||
【正文四段结构(必须)】
|
||||
段1·痛点引入:描写使用前的困境/触发场景(身份场景A·起因情绪B)
|
||||
段2·实测记录:真实使用过程,带上小缺点C(真实感来源)
|
||||
段3·种草核心:产品带来的变化,用感受描述而非功效声称
|
||||
段4·引导收尾:从收尾策略池随机选一种,佛系口吻,不强推,末尾带1-2个相关话题标签
|
||||
|
||||
【字数】正文350-400字(不含标题tags),3-5处段落空行增强可读性
|
||||
""".strip()
|
||||
|
||||
# ── Q8 标题公式(5类结构,每批次覆盖不同类型,不重复)──────────────────────
|
||||
_TITLE_FORMULA = """
|
||||
【标题公式(5类,每条用不同类型,禁止同批重复)】
|
||||
肤质型:「{肤质}+用了{产品}+{感受}」例→"油皮用了素颜霜整个夏天不脱妆"
|
||||
价格型:「{价格锚点}+{产品}+{功效感受}」例→"三位数买到大牌平替,用完第一罐回购第二罐"
|
||||
功效型:「{使用场景}+{产品}+{可感知变化}」例→"早起素颜出门靠这罐,同事问我最近皮肤怎么了"
|
||||
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神奇产品,涂上去感觉素颜也能出门了"
|
||||
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"被人说皮肤变好了,没说的是我用了它一个月"
|
||||
硬性约束:标题≤20字;禁止出现"绝绝子/YYDS/杀疯了";不直接写功效词(美白/祛斑等)。
|
||||
""".strip()
|
||||
_ANTI_AI = f"""
|
||||
【反AI味必须遵守】
|
||||
- 禁止固定开篇套话:不许"姐妹们/宝子们/今天给大家分享"开头
|
||||
- 禁止以下AI味词出现在正文:{_NEGATIVE_WORDS}
|
||||
- 禁止人群重叠:{'{count}'}条文案中身份场景不能重复(靠变量池ABC保证)
|
||||
- 禁止场景重复:同批次文案不能都是"早上上班"或都是"学生宿舍"
|
||||
- 避免生硬推销词:按头安利/绝绝子/闭眼冲 不能出现
|
||||
""".strip()
|
||||
|
||||
# ── 系统 prompt(合并Q2/Q3/Q4/Q7/Q8,数据层走build_prompt动态注入)──────────
|
||||
COPY_SYSTEM = f"""{_PERSONA}
|
||||
|
||||
合规红线(全品类通用):
|
||||
- 禁用"美白、祛斑、速效、医用、药妆",不得暗示治疗或改善疾病
|
||||
- 不得承诺效果,不得出现before/after变白对比暗示
|
||||
- 图文合规:避免社交App界面、点赞评论等截图元素
|
||||
|
||||
{_ANTI_AI.format(count="N")}
|
||||
|
||||
{_TITLE_FORMULA}
|
||||
|
||||
{_EMOJI_RULES}
|
||||
|
||||
{_STRUCTURE_RULES}
|
||||
|
||||
返回纯JSON数组,每条字段:
|
||||
title(标题≤20字)/ content(正文,严格350-400字,按emoji规则适度带表情)/ tags(list,3-5个#话题)
|
||||
angle(本条角度标签)/ coverTitle(封面大字≤10字)/ imageBrief(配图方向)
|
||||
|
||||
硬性格式:只输出JSON,不要markdown代码块,字符串内用中文引号「」。"""
|
||||
|
||||
|
||||
def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
|
||||
"""
|
||||
组装文案生成 user_prompt。
|
||||
数据层:product 动态注入(name/selling_points/style_tone/text_angles/custom_prompt)
|
||||
方法层:已在 COPY_SYSTEM 固定,这里只注入产品数据+随机变量
|
||||
"""
|
||||
name = product.get("name", "产品")
|
||||
selling = "、".join(product.get("selling_points") or ["核心卖点待录入"])
|
||||
style = product.get("style_tone", "素人日常分享风")
|
||||
angles = product.get("text_angles") or []
|
||||
custom = (product.get("custom_prompt") or "").strip()
|
||||
brand_kw = (product.get("brand_keyword") or "").strip()
|
||||
|
||||
# Q1:每条抽一组随机变量,传给模型作角色约束
|
||||
combos = [_pick_combo() for _ in range(count)]
|
||||
combos_text = "\n".join(
|
||||
f" 第{i+1}条:身份场景=「{c['identity']}」·起因情绪=「{c['emotion']}」·小缺点=「{c['flaw']}」"
|
||||
for i, c in enumerate(combos)
|
||||
)
|
||||
|
||||
angle_hint = f"文案角度要覆盖:{'、'.join(angles)}(每条用不同角度)。" if angles else ""
|
||||
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else ""
|
||||
|
||||
lines = [
|
||||
f"产品:{name}",
|
||||
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效词;翻译范例:'烟酰胺'→'熬夜后第二天脸不那么黄了','高保湿'→'涂上去一整天都没搓泥拔干'):{selling}",
|
||||
f"风格调性:{style}",
|
||||
angle_hint,
|
||||
brand_rule,
|
||||
custom,
|
||||
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】",
|
||||
combos_text,
|
||||
extra_rules,
|
||||
f"\n请严格按5步框架+四段结构生成 {count} 条,每条350-400字,返回纯JSON数组。",
|
||||
]
|
||||
return "\n".join(l for l in lines if l)
|
||||
|
||||
|
||||
def parse_json_array(raw: str) -> list[dict]:
|
||||
"""从模型输出提取 JSON 数组(容错 markdown 包裹)"""
|
||||
text = re.sub(r"```(?:json)?", "", raw).strip()
|
||||
start, end = text.find("["), text.rfind("]")
|
||||
if start == -1 or end == -1:
|
||||
return []
|
||||
try:
|
||||
return json.loads(text[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
|
||||
def build_local_drafts(product: dict, count: int) -> list[dict]:
|
||||
"""本地模板兜底(保证永不空手,遵循四段结构)
|
||||
角度从 text_angles 循环取(保证N条角度各不同,不被 dedupe_copies 吞掉)
|
||||
"""
|
||||
name = product.get("name", "产品")
|
||||
points = product.get("selling_points") or ["使用方便", "真实可感知"]
|
||||
# 从产品档案取角度池,不够就用通用角度兜底,保证每条都不同
|
||||
_fallback_angles = ["生活场景型", "成分分析型", "使用感受型", "性价比型", "痛点切入型"]
|
||||
angles_pool = product.get("text_angles") or _fallback_angles
|
||||
for i in range(count):
|
||||
c = _pick_combo()
|
||||
# 循环取不同角度(角度相同的两条会被 dedupe_copies 过滤掉,所以必须不重复)
|
||||
angle = angles_pool[i % len(angles_pool)]
|
||||
yield {
|
||||
"title": f"发现一个{name}的{angle}用法",
|
||||
"content": (
|
||||
f"{c['identity']},{c['emotion']}。\n\n"
|
||||
f"用了一段时间,{points[i % len(points)]}这点最让我意外。\n\n"
|
||||
f"说个小缺点:{c['flaw']},后来才摸到感觉。\n\n"
|
||||
f"反正现在用顺手了。✅"
|
||||
),
|
||||
"tags": [f"#{name}", "#真实测评", "#好物分享"],
|
||||
"angle": angle,
|
||||
"coverTitle": f"{name}{angle}",
|
||||
"imageBrief": "封面产品近景,内页核心卖点+真实使用场景。",
|
||||
}
|
||||
125
backend/app/services/ai_engine/banned_word_checker.py
Normal file
125
backend/app/services/ai_engine/banned_word_checker.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
违禁词三级处理(扒 copy.js sanitizePlanningText 扩展为三级)
|
||||
🟢 auto_fix = 自动改写(replacement 字段给出替换词)
|
||||
🟡 soft_warn = 软提示(返回建议词,不阻塞)
|
||||
🔴 hard_block= 硬拦截(直接返回 None,拦住发布)
|
||||
|
||||
词库来自数据库 banned_words 表(level + replacement 字段),
|
||||
DB 未配时用本模块内置默认词库作冷启动。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
BannedLevel = Literal["auto_fix", "soft_warn", "hard_block"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BannedWordEntry:
|
||||
word: str
|
||||
level: BannedLevel
|
||||
replacement: str | None = None # auto_fix 时提供替换词
|
||||
|
||||
|
||||
# ── 默认词库(北哥回填解读与落点 §4.3,数据库未配时使用)─
|
||||
DEFAULT_BANNED_WORDS: list[BannedWordEntry] = [
|
||||
# 功效违禁(auto_fix:改写成合规表达,对应北哥"提亮肤色感/改善暗沉观感")
|
||||
BannedWordEntry("美白", "auto_fix", "提亮肤色感"),
|
||||
BannedWordEntry("祛斑", "auto_fix", "改善暗沉观感"),
|
||||
# 功效违禁(hard_block:无法合规改写,直接拦截)
|
||||
BannedWordEntry("速效", "hard_block"),
|
||||
BannedWordEntry("医用", "hard_block"),
|
||||
BannedWordEntry("药妆", "hard_block"),
|
||||
BannedWordEntry("强效焕白", "hard_block"),
|
||||
# 保证性词(soft_warn)
|
||||
BannedWordEntry("绝对", "soft_warn"),
|
||||
BannedWordEntry("第一名", "soft_warn"),
|
||||
BannedWordEntry("再也不", "soft_warn"),
|
||||
# 夸张词(soft_warn)
|
||||
BannedWordEntry("杀疯了", "soft_warn"),
|
||||
BannedWordEntry("秒杀", "soft_warn"),
|
||||
BannedWordEntry("震撼", "soft_warn"),
|
||||
# AI 味词(auto_fix,置换为口语表达;同时在 _NEGATIVE_WORDS prompt负向约束里已禁止AI写进正文)
|
||||
BannedWordEntry("神器", "auto_fix", "好用的"),
|
||||
BannedWordEntry("福音", "auto_fix", "适合的"),
|
||||
BannedWordEntry("救急单品", "auto_fix", "随手备用的"),
|
||||
BannedWordEntry("遮羞布", "auto_fix", "底妆感"), # 北哥原文补录
|
||||
BannedWordEntry("不仅而且", "auto_fix", ",另外"),
|
||||
BannedWordEntry("焕发", "auto_fix", "呈现"),
|
||||
BannedWordEntry("守护", "auto_fix", ""),
|
||||
BannedWordEntry("尽享", "auto_fix", "使用"),
|
||||
BannedWordEntry("日常维稳", "auto_fix", "日常保养"),
|
||||
BannedWordEntry("精简底妆", "auto_fix", "轻便底妆"),
|
||||
# 视觉违禁(hard_block,文案含这些词不许过)
|
||||
BannedWordEntry("前后对比", "hard_block"),
|
||||
BannedWordEntry("使用前后", "hard_block"),
|
||||
BannedWordEntry("变白", "auto_fix", "自然光泽感"),
|
||||
BannedWordEntry("瑕疵消失", "auto_fix", "妆感更服帖"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
text: str # 原文(soft_warn/hard_block 场景下保持原文)
|
||||
fixed_text: str | None # auto_fix 后的文本;其他级别为 None
|
||||
status: Literal["pass", "auto_fixed", "soft_warn", "hard_block"]
|
||||
found: list[dict] = field(default_factory=list)
|
||||
# found 每项: {"word": str, "level": BannedLevel, "replacement": str|None}
|
||||
|
||||
|
||||
def check_and_fix(
|
||||
text: str,
|
||||
entries: list[BannedWordEntry] | None = None,
|
||||
) -> CheckResult:
|
||||
"""
|
||||
对一段文本做三级违禁词扫描。
|
||||
entries:优先用 DB 词条,为 None 时用默认词库。
|
||||
"""
|
||||
word_list = entries if entries is not None else DEFAULT_BANNED_WORDS
|
||||
found: list[dict] = []
|
||||
working = text
|
||||
|
||||
# 先扫描所有命中
|
||||
for entry in word_list:
|
||||
if entry.word.lower() in working.lower():
|
||||
found.append({
|
||||
"word": entry.word,
|
||||
"level": entry.level,
|
||||
"replacement": entry.replacement,
|
||||
})
|
||||
|
||||
if not found:
|
||||
return CheckResult(text=text, fixed_text=None, status="pass", found=[])
|
||||
|
||||
# 有 hard_block → 直接拦截
|
||||
if any(f["level"] == "hard_block" for f in found):
|
||||
return CheckResult(text=text, fixed_text=None, status="hard_block", found=found)
|
||||
|
||||
# 只有 soft_warn → 软提示,不改文字
|
||||
if any(f["level"] == "soft_warn" for f in found) and \
|
||||
all(f["level"] in ("soft_warn", "auto_fix") for f in found):
|
||||
# 仍执行 auto_fix 改写,但结果状态是 soft_warn(优先级高)
|
||||
for f in found:
|
||||
if f["level"] == "auto_fix" and f["replacement"] is not None:
|
||||
working = re.sub(re.escape(f["word"]), f["replacement"], working, flags=re.IGNORECASE)
|
||||
return CheckResult(text=text, fixed_text=working, status="soft_warn", found=found)
|
||||
|
||||
# 只有 auto_fix → 自动改写,返回 fixed_text
|
||||
for f in found:
|
||||
if f["level"] == "auto_fix" and f["replacement"] is not None:
|
||||
working = re.sub(re.escape(f["word"]), f["replacement"], working, flags=re.IGNORECASE)
|
||||
return CheckResult(text=text, fixed_text=working, status="auto_fixed", found=found)
|
||||
|
||||
|
||||
def build_entries_from_db(rows: list[dict]) -> list[BannedWordEntry]:
|
||||
"""把 DB banned_words 行转成 BannedWordEntry 列表"""
|
||||
return [
|
||||
BannedWordEntry(
|
||||
word=r["word"],
|
||||
level=r["level"],
|
||||
replacement=r.get("replacement"),
|
||||
)
|
||||
for r in rows
|
||||
if r.get("word") and r.get("level") in ("auto_fix", "soft_warn", "hard_block")
|
||||
]
|
||||
139
backend/app/services/ai_engine/constants.py
Normal file
139
backend/app/services/ai_engine/constants.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
AI 引擎中心常量
|
||||
扒自:worker/src/copy.js + image.js 上线版
|
||||
业务参数不写死(基石A)——分值权重可由产品档案配置覆盖
|
||||
"""
|
||||
|
||||
# ── 合规红线 ──────────────────────────────────────────────
|
||||
# 初始默认词库(数据库 banned_words 表可覆盖,updatable=True)
|
||||
BANNED_WORDS_DEFAULT = ["美白", "祛斑", "速效", "医用", "药妆"]
|
||||
BANNED_VISUAL_WORDS = [
|
||||
"前后对比", "使用前后", "用前用后",
|
||||
"before", "after", "变白", "瑕疵消失", "治疗前后",
|
||||
]
|
||||
# 内部提示词(不能混入正文 content 字段)
|
||||
INTERNAL_COPY_HINTS = [
|
||||
"配图建议", "图片方向", "内页规划", "适合做成",
|
||||
"不要做促销海报", "配图说明", "封面建议",
|
||||
]
|
||||
|
||||
# ── 机械五维打分基线(仅轨B导入文案/降级回退用;轨A已切 AI 评委 llm_score_copy)─────
|
||||
# 历史注:轨A原用此5维,2026-06-15切至7维AI评委(6维+合规)后此处只作轨B+回退占位。
|
||||
# 关键词维度(keyword20)因 products 表无 keywords 字段导致 matched 恒空已知不准;
|
||||
# 轨A不再依赖此权重,轨B展示参考可接受。不按此调分,真过关靠北哥抽检。
|
||||
SCORE_WEIGHTS = {
|
||||
"title": 25,
|
||||
"emotion": 25,
|
||||
"selling": 25,
|
||||
"keyword": 20,
|
||||
"compliance": 5,
|
||||
}
|
||||
# ── AI 评委 7 维满分分布(倩倩姐2026-06-15拍板·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
|
||||
# 6维AI读分(痛点18+情绪18+买点18+钩子15+标题13+真实感13=95) + 合规5 = 100
|
||||
# "真实感"=富贵"很少提产品/前70%干货后30%植入"原则,替换旧机械维度"产品聚焦一件事(16)"
|
||||
AI_DIM_WEIGHTS = {
|
||||
"痛点人群精准": 18,
|
||||
"情绪张力": 18,
|
||||
"买点转化": 18,
|
||||
"开头钩子": 15,
|
||||
"标题点击力": 13,
|
||||
"真实感": 13,
|
||||
"compliance": 5, # 机械硬拦,不进 AI 评委
|
||||
}
|
||||
# 过线分。倩倩姐2026-06-15拍板:80是临时观察值(AI评委给分克制,84文案实为合格)。
|
||||
# 倩倩姐2026-06-15再次拍板:维持80临时线,不准擅自调85。方向=提生成质量顶分数,不降标准。
|
||||
# 真过关靠北哥抽检;提质量方向=优化生成 prompt,不靠提高门槛凑数。
|
||||
QUALITY_PASS_SCORE = 80
|
||||
|
||||
# ── 文案去重阈值 ──────────────────────────────────────────
|
||||
DEDUP_TITLE_THRESHOLD = 0.82 # 标题相似度≥此值判重
|
||||
DEDUP_TITLE_CONTENT_TITLE = 0.65 # 标题+正文联合判重时的标题阈值
|
||||
DEDUP_TITLE_CONTENT_BODY = 0.72 # 标题+正文联合判重时的正文阈值
|
||||
|
||||
# ── 自动优化循环 ──────────────────────────────────────────
|
||||
MAX_OPTIMIZE_ROUNDS = 2 # 最多重生成轮次
|
||||
|
||||
# ── storyboard 分镜角色(枚举不写死数量)────────────────
|
||||
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
|
||||
PAGE_ROLES = [
|
||||
{"role": "hook", "name": "封面痛点大字", "focus": "负责点击:强情绪大字标题压痛点,产品露出,真实生活场景,像用户主动分享,不像广告海报"},
|
||||
{"role": "product_closeup", "name": "单品特写", "focus": "负责种草锚点:单品高清特写+品牌词自然植入,第2/6张都带品牌词,强化记忆"},
|
||||
{"role": "ingredient", "name": "成分拆解", "focus": "负责信任:核心成分信息、作用说明,避免医疗化和绝对化表达,信息清晰可信"},
|
||||
{"role": "texture", "name": "质地展示", "focus": "负责种草:质地近景、涂抹过程、肤感说明,真实手部/桌面/日常光线"},
|
||||
{"role": "applied_proof", "name": "上脸对比", "focus": "负责证明:可感知上脸效果,展示涂抹前后质地变化(不做肤色变白/瑕疵消失等违规暗示),第5张"},
|
||||
{"role": "closer", "name": "促单收尾", "focus": "负责转化:转化句+品牌词,引导搜索品牌词成交,软性收尾不硬广,第6张再带一次品牌词"},
|
||||
# 扩展角色(8张链路用)
|
||||
{"role": "pain_scene", "name": "痛点共鸣", "focus": "负责共鸣:展示目标人群的真实困扰和使用前情境,但不做功效前后对比"},
|
||||
{"role": "social_proof","name": "信任背书", "focus": "负责背书:多人反馈、囤货、复购等真实社交证据"},
|
||||
{"role": "scenario", "name": "多场景演示", "focus": "负责代入:多场景使用展示,不做夸大效果承诺"},
|
||||
{"role": "tutorial", "name": "使用教程", "focus": "负责降低门槛:简洁步骤、用量、注意事项"},
|
||||
]
|
||||
PAGE_ROLE_MAP = {r["role"]: r for r in PAGE_ROLES}
|
||||
|
||||
# ── 生图风格预设(扒 image.js STYLE_PROMPTS:26-29)──────────
|
||||
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
|
||||
STYLE_PROMPTS = {
|
||||
"xiaohongshu_cover": "小红书种草风,独立3:4图文海报/素材图,1024×1536构图,明亮干净,真实实拍质感,醒目中文短标题,文字在安全区内",
|
||||
"comparison": "小红书说明对比风,独立3:4图文海报/素材图,1024×1536构图,质地/场景/肤感左右或上下对比,信息层级清晰",
|
||||
"ingredient": "小红书成分科普风,独立3:4图文海报/素材图,1024×1536构图,成分卡片布局,浅色商务美妆风,避免医疗化表达",
|
||||
}
|
||||
STYLE_DEFAULT = "xiaohongshu_cover"
|
||||
|
||||
# ── 叙事链路说明(扒 image.js planImageSet narrativeText:677-681)──
|
||||
# 按图数告诉模型整组图的种草节奏,让每张各司其职不雷同
|
||||
NARRATIVE_BY_COUNT = {
|
||||
3: "3张极速链路:第1张负责点击,第2张是按品类变化的核心证明页,第3张负责软性转化。",
|
||||
6: "6张标准种草链路:封面点击、单品特写带品牌词、成分信任、质地种草、上脸证明、促单转化,每张画面和文字各司其职不重复。",
|
||||
8: "8张沉浸测评链路:点击、痛点共鸣、单品特写、成分、质地、上脸证明、背书、软性转化。",
|
||||
}
|
||||
|
||||
# ── 3套正交叙事策略(倩倩姐2026-06-15起草,北哥过目版)──────────────
|
||||
# A痛点先行/B场景先行/C成分背书先行,三套正交轴拉开差异
|
||||
# 每套叙事链路注入 base_prompt 叙事链路段,替换 NARRATIVE_BY_COUNT 默认值
|
||||
NARRATIVE_BY_STRATEGY = {
|
||||
"A": (
|
||||
'【套A·痛点先行】整组基调:紧迫感、强对比、情绪共鸣,文字短促带感叹号,直戳"脸黄显疲惫""素颜不敢出门"。'
|
||||
'6张链路:①痛点暴击封面(强情绪大字直击暗黄/素颜焦虑)→ ②暗黄脸实拍对比(感叹号+对比词制造紧迫感)'
|
||||
'→ ③单品特写+品牌词 → ④成分为什么能救暗黄(成分拆解+信任) → ⑤上脸提亮实证 → ⑥"别再顶着黄脸早八"软性促单。'
|
||||
),
|
||||
"B": (
|
||||
'【套B·场景先行】整组基调:轻松、生活化、代入感,突出"快/省时/伪素颜自由",点到性价比不堆砌。'
|
||||
'6张链路:①"早八来不及"场景封面(生活场景钩子) → ②手忙脚乱通勤场景(代入早八焦虑)'
|
||||
'→ ③一抹搞定单品特写+品牌词 → ④养肤成分让你敢素颜 → ⑤30秒上脸效果 → ⑥"伪素颜自由+平价"软性促单。'
|
||||
),
|
||||
"C": (
|
||||
'【套C·成分背书先行】整组基调:专业、可信、真实测评感,强调成分逻辑+前后对比+像有用户实证背书。'
|
||||
'6张链路:①成分权威封面(核心成分信息锚定信任) → ②核心成分图解(作用说明+清晰可信)'
|
||||
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实上脸细节 → ⑥"成分党闭眼入"软性促单。'
|
||||
),
|
||||
}
|
||||
|
||||
# ── 生图通道 ──────────────────────────────────────────────
|
||||
IMAGE_RETRY_ATTEMPTS = 3
|
||||
IMAGE_RETRY_BACKOFF_BASE = 2.0 # 指数退避底数(秒)
|
||||
IMAGE_SIZE_DEFAULT = "1024x1536"
|
||||
|
||||
# ── 生图合规负向约束(方法层常量,全品类共用,可扩展)───────────────────────
|
||||
# 追加到每个 base_prompt 末尾,防模型脑补违禁词/真实品牌到包装
|
||||
IMAGE_NEGATIVE_CONSTRAINTS = (
|
||||
"【包装合规硬性禁止——必须严格遵守】"
|
||||
"①包装/瓶身/标签上禁止出现任何违禁词:美白/whitening/祛斑/brightening/"
|
||||
"医用/medical/drug/药妆/速效/instant,中英文全禁;"
|
||||
"②禁止脑补任何真实品牌名或logo(如水密码/WETCODE/兰蔻/SK-II等),"
|
||||
"产品包装只允许出现用户传入的指定品牌词,未传则画无字素瓶;"
|
||||
"③英文功效词(ANTI-AGING/TONE-UP/BRIGHTENING/FIRMING等)禁止印在包装;"
|
||||
"④如果提供了产品参考图,包装文字以参考图为准,不得自行添加或修改任何文字。"
|
||||
"⑤背景纯净:禁止出现电子设备/笔记本电脑/键盘/手机/桌面杂物等无关物体(参考图若含此类背景一律不沿用),"
|
||||
"只保留浅色简洁台面或产品定制场景,主体聚焦产品本身。"
|
||||
)
|
||||
|
||||
# ── 飞轮信号权重(初始默认,北哥可校准)────────────────
|
||||
FLYWHEEL_WEIGHTS = {
|
||||
"text_select": 3,
|
||||
"image_select": 3,
|
||||
"approve": 5,
|
||||
"reject_with_reason": -3,
|
||||
"regenerate": -1,
|
||||
}
|
||||
FLYWHEEL_LOOKBACK = 50 # 聚合最近N条事件
|
||||
FLYWHEEL_COLD_START = 5 # 信号不足N条时用产品档案冷启动
|
||||
225
backend/app/services/ai_engine/gemini_factory.py
Normal file
225
backend/app/services/ai_engine/gemini_factory.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
gemini_factory.py — 每任务构建独立的 AI client 实例
|
||||
解决全局单例问题(扒 banana gemini_service.py __init__,改造为每任务局部实例)
|
||||
|
||||
铁律(基石B):
|
||||
- 调用方只传 task_id,不传 key
|
||||
- 本模块在 worker 内部查库 → Fernet 解密 → 构建 client
|
||||
- 解密结果只活在局部变量,函数返回后即销毁
|
||||
- 绝不打印 / 记录 / 传递明文 key
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIClients:
|
||||
"""
|
||||
一个任务专用的 AI client 集合。
|
||||
worker 在任务开始时构建,任务结束后释放(局部变量,不存 Redis/DB)。
|
||||
"""
|
||||
# httpx AsyncClient 懒加载 + 按事件循环缓存:Celery 每任务多次 asyncio.run,
|
||||
# 持久 client 会绑死到首个已关闭的 loop → "Event loop is closed"。
|
||||
# 故只存 token/base,按当前运行 loop 缓存 client,loop 变了就重建。
|
||||
_gpt_token: str | None = field(default=None, repr=False)
|
||||
_gpt_base: str | None = field(default=None, repr=False)
|
||||
_gpt_client: httpx.AsyncClient | None = field(default=None, repr=False)
|
||||
_gpt_client_loop_id: int | None = field(default=None, repr=False)
|
||||
# 备用 OpenAI 兼容中转站(codeproxy):apiports 503 时真正切过去(独立 base+key)
|
||||
_alt_token: str | None = field(default=None, repr=False)
|
||||
_alt_base: str | None = field(default=None, repr=False)
|
||||
# 多 base/token 的 client 池:key=(base,token的id),按 loop 失效重建
|
||||
_client_pool: dict = field(default_factory=dict, repr=False)
|
||||
_pool_loop_id: int | None = field(default=None, repr=False)
|
||||
_gemini_key: str | None = field(default=None, repr=False) # 局部变量不打印
|
||||
_model_image: str = "gpt-image-2"
|
||||
_model_text: str = "claude-sonnet-4-5" # apiports无gpt-4o-mini,文案用claude中文质量好
|
||||
|
||||
def _client(self) -> httpx.AsyncClient:
|
||||
"""主通道(apiports) client,按当前事件循环缓存"""
|
||||
return self._client_for(self._gpt_base, self._gpt_token)
|
||||
|
||||
def _client_for(self, base: str | None, token: str | None) -> httpx.AsyncClient:
|
||||
"""按 (base, token) 返回可用 client;loop 变化则整池重建(避免跨 loop 复用)"""
|
||||
if not token:
|
||||
raise RuntimeError("GPT client 未初始化(缺 token)")
|
||||
loop_id = id(asyncio.get_running_loop())
|
||||
if self._pool_loop_id != loop_id:
|
||||
self._client_pool = {}
|
||||
self._pool_loop_id = loop_id
|
||||
ck = (base or "", token)
|
||||
if ck not in self._client_pool:
|
||||
self._client_pool[ck] = httpx.AsyncClient(
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
base_url=base or None,
|
||||
timeout=120.0,
|
||||
)
|
||||
return self._client_pool[ck]
|
||||
|
||||
# ── ImageClient 协议实现(供 image_gen.py 使用)────────
|
||||
|
||||
def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]:
|
||||
"""按 provider 选 (base, token, client);codeproxy 走备用站独立 base+key"""
|
||||
if provider == "codeproxy" and self._alt_token:
|
||||
base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
|
||||
return base, self._alt_token, self._client_for(base, self._alt_token)
|
||||
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
|
||||
return base, self._gpt_token, self._client_for(self._gpt_base, self._gpt_token)
|
||||
|
||||
async def gpt_edits(self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None) -> bytes:
|
||||
"""GPT edits endpoint(带产品参考图,禁纯文生图)"""
|
||||
import io
|
||||
files: list[tuple] = [("prompt", (None, prompt))]
|
||||
for i, img in enumerate(reference_images):
|
||||
files.append(("image[]", (f"ref_{i}.png", io.BytesIO(img), "image/png")))
|
||||
files.append(("size", (None, size)))
|
||||
files.append(("model", (None, self._model_image)))
|
||||
base, _, client = self._gpt_target(provider)
|
||||
resp = await client.post(f"{base}/images/edits", files=files, timeout=120.0)
|
||||
resp.raise_for_status()
|
||||
return _extract_image_bytes(resp.json())
|
||||
|
||||
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes:
|
||||
"""GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)"""
|
||||
base, _, client = self._gpt_target(provider)
|
||||
payload = {"model": self._model_image, "prompt": prompt, "n": 1, "size": size}
|
||||
resp = await client.post(f"{base}/images/generations", json=payload, timeout=120.0)
|
||||
resp.raise_for_status()
|
||||
return _extract_image_bytes(resp.json())
|
||||
|
||||
async def gemini_generate(self, prompt: str, reference_images: list[bytes], model: str) -> bytes:
|
||||
"""Gemini 生图(备用通道)"""
|
||||
if not self._gemini_key:
|
||||
raise RuntimeError("Gemini key 未初始化")
|
||||
gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta")
|
||||
url = f"{gemini_base}/models/{model}:generateContent?key={self._gemini_key}"
|
||||
parts: list[dict] = [{"text": prompt}]
|
||||
for img in reference_images:
|
||||
import base64
|
||||
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}})
|
||||
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=payload, timeout=120.0)
|
||||
resp.raise_for_status()
|
||||
return _extract_gemini_image(resp.json())
|
||||
|
||||
async def chat_complete(self, messages: list[dict], model: str | None = None, max_tokens: int = 4096, temperature: float = 0.75) -> str:
|
||||
"""文字生成(文案生成用)"""
|
||||
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
|
||||
payload = {"model": model or self._model_text, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}
|
||||
# 单批≤4条文案正常 40-55s 返回;apiports 网关 ~60s 上限。客户端超时设 75s:
|
||||
# 略高于网关上限即可,过长(如180s)会在 apiports 卡顿时干等,拖慢整体。
|
||||
timeout = float(os.environ.get("TEXT_LLM_TIMEOUT", "75"))
|
||||
resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"] or ""
|
||||
|
||||
async def gpt_vision_analyze(self, prompt: str, images: list[bytes], model: str | None = None) -> str:
|
||||
"""
|
||||
GPT/Claude vision 读产品图,返回 JSON 字符串。
|
||||
messages content 混合 text + image_url(base64),OpenAI vision 格式。
|
||||
model 默认最强档(claude-opus-4-8),绝不偷降级。
|
||||
最多传 4 张图,避免超 token。
|
||||
"""
|
||||
import base64
|
||||
content: list[dict] = [{"type": "text", "text": prompt}]
|
||||
for img in images[:4]:
|
||||
b64 = base64.b64encode(img).decode()
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
||||
})
|
||||
used_model = model or os.environ.get("MODEL_TEXT", "claude-opus-4-8")
|
||||
messages = [{"role": "user", "content": content}]
|
||||
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
|
||||
payload = {
|
||||
"model": used_model,
|
||||
"messages": messages,
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=90.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"] or ""
|
||||
|
||||
# duck-type: text_variants._call_llm 用的属性
|
||||
@property
|
||||
def _model(self) -> str:
|
||||
return self._model_text
|
||||
|
||||
async def aclose(self) -> None:
|
||||
# client 可能绑在已关闭的 loop(Celery 多次 asyncio.run),aclose 也可能报
|
||||
# "Event loop is closed",吞掉即可——进程级连接随 loop 关闭自然释放。
|
||||
for c in list(self._client_pool.values()):
|
||||
try:
|
||||
await c.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
self._client_pool = {}
|
||||
self._pool_loop_id = None
|
||||
self._gpt_client = None
|
||||
self._gpt_client_loop_id = None
|
||||
|
||||
|
||||
def build_ai_clients(plain_key: str, gemini_key: str | None = None) -> AIClients:
|
||||
"""
|
||||
用解密后的明文 key 构建 AIClients。
|
||||
只在 Celery worker 函数体内调用,plain_key 是局部变量。
|
||||
httpx client 不在此预创建(避免绑死到调用方 loop),首次 await 时按 loop 懒建。
|
||||
调用完成后 caller 负责 await clients.aclose()。
|
||||
"""
|
||||
gpt_base = (
|
||||
os.environ.get("IMAGE_API_BASE") # 旧变量名
|
||||
or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名
|
||||
or ""
|
||||
).rstrip("/")
|
||||
# 备用站 codeproxy:系统级 key(非用户录入),apiports 503 时切过去保生图成功
|
||||
alt_base = (os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
|
||||
alt_token = os.environ.get("CODEPROXY_KEY") or None
|
||||
return AIClients(
|
||||
_gpt_token=plain_key,
|
||||
_gpt_base=gpt_base or None,
|
||||
_alt_base=alt_base or None,
|
||||
_alt_token=alt_token,
|
||||
_gemini_key=gemini_key,
|
||||
_model_image=os.environ.get("IMAGE_MODEL") or os.environ.get("MODEL_IMAGE", "gpt-image-2"),
|
||||
_model_text=os.environ.get("MODEL_TEXT", "claude-opus-4-8"),
|
||||
)
|
||||
|
||||
|
||||
# ── 图片响应解析工具 ─────────────────────────────────────
|
||||
|
||||
def _extract_image_bytes(resp_json: dict) -> bytes:
|
||||
"""从 OpenAI images API 响应提取图片 bytes(b64 或 url)"""
|
||||
import base64
|
||||
data = resp_json.get("data", [{}])
|
||||
if not data:
|
||||
raise ValueError("图片 API 返回空 data")
|
||||
item = data[0]
|
||||
if "b64_json" in item:
|
||||
return base64.b64decode(item["b64_json"])
|
||||
if "url" in item:
|
||||
resp = httpx.get(item["url"], timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
raise ValueError(f"无法解析图片响应:{list(item.keys())}")
|
||||
|
||||
|
||||
def _extract_gemini_image(resp_json: dict) -> bytes:
|
||||
"""从 Gemini generateContent 响应提取图片 bytes"""
|
||||
import base64
|
||||
candidates = resp_json.get("candidates", [])
|
||||
for cand in candidates:
|
||||
parts = cand.get("content", {}).get("parts", [])
|
||||
for part in parts:
|
||||
if "inlineData" in part:
|
||||
return base64.b64decode(part["inlineData"]["data"])
|
||||
raise ValueError("Gemini 响应中未找到图片数据")
|
||||
175
backend/app/services/ai_engine/image_gen.py
Normal file
175
backend/app/services/ai_engine/image_gen.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
生图通道 — gpt-image-2 主(edits 带产品图) / Gemini 备 + 重试退避
|
||||
扒自:worker/src/image.js generateOneImage / requestProviderImage / imageProviderOrder
|
||||
新增:asyncio 重试退避(上线版缺的,banana 有 _retry 思路)
|
||||
|
||||
铁律:
|
||||
- IMAGE_PROVIDER_PRIMARY/FALLBACK 走环境变量,不写死
|
||||
- GPT 主通道必须有产品参考图,无图报错(禁纯文生图防产品跑偏)
|
||||
- key 不在本模块,由 worker 传入构造好的 async HTTP client
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT
|
||||
from .storyboard import plan_image_set, sanitize_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageClient(Protocol):
|
||||
"""worker 注入的图片生成客户端协议(隔离 key 细节)"""
|
||||
async def gpt_edits(
|
||||
self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None
|
||||
) -> bytes: ...
|
||||
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes: ...
|
||||
async def gemini_generate(
|
||||
self, prompt: str, reference_images: list[bytes], model: str
|
||||
) -> bytes: ...
|
||||
|
||||
|
||||
def _image_provider_order() -> list[str]:
|
||||
"""从环境变量读主备顺序(扒 imageProviderOrder)"""
|
||||
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower()
|
||||
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower()
|
||||
seen: list[str] = []
|
||||
for p in [primary, fallback]:
|
||||
if p and p not in seen:
|
||||
seen.append(p)
|
||||
return seen
|
||||
|
||||
|
||||
def _gemini_models() -> list[str]:
|
||||
"""Gemini fallback 模型列表(多模型依次重试)"""
|
||||
env_val = os.environ.get("GEMINI_IMAGE_MODELS", "gemini-2.0-flash-preview-image-generation,imagen-3.0-generate-002")
|
||||
return [m.strip() for m in env_val.split(",") if m.strip()]
|
||||
|
||||
|
||||
async def _retry(coro_fn, attempts: int = IMAGE_RETRY_ATTEMPTS, backoff: float = IMAGE_RETRY_BACKOFF_BASE) -> Any:
|
||||
"""指数退避重试(扒 banana _retry 思路)"""
|
||||
last_exc: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return await coro_fn()
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if i < attempts - 1:
|
||||
wait = backoff ** i
|
||||
logger.warning("生图失败第%d次,%.1fs后重试:%s", i + 1, wait, exc)
|
||||
await asyncio.sleep(wait)
|
||||
raise RuntimeError(f"重试{attempts}次均失败") from last_exc
|
||||
|
||||
|
||||
async def _request_gpt(client: ImageClient, prompt: str, reference_images: list[bytes], provider: str | None = None) -> bytes:
|
||||
if reference_images:
|
||||
return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider)
|
||||
# 无产品参考图时降级为纯文生图(需 ALLOW_TEXT_ONLY_IMAGE=true 或 M2阶段)
|
||||
allow_text_only = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "true").lower() == "true"
|
||||
if allow_text_only:
|
||||
logger.warning("无产品参考图,降级为纯文生图(可能产品跑偏,建议前端上传参考图)")
|
||||
return await client.gpt_generate(prompt, IMAGE_SIZE_DEFAULT, provider)
|
||||
raise ValueError("GPT 主通道缺产品图:禁止纯文生图以免产品跑偏(设 ALLOW_TEXT_ONLY_IMAGE=true 可解锁)")
|
||||
|
||||
|
||||
async def _request_gemini(client: ImageClient, prompt: str, reference_images: list[bytes]) -> bytes:
|
||||
errors: list[str] = []
|
||||
for model in _gemini_models():
|
||||
try:
|
||||
return await client.gemini_generate(prompt, reference_images, model)
|
||||
except Exception as exc:
|
||||
errors.append(f"{model}: {exc}")
|
||||
raise RuntimeError("Gemini 全部模型失败:" + ";".join(errors))
|
||||
|
||||
|
||||
async def generate_one_image(
|
||||
client: ImageClient,
|
||||
prompt: str,
|
||||
reference_images: list[bytes] | None = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
主入口:按主备顺序依次尝试,每个 provider 内部有重试退避。
|
||||
返回图片 bytes(PNG/JPEG)。
|
||||
"""
|
||||
refs = reference_images or []
|
||||
providers = _image_provider_order()
|
||||
errors: list[str] = []
|
||||
|
||||
for provider in providers:
|
||||
try:
|
||||
# apiports/codeproxy/openai 都是 OpenAI 兼容中转站,走 gpt 协议,
|
||||
# 但传 provider 进去 → client 按 provider 切到对应中转站的 base+key。
|
||||
# 这才是真主备:apiports 503 → codeproxy 用独立 base+key 顶上。
|
||||
if provider in ("apiports", "codeproxy", "openai"):
|
||||
img = await _retry(lambda p=provider: _request_gpt(client, prompt, refs, p))
|
||||
elif provider == "gpt":
|
||||
img = await _retry(lambda: _request_gpt(client, prompt, refs, None))
|
||||
elif provider == "gemini":
|
||||
img = await _retry(lambda: _request_gemini(client, prompt, refs))
|
||||
else:
|
||||
raise ValueError(f"未知图片通道:{provider}")
|
||||
return img
|
||||
except Exception as exc:
|
||||
errors.append(f"{provider}: {exc}")
|
||||
logger.warning("图片通道 %s 失败,尝试下一个:%s", provider, exc)
|
||||
|
||||
raise RuntimeError("所有图片通道均失败:" + ";".join(errors))
|
||||
|
||||
|
||||
async def generate_storyboard_images(
|
||||
client: ImageClient,
|
||||
note: dict,
|
||||
product: dict,
|
||||
image_count: int = 3,
|
||||
reference_images: list[bytes] | None = None,
|
||||
analysis: dict | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
按 storyboard 逐张生图(asyncio.gather 并发),返回每张结果列表。
|
||||
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
|
||||
每项:{role, name, image_bytes, error}
|
||||
"""
|
||||
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
|
||||
storyboard = plan["storyboard"]
|
||||
base_prompt = plan["base_prompt"]
|
||||
|
||||
async def _gen_one(item: dict) -> dict:
|
||||
# 逐图 prompt 9 字段(扒 promptFromStoryboard:323-334),每张差异化
|
||||
brand_line = ""
|
||||
if item.get("brand_keyword"):
|
||||
brand_line = f"品牌词=「{item['brand_keyword']}」,{item.get('brand_keyword_rule','自然植入')}。"
|
||||
per_prompt = (
|
||||
f"{base_prompt}\n"
|
||||
f"本张名称={item['name']}。"
|
||||
f"本张目标={item.get('goal') or item['focus']}。"
|
||||
f"图上主文字=「{sanitize_text(item.get('overlay_text',''), 20)}」。"
|
||||
f"使用卖点={item.get('selling_point','')}。"
|
||||
f"文案依据={item.get('source_basis','')}。"
|
||||
f"画面主体={item.get('visual_strategy','')}。"
|
||||
f"素材使用={item.get('asset_use','')}。"
|
||||
f"{brand_line}"
|
||||
f"禁止事项={item.get('forbidden','')}。"
|
||||
"排版要求:独立小红书3:4图文海报,画面完整;标题只出现一次,不与其他页重复;"
|
||||
"中文文字少而清晰,主标题+最多3个短点位;可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌;"
|
||||
"不要生成App截图或笔记详情页界面。"
|
||||
)
|
||||
try:
|
||||
img_bytes = await generate_one_image(client, per_prompt, reference_images)
|
||||
return {"role": item["role"], "name": item["name"], "image_bytes": img_bytes, "error": None}
|
||||
except Exception as exc:
|
||||
logger.error("分镜 %s 生图失败: %s", item["role"], exc)
|
||||
return {"role": item["role"], "name": item["name"], "image_bytes": None, "error": str(exc)}
|
||||
|
||||
# 限并发:apiports 图片接口有 QPS 限制,6 张全并发会撞 429/503
|
||||
concurrency = int(os.environ.get("IMAGE_CONCURRENCY", "2"))
|
||||
sem = asyncio.Semaphore(max(1, concurrency))
|
||||
|
||||
async def _gen_guarded(item: dict) -> dict:
|
||||
async with sem:
|
||||
return await _gen_one(item)
|
||||
|
||||
results = await asyncio.gather(*(_gen_guarded(item) for item in storyboard))
|
||||
return list(results)
|
||||
177
backend/app/services/ai_engine/image_postprocessor.py
Normal file
177
backend/app/services/ai_engine/image_postprocessor.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
图片后处理(去AI化主路)
|
||||
对齐大卫 xhs-tool/backend/infrastructure/imagePostProcess.js(运营实测去AI化版)。
|
||||
|
||||
主路 = 尺寸可选(±2%容差内不resize) + SynthID破除(可选) + 高保真重编码去元数据。
|
||||
|
||||
诚实声明:C2PA 元数据可去除;私有像素水印(如 SynthID)只能削弱,不保证 100% 清除。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageEnhance, ImageOps
|
||||
_PILLOW_OK = True
|
||||
except ImportError:
|
||||
_PILLOW_OK = False
|
||||
logger.warning("Pillow 未安装,image_postprocessor 不可用")
|
||||
|
||||
# 比例映射表,对齐大卫 RATIO_MAP。key 为字符串如 '3:4'
|
||||
RATIO_MAP: dict[str, tuple[int, int]] = {
|
||||
"1:1": (1024, 1024),
|
||||
"3:4": (1024, 1536), # gpt-image-2 原生尺寸,默认
|
||||
"4:3": (1536, 1024),
|
||||
"9:16": (864, 1536),
|
||||
"16:9": (1536, 864),
|
||||
}
|
||||
|
||||
# ±2% 容差内不做 resize,避免无谓重采样(对齐大卫 diff > 0.02 才 resize)
|
||||
_RATIO_TOLERANCE = 0.02
|
||||
|
||||
|
||||
def _need_resize(actual_w: int, actual_h: int, target_w: int, target_h: int) -> bool:
|
||||
"""判断实际比例与目标比例差距是否超出容差。"""
|
||||
actual_ratio = actual_w / actual_h
|
||||
target_ratio = target_w / target_h
|
||||
diff = abs(actual_ratio - target_ratio) / target_ratio
|
||||
return diff > _RATIO_TOLERANCE
|
||||
|
||||
|
||||
def process_image(
|
||||
image_bytes: bytes,
|
||||
aspect_ratio: str = "3:4",
|
||||
resample_strength: int = 1, # 0=不重采样, 1=轻采样(默认), 2=重采样
|
||||
) -> bytes:
|
||||
"""
|
||||
处理单张图片。
|
||||
|
||||
参数:
|
||||
image_bytes — 原始图片 bytes(PNG/JPEG/WebP 等)
|
||||
aspect_ratio — 目标比例,取 RATIO_MAP 的 key,默认 '3:4'=1024×1536
|
||||
resample_strength — 轻重采样削像素水印(0/1/2),默认 1=轻采样
|
||||
|
||||
返回 JPEG bytes(无 EXIF/C2PA/XMP 元数据)。
|
||||
失败时降级返回原图 bytes,不抛异常(对齐大卫 catch 返回原图)。
|
||||
"""
|
||||
if not _PILLOW_OK:
|
||||
logger.error("Pillow 未安装,跳过后处理,返回原图")
|
||||
return image_bytes
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
||||
actual_w, actual_h = img.size
|
||||
target = RATIO_MAP.get(aspect_ratio)
|
||||
|
||||
# --- Step1: 尺寸对齐(±2% 容差内跳过 resize)---
|
||||
if target:
|
||||
tw, th = target
|
||||
if _need_resize(actual_w, actual_h, tw, th):
|
||||
img = ImageOps.fit(img, (tw, th), method=Image.LANCZOS)
|
||||
logger.debug("resize %dx%d → %dx%d (ratio=%s)", actual_w, actual_h, tw, th, aspect_ratio)
|
||||
|
||||
# --- Step2: resample_strength 削像素水印(可选,默认轻采样)---
|
||||
img = _apply_resample(img, resample_strength)
|
||||
|
||||
# --- Step3: SynthID 破除(SYNTHID_HARD_MODE=1 才开,默认关)---
|
||||
if os.environ.get("SYNTHID_HARD_MODE") == "1" and target:
|
||||
img = _apply_synthid_break(img, target)
|
||||
|
||||
# --- Step4: 高保真 JPEG 重编码,去所有元数据 ---
|
||||
buf = io.BytesIO()
|
||||
img.save(
|
||||
buf,
|
||||
format="JPEG",
|
||||
quality=100,
|
||||
subsampling=0, # 4:4:4 chroma
|
||||
optimize=True,
|
||||
# 不传 exif/icc_profile/xmp = 不写入任何元数据
|
||||
)
|
||||
result = buf.getvalue()
|
||||
logger.debug("后处理完成 %d B → %d B (ratio=%s)", len(image_bytes), len(result), aspect_ratio)
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("图片后处理失败,降级返回原图: %s", exc)
|
||||
return image_bytes
|
||||
|
||||
|
||||
def _apply_resample(img: "Image.Image", strength: int) -> "Image.Image":
|
||||
"""
|
||||
轻/重采样削像素级水印(resample_strength 控制)。
|
||||
0 — 不采样,仅靠重编码去元数据。
|
||||
1 — 轻采样:缩98%再回原尺寸,保视觉质量,削弱像素水印(对齐旧逻辑)。
|
||||
2 — 重采样:两次缩放,削弱更多,轻微质量损失。
|
||||
"""
|
||||
if strength < 1:
|
||||
return img
|
||||
w, h = img.size
|
||||
img = img.resize((int(w * 0.98), int(h * 0.98)), Image.LANCZOS)
|
||||
img = img.resize((w, h), Image.LANCZOS)
|
||||
if strength >= 2:
|
||||
img = img.resize((int(w * 0.96), int(h * 0.96)), Image.LANCZOS)
|
||||
img = img.resize((w, h), Image.LANCZOS)
|
||||
return img
|
||||
|
||||
|
||||
def _apply_synthid_break(img: "Image.Image", target: tuple[int, int]) -> "Image.Image":
|
||||
"""
|
||||
SynthID 破除(SYNTHID_HARD_MODE=1 时调用):
|
||||
对齐大卫逻辑 — 缩到(w-2,h-2)再裁掉1px边 + 亮度*1.005/饱和*0.998。
|
||||
诚实声明:只能削弱 SynthID,不保证 100% 清除。
|
||||
"""
|
||||
tw, th = target
|
||||
img = ImageOps.fit(img, (tw - 2, th - 2), method=Image.LANCZOS)
|
||||
# 裁掉1px边,消除边缘水印残留
|
||||
img = img.crop((1, 1, tw - 3, th - 3))
|
||||
# 微调亮度/饱和(对齐大卫 modulate brightness/saturation)
|
||||
img = ImageEnhance.Brightness(img).enhance(1.005)
|
||||
img = ImageEnhance.Color(img).enhance(0.998)
|
||||
return img
|
||||
|
||||
|
||||
def batch_process(
|
||||
images: list[bytes],
|
||||
aspect_ratio: str = "3:4",
|
||||
resample_strength: int = 1,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
批量后处理。返回 [{index, data, error}],单张失败不阻塞其余。
|
||||
"""
|
||||
results = []
|
||||
for i, img_bytes in enumerate(images):
|
||||
try:
|
||||
processed = process_image(img_bytes, aspect_ratio=aspect_ratio,
|
||||
resample_strength=resample_strength)
|
||||
results.append({"index": i, "data": processed, "error": None})
|
||||
except Exception as exc:
|
||||
logger.error("图片[%d]后处理失败: %s", i, exc)
|
||||
results.append({"index": i, "data": img_bytes, "error": str(exc)})
|
||||
return results
|
||||
|
||||
|
||||
async def gemini_rewatermark_fallback(
|
||||
client: "Any", # GeminiClient,由 worker 注入
|
||||
image_bytes: bytes,
|
||||
) -> bytes:
|
||||
"""
|
||||
备选路:Gemini 重绘去水印。
|
||||
⚠️ 对海报中文大字有改字风险,仅特殊场景启用。
|
||||
"""
|
||||
prompt = (
|
||||
"Remove all watermarks, text overlays, and digital signatures from this image. "
|
||||
"Reconstruct any covered areas naturally to match the surrounding content. "
|
||||
"Return a clean version of the same image without any watermarks."
|
||||
)
|
||||
try:
|
||||
result = await client.gemini_generate(
|
||||
prompt, [image_bytes], "gemini-2.0-flash-preview-image-generation"
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.error("Gemini 去水印失败,降级返回原图: %s", exc)
|
||||
return image_bytes
|
||||
96
backend/app/services/ai_engine/llm_scorer.py
Normal file
96
backend/app/services/ai_engine/llm_scorer.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
llm_scorer.py — AI 评委打分入口(让模型真读文案,替代机械找词)
|
||||
合规第7维仍走机械硬拦(score_compliance);AI 读前6维给分+理由。
|
||||
任何异常/解析失败 → 回退旧机械 score_copy,绝不卡链路。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .constants import BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, QUALITY_PASS_SCORE
|
||||
from ._scoring_dims import score_compliance
|
||||
from .text_scoring import score_copy
|
||||
from ._score_prompt import SCORER_PERSONA, build_score_prompt, COMPLIANCE_MAX
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 6 个 AI 维度满分(倩倩姐2026-06-15拍板·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
|
||||
# 痛点18+情绪18+买点18+钩子15+标题13+真实感13=95,+合规5=100
|
||||
# "真实感"替换旧"产品聚焦一件事",对齐富贵"很少提产品/前70%干货后30%植入"原则
|
||||
_DIM_MAX = {
|
||||
"痛点人群精准": 18, "情绪张力": 18, "买点转化": 18,
|
||||
"开头钩子": 15, "标题点击力": 13, "真实感": 13,
|
||||
}
|
||||
# 评委合规相关默认权重(仅供 score_compliance 复用其内部硬拦逻辑)
|
||||
_COMPLIANCE_W = {"compliance": COMPLIANCE_MAX}
|
||||
|
||||
|
||||
def _parse_verdict(raw: str) -> dict | None:
|
||||
"""从模型输出里抠出 JSON 对象,失败返 None。"""
|
||||
s = raw.strip()
|
||||
m = re.search(r"\{.*\}", s, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(m.group(0))
|
||||
return obj if isinstance(obj, dict) and isinstance(obj.get("dims"), list) else None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def llm_score_copy(
|
||||
client: Any,
|
||||
copy: dict[str, Any],
|
||||
source: dict[str, Any],
|
||||
banned_words: list[str] | None = None,
|
||||
pass_score: int = QUALITY_PASS_SCORE,
|
||||
) -> dict[str, Any]:
|
||||
"""AI 评委读 1 条文案 → 6维分+理由,合规机械硬拦。返回与 score_copy 同结构。"""
|
||||
bwords = list(set((banned_words or []) + BANNED_WORDS_DEFAULT + BANNED_VISUAL_WORDS))
|
||||
full = f"{copy.get('title','')}\n{copy.get('content','')}\n{' '.join(str(t) for t in copy.get('tags',[]))}"
|
||||
dim_comp, found_all = score_compliance(full, bwords, _COMPLIANCE_W)
|
||||
|
||||
prompt = build_score_prompt(copy, source)
|
||||
raw = ""
|
||||
backoff = [5, 10, 20]
|
||||
for attempt in range(4):
|
||||
try:
|
||||
raw = await client.chat_complete(
|
||||
messages=[{"role": "system", "content": SCORER_PERSONA},
|
||||
{"role": "user", "content": prompt}],
|
||||
model=client._model, max_tokens=1500, temperature=0.3,
|
||||
)
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001 — 含 httpx.HTTPStatusError 503/429
|
||||
status = getattr(getattr(exc, "response", None), "status_code", 0)
|
||||
if status in (503, 429) and attempt < 3:
|
||||
await asyncio.sleep(backoff[min(attempt, 2)])
|
||||
continue
|
||||
logger.warning("AI评委调用失败,回退机械打分: %s", exc)
|
||||
return score_copy(copy, source, banned_words, pass_score=pass_score)
|
||||
|
||||
verdict = _parse_verdict(raw)
|
||||
if not verdict:
|
||||
logger.warning("AI评委输出解析失败,回退机械打分。raw[:120]=%s", raw[:120])
|
||||
return score_copy(copy, source, banned_words, pass_score=pass_score)
|
||||
|
||||
details: list[dict] = []
|
||||
for d in verdict["dims"]:
|
||||
item = str(d.get("item", "")).strip()
|
||||
if item not in _DIM_MAX: # 只收白名单6维,模型偶尔多吐"总分"等噪声项,丢弃
|
||||
continue
|
||||
mx = _DIM_MAX[item]
|
||||
sc = max(0, min(mx, int(round(float(d.get("score", 0))))))
|
||||
details.append({"item": item, "score": sc, "max": mx, "note": str(d.get("reason", ""))[:60]})
|
||||
details.append(dim_comp)
|
||||
|
||||
total = max(0, min(100, sum(d["score"] for d in details)))
|
||||
passed = (total >= pass_score) and not found_all
|
||||
return {
|
||||
"score": total, "score_detail": details, "passed": passed,
|
||||
"banned_words_found": found_all,
|
||||
"verdict": verdict.get("verdict", ""), "summary": str(verdict.get("summary", ""))[:120],
|
||||
}
|
||||
132
backend/app/services/ai_engine/package_exporter.py
Normal file
132
backend/app/services/ai_engine/package_exporter.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
package_exporter.py — 达人素材交付包生成
|
||||
架构方案§五 1A步骤5:按笔记分文件夹 + 图(01/02/03) + 文案.txt + 发布清单 + 合规说明
|
||||
路径规则:uploads/packages/{workspace_id}/{task_id}/note_{n}/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 文件夹结构
|
||||
# uploads/packages/{workspace_id}/{task_id}/
|
||||
# note_01/
|
||||
# 01_hook.jpg # 按 seq 序号命名防传错序
|
||||
# 02_proof.jpg
|
||||
# 文案.txt # 标题 + 正文 + 标签
|
||||
# note_02/
|
||||
# ...
|
||||
# 📋发布清单.txt
|
||||
# ✅合规说明.txt
|
||||
# package.zip # 最终打包文件
|
||||
|
||||
|
||||
def build_delivery_package(
|
||||
workspace_id: int,
|
||||
task_id: int,
|
||||
notes: list[dict], # 每条笔记,含 text_candidate + image_candidates
|
||||
base_path: str = "uploads/packages",
|
||||
) -> str:
|
||||
"""
|
||||
打包交付,返回 zip 文件的本地路径。
|
||||
notes 格式:[{
|
||||
"title": str, "content": str, "tags": list[str],
|
||||
"images": [{"seq": int, "role": str, "data": bytes}],
|
||||
"banned_word_status": str, # 合规说明用
|
||||
}]
|
||||
"""
|
||||
package_dir = Path(base_path) / str(workspace_id) / str(task_id)
|
||||
package_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
note_dirs: list[Path] = []
|
||||
for idx, note in enumerate(notes, start=1):
|
||||
note_dir = package_dir / f"note_{idx:02d}"
|
||||
note_dir.mkdir(exist_ok=True)
|
||||
note_dirs.append(note_dir)
|
||||
|
||||
# ── 图片文件(按 seq 序号命名)
|
||||
for img in sorted(note.get("images", []), key=lambda x: x.get("seq", 0)):
|
||||
seq = img.get("seq", idx)
|
||||
role = img.get("role", "img")
|
||||
fname = f"{seq:02d}_{role}.jpg"
|
||||
img_data = img.get("data", b"")
|
||||
if img_data:
|
||||
(note_dir / fname).write_bytes(img_data)
|
||||
|
||||
# ── 文案.txt(标题 + 正文 + 标签,达人可直接复制)
|
||||
tags = note.get("tags") or []
|
||||
body = note.get("content", "")
|
||||
# 正文末尾如果 LLM 已写入 #话题 标签,不再重复追加(避免重复)
|
||||
body_has_tags = bool(tags) and any(
|
||||
t.strip("#") in body for t in tags if t
|
||||
)
|
||||
copy_lines = [
|
||||
f"【标题】{note.get('title', '')}",
|
||||
"",
|
||||
body,
|
||||
]
|
||||
if tags and not body_has_tags:
|
||||
copy_lines += ["", " ".join(tags)]
|
||||
(note_dir / "文案.txt").write_text("\n".join(copy_lines), encoding="utf-8")
|
||||
|
||||
# ── 发布清单.txt
|
||||
checklist_lines = [
|
||||
"📋 发布清单",
|
||||
f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
f"任务ID:{task_id}",
|
||||
"",
|
||||
]
|
||||
for idx, note in enumerate(notes, start=1):
|
||||
title = note.get("title", f"笔记{idx}")
|
||||
n_images = len(note.get("images", []))
|
||||
checklist_lines.append(f"note_{idx:02d} 标题:{title[:30]} 图片数:{n_images}")
|
||||
checklist_lines += [
|
||||
"",
|
||||
"发布注意事项:",
|
||||
"- 每条笔记图片按 01/02/03 顺序上传,避免传错序",
|
||||
"- 文案.txt 中标题/正文/标签已区分,复制对应部分",
|
||||
"- 品牌词已植入,请勿删除",
|
||||
"- 不要添加链接(种品牌词,引导天猫搜索成交)",
|
||||
]
|
||||
(package_dir / "📋发布清单.txt").write_text("\n".join(checklist_lines), encoding="utf-8")
|
||||
|
||||
# ── 合规说明.txt
|
||||
compliance_lines = [
|
||||
"✅ 合规说明",
|
||||
f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
"",
|
||||
"本批次内容已完成以下合规处理:",
|
||||
"1. 违禁词扫描(美白/祛斑/速效/医用/药妆等)",
|
||||
"2. 视觉违禁词处理(前后对比/变白等)",
|
||||
"3. 图片去水印处理(C2PA元数据已清除)",
|
||||
"",
|
||||
"各笔记合规状态:",
|
||||
]
|
||||
for idx, note in enumerate(notes, start=1):
|
||||
status = note.get("banned_word_status", "pass")
|
||||
status_label = {"pass": "✅通过", "auto_fixed": "✅自动改写", "soft_warn": "⚠️软提示", "hard_block": "❌硬拦截"}.get(status, status)
|
||||
compliance_lines.append(f"note_{idx:02d}:{status_label}")
|
||||
compliance_lines += [
|
||||
"",
|
||||
"注:C2PA元数据可去除;私有像素水印只能削弱,不保证100%清除。",
|
||||
"如有合规疑问,请联系运营团队。",
|
||||
]
|
||||
(package_dir / "✅合规说明.txt").write_text("\n".join(compliance_lines), encoding="utf-8")
|
||||
|
||||
# ── 打 zip
|
||||
zip_path = package_dir / "package.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for note_dir in note_dirs:
|
||||
for fpath in sorted(note_dir.iterdir()):
|
||||
zf.write(fpath, arcname=f"{note_dir.name}/{fpath.name}")
|
||||
zf.write(package_dir / "📋发布清单.txt", arcname="📋发布清单.txt")
|
||||
zf.write(package_dir / "✅合规说明.txt", arcname="✅合规说明.txt")
|
||||
|
||||
logger.info("delivery package built: %s (notes=%d)", zip_path, len(notes))
|
||||
return str(zip_path)
|
||||
145
backend/app/services/ai_engine/preference_aggregator.py
Normal file
145
backend/app/services/ai_engine/preference_aggregator.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
偏好飞轮聚合(preference_aggregator)
|
||||
扒自:Clover架构方案.md §偏好飞轮怎么转 + PRD §8
|
||||
三层继承:L1 公司品牌基线 > L2 矩阵号人设(二期)> L3 个人手感
|
||||
聚合最近 FLYWHEEL_LOOKBACK 条 events → prompt 片段注入文案生成
|
||||
|
||||
关键:
|
||||
- 按 product_id 分开学(素颜霜偏好不串精华)
|
||||
- 信号不足 FLYWHEEL_COLD_START 条时,用产品档案冷启动
|
||||
- 返回结构对齐 API契约 GET /tasks/{id}/preference/context
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from .constants import FLYWHEEL_LOOKBACK, FLYWHEEL_COLD_START
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def aggregate_preference_context(
|
||||
events: list[dict],
|
||||
product: dict,
|
||||
workspace_id: int,
|
||||
product_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
输入:最近 preference_events 行(已按 workspace_id+product_id 过滤)
|
||||
输出:{recent_preference, reject_reasons, injected_count, prompt_fragment}
|
||||
prompt_fragment 直接注入文案生成 prompt
|
||||
"""
|
||||
# 按 product_id 过滤(防串货)
|
||||
relevant = [
|
||||
e for e in events
|
||||
if e.get("workspace_id") == workspace_id and e.get("product_id") == product_id
|
||||
][:FLYWHEEL_LOOKBACK]
|
||||
|
||||
injected_count = len(relevant)
|
||||
|
||||
if injected_count < FLYWHEEL_COLD_START:
|
||||
# 冷启动:用产品档案静态基线
|
||||
return _cold_start(product, injected_count)
|
||||
|
||||
# ── 统计最常选角度(text_select + approve 信号)
|
||||
angle_counts: Counter = Counter()
|
||||
reject_reasons: list[str] = []
|
||||
|
||||
for e in relevant:
|
||||
sig_type = e.get("signal_type", "")
|
||||
angle = str(e.get("angle_label", "")).strip()
|
||||
weight = int(e.get("signal_weight", 1))
|
||||
|
||||
if sig_type in ("text_select", "approve") and angle:
|
||||
angle_counts[angle] += weight
|
||||
elif sig_type == "reject_with_reason":
|
||||
reason = str(e.get("reason", "")).strip()
|
||||
if reason:
|
||||
reject_reasons.append(reason)
|
||||
|
||||
# 取权重最高的角度
|
||||
top_angles = [a for a, _ in angle_counts.most_common(3)]
|
||||
# 取最近3条打回原因
|
||||
recent_rejects = reject_reasons[-3:] if reject_reasons else []
|
||||
|
||||
# ── 拼 prompt 片段(三层继承:L1>L2>L3,一期只跑L1+L3)
|
||||
prompt_fragment = _build_prompt_fragment(top_angles, recent_rejects, product)
|
||||
|
||||
# ── 人类可读摘要(前端"本次已注入"显示)
|
||||
if top_angles:
|
||||
pref_summary = f"最近偏好角度:{'、'.join(top_angles)}(已选{injected_count}次信号)"
|
||||
else:
|
||||
pref_summary = f"已注入{injected_count}条偏好信号"
|
||||
|
||||
return {
|
||||
"recent_preference": pref_summary,
|
||||
"reject_reasons": recent_rejects,
|
||||
"injected_count": injected_count,
|
||||
"prompt_fragment": prompt_fragment, # 注入 generate_text_variants extra_rules
|
||||
}
|
||||
|
||||
|
||||
def _cold_start(product: dict, injected_count: int) -> dict:
|
||||
"""信号不足时用产品档案基线"""
|
||||
angles = product.get("text_angles") or []
|
||||
style = product.get("style_tone", "素人分享风")
|
||||
fragment = ""
|
||||
if angles:
|
||||
fragment = f"优先覆盖以下文案角度:{'、'.join(angles[:3])}。风格调性:{style}。"
|
||||
return {
|
||||
"recent_preference": f"冷启动(历史信号{injected_count}条,不足{FLYWHEEL_COLD_START}条),使用产品档案基线",
|
||||
"reject_reasons": [],
|
||||
"injected_count": injected_count,
|
||||
"prompt_fragment": fragment,
|
||||
}
|
||||
|
||||
|
||||
def _build_prompt_fragment(
|
||||
top_angles: list[str],
|
||||
reject_reasons: list[str],
|
||||
product: dict,
|
||||
) -> str:
|
||||
"""
|
||||
组装注入文案 prompt 的片段
|
||||
越积累越精准:1次=全靠基线;10次=知道偏好角度;30次=措辞从"供参考"升为明确指令
|
||||
"""
|
||||
lines: list[str] = []
|
||||
if top_angles:
|
||||
lines.append(f"【偏好角度参考】历史选择偏好:{'、'.join(top_angles)},请优先采用这些角度方向。")
|
||||
if reject_reasons:
|
||||
formatted = ";".join(f"「{r}」" for r in reject_reasons)
|
||||
lines.append(f"【打回原因参考】以下问题请主动规避:{formatted}。")
|
||||
# L1 品牌基线(产品档案 custom_prompt)
|
||||
custom = (product.get("custom_prompt") or "").strip()
|
||||
if custom:
|
||||
lines.append(f"【品牌基线】{custom}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def collect_preference_event(
|
||||
signal_type: str,
|
||||
user_id: int,
|
||||
workspace_id: int,
|
||||
product_id: int,
|
||||
angle_label: str = "",
|
||||
reason: str = "",
|
||||
weights: dict[str, int] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
构造 preference_event 行(由业务接口内部调用,不暴露给前端)
|
||||
返回待插 DB 的字段 dict
|
||||
"""
|
||||
from .constants import FLYWHEEL_WEIGHTS
|
||||
w_map = weights or FLYWHEEL_WEIGHTS
|
||||
weight = w_map.get(signal_type, 0)
|
||||
return {
|
||||
"signal_type": signal_type,
|
||||
"signal_weight": weight,
|
||||
"user_id": user_id,
|
||||
"workspace_id": workspace_id,
|
||||
"product_id": product_id,
|
||||
"angle_label": angle_label,
|
||||
"reason": reason,
|
||||
"data_ownership": "client_data", # 原始行为信号归客户(PRD §3 data_ownership)
|
||||
}
|
||||
109
backend/app/services/ai_engine/prompt_composer.py
Normal file
109
backend/app/services/ai_engine/prompt_composer.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
prompt_composer.py — 统一 prompt 组装入口(≤100行)
|
||||
扒自:banana prompts/service.py + worker/src/copy.js prompt 逻辑
|
||||
Lead 指名接口:compose_variants / compose_preference_context
|
||||
|
||||
组装逻辑委托:
|
||||
_text_prompt.py → build_prompt (文案 prompt 主体)
|
||||
preference_aggregator.py → aggregate_preference_context (飞轮上下文)
|
||||
|
||||
原则:prompt 组装从这里进,不散落在 text_variants / generate_text_variants 里。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ._text_prompt import build_prompt, COPY_SYSTEM
|
||||
from .preference_aggregator import aggregate_preference_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 主接口 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def compose_variants(
|
||||
product: dict,
|
||||
count: int,
|
||||
flywheel_context: str = "",
|
||||
extra_rules: str = "",
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
一次出 count 角度文案的完整 prompt。
|
||||
|
||||
返回 (system_prompt, user_prompt)。
|
||||
飞轮片段追加到 user_prompt 末尾(不改 system,避免覆盖质量红线)。
|
||||
|
||||
参数:
|
||||
product — 产品档案 dict(name/selling_points/text_angles/custom_prompt 等)
|
||||
count — 需要几条
|
||||
flywheel_context— 由 compose_preference_context 返回的 prompt_fragment
|
||||
extra_rules — 额外规则(优化循环重生成时传 hint)
|
||||
"""
|
||||
combined_extra = "\n".join(filter(None, [flywheel_context, extra_rules]))
|
||||
user_prompt = build_prompt(product, count, extra_rules=combined_extra)
|
||||
logger.debug(
|
||||
"compose_variants: product=%s count=%d flywheel_len=%d",
|
||||
product.get("name", "?"), count, len(flywheel_context),
|
||||
)
|
||||
return COPY_SYSTEM, user_prompt
|
||||
|
||||
|
||||
def compose_preference_context(
|
||||
events: list[dict],
|
||||
product: dict,
|
||||
workspace_id: int,
|
||||
product_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
聚合偏好事件 → 可注入 prompt 的飞轮上下文。
|
||||
|
||||
返回结构(对齐 API契约 GET /tasks/{id}/preference/context):
|
||||
{
|
||||
recent_preference: str, # 人类可读摘要(前端"本次已注入"显示)
|
||||
reject_reasons: list, # 最近打回原因
|
||||
injected_count: int, # 有效信号数
|
||||
prompt_fragment: str, # 注入 compose_variants flywheel_context 的字符串
|
||||
}
|
||||
|
||||
信号不足 FLYWHEEL_COLD_START 条时用产品档案冷启动。
|
||||
按 workspace_id + product_id 双维过滤(素颜霜偏好不串精华)。
|
||||
"""
|
||||
return aggregate_preference_context(events, product, workspace_id, product_id)
|
||||
|
||||
|
||||
# ── 辅助:解析模型返回的 JSON(给 text_variants 调用,集中不散) ──────────────
|
||||
|
||||
def parse_model_output(raw: str) -> list[dict]:
|
||||
"""从 LLM 原始输出提取 JSON 数组(容错 markdown 包裹)"""
|
||||
from ._text_prompt import parse_json_array
|
||||
return parse_json_array(raw)
|
||||
|
||||
|
||||
# ── 辅助:图片 prompt 组装入口(预留,联调时填充)─────────────────────────────
|
||||
|
||||
def compose_image_prompt(
|
||||
role_name: str,
|
||||
visual_system: dict,
|
||||
product: dict,
|
||||
extra: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
单张分镜 prompt 组装(供 image_gen.generate_one_image 调用)。
|
||||
TODO: 联调后从 storyboard.plan_image_set 取 base_prompt 注入。
|
||||
|
||||
role_name — 分镜角色(hook / pain_scene / closer 等)
|
||||
visual_system— build_visual_system 返回的视觉系统 dict
|
||||
extra — 追加约束(飞轮图片偏好片段,二期接入)
|
||||
"""
|
||||
name = product.get("name", "产品")
|
||||
style = visual_system.get("style", "")
|
||||
palette = visual_system.get("color_palette", "")
|
||||
base = visual_system.get("base_prompt", "")
|
||||
lines = [
|
||||
f"[{role_name}] 为产品「{name}」生成种草图。",
|
||||
base and f"视觉基调:{base}",
|
||||
style and f"摄影风格:{style}",
|
||||
palette and f"色调:{palette}",
|
||||
extra,
|
||||
]
|
||||
return "\n".join(l for l in lines if l)
|
||||
197
backend/app/services/ai_engine/storyboard.py
Normal file
197
backend/app/services/ai_engine/storyboard.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
storyboard 分镜引擎
|
||||
扒自:worker/src/image.js
|
||||
- getNarrativeRoles:按图数取分镜角色
|
||||
- proof_strategy:按品类定证明页策略(品类不写死,走数据驱动)
|
||||
- build_visual_system:成组视觉统一
|
||||
- plan_image_set:组装最终分镜计划
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from .constants import (
|
||||
PAGE_ROLE_MAP, IMAGE_NEGATIVE_CONSTRAINTS,
|
||||
STYLE_PROMPTS, STYLE_DEFAULT, NARRATIVE_BY_COUNT, NARRATIVE_BY_STRATEGY,
|
||||
)
|
||||
# sanitize_text 移至 templates(腾行数),此处 re-export 供 image_gen 沿用 import
|
||||
from .storyboard_templates import role_template, proof_strategy, sanitize_text # noqa: F401
|
||||
|
||||
|
||||
def clamp_count(value: int, fallback: int = 6, lo: int = 1, hi: int = 8) -> int:
|
||||
try:
|
||||
return max(lo, min(hi, int(value)))
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
def short_selling_points(points, fallback: str = "") -> str:
|
||||
"""3个短卖点拼成 a / b / c(扒 shortSellingPoints:112-120)"""
|
||||
src = points if isinstance(points, list) else str(points or "").split("、")
|
||||
clean = [sanitize_text(p, 18) for p in src if sanitize_text(p, 18)][:3]
|
||||
return " / ".join(clean) if clean else sanitize_text(fallback, 28)
|
||||
|
||||
|
||||
def short_tags(tags, keywords=None) -> str:
|
||||
"""标签去#截断拼成 #a #b(扒 shortTags:48-54)"""
|
||||
merged = list(tags or []) + list(keywords or [])
|
||||
out = []
|
||||
for t in merged:
|
||||
c = sanitize_text(str(t), 12).lstrip("#")
|
||||
if c:
|
||||
out.append(f"#{c}")
|
||||
return " ".join(out[:5])
|
||||
|
||||
|
||||
def analyze_copy_for_image(note: dict, product: dict) -> dict:
|
||||
"""
|
||||
从文案+产品提取生图锚点(扒 analyzeCopyForImage:129-148)
|
||||
给每张图填 audience/pain/scene/hook,让画面有真实代入而非空泛。
|
||||
"""
|
||||
text = f"{note.get('title','')}。{note.get('coverTitle','')}。{note.get('content','')}"
|
||||
tags = [sanitize_text(str(t).lstrip('#'), 12) for t in (note.get("tags") or [])]
|
||||
audience = sanitize_text(
|
||||
product.get("target_audience")
|
||||
or next((t for t in tags if re.search(r"党|人|妈妈|女生|学生|通勤|上班|办公室", t)), "")
|
||||
or "目标用户", 18)
|
||||
scene = sanitize_text(
|
||||
next((t for t in tags if re.search(r"通勤|宿舍|上课|约会|出门|办公室|旅行|居家|工位", t)), "")
|
||||
or "日常自然光场景", 18)
|
||||
pain = sanitize_text(
|
||||
next((w for w in re.split(r"[、,,。;;!!??\n]", text)
|
||||
if re.search(r"暗沉|没气色|假白|卡粉|搓泥|油|干|赶时间|预算|麻烦", w)), "")
|
||||
or "日常使用痛点", 18)
|
||||
hook = sanitize_text(note.get("coverTitle") or note.get("title") or f"{audience}{scene}", 18)
|
||||
return {"audience": audience, "scene": scene, "pain": pain, "hook": hook}
|
||||
|
||||
|
||||
def get_narrative_roles(image_count: int = 6) -> list[dict]:
|
||||
"""
|
||||
按图数返回分镜角色列表(扒 getNarrativeRoles,Q6对齐北哥6张套路)
|
||||
≤3 张:极速链路 hook / applied_proof / closer
|
||||
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
|
||||
>6 张:沉浸链路 + pain_scene / scenario / social_proof
|
||||
"""
|
||||
count = clamp_count(image_count)
|
||||
m = PAGE_ROLE_MAP
|
||||
if count <= 3:
|
||||
sequence = ["hook", "applied_proof", "closer"]
|
||||
elif count <= 6:
|
||||
# Q6:北哥6张标准顺序——品牌词在②(product_closeup)和⑥(closer)两次出现
|
||||
sequence = ["hook", "product_closeup", "ingredient", "texture", "applied_proof", "closer"]
|
||||
else:
|
||||
# 8张沉浸链路:在北哥6张基础上插入 pain_scene / social_proof
|
||||
sequence = ["hook", "pain_scene", "product_closeup", "ingredient", "texture", "applied_proof", "social_proof", "closer"]
|
||||
return [m[r] for r in sequence[:count] if r in m]
|
||||
|
||||
|
||||
# ── proofStrategy 已移至 storyboard_templates.proof_strategy(腾行数,超200拆)
|
||||
|
||||
|
||||
def build_visual_system(product: dict, analysis: dict | None = None) -> dict:
|
||||
"""
|
||||
成组视觉统一(扒 buildVisualSystem)
|
||||
analysis 来自 product.js 分析结果(visualIdentity),可空
|
||||
"""
|
||||
identity = (analysis or {}).get("visualIdentity", {})
|
||||
palette = (
|
||||
"、".join(identity["colorPalette"][:5])
|
||||
if isinstance(identity.get("colorPalette"), list) and identity["colorPalette"]
|
||||
else "提取产品包装主色,搭配浅色真实生活背景"
|
||||
)
|
||||
return {
|
||||
"palette": palette,
|
||||
"typography": identity.get("typographyStyle", "主标题清晰黑体或手写感标题,辅助文字便签/勾选标注,字重颜色保持同一体系"),
|
||||
"sticker": identity.get("stickerLanguage", "少量箭头、放大镜、勾选、小表情、便签,不使用促销按钮"),
|
||||
"layout": identity.get("layoutStyle", "同一组图片保持色调、光线、产品露出方式一致,每张图承担不同叙事角色"),
|
||||
"texture": identity.get("materialTexture", "产品包装、质地、手背/上脸肤感要真实自然"),
|
||||
"package_details": identity.get("packageDetails", "如果提供产品图,必须还原包装颜色、瓶身形状、标签方向和主视觉"),
|
||||
"xhs_style_preset": identity.get("xhsStylePreset", "真实测评风/手写安利风/清单便签风"),
|
||||
"symbol_system": identity.get("symbolSystem", "中等密度小红书种草符号:✅ ✨ 🌿 💧 🪞 🧴 📦 🔍 💛,每张最多2-4个"),
|
||||
"quality_rules": [
|
||||
"同组图片字体体系相对一致,但不要像固定模板",
|
||||
"每张压图文字必须服务当前叙事角色,不能重复封面标题",
|
||||
"护肤品优先出现手背涂抹、质地微距、自然上脸局部或真实生活场景",
|
||||
"人物真实自然,有轻微皮肤纹理和生活感,不要AI精修美女",
|
||||
"禁止乱码、错别字、App底栏、Like评论分享、硬广价格牌、虚假功效before/after",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def plan_image_set(note: dict, product: dict, image_count: int = 3, analysis: dict | None = None, strategy: str | None = None) -> dict:
|
||||
"""
|
||||
组装分镜计划(主入口)
|
||||
strategy: None=默认按图数叙事,'A'/'B'/'C'=三套正交叙事策略
|
||||
返回:{requested_count, storyboard, visual_system, base_prompt}
|
||||
storyboard 每项:{role, name, focus, overlay_text, prompt_for_item}
|
||||
"""
|
||||
count = clamp_count(image_count)
|
||||
roles = get_narrative_roles(count)
|
||||
visual = build_visual_system(product, analysis)
|
||||
category = product.get("category", "通用好物")
|
||||
points = product.get("selling_points") or ["核心买点"]
|
||||
src = analyze_copy_for_image(note, product) # 文案锚点:audience/pain/scene/hook
|
||||
|
||||
storyboard = []
|
||||
brand_kw = sanitize_text(product.get("brand_keyword") or "", 12)
|
||||
brand_roles = {"product_closeup", "closer"} # Q6:第2/6张带品牌词
|
||||
|
||||
for i, role in enumerate(roles):
|
||||
point = sanitize_text(points[i % len(points)], 18)
|
||||
tpl = role_template(role["role"])
|
||||
proof = proof_strategy(category, point) # 仅 applied_proof 用品类证明
|
||||
# 填模板占位:每角色画面/文字各不同(修缩水根因——不再全角色共用proof)
|
||||
fill = {
|
||||
"audience": src["audience"], "pain": src["pain"], "scene": src["scene"],
|
||||
"hook": src["hook"], "point": point, "brand": brand_kw or product.get("name", "产品"),
|
||||
"proof_overlay": proof.get("overlay", point),
|
||||
"proof_visual": proof.get("visual", ""),
|
||||
"proof_forbidden": proof.get("forbidden", ""),
|
||||
}
|
||||
item = {
|
||||
"role": role["role"],
|
||||
"name": role["name"],
|
||||
"focus": role["focus"],
|
||||
"goal": sanitize_text(tpl["goal"].format(**fill), 40),
|
||||
"overlay_text": sanitize_text(tpl["overlay"].format(**fill), 20),
|
||||
"visual_strategy": sanitize_text(tpl["visual"].format(**fill), 120),
|
||||
"source_basis": sanitize_text(tpl["basis"].format(**fill), 60),
|
||||
"selling_point": point,
|
||||
"asset_use": proof.get("asset_use", "产品图保证包装准确,参考图用于真实场景"),
|
||||
"forbidden": sanitize_text(tpl["forbidden"].format(**fill), 60),
|
||||
}
|
||||
if brand_kw and role["role"] in brand_roles:
|
||||
item["brand_keyword"] = brand_kw
|
||||
item["brand_keyword_rule"] = "品牌词自然融入画面文字或产品露出,不做广告牌感"
|
||||
storyboard.append(item)
|
||||
|
||||
# base_prompt 全局规则(扒 planImageSet basePrompt:682-690)
|
||||
product_name = product.get("name", "产品")
|
||||
cover_title = sanitize_text(note.get("coverTitle") or note.get("title") or "", 18)
|
||||
style_text = STYLE_PROMPTS.get(product.get("style_tone") or STYLE_DEFAULT, STYLE_PROMPTS[STYLE_DEFAULT])
|
||||
# strategy非空时取对应正交叙事,否则按图数取默认链路
|
||||
narrative = NARRATIVE_BY_STRATEGY.get(strategy) if strategy else None
|
||||
if not narrative:
|
||||
narrative = NARRATIVE_BY_COUNT.get(count, NARRATIVE_BY_COUNT[6])
|
||||
sp_text = short_selling_points(points, cover_title)
|
||||
tag_text = short_tags(note.get("tags") or [], product.get("keywords") or [])
|
||||
base_prompt = (
|
||||
f"生成一张小红书可上传的独立3:4图文海报/素材图,目标比例1024×1536,可直接上传的独立图片,不是提示词,不是App截图。"
|
||||
f"产品:{product_name}。短标题:{cover_title}。"
|
||||
f"短卖点:{sp_text}。短标签:{tag_text}。"
|
||||
f"叙事链路:{narrative}"
|
||||
f"视觉风格:{style_text}。"
|
||||
f"成组视觉:主色={visual['palette']};字体={visual['typography']};贴纸={visual['sticker']};"
|
||||
f"符号系统={visual['symbol_system']};产品还原={visual['package_details']}。"
|
||||
"重要限制:中文文字少而清晰,每张只允许一个主标题,同一句话禁止重复出现,正文点位最多3条;"
|
||||
"四周留安全边距,文字不贴边不被裁切;真实自然像实拍素材后排版,降低AI味;"
|
||||
"禁止生成小红书App界面截图、Like/评论/分享/底栏/头像等社交元素;"
|
||||
"禁止肤色变白、瑕疵消失、治疗前后等视觉暗示,允许安全的未推开/推开后质地状态对比;"
|
||||
"如果提供产品图,产品是不可修改的真实商品锚点,禁止改名、换包装、混入其他产品。"
|
||||
f"\n{IMAGE_NEGATIVE_CONSTRAINTS}"
|
||||
)
|
||||
|
||||
return {
|
||||
"requested_count": count,
|
||||
"storyboard": storyboard,
|
||||
"visual_system": visual,
|
||||
"base_prompt": base_prompt,
|
||||
}
|
||||
174
backend/app/services/ai_engine/storyboard_templates.py
Normal file
174
backend/app/services/ai_engine/storyboard_templates.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
角色差异化分镜模板
|
||||
扒自:worker/src/image.js buildImageStoryboard storyboardByRole(222-321)
|
||||
|
||||
每个分镜角色有【各自不同】的画面/文字/构图策略,这是"小红书风格不雷同"的根因。
|
||||
之前缩水:6张共用同一个品类 proof 策略 → 图全长一样。
|
||||
模板里 {占位} 在 storyboard.py 运行时按文案/卖点填充。
|
||||
|
||||
字段含义(对齐 promptFromStoryboard 9 字段):
|
||||
goal 本张目标(这张图要让用户产生什么动作/情绪)
|
||||
overlay 图上主文字模板(每张不同,不重复封面标题)
|
||||
visual 画面主体(构图、景别、道具、光线——这是不雷同的关键)
|
||||
basis 文案依据(这张图从文案哪里来,给模型锚点)
|
||||
forbidden 本张禁止事项
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
|
||||
# ── sanitize(扒 sanitizeImagePlanText,防违禁视觉词进 prompt)
|
||||
_SANITIZE_RULES: list[tuple[str, str]] = [
|
||||
(r"before\s*&\s*after", "质地与肤感说明"),
|
||||
(r"before\s*/?\s*after", "质地与肤感说明"),
|
||||
(r"\bbefore\b", "质地状态"),
|
||||
(r"\bafter\b", "上脸肤感"),
|
||||
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"),
|
||||
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
|
||||
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
|
||||
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
|
||||
(r"治疗前后|治疗后|医美前后|治愈|修复受损", "日常使用场景说明"),
|
||||
]
|
||||
|
||||
|
||||
def sanitize_text(value: str, max_len: int = 56) -> str:
|
||||
s = str(value)
|
||||
for pattern, repl in _SANITIZE_RULES:
|
||||
s = re.sub(pattern, repl, s, flags=re.IGNORECASE)
|
||||
return re.sub(r"\s+", " ", s).strip()[:max_len]
|
||||
|
||||
|
||||
# 北哥6张标准套 + 8张扩展角色,每角色独立画面策略
|
||||
ROLE_STORYBOARD_TPL: dict[str, dict] = {
|
||||
"hook": {
|
||||
"goal": "让{audience}因为{pain}停下划走,产生点开欲",
|
||||
"overlay": "{hook}",
|
||||
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实肤感/手部细节,像iPhone随手实拍的封面,不是海报",
|
||||
"basis": "来自选中文案标题、人群{audience}、痛点{pain}",
|
||||
"forbidden": "不要价格、不要重复后续卖点、不要App界面、不要广告海报感",
|
||||
},
|
||||
"product_closeup": {
|
||||
"goal": "建立单品记忆锚点,让用户记住是哪个产品",
|
||||
"overlay": "{brand}",
|
||||
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读,品牌词自然出现在画面或瓶身",
|
||||
"basis": "来自产品名和品牌词,第2张和第6张都要带品牌词强化记忆",
|
||||
"forbidden": "不要堆多个产品、不要花哨背景抢主体、不要改包装文字",
|
||||
},
|
||||
"ingredient": {
|
||||
"goal": "用成分/配方信息建立信任,但不医疗化",
|
||||
"overlay": "看清{point}",
|
||||
"visual": "成分卡片式布局,产品+成分图标/短说明,浅色商务美妆风,信息层级清楚",
|
||||
"basis": "来自卖点里的成分/功效点,理性表达不夸大",
|
||||
"forbidden": "不要治疗/改善疾病承诺、不要医生背书、不要绝对化",
|
||||
},
|
||||
"texture": {
|
||||
"goal": "让用户看到{point}的真实质感证据",
|
||||
"overlay": "{point}看得见",
|
||||
"visual": "手背或指尖涂抹质地微距,产品放在旁边,自然光,保留真实皮肤纹理,能看清延展和肤感",
|
||||
"basis": "来自卖点里的质地/肤感描述",
|
||||
"forbidden": "不要生成变白效果、不要医疗化对比、不要和封面同构图",
|
||||
},
|
||||
"applied_proof": {
|
||||
"goal": "用可感知的上脸/使用证据证明{point}",
|
||||
"overlay": "{proof_overlay}",
|
||||
"visual": "{proof_visual}",
|
||||
"basis": "来自核心卖点{point}和用户对效果的关注",
|
||||
"forbidden": "{proof_forbidden}",
|
||||
},
|
||||
"closer": {
|
||||
"goal": "用囤货/省钱情报/搜索暗号完成软性转化",
|
||||
"overlay": "这波真的会囤 {brand}",
|
||||
"visual": "拆箱、囤货角或产品放在日常物品旁,真实分享氛围,轻量搜索/品牌词暗号提示,再带一次品牌词引导成交",
|
||||
"basis": "来自价格心智/选择理由,但不做硬广",
|
||||
"forbidden": "不要大促价格牌、不要购买按钮、不要红黄电商风",
|
||||
},
|
||||
# ── 8张扩展角色 ──
|
||||
"pain_scene": {
|
||||
"goal": "让用户共鸣{pain}",
|
||||
"overlay": "{pain}真的懂",
|
||||
"visual": "{scene}里的真实困扰场景,产品作为解决方案线索出现,不做使用前后对比",
|
||||
"basis": "来自文案痛点和目标人群",
|
||||
"forbidden": "不要夸大焦虑、不要before/after",
|
||||
},
|
||||
"social_proof": {
|
||||
"goal": "补足信任背书,让内容不像单方面推销",
|
||||
"overlay": "身边人都在问",
|
||||
"visual": "产品在包里/桌面/宿舍囤货角,配简短手写感反馈气泡,真实随手拍",
|
||||
"basis": "来自评论区语言/选择理由,缺评论时用低调口碑表达",
|
||||
"forbidden": "不要假造大量头像评论、不要App评论区截图",
|
||||
},
|
||||
"scenario": {
|
||||
"goal": "展示{scene}以外的多场景使用代入",
|
||||
"overlay": "这些场景都能用",
|
||||
"visual": "2-3个生活小场景拼贴:宿舍/通勤包/办公桌,产品贯穿其中,统一光线",
|
||||
"basis": "来自目标人群的多场景使用需求",
|
||||
"forbidden": "不要电商详情页拼贴、不要夸大效果",
|
||||
},
|
||||
"tutorial": {
|
||||
"goal": "降低使用门槛,告诉用户怎么用",
|
||||
"overlay": "三步就上手",
|
||||
"visual": "三步手势教程:取量、点涂/使用、收尾,干净背景,产品在画面内",
|
||||
"basis": "来自文案里的快速/懒人使用场景",
|
||||
"forbidden": "不要复杂说明书、不要过多文字",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def role_template(role: str) -> dict:
|
||||
"""取角色模板,未知角色用 applied_proof 兜底(和源头一致)"""
|
||||
return ROLE_STORYBOARD_TPL.get(role, ROLE_STORYBOARD_TPL["applied_proof"])
|
||||
|
||||
|
||||
# ── proofStrategy(按品类定 applied_proof 证明页策略,扒 image.js:163-208)
|
||||
# 品类来自 product.category,不硬编码枚举;无匹配走"通用好物"兜底
|
||||
PROOF_STRATEGIES: dict[str, dict] = {
|
||||
"个护护理": {
|
||||
"overlay_tpl": "{point}看得见",
|
||||
"visual": "手部/身体局部使用证明:少量点涂、推开后吸收状态、真实纹理和自然光;产品只做辅助露出",
|
||||
"asset_use": "优先使用实拍/参考图中的手部、干纹、涂抹、随身场景;产品图保证包装准确",
|
||||
"forbidden": "不要变白、祛斑、医学效果、before/after字样;不要和封面同构图",
|
||||
},
|
||||
"美妆护肤": {
|
||||
"overlay_tpl": "{point}看得见",
|
||||
"visual": "肤感/质地证明:手背、脸颊局部或质地微距,展示推开前后真实状态,保留皮肤纹理和自然光",
|
||||
"asset_use": "优先使用实拍/参考图中的手背、上脸、质地素材;产品图辅助露出",
|
||||
"forbidden": "不要变白、祛斑、医学效果、before/after字样;不要和封面同构图",
|
||||
},
|
||||
"食品饮品": {
|
||||
"overlay_tpl": "{point}一眼懂",
|
||||
"visual": "冲泡/开袋/入口证明:展示包装、杯中状态、质地颜色或一口口感,真实桌面光线",
|
||||
"asset_use": "产品图保证包装准确,参考图用于杯子、开袋、冲泡、办公室/居家场景",
|
||||
"forbidden": "不要涂抹、不要护肤肤感、不要医疗健康承诺",
|
||||
},
|
||||
"营养健康": {
|
||||
"overlay_tpl": "看清{point}",
|
||||
"visual": "理性证明页:包装、成分表、使用场景和每日习惯卡片,信息清晰但不做治疗承诺",
|
||||
"asset_use": "产品图和说明图用于成分/包装准确,参考图用于日常使用场景",
|
||||
"forbidden": "不要治疗、改善疾病、速效、医生背书、前后对比",
|
||||
},
|
||||
"家居生活": {
|
||||
"overlay_tpl": "{point}真省事",
|
||||
"visual": "使用过程证明:展示痛点场景、产品介入和使用过程细节,强调顺手/收纳/效率",
|
||||
"asset_use": "参考图用于真实家居环境,产品图保证外观准确",
|
||||
"forbidden": "不要护肤涂抹,不要虚假夸大结果",
|
||||
},
|
||||
"服饰穿搭": {
|
||||
"overlay_tpl": "{point}有细节",
|
||||
"visual": "上身/材质证明:展示面料纹理、版型细节或普通身材上身局部,真实自然",
|
||||
"asset_use": "参考图用于上身/搭配/材质,产品图保证款式颜色准确",
|
||||
"forbidden": "不要护肤涂抹,不要过度精修模特感",
|
||||
},
|
||||
"通用好物": {
|
||||
"overlay_tpl": "{point}清晰可见",
|
||||
"visual": "产品使用场景证明:真实道具/场景,展示产品细节和使用过程",
|
||||
"asset_use": "产品图保证准确,参考图用于场景辅助",
|
||||
"forbidden": "不要夸大效果,不要硬广式价格牌",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def proof_strategy(category: str, point: str) -> dict:
|
||||
"""取品类证明策略,无匹配用通用兜底(扒 proofStrategy)"""
|
||||
s = PROOF_STRATEGIES.get(category, PROOF_STRATEGIES["通用好物"]).copy()
|
||||
s["overlay"] = s.pop("overlay_tpl", "{point}").format(point=point)
|
||||
return s
|
||||
|
||||
93
backend/app/services/ai_engine/text_scoring.py
Normal file
93
backend/app/services/ai_engine/text_scoring.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
text_scoring.py — 五维打分接口 + 去重(≤100行)
|
||||
打分维度逻辑见 _scoring_dims.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
from .constants import (
|
||||
BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS,
|
||||
SCORE_WEIGHTS, QUALITY_PASS_SCORE,
|
||||
DEDUP_TITLE_THRESHOLD, DEDUP_TITLE_CONTENT_TITLE, DEDUP_TITLE_CONTENT_BODY,
|
||||
)
|
||||
from ._scoring_dims import (
|
||||
_cat_words, score_title, score_emotion, score_selling,
|
||||
score_keyword, score_compliance,
|
||||
)
|
||||
|
||||
|
||||
def score_copy(
|
||||
copy: dict[str, Any],
|
||||
source: dict[str, Any],
|
||||
banned_words: list[str] | None = None,
|
||||
weights: dict[str, int] | None = None,
|
||||
pass_score: int = QUALITY_PASS_SCORE,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
五维打分(标题25 / 情绪25 / 买点25 / 关键词20 / 合规5)
|
||||
返回:{score, score_detail, passed, banned_words_found}
|
||||
"""
|
||||
w = weights or SCORE_WEIGHTS
|
||||
bwords = list(set((banned_words or []) + BANNED_WORDS_DEFAULT + BANNED_VISUAL_WORDS))
|
||||
|
||||
title = str(copy.get("title", ""))
|
||||
content = str(copy.get("content", ""))
|
||||
tags = " ".join(str(t) for t in copy.get("tags", []))
|
||||
full = f"{title}\n{content}\n{tags}\n{copy.get('imageBrief','')}"
|
||||
|
||||
selling_points = source.get("selling_points", []) or []
|
||||
keywords = source.get("keywords", []) or []
|
||||
category = source.get("category", "通用好物")
|
||||
cat_w = _cat_words(category)
|
||||
|
||||
dim_title = score_title(title, cat_w, w)
|
||||
dim_emotion = score_emotion(full, w)
|
||||
dim_selling = score_selling(copy, full, selling_points, w)
|
||||
dim_keyword = score_keyword(copy, tags, keywords, w)
|
||||
dim_compliance, found_all = score_compliance(full, bwords, w)
|
||||
|
||||
details = [dim_title, dim_emotion, dim_selling, dim_keyword, dim_compliance]
|
||||
total = max(0, min(100, sum(d["score"] for d in details)))
|
||||
passed = (total >= pass_score) and not found_all
|
||||
|
||||
return {"score": total, "score_detail": details, "passed": passed, "banned_words_found": found_all}
|
||||
|
||||
|
||||
def _sim(a: str, b: str) -> float:
|
||||
return SequenceMatcher(None, a, b).ratio()
|
||||
|
||||
|
||||
def _copy_signature(copy: dict) -> str:
|
||||
content = str(copy.get("content", ""))
|
||||
opening = re.sub(r"\s+", "", content[:30])
|
||||
return f"{copy.get('title', '')}|{opening}"
|
||||
|
||||
|
||||
def is_similar_copy(a: dict, b: dict) -> bool:
|
||||
"""同质化判重(标题≥0.82 OR 标题≥0.65且正文≥0.72)"""
|
||||
t = _sim(str(a.get("title", "")), str(b.get("title", "")))
|
||||
if t >= DEDUP_TITLE_THRESHOLD:
|
||||
return True
|
||||
if t >= DEDUP_TITLE_CONTENT_TITLE:
|
||||
if _sim(str(a.get("content",""))[:200], str(b.get("content",""))[:200]) >= DEDUP_TITLE_CONTENT_BODY:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def dedupe_copies(copies: list[dict], previous: list[dict] | None = None) -> list[dict]:
|
||||
"""本轮内互去重 + 与历史去重 + angle 去重"""
|
||||
history = previous or []
|
||||
kept: list[dict] = []
|
||||
used_angles: set[str] = set()
|
||||
for c in copies:
|
||||
sig = _copy_signature(c)
|
||||
if any(_copy_signature(h) == sig for h in history): continue
|
||||
if any(is_similar_copy(c, h) for h in history): continue
|
||||
if any(is_similar_copy(c, k) for k in kept): continue
|
||||
angle = str(c.get("angle", "")).strip()
|
||||
if angle and angle in used_angles: continue
|
||||
if angle: used_angles.add(angle)
|
||||
kept.append(c)
|
||||
return kept
|
||||
186
backend/app/services/ai_engine/text_variants.py
Normal file
186
backend/app/services/ai_engine/text_variants.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
text_variants.py — 文案双轨主入口(≤100行)
|
||||
轨A: generate_text_variants — 调 LLM 出 N 角度 JSON
|
||||
轨B: text_import_handler — 导入外部文案进候选池
|
||||
|
||||
prompt 组装/解析见 _text_prompt.py;评分/去重见 text_scoring.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .constants import MAX_OPTIMIZE_ROUNDS
|
||||
from ._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array, build_local_drafts
|
||||
from .text_scoring import score_copy, dedupe_copies
|
||||
from .llm_scorer import llm_score_copy
|
||||
from .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _call_llm(client: Any, prompt: str, max_tokens: int = 8192) -> str:
|
||||
"""统一 LLM 调用,client 由 worker 注入,隔离 key。
|
||||
G1坑修复:AIClients 没有 .chat.completions,正确方法是 .chat_complete()
|
||||
S8: 503/429 指数退避重试(最多3次,2^attempt 秒),其他异常直接降级返 ''。
|
||||
max_tokens 由调用方按批量缩放:opus 会尽量填满输出空间,8192 token 的生成
|
||||
单批 >60s 必撞 apiports 网关上限返 503(task46 实测每请求恰挂 ~61s)。实测单条
|
||||
max_tokens=1500~2500 仅 16~18s。故按条数动态收,墙钟压进 60s 网关窗口内。
|
||||
"""
|
||||
import httpx
|
||||
|
||||
# 倩倩姐2026-06-13拍板"加大重试+拉长退避":apiports负载波动时单条opus也会被
|
||||
# 拖过60s返503,短退避(1/2/4s)赶不开高负载窗口。故重试5次、退避拉长到最长30s,
|
||||
# 给中转站负载回落留时间。墙钟换稳定(MVP免费阶段可接受)。
|
||||
max_attempts = 5
|
||||
backoff = [5, 10, 20, 30] # 第1~4次重试前等待秒数,拉长跨过apiports高负载窗口
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return await client.chat_complete(
|
||||
messages=[
|
||||
{"role": "system", "content": COPY_SYSTEM},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
model=client._model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.75,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else 0
|
||||
if status in (503, 429) and attempt < max_attempts - 1:
|
||||
wait = backoff[min(attempt, len(backoff) - 1)]
|
||||
logger.warning(
|
||||
"LLM 返回 %s,第%d/%d次重试,等待 %ds: %s",
|
||||
status, attempt + 1, max_attempts - 1, wait, exc,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
logger.error("LLM HTTP错误(不可重试或已达上限): %s: %s", type(exc).__name__, exc)
|
||||
return ""
|
||||
except Exception as exc:
|
||||
# 其他异常(超时/网络断开等)不重试,直接降级
|
||||
logger.error("LLM 调用失败: %s: %s", type(exc).__name__, exc)
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
# apiports 网关单次响应有 ~60s 上限,claude 一次生成 >4 条长文案会超时返 503。
|
||||
# 故分批:每批最多 4 条,串行调用合并。批大小可经 TEXT_BATCH_SIZE 调。
|
||||
TEXT_BATCH_SIZE = int(os.environ.get("TEXT_BATCH_SIZE", "4"))
|
||||
|
||||
|
||||
async def _generate_one_batch(llm_client: Any, product: dict, batch_n: int, extra: str) -> list[dict]:
|
||||
"""生成一批 batch_n 条,含解析重试(最多2次)。失败返回空列表。
|
||||
max_tokens 按条数缩放(每条约 1800 token,封顶 8192),压进 apiports 60s 网关窗口。"""
|
||||
batch_max_tokens = min(8192, max(1800, batch_n * 1800))
|
||||
for attempt in range(2):
|
||||
raw = await _call_llm(llm_client, build_prompt(product, batch_n, extra_rules=extra), batch_max_tokens)
|
||||
parsed = parse_json_array(raw)
|
||||
if parsed:
|
||||
return parsed
|
||||
logger.warning("文案批(%d条)第%d次解析失败%s", batch_n, attempt + 1,
|
||||
",重试" if attempt == 0 else ",放弃本批")
|
||||
return []
|
||||
|
||||
|
||||
async def _generate_in_batches(llm_client: Any, product: dict, count: int, extra: str) -> list[dict]:
|
||||
"""把 count 条按 TEXT_BATCH_SIZE 分批,串行调用合并。
|
||||
串行而非并发:opus 单批就慢(~300s)且 apiports 限并发,多批 gather 会触发
|
||||
大面积 503 雪崩(task45 实测)。故改串行,墙钟换稳定。"""
|
||||
sizes: list[int] = []
|
||||
remaining = count
|
||||
while remaining > 0:
|
||||
n = min(TEXT_BATCH_SIZE, remaining)
|
||||
sizes.append(n)
|
||||
remaining -= n
|
||||
collected: list[dict] = []
|
||||
for n in sizes:
|
||||
r = await _generate_one_batch(llm_client, product, n, extra)
|
||||
collected.extend(r)
|
||||
return collected
|
||||
|
||||
|
||||
async def generate_text_variants(
|
||||
llm_client: Any,
|
||||
product: dict,
|
||||
count: int,
|
||||
previous_copies: list[dict] | None = None,
|
||||
banned_word_rows: list[dict] | None = None,
|
||||
flywheel_context: str = "",
|
||||
) -> list[dict]:
|
||||
"""轨A:一次出 count 条不同角度文案,三层兜底,自动优化循环"""
|
||||
banned_entries = build_entries_from_db(banned_word_rows or [])
|
||||
extra = flywheel_context
|
||||
|
||||
copies: list[dict] = await _generate_in_batches(llm_client, product, count, extra)
|
||||
if not copies:
|
||||
copies = list(build_local_drafts(product, count)) # generator → list
|
||||
|
||||
candidates: list[dict] = []
|
||||
for c in copies:
|
||||
ban: CheckResult = check_and_fix(
|
||||
f"{c.get('title','')} {c.get('content','')}",
|
||||
banned_entries or None,
|
||||
)
|
||||
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
|
||||
c.update({"source": "ai", "score": scored["score"], "score_detail": scored["score_detail"],
|
||||
"passed": scored["passed"], "banned_word_status": ban.status,
|
||||
"verdict": scored.get("verdict", ""), "summary": scored.get("summary", "")})
|
||||
if ban.status == "auto_fixed" and ban.fixed_text:
|
||||
c["content"] = ban.fixed_text
|
||||
candidates.append(c)
|
||||
|
||||
failed = [c for c in candidates if not c["passed"] and c["banned_word_status"] != "hard_block"]
|
||||
# 优化轮默认关闭:apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503,
|
||||
# 严重拖慢出文案(实测 +100s+)。质量优化等北哥 prompt 方案到位再开(架构已留位)。
|
||||
optimize_enabled = os.environ.get("TEXT_OPTIMIZE_ENABLED", "false").lower() == "true"
|
||||
rounds = MAX_OPTIMIZE_ROUNDS if optimize_enabled else 0
|
||||
for _ in range(rounds):
|
||||
if not failed:
|
||||
break
|
||||
# 优化轮也受 60s 网关上限约束:一次最多重生成 TEXT_BATCH_SIZE 条
|
||||
batch_failed = failed[:TEXT_BATCH_SIZE]
|
||||
hint = "\n".join(
|
||||
f"标题「{c['title']}」{c['score']}分,需改进:" +
|
||||
";".join(d["note"] for d in c.get("score_detail", []) if d["score"] < d["max"] * 0.72)
|
||||
for c in batch_failed
|
||||
)
|
||||
raw2 = await _call_llm(llm_client, build_prompt(
|
||||
product, len(batch_failed),
|
||||
extra_rules=f"以下文案未达标,请重新生成并改进:\n{hint}\n不要重复已有标题和角度。",
|
||||
), min(8192, max(1800, len(batch_failed) * 1800)))
|
||||
if not raw2:
|
||||
# LLM 失败(如 503/超时):优化是锦上添花,原始候选已够用,不再耗时重试
|
||||
logger.warning("文案优化轮 LLM 失败,沿用原始候选不再重试")
|
||||
break
|
||||
for nc in parse_json_array(raw2):
|
||||
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
|
||||
nc.update({"source": "ai", "score": sc2["score"], "score_detail": sc2["score_detail"],
|
||||
"passed": sc2["passed"], "banned_word_status": "pass",
|
||||
"verdict": sc2.get("verdict", ""), "summary": sc2.get("summary", "")})
|
||||
candidates.append(nc)
|
||||
failed = [c for c in candidates if not c["passed"]]
|
||||
|
||||
return dedupe_copies(candidates, previous_copies or [])[:count]
|
||||
|
||||
|
||||
def text_import_handler(
|
||||
raw_text: str,
|
||||
product: dict,
|
||||
banned_word_rows: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""轨B:导入外部文案(豆包等)直接进候选池,source=import"""
|
||||
banned_entries = build_entries_from_db(banned_word_rows or [])
|
||||
lines = raw_text.strip().splitlines()
|
||||
title = lines[0].strip() if lines else ""
|
||||
content = "\n".join(lines[1:]).strip() if len(lines) > 1 else raw_text.strip()
|
||||
candidate: dict = {"title": title, "content": content, "tags": [], "angle": "import",
|
||||
"buyingPoint": "", "coverTitle": title, "imageBrief": "", "source": "import"}
|
||||
ban = check_and_fix(f"{title} {content}", banned_entries or None)
|
||||
# 轨B(导入外部文案)走机械 score_copy 而非 AI 评委:导入的是用户自带成品,评分仅作
|
||||
# 参考展示不卡发布;且本函数同步、改 await 会扩大到调用方。AI 评委只用于轨A生成链路。
|
||||
scored = score_copy(candidate, product, [e.word for e in banned_entries])
|
||||
candidate.update({"score": scored["score"], "score_detail": scored["score_detail"],
|
||||
"passed": scored["passed"], "banned_word_status": ban.status})
|
||||
return candidate
|
||||
71
backend/app/services/auth_service.py
Normal file
71
backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
app/services/auth_service.py — 认证 service
|
||||
密码哈希校验、用户查找、响应格式化。
|
||||
路由层不含业务逻辑,全在此。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import raise_unauthorized
|
||||
from app.middleware.workspace_guard import CurrentUser
|
||||
from app.models.user import User
|
||||
from app.models.workspace import WorkspaceMember
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def authenticate_user(
|
||||
db: Session, username: str, password: str
|
||||
) -> tuple[User, int, str]:
|
||||
"""
|
||||
验证用户名+密码,返回 (user, workspace_id, role)。
|
||||
失败抛 CloverHTTPException 40101。
|
||||
"""
|
||||
user = db.query(User).filter(
|
||||
User.username == username, User.is_active == True
|
||||
).first()
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
raise_unauthorized("用户名或密码错误")
|
||||
|
||||
# 取用户所在的第一个 workspace(手动建账号场景只有一个)
|
||||
member = (
|
||||
db.query(WorkspaceMember)
|
||||
.filter(WorkspaceMember.user_id == user.id)
|
||||
.first()
|
||||
)
|
||||
if not member:
|
||||
raise_unauthorized("用户未加入任何 workspace,请联系管理员")
|
||||
|
||||
# 记录登录
|
||||
try:
|
||||
from app.models.user import LoginRecord
|
||||
db.add(LoginRecord(user_id=user.id))
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.warning("Failed to write login_record for user=%s", user.id)
|
||||
db.rollback()
|
||||
|
||||
return user, member.workspace_id, member.role
|
||||
|
||||
|
||||
def build_user_response(user: User, workspace_id: int, role: str) -> dict:
|
||||
"""格式化用户响应体(契约§4 DTO)。"""
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"current_workspace_id": workspace_id,
|
||||
"role": role,
|
||||
}
|
||||
121
backend/app/services/flywheel_service.py
Normal file
121
backend/app/services/flywheel_service.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
app/services/flywheel_service.py — 飞轮信号写入 + 偏好上下文聚合
|
||||
preference_collector:三信号入口(选文案/选图/审核)写入 preference_events。
|
||||
preference_aggregator:查最近50条 → 最常选角度 + 打回原因近3条原文拼 prompt。
|
||||
飞轮不暴露独立埋点端点,只由业务接口内部调用(契约红线)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership, SignalType
|
||||
from app.middleware.workspace_guard import CurrentUser
|
||||
from app.models.flywheel import PreferenceEvent
|
||||
from app.models.task import GenerationTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 实时聚合窗口:最近50条事件
|
||||
_AGGREGATION_WINDOW = 50
|
||||
# 冷启动阈值:不足5条信号用产品档案冷启动
|
||||
_COLD_START_THRESHOLD = 5
|
||||
|
||||
|
||||
def record_signal(
|
||||
db: Session,
|
||||
current_user: CurrentUser,
|
||||
task: GenerationTask,
|
||||
signal_type: str,
|
||||
candidate_id: int | None = None,
|
||||
angle_label: str | None = None,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
写入飞轮信号。
|
||||
workspace_id + product_id 都必须有(基石C + 按产品分开学)。
|
||||
signal_weight 用枚举默认值,北哥可校准。
|
||||
data_ownership 默认 client_data(选择行为归客户)。
|
||||
"""
|
||||
weight = SIGNAL_WEIGHTS.get(signal_type, 0)
|
||||
event = PreferenceEvent(
|
||||
workspace_id=current_user.workspace_id,
|
||||
product_id=task.product_id,
|
||||
task_id=task.id,
|
||||
user_id=current_user.user_id,
|
||||
signal_type=signal_type,
|
||||
signal_weight=weight,
|
||||
candidate_id=candidate_id,
|
||||
angle_label=angle_label,
|
||||
reason=reason,
|
||||
data_ownership=DataOwnership.CLIENT_DATA,
|
||||
)
|
||||
try:
|
||||
db.add(event)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Flywheel signal: type=%s weight=%s user=%s product=%s",
|
||||
signal_type, weight, current_user.user_id, task.product_id,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.error(
|
||||
"Failed to write preference_event: type=%s user=%s",
|
||||
signal_type, current_user.user_id,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def get_preference_context(
|
||||
db: Session, workspace_id: int, product_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
实时聚合偏好上下文(最近50条 events)。
|
||||
返回:recent_preference摘要 + reject_reasons近3条 + injected_count。
|
||||
不足5条 → 冷启动提示(产品档案兜底,由 AIE prompt 层读 products.custom_prompt)。
|
||||
按 workspace_id + product_id 严格过滤(不串数据,基石C)。
|
||||
"""
|
||||
recent = (
|
||||
db.query(PreferenceEvent)
|
||||
.filter(
|
||||
PreferenceEvent.workspace_id == workspace_id,
|
||||
PreferenceEvent.product_id == product_id,
|
||||
)
|
||||
.order_by(desc(PreferenceEvent.created_at))
|
||||
.limit(_AGGREGATION_WINDOW)
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(recent) < _COLD_START_THRESHOLD:
|
||||
return {
|
||||
"recent_preference": "信号不足,使用产品档案基线(冷启动)",
|
||||
"reject_reasons": [],
|
||||
"injected_count": len(recent),
|
||||
}
|
||||
|
||||
# 统计最常被选中的角度
|
||||
angle_counts: dict[str, int] = {}
|
||||
for ev in recent:
|
||||
if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE) and ev.angle_label:
|
||||
angle_counts[ev.angle_label] = angle_counts.get(ev.angle_label, 0) + 1
|
||||
|
||||
top_angles = sorted(angle_counts.items(), key=lambda x: x[1], reverse=True)[:3]
|
||||
if top_angles:
|
||||
pref_desc = ";".join(f"{a}(已选{c}次)" for a, c in top_angles)
|
||||
preference_summary = f"最近偏好:{pref_desc}"
|
||||
else:
|
||||
preference_summary = "暂无明显角度偏好"
|
||||
|
||||
# 取最近3条打回原因原文(不做 AI 归纳,契约§3)
|
||||
reject_reasons = [
|
||||
ev.reason for ev in recent
|
||||
if ev.signal_type == SignalType.REJECT_WITH_REASON and ev.reason
|
||||
][:3]
|
||||
|
||||
return {
|
||||
"recent_preference": preference_summary,
|
||||
"reject_reasons": reject_reasons,
|
||||
"injected_count": len(recent),
|
||||
}
|
||||
86
backend/app/services/task_service.py
Normal file
86
backend/app/services/task_service.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
app/services/task_service.py — 任务创建 service
|
||||
校验有无 key → 建 GenerationTask → 只推 task_id 入队,绝不传 key(基石B)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import raise_business
|
||||
from app.middleware.workspace_guard import CurrentUser
|
||||
from app.models.task import GenerationTask
|
||||
from app.models.workspace import UserApiKey
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_user_has_key(db: Session, user_id: int, workspace_id: int) -> None:
|
||||
"""校验用户在此 workspace 是否有可用 API Key(openai/apiports均可),没有则引导去配置。"""
|
||||
key = (
|
||||
db.query(UserApiKey)
|
||||
.filter(
|
||||
UserApiKey.user_id == user_id,
|
||||
UserApiKey.workspace_id == workspace_id,
|
||||
UserApiKey.provider.in_(["openai", "apiports"]), # G6坑修复:接受主备通道名
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not key:
|
||||
raise_business("尚未配置 API Key,请先在设置中录入")
|
||||
|
||||
|
||||
def create_generation_task(
|
||||
db: Session,
|
||||
current_user: CurrentUser,
|
||||
body, # CreateTaskRequest
|
||||
) -> GenerationTask:
|
||||
"""
|
||||
建 GenerationTask 并推 Celery 队列。
|
||||
只传 task_id,绝不传 key(基石B)。
|
||||
"""
|
||||
if body.track == "ai":
|
||||
# 轨A:先检查有没有 key
|
||||
_check_user_has_key(db, current_user.user_id, current_user.workspace_id)
|
||||
|
||||
# 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
|
||||
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
|
||||
need_img = getattr(body, "need_product_image", True)
|
||||
if need_img:
|
||||
from app.models.product import Product
|
||||
product = db.query(Product).filter(
|
||||
Product.id == body.product_id,
|
||||
Product.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not product:
|
||||
raise_business("产品不存在")
|
||||
if not (product.image_path or "").strip():
|
||||
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关")
|
||||
|
||||
task = GenerationTask(
|
||||
workspace_id=current_user.workspace_id,
|
||||
product_id=body.product_id,
|
||||
operator_id=current_user.user_id,
|
||||
theme=body.theme,
|
||||
text_count=body.text_count,
|
||||
image_count=body.image_count,
|
||||
track=body.track,
|
||||
need_product_image=need_img,
|
||||
status="pending",
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
logger.info("GenerationTask created: id=%s ws=%s", task.id, current_user.workspace_id)
|
||||
|
||||
if body.track == "ai":
|
||||
enqueue_generation(task.id)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
def enqueue_generation(task_id: int) -> None:
|
||||
"""只推 task_id 入队,绝不推 key(基石B)。"""
|
||||
from app.workers.tasks import run_generation_pipeline
|
||||
run_generation_pipeline.delay(task_id)
|
||||
logger.info("Enqueued task_id=%s", task_id)
|
||||
7
backend/app/utils/README.md
Normal file
7
backend/app/utils/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# app/utils/
|
||||
|
||||
工具层占位:
|
||||
- fernet_utils.py # Fernet加密/解密(FERNET_KEY走环境变量,绝不进代码库)
|
||||
- sse_utils.py # SSE推送工具(补发历史事件,前端按event_seq去重)
|
||||
- pagination.py # 统一分页工具
|
||||
- ai_usage_logger.py # AI调用用量记录(每次调用记usage,归因到个人key)
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
49
backend/app/utils/fernet_utils.py
Normal file
49
backend/app/utils/fernet_utils.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
app/utils/fernet_utils.py — Fernet 加解密工具(按 Lead 规范路径)
|
||||
FERNET_KEY 走环境变量,绝不进代码库(基石B)。
|
||||
此模块是 app/core/security.py 中 Fernet 功能的独立导出,
|
||||
供 AIE / worker 层直接 import,无需依赖 FastAPI 上下文。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_fernet_instance: Fernet | None = None
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet_instance
|
||||
if _fernet_instance is None:
|
||||
settings = get_settings()
|
||||
_fernet_instance = Fernet(settings.FERNET_KEY.encode())
|
||||
return _fernet_instance
|
||||
|
||||
|
||||
def encrypt_key(plain_key: str) -> str:
|
||||
"""
|
||||
加密 API Key,返回密文字符串。
|
||||
调用方绝不打印 plain_key(基石B)。
|
||||
"""
|
||||
return _get_fernet().encrypt(plain_key.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_key(encrypted_key: str) -> str:
|
||||
"""
|
||||
解密 API Key。只在 Celery worker 内部调用。
|
||||
解密结果只活在调用函数的局部变量,不落盘、不打日志。
|
||||
"""
|
||||
try:
|
||||
return _get_fernet().decrypt(encrypted_key.encode()).decode()
|
||||
except InvalidToken:
|
||||
logger.error("Fernet decryption failed: token invalid or key rotated")
|
||||
raise ValueError("API key decryption failed")
|
||||
|
||||
|
||||
def mask_key(plain_key: str) -> str:
|
||||
"""只返回后4位(展示用,不暴露完整 key)。"""
|
||||
return plain_key[-4:] if len(plain_key) >= 4 else "****"
|
||||
122
backend/app/utils/sse_utils.py
Normal file
122
backend/app/utils/sse_utils.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
app/utils/sse_utils.py — SSE 工具函数(按 Lead 规范路径)
|
||||
补发历史事件、event_seq 去重、Redis pub/sub 推送。
|
||||
供 api/v1/stream.py 和 AIE worker 共用。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# SSE 事件类型(契约§2全列)
|
||||
SSE_EVENT_TYPES = frozenset({
|
||||
"task_started", "analyze_done", "text_progress", "text_candidate",
|
||||
"image_progress", "image_candidate", "flywheel_injected",
|
||||
"batch_failed", "task_done", "error", "heartbeat",
|
||||
})
|
||||
|
||||
_HISTORY_KEY_TPL = "sse:task:{task_id}:events"
|
||||
_CHANNEL_TPL = "sse:task:{task_id}"
|
||||
_HISTORY_TTL_SECONDS = 3600 # 历史事件保留 1h
|
||||
|
||||
|
||||
def format_sse(event: str, data: dict, seq: int | None = None) -> str:
|
||||
"""格式化单条 SSE 消息(text/event-stream 格式)。"""
|
||||
payload = json.dumps(data, ensure_ascii=False)
|
||||
lines = [f"event: {event}", f"data: {payload}"]
|
||||
if seq is not None:
|
||||
lines.append(f"id: {seq}")
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
|
||||
async def push_event(
|
||||
redis_client,
|
||||
task_id: int,
|
||||
workspace_id: int,
|
||||
event: str,
|
||||
data: dict[str, Any],
|
||||
seq: int,
|
||||
) -> None:
|
||||
"""
|
||||
推送事件到 Redis:
|
||||
1. 追加到历史 list(供断线重连补发)
|
||||
2. Publish 到 channel(供在线客户端实时收)
|
||||
"""
|
||||
if event not in SSE_EVENT_TYPES:
|
||||
logger.warning("Unknown SSE event type: %s", event)
|
||||
|
||||
record = {"event": event, "data": data, "seq": seq, "workspace_id": workspace_id}
|
||||
payload = json.dumps(record, ensure_ascii=False)
|
||||
|
||||
hist_key = _HISTORY_KEY_TPL.format(task_id=task_id)
|
||||
channel = _CHANNEL_TPL.format(task_id=task_id)
|
||||
|
||||
await redis_client.rpush(hist_key, payload)
|
||||
await redis_client.expire(hist_key, _HISTORY_TTL_SECONDS)
|
||||
await redis_client.publish(channel, payload)
|
||||
|
||||
|
||||
async def get_history_events(
|
||||
redis_client, task_id: int, after_seq: int
|
||||
) -> list[dict]:
|
||||
"""取 task 历史事件中 seq > after_seq 的部分(断线重连补发)。"""
|
||||
hist_key = _HISTORY_KEY_TPL.format(task_id=task_id)
|
||||
try:
|
||||
raw_list = await redis_client.lrange(hist_key, 0, -1)
|
||||
result = []
|
||||
for raw in raw_list:
|
||||
ev = json.loads(raw)
|
||||
if ev.get("seq", 0) > after_seq:
|
||||
result.append(ev)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning("get_history_events failed task=%s: %s", task_id, exc)
|
||||
return []
|
||||
|
||||
|
||||
async def stream_events(
|
||||
redis_client,
|
||||
task_id: int,
|
||||
workspace_id: int,
|
||||
last_seq: int = 0,
|
||||
heartbeat_interval: int = 25,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
主 SSE 生成器:
|
||||
1. 先补发 last_seq 之后的历史事件
|
||||
2. 再订阅 Redis channel 实时推新事件
|
||||
3. 每 heartbeat_interval 秒推一次保活 heartbeat
|
||||
"""
|
||||
# 1. 补发历史
|
||||
history = await get_history_events(redis_client, task_id, last_seq)
|
||||
for ev in history:
|
||||
yield format_sse(ev["event"], ev["data"], ev.get("seq"))
|
||||
|
||||
# 2. 实时订阅
|
||||
pubsub = redis_client.pubsub()
|
||||
channel = _CHANNEL_TPL.format(task_id=task_id)
|
||||
await pubsub.subscribe(channel)
|
||||
elapsed = 0
|
||||
try:
|
||||
while True:
|
||||
msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
||||
if msg and msg["type"] == "message":
|
||||
ev = json.loads(msg["data"])
|
||||
if ev.get("workspace_id") != workspace_id:
|
||||
continue # 防越权订阅他人任务
|
||||
yield format_sse(ev["event"], ev["data"], ev.get("seq"))
|
||||
if ev["event"] in ("task_done", "error"):
|
||||
break
|
||||
else:
|
||||
elapsed += 1
|
||||
if elapsed >= heartbeat_interval:
|
||||
elapsed = 0
|
||||
yield format_sse("heartbeat", {"ts": asyncio.get_event_loop().time()})
|
||||
finally:
|
||||
await pubsub.unsubscribe(channel)
|
||||
11
backend/app/workers/README.md
Normal file
11
backend/app/workers/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# app/workers/
|
||||
|
||||
Celery worker 占位:
|
||||
- celery_app.py # Celery实例配置(broker=Redis)
|
||||
- task_runner.py # 主任务:只接收task_id → 查库→FERNET_KEY解密key → 调模型
|
||||
# 铁律:明文key绝不进Celery参数,只在函数局部变量,不落盘不打日志
|
||||
- subtasks/
|
||||
analyze.py # 分析标杆笔记8特征
|
||||
generate_text.py # 文案双轨(轨A一次5角度JSON/轨B跳过)
|
||||
generate_image.py # 并发生图asyncio.gather(A/B/C三策略)
|
||||
postprocess.py # 去水印后处理
|
||||
0
backend/app/workers/__init__.py
Normal file
0
backend/app/workers/__init__.py
Normal file
36
backend/app/workers/celery_app.py
Normal file
36
backend/app/workers/celery_app.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
app/workers/celery_app.py — Celery 任务框架壳
|
||||
铁律:只传 task_id,绝不传 key(基石B)。
|
||||
worker 内查库 → Fernet 解密 → 局部变量,不落盘不打日志。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from celery import Celery
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
celery_app = Celery(
|
||||
"clover",
|
||||
broker=settings.celery_broker(),
|
||||
backend=settings.celery_backend(),
|
||||
include=["app.workers.tasks", "app.workers.replenish_task"],
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
result_serializer="json",
|
||||
accept_content=["json"],
|
||||
timezone="Asia/Shanghai",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_acks_late=True, # 任务处理完才 ACK,防丢失
|
||||
worker_prefetch_multiplier=1, # 一次只取1条,防长任务堆积
|
||||
task_routes={
|
||||
"app.workers.tasks.run_generation_pipeline": {"queue": "generation"},
|
||||
"app.workers.tasks.build_delivery_package": {"queue": "packaging"},
|
||||
},
|
||||
)
|
||||
104
backend/app/workers/packaging_task.py
Normal file
104
backend/app/workers/packaging_task.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
app/workers/packaging_task.py — 交付打包 Celery 任务
|
||||
build_delivery_package:查已选文案+图片 → package_exporter → 存路径
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_db():
|
||||
from app.core.database import SessionLocal
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="app.workers.tasks.build_delivery_package",
|
||||
max_retries=2,
|
||||
default_retry_delay=10,
|
||||
queue="packaging",
|
||||
)
|
||||
def build_delivery_package(self, package_id: int) -> dict:
|
||||
"""打包交付任务。查 delivery_packages → 收集笔记 → package_exporter"""
|
||||
logger.info("build_delivery_package start: package_id=%s", package_id)
|
||||
db = _get_db()
|
||||
try:
|
||||
from app.models.task import DeliveryPackage, TextCandidate, ImageCandidate
|
||||
from app.constants.enums import PackageStatus
|
||||
|
||||
pkg = db.query(DeliveryPackage).filter(DeliveryPackage.id == package_id).first()
|
||||
if not pkg:
|
||||
raise ValueError(f"package_id={package_id} not found")
|
||||
|
||||
workspace_id = pkg.workspace_id
|
||||
task_id = pkg.task_id
|
||||
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
|
||||
|
||||
selected_text = db.query(TextCandidate).filter(
|
||||
TextCandidate.task_id == task_id, TextCandidate.is_selected == True,
|
||||
).first()
|
||||
# 整套全打(倩倩姐2026-06-08拍板):一条笔记的全部图按 seq 排序进包,
|
||||
# 不再只打 is_selected 的封面。北哥6张标准套 seq=1 是 hook 封面,天然排第一。
|
||||
selected_images = db.query(ImageCandidate).filter(
|
||||
ImageCandidate.task_id == task_id,
|
||||
).order_by(ImageCandidate.seq).all()
|
||||
|
||||
if not selected_text:
|
||||
raise ValueError("无已选文案,请先选择文案")
|
||||
|
||||
text_data = json.loads(selected_text.content or "{}")
|
||||
images_data = []
|
||||
for ic in selected_images:
|
||||
img_bytes = b""
|
||||
if ic.url:
|
||||
# url 形如 /uploads/ws/task/file.jpg,本身已含 uploads 前缀。
|
||||
# 工作目录是 /app,直接 lstrip("/") 当相对路径读,不能再拼 upload_base(会重复 uploads/uploads)。
|
||||
rel = ic.url.lstrip("/")
|
||||
abs_path = rel
|
||||
try:
|
||||
with open(abs_path, "rb") as f:
|
||||
img_bytes = f.read()
|
||||
except OSError as e:
|
||||
logger.warning("图片读取失败,跳过:%s %s", abs_path, e)
|
||||
images_data.append({
|
||||
"seq": ic.seq,
|
||||
"role": ic.role.value if hasattr(ic.role, "value") else str(ic.role),
|
||||
"data": img_bytes,
|
||||
})
|
||||
|
||||
notes = [{
|
||||
"title": text_data.get("title", ""),
|
||||
"content": text_data.get("content", ""),
|
||||
"tags": text_data.get("tags", []),
|
||||
"images": images_data,
|
||||
"banned_word_status": (selected_text.banned_word_status.value
|
||||
if hasattr(selected_text.banned_word_status, "value")
|
||||
else str(selected_text.banned_word_status)),
|
||||
}]
|
||||
|
||||
from app.services.ai_engine.package_exporter import build_delivery_package as do_build
|
||||
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开
|
||||
packages_base = f"{upload_base}/packages"
|
||||
zip_path = do_build(workspace_id, task_id, notes, base_path=packages_base)
|
||||
|
||||
pkg.package_path = zip_path
|
||||
pkg.download_url = f"/api/v1/delivery-packages/{package_id}/download-file"
|
||||
pkg.status = PackageStatus.READY
|
||||
db.commit()
|
||||
|
||||
logger.info("delivery package ready: package_id=%s path=%s", package_id, zip_path)
|
||||
return {"package_id": package_id, "status": "ready", "path": zip_path}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("build_delivery_package failed: package_id=%s err=%s", package_id, exc)
|
||||
raise self.retry(exc=exc)
|
||||
finally:
|
||||
db.close()
|
||||
298
backend/app/workers/pipeline_io.py
Normal file
298
backend/app/workers/pipeline_io.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
app/workers/pipeline_io.py — 生产链 Step5-8
|
||||
Step5: 文案生成(generate_text_variants)
|
||||
Step6: 图片生成(generate_storyboard_images,asyncio.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
|
||||
93
backend/app/workers/pipeline_steps.py
Normal file
93
backend/app/workers/pipeline_steps.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
app/workers/pipeline_steps.py — 生产链 Step1-4
|
||||
Step1: 查 DB(task/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: 构建 AIClients,plain_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
|
||||
100
backend/app/workers/replenish_task.py
Normal file
100
backend/app/workers/replenish_task.py
Normal 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()
|
||||
155
backend/app/workers/tasks.py
Normal file
155
backend/app/workers/tasks.py
Normal 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 Session(Celery 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
100
backend/docker-compose.yml
Normal 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
16
backend/fire_real.py
Normal 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
95
backend/main.py
Normal 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
36
backend/nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
35
backend/plans/prompt缩水诊断与补齐.md
Normal file
35
backend/plans/prompt缩水诊断与补齐.md
Normal 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
38
backend/requirements.txt
Normal 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
|
||||
103
backend/scripts/real_image_check.py
Normal file
103
backend/scripts/real_image_check.py
Normal 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)
|
||||
13
backend/scripts/run_log.txt
Normal file
13
backend/scripts/run_log.txt
Normal 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
|
||||
====================
|
||||
|
||||
156
backend/scripts/seed_data.py
Normal file
156
backend/scripts/seed_data.py
Normal 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_keys(R3:明文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()
|
||||
93
backend/scripts/verify_real_text_gen.py
Normal file
93
backend/scripts/verify_real_text_gen.py
Normal 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
26
backend/test_3sets_e2e.py
Normal 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
Reference in New Issue
Block a user