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

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

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

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
.git
.env
.env.*
!.env.example
**/__pycache__/
**/*.py[cod]
**/.pytest_cache/
**/.hypothesis/
**/node_modules/
**/.next/
**/dist/
**/build/
**/logs/
**/*.log
**/verify_output/
**/output_e2e/
screenshot-*.png
*.zip
*.key
*.pem
.DS_Store

43
.env.example Normal file
View File

@@ -0,0 +1,43 @@
APP_ENV=production
# Required secrets: generate unique values for every customer environment.
FERNET_KEY=replace-with-32-plus-character-fernet-key
JWT_SECRET=replace-with-32-plus-character-jwt-secret
# Database credentials used by docker-compose.prod.yml.
MYSQL_ROOT_PASSWORD=replace-with-strong-root-password
MYSQL_DB=clover
MYSQL_USER=clover
MYSQL_PASSWORD=replace-with-strong-db-password
DATABASE_URL=mysql+pymysql://clover:replace-with-strong-db-password@mysql:3306/clover
MONGO_URI=mongodb://mongo:27017/clover_trace
REDIS_URL=redis://redis:6379/0
# Public URLs.
# If frontend and API share the same domain through nginx, keep NEXT_PUBLIC_* empty.
# If using split domains, set them to the public backend origin, such as https://api.example.com.
# NEXT_PUBLIC_* are build-time variables. After changing them, rebuild the frontend image.
NEXT_PUBLIC_API_BASE_URL=
NEXT_PUBLIC_SSE_BASE_URL=
# Production should use the real customer domain(s). Do not use "*" in production.
CORS_ALLOW_ORIGINS=https://your-domain.example
# Image/text provider routing.
IMAGE_PROVIDER_PRIMARY=gpt
IMAGE_PROVIDER_FALLBACK=gemini
IMAGE_MODEL=gpt-image-2
MODEL_IMAGE=gpt-image-2
MODEL_TEXT=claude-opus-4-8
APIPORTS_BASE_URL=https://api.apiyi.com/v1
CODEPROXY_BASE_URL=
GEMINI_API_URL=https://generativelanguage.googleapis.com/v1beta
# Optional platform keys. Normal customer keys should be entered in the UI.
APIPORTS_KEY=
CODEPROXY_KEY=
MAX_CONCURRENT_TASKS_PER_USER=5
UVICORN_WORKERS=2
UPLOAD_BASE_PATH=uploads/packages
UPLOAD_ABS_ROOT=/app/uploads

51
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Clover CI
on:
push:
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --upgrade pip
- run: pip install -r requirements.txt pytest
- run: python -m compileall -q app tests
- run: python -m pytest
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run build
- run: npm audit --audit-level=high --omit=dev
python-audit:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --upgrade pip pip-audit
- run: pip-audit -r requirements.txt

6
.gitignore vendored
View File

@@ -22,9 +22,15 @@ build/
logs/ logs/
*.log *.log
.DS_Store .DS_Store
verify_output/
**/verify_output/
output_e2e/
**/output_e2e/
screenshot-*.png
# Celery / Redis 本地 # Celery / Redis 本地
celerybeat-schedule celerybeat-schedule
dump.rdb dump.rdb
.env.bak .env.bak
*.env.bak
*.tsbuildinfo *.tsbuildinfo

View File

@@ -44,6 +44,7 @@
- ①**改稿进飞轮D1最强信号必做**运营改AI稿→diff喂回飞轮。**入飞轮时机=先激进(改了就入)跑一周,北哥用后按真实信号质量校准是否加"过审才入"闸门**细案E18/E19。需补 SignalType.text_edit + TextCandidate.edited字段 + 改稿端点(走task_actions不碰review.py)。 - ①**改稿进飞轮D1最强信号必做**运营改AI稿→diff喂回飞轮。**入飞轮时机=先激进(改了就入)跑一周,北哥用后按真实信号质量校准是否加"过审才入"闸门**细案E18/E19。需补 SignalType.text_edit + TextCandidate.edited字段 + 改稿端点(走task_actions不碰review.py)。
- ②**AI评图分进飞轮E12做扎实版**AI评图分**只做"生成时筛选+前端展示"**,前端要呈现"这张图AI评了X分+已被选入飞轮"。**真正影响下次生成的飞轮权重只认真实信号(选了哪张/改了什么/过审与否不拿AI主观分当权重**——守住eval_score留NULL铁律AI分是展示层不是权重层。 - ②**AI评图分进飞轮E12做扎实版**AI评图分**只做"生成时筛选+前端展示"**,前端要呈现"这张图AI评了X分+已被选入飞轮"。**真正影响下次生成的飞轮权重只认真实信号(选了哪张/改了什么/过审与否不拿AI主观分当权重**——守住eval_score留NULL铁律AI分是展示层不是权重层。
- ③**前端呈现=轻量化关键节点反馈不做独立飞轮主视图大页E01**:在生成/选择/改稿/审核节点即时提示"已学习你的偏好""本次参考了你上周改的稿""飞轮已积累N条信号",嵌现有流程,纵切片友好。 - ③**前端呈现=轻量化关键节点反馈不做独立飞轮主视图大页E01**:在生成/选择/改稿/审核节点即时提示"已学习你的偏好""本次参考了你上周改的稿""飞轮已积累N条信号",嵌现有流程,纵切片友好。
- ④🔴 **导入文案飞轮权重要更重倩倩姐2026-06-26拍板**:客户导入的外部文案=客户实跑验证过、跑得好的样本,是**最值得学习的范本**,飞轮信号权重要高于普通 AI 选稿信号。需补 SignalType如 text_import并给更高权重与"导入轨去生图"链路配套。守住 eval_score 留 NULL 铁律不变。
- 🔴 **竞品笔记数据监控=不做倩倩姐2026-06-16拍板**E04竞品数据全监控/反推关键词=自动抓小红书,命中合规反爬红线,直接砍,不进任何期。 - 🔴 **竞品笔记数据监控=不做倩倩姐2026-06-16拍板**E04竞品数据全监控/反推关键词=自动抓小红书,命中合规反爬红线,直接砍,不进任何期。
- 发布:不自动发小红书,产出"达人素材交付包"人工发。 - 发布:不自动发小红书,产出"达人素材交付包"人工发。
- 数据归属:原始数据(输入+产出+素材包)归客户可导出;飞轮偏好归平台。 - 数据归属:原始数据(输入+产出+素材包)归客户可导出;飞轮偏好归平台。

View File

@@ -6,7 +6,7 @@
## 1. 产品定位 ## 1. 产品定位
- 一句话:北哥团队的小红书达人素材"中央厨房"多租户SaaS - 一句话:北哥团队的小红书达人素材"中央厨房"多租户SaaS
- 界面名:龙石小红书内容生产平台 ; 项目代号Clover🍀 - 界面名:Clover小红书内容生产平台 ; 项目代号Clover🍀
- 核心差异化 = **偏好飞轮**(越用越懂你的"老师傅") - 核心差异化 = **偏好飞轮**(越用越懂你的"老师傅")
## 2. 目标用户与三角色 ## 2. 目标用户与三角色

View File

@@ -1,4 +1,4 @@
# Clover PRD-前端(界面名:龙石小红书内容生产平台) # Clover PRD-前端(界面名:Clover小红书内容生产平台)
> 前端技术框架。**承接层**5屏画面金标准见 `../../前端交互设计.md`(经倩倩姐确认),视觉见 `../../前端设计参考图.png`。本文①承接5屏②补工程细节③对接后端契约——**唯一冻结源 = `../启动包/API契约.md`**(端点§1 / SSE事件§2 / 错误码§0),不看 PRD-后端。 > 前端技术框架。**承接层**5屏画面金标准见 `../../前端交互设计.md`(经倩倩姐确认),视觉见 `../../前端设计参考图.png`。本文①承接5屏②补工程细节③对接后端契约——**唯一冻结源 = `../启动包/API契约.md`**(端点§1 / SSE事件§2 / 错误码§0),不看 PRD-后端。
> 技术栈Next.js+React+TS+Tailwind+Zustand(范式扒banana/frontend,全新重建)。PC优先,最小宽度1280px,不做移动端。 > 技术栈Next.js+React+TS+Tailwind+Zustand(范式扒banana/frontend,全新重建)。PC优先,最小宽度1280px,不做移动端。
@@ -21,7 +21,7 @@
- 🔴 角色路由守卫:/review只组长+管理员;/config只管理员;越权重定向工作台 - 🔴 角色路由守卫:/review只组长+管理员;/config只管理员;越权重定向工作台
## 2. 全局布局(对齐设计图) ## 2. 全局布局(对齐设计图)
- 顶部:标题"龙石小红书内容生产平台"+右上角当前用户(运营-小李) - 顶部:标题"Clover小红书内容生产平台"+右上角当前用户(运营-小李)
- 5步流程条(1开新任务/2选择文案/3选择图片/4确认文案/5成片发布)——熟手常驻;组长/管理员登录后不走此条(各进自己首页) - 5步流程条(1开新任务/2选择文案/3选择图片/4确认文案/5成片发布)——熟手常驻;组长/管理员登录后不走此条(各进自己首页)
- 左侧8项导航(工作台/开新任务/选择文案/选择图片/建立任务/任务列表/历史归档/配置中心) - 左侧8项导航(工作台/开新任务/选择文案/选择图片/建立任务/任务列表/历史归档/配置中心)
- 主区 + 右侧配置预览面板 - 主区 + 右侧配置预览面板

72
README.md Normal file
View File

@@ -0,0 +1,72 @@
# Clover 客户交付运行说明
Clover 是北哥小红书产品内容生产系统,包含:
- `backend/`FastAPI + Celery + MySQL + MongoDB + Redis
- `frontend/`Next.js 运营端
- `docker-compose.prod.yml`:客户交付部署入口,数据库端口不暴露到宿主机
## 交付前必做
1. 复制 `.env.example``.env`,填入客户环境专属密钥和域名。
2. 生成新的 `FERNET_KEY``JWT_SECRET`、数据库密码,不复用开发环境值。
3. 同域 nginx 部署时 `NEXT_PUBLIC_API_BASE_URL``NEXT_PUBLIC_SSE_BASE_URL` 保持空;前后端分域时填公网 API 域名,修改后必须重新 build 前端镜像。
4. 设置 `CORS_ALLOW_ORIGINS` 为客户前端域名,多个域名用英文逗号分隔。
5. 交付包不要包含任何真实 `.env``.env.bak``node_modules``.next``verify_output`、截图或本地日志。
## 生产启动
HTTP 内测:
```bash
docker compose -f docker-compose.prod.yml --env-file .env up -d --build
```
HTTPS 正式部署需先放置证书:
```text
certs/fullchain.pem
certs/privkey.pem
```
然后使用 override
```bash
docker compose -f docker-compose.prod.yml -f docker-compose.https.yml --env-file .env up -d --build
```
健康检查:
```bash
curl http://localhost/health
```
前端默认访问 nginx 入口:
```text
http://localhost
```
## 本地验证
后端:
```bash
cd backend
python3 -m compileall -q app tests
python3 -m pytest
```
前端:
```bash
cd frontend
npm run lint
npm run build
```
## 已知交付边界
- 用户 API Key 通过前端设置页录入,后端只保存 Fernet 加密密文。
- `apiports` 图片编辑走 `/chat/completions` 多模态参考图路径;`codeproxy` 图片编辑走 `/images/edits`
- A/B/C 三套内容必须各选择一条文案,并且每套选满配置的图片数量后才能提交审核。

15
backend/.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.env
.env.*
!.env.example
__pycache__/
**/__pycache__/
*.py[cod]
.pytest_cache/
.hypothesis/
verify_output/
scripts/run_log.txt
logs/
*.log
*.key
*.pem
.DS_Store

26
backend/.env.example Normal file
View File

@@ -0,0 +1,26 @@
APP_ENV=production
FERNET_KEY=replace-with-32-plus-character-fernet-key
JWT_SECRET=replace-with-32-plus-character-jwt-secret
DATABASE_URL=mysql+pymysql://clover:replace-with-strong-db-password@mysql:3306/clover
MONGO_URI=mongodb://mongo:27017/clover_trace
REDIS_URL=redis://redis:6379/0
# Production should use the real customer domain(s). Do not use "*" in production.
CORS_ALLOW_ORIGINS=https://your-domain.example
IMAGE_PROVIDER_PRIMARY=gpt
IMAGE_PROVIDER_FALLBACK=gemini
IMAGE_MODEL=gpt-image-2
MODEL_IMAGE=gpt-image-2
MODEL_TEXT=claude-opus-4-8
APIPORTS_BASE_URL=https://api.apiyi.com/v1
CODEPROXY_BASE_URL=
GEMINI_API_URL=https://generativelanguage.googleapis.com/v1beta
APIPORTS_KEY=
CODEPROXY_KEY=
MAX_CONCURRENT_TASKS_PER_USER=5
UVICORN_WORKERS=2
UPLOAD_BASE_PATH=uploads/packages
UPLOAD_ABS_ROOT=/app/uploads

View File

@@ -14,15 +14,16 @@ backend/
constants/ # 策略命名中心文件,消除打架 constants/ # 策略命名中心文件,消除打架
utils/ # Fernet加密、SSE工具等 utils/ # Fernet加密、SSE工具等
alembic/ alembic/
versions/ # 001-004 migration 脚本 versions/ # 001-022 migration 脚本
tests/ # TDD 测试用例 tests/ # TDD 测试用例
``` ```
## 依赖(需装) ## 依赖
见 requirements.txt(待 BE agent 填) `requirements.txt`,依赖已锁版本。
## 启动 ## 启动
docker-compose.yml(待 BE agent 填) 本地开发可用 `backend/docker-compose.yml`
客户交付请用项目根目录 `docker-compose.prod.yml`,它不挂载源码、不暴露 MySQL/Mongo/Redis 到宿主机。
## 铁律提醒 ## 铁律提醒
- API Key 绝不进 Celery 参数,只传 task_id - API Key 绝不进 Celery 参数,只传 task_id

View File

@@ -0,0 +1,24 @@
"""022 users 表加 must_change_password 首登改密标记
Revision ID: 022
Revises: 021
Create Date: 2026-06-29
"""
from alembic import op
import sqlalchemy as sa
revision = "022"
down_revision = "021"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"users",
sa.Column("must_change_password", sa.Boolean(), nullable=False, server_default=sa.false()),
)
def downgrade():
op.drop_column("users", "must_change_password")

View File

@@ -0,0 +1,47 @@
"""023 benchmark link and screenshot fields use text
Revision ID: 023
Revises: 022
Create Date: 2026-06-29
"""
from alembic import op
import sqlalchemy as sa
revision = "023"
down_revision = "022"
branch_labels = None
depends_on = None
def upgrade():
op.alter_column(
"benchmark_notes",
"screenshot_url",
existing_type=sa.String(length=512),
type_=sa.Text(),
existing_nullable=True,
)
op.alter_column(
"benchmark_notes",
"link_url",
existing_type=sa.String(length=512),
type_=sa.Text(),
existing_nullable=True,
)
def downgrade():
op.alter_column(
"benchmark_notes",
"link_url",
existing_type=sa.Text(),
type_=sa.String(length=512),
existing_nullable=True,
)
op.alter_column(
"benchmark_notes",
"screenshot_url",
existing_type=sa.Text(),
type_=sa.String(length=512),
existing_nullable=True,
)

View File

@@ -0,0 +1,35 @@
"""024 preference_events.signal_type ENUM 补 text_import飞轮④导入文案信号
Revision ID: 024
Revises: 023
Create Date: 2026-06-29
signal_type 是 MySQL ENUM,原6值无导入信号。导入文案=客户实跑验证过、跑得好的范本
(倩倩姐2026-06-26拍板),记 text_import 信号且权重高于改稿/AI选稿。
不补 ENUM 直接插 text_import 会 Data truncated。
"""
from alembic import op
revision = "024"
down_revision = "023"
branch_labels = None
depends_on = None
_ENUM_WITH_IMPORT = (
"ENUM('text_select','image_select','approve','reject_with_reason',"
"'regenerate','text_edit','text_import') NOT NULL"
)
_ENUM_ORIG = (
"ENUM('text_select','image_select','approve','reject_with_reason',"
"'regenerate','text_edit') NOT NULL"
)
def upgrade():
op.execute(f"ALTER TABLE preference_events MODIFY COLUMN signal_type {_ENUM_WITH_IMPORT}")
def downgrade():
# 回退前先删 text_import 行,避免落在旧 ENUM 外越界(这些是新功能信号,旧版本本不该有)
op.execute("DELETE FROM preference_events WHERE signal_type='text_import'")
op.execute(f"ALTER TABLE preference_events MODIFY COLUMN signal_type {_ENUM_ORIG}")

View File

@@ -43,6 +43,10 @@ class CreateApiKeyRequest(BaseModel):
# 注:不接收 url 字段token站固定自家站基石B # 注:不接收 url 字段token站固定自家站基石B
class TestApiKeyRequest(CreateApiKeyRequest):
pass
def _format_key(k: UserApiKey) -> dict: def _format_key(k: UserApiKey) -> dict:
"""只显 provider + key_last4不暴露余额/用量/encrypted_key红线""" """只显 provider + key_last4不暴露余额/用量/encrypted_key红线"""
return { return {
@@ -77,8 +81,13 @@ def create_api_key(
current_user: Annotated[CurrentUser, Depends(require_write_permission)], current_user: Annotated[CurrentUser, Depends(require_write_permission)],
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""录入 API KeyFernet 加密存储只保存后4位明文用于展示""" """录入/更新 API KeyFernet 加密存储只保存后4位明文用于展示
from app.core.response import raise_business
覆盖式倩倩姐2026-06-23拍板同 user+workspace+provider 已存在则直接更新,
不报错——「切换/修改 key」无需先删再录前端点「修改」重录即覆盖。
"""
encrypted = encrypt_api_key(body.api_key)
last4 = mask_api_key(body.api_key)
# 检查同 user+workspace+provider 是否已有 # 检查同 user+workspace+provider 是否已有
existing = ( existing = (
db.query(UserApiKey) db.query(UserApiKey)
@@ -90,15 +99,19 @@ def create_api_key(
.first() .first()
) )
if existing: if existing:
raise_business(f"已存在 {body.provider} 的 API Key请先删除再录入") existing.encrypted_key = encrypted
existing.key_last4 = last4
db.commit()
db.refresh(existing)
logger.info("API key updated: user=%s provider=%s", current_user.user_id, body.provider)
return ok(_format_key(existing))
encrypted = encrypt_api_key(body.api_key)
key_obj = UserApiKey( key_obj = UserApiKey(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=current_user.workspace_id, workspace_id=current_user.workspace_id,
provider=body.provider, provider=body.provider,
encrypted_key=encrypted, encrypted_key=encrypted,
key_last4=mask_api_key(body.api_key), key_last4=last4,
) )
db.add(key_obj) db.add(key_obj)
db.commit() db.commit()
@@ -107,6 +120,48 @@ def create_api_key(
return ok(_format_key(key_obj)) return ok(_format_key(key_obj))
@router.post("/test")
async def test_api_key(
body: TestApiKeyRequest,
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
):
"""用客户输入的明文 key 做一次轻量连通性测试,不落库。"""
import httpx
from app.core.config import get_settings
from app.core.response import raise_business
settings = get_settings()
provider = body.provider
if provider in ("openai", "apiports"):
base = (settings.APIPORTS_BASE_URL or settings.IMAGE_API_BASE).rstrip("/")
model = settings.MODEL_TEXT
elif provider == "codeproxy":
base = settings.CODEPROXY_BASE_URL.rstrip("/")
model = "gpt-5.5"
else:
raise_business("该 Provider 暂不支持在线测试")
if not base:
raise_business("服务端未配置该 Provider 的 Base URL")
try:
async with httpx.AsyncClient(timeout=12) as client:
resp = await client.post(
f"{base}/chat/completions",
headers={"Authorization": f"Bearer {body.api_key}"},
json={
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 4,
},
)
resp.raise_for_status()
except Exception as exc:
raise_business(f"Key 测试失败:{type(exc).__name__}")
logger.info("API key test ok: user=%s provider=%s", current_user.user_id, provider)
return ok({"ok": True, "provider": provider})
@router.delete("/{key_id}") @router.delete("/{key_id}")
def delete_api_key( def delete_api_key(
key_id: int, key_id: int,

View File

@@ -34,6 +34,11 @@ class LoginRequest(BaseModel):
return v return v
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
# ── 路由 ─────────────────────────────────────────────────── # ── 路由 ───────────────────────────────────────────────────
@router.post("/login") @router.post("/login")
def login(body: LoginRequest, db: Session = Depends(get_db)): def login(body: LoginRequest, db: Session = Depends(get_db)):
@@ -47,6 +52,18 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
}) })
@router.post("/change-password")
def change_password(
body: ChangePasswordRequest,
current_user: Annotated[CurrentUser, Depends(get_current_user)],
db: Session = Depends(get_db),
):
"""修改当前登录用户密码;首登强制改密也走此接口。"""
from app.services.auth_service import change_password as do_change_password
do_change_password(db, current_user.user_id, body.current_password, body.new_password)
return ok({"changed": True})
@router.get("/me") @router.get("/me")
def get_me( def get_me(
current_user: Annotated[CurrentUser, Depends(get_current_user)], current_user: Annotated[CurrentUser, Depends(get_current_user)],
@@ -63,6 +80,7 @@ def get_me(
"email": user.email, "email": user.email,
"current_workspace_id": current_user.workspace_id, "current_workspace_id": current_user.workspace_id,
"role": current_user.role, "role": current_user.role,
"must_change_password": bool(getattr(user, "must_change_password", False)),
"workspace": { "workspace": {
"id": ws.id, "name": ws.name, "slug": ws.slug, "id": ws.id, "name": ws.name, "slug": ws.slug,
} if ws else None, } if ws else None,

View File

@@ -4,16 +4,19 @@ app/api/v1/benchmarks.py — 标杆笔记 + 违禁词路由(管理员)
""" """
import logging import logging
import os
import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, File, Form, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found from app.core.response import ok, paginate, raise_business, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
from app.models.product import BannedWord, BenchmarkNote from app.models.product import BannedWord, BenchmarkNote, Product
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["products"]) router = APIRouter(tags=["products"])
@@ -45,6 +48,8 @@ def _fmt_benchmark(b: BenchmarkNote) -> dict:
"screenshot_url": b.screenshot_url, "screenshot_url": b.screenshot_url,
"highlights": b.highlights, "link_url": b.link_url, "highlights": b.highlights, "link_url": b.link_url,
"features": features, "analyze_status": b.analyze_status, "features": features, "analyze_status": b.analyze_status,
"analysis_source": features.get("source") if isinstance(features, dict) else None,
"analysis_warning": features.get("warning") if isinstance(features, dict) else None,
"created_at": b.created_at.isoformat(), "created_at": b.created_at.isoformat(),
} }
@@ -57,6 +62,70 @@ def _fmt_banned(bw: BannedWord) -> dict:
} }
_ALLOWED_IMAGE_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
_MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
def _check_image_magic(data: bytes) -> bool:
if len(data) < 12:
return False
if data[:3] == b"\xff\xd8\xff":
return True
if data[:8] == b"\x89PNG\r\n\x1a\n":
return True
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return True
return False
def _clean_link_url(link_url: str | None) -> str | None:
value = (link_url or "").strip()
if not value:
return None
if not value.startswith(("http://", "https://")):
raise_business("原始链接必须以 http:// 或 https:// 开头")
return value
def _is_benchmark_upload_path(path: str) -> bool:
upload_root = os.path.abspath(get_settings().UPLOAD_ABS_ROOT)
benchmark_root = os.path.join(upload_root, "benchmarks")
candidate = os.path.abspath(path)
try:
return os.path.commonpath([benchmark_root, candidate]) == benchmark_root
except ValueError:
return False
async def _save_benchmark_screenshot(
file: UploadFile,
workspace_id: int,
product_id: int,
) -> str:
if file.content_type not in _ALLOWED_IMAGE_CONTENT_TYPES:
raise_business(f"不支持的截图类型 {file.content_type},仅支持 JPEG/PNG/WebP")
data = await file.read()
if len(data) > _MAX_IMAGE_SIZE_BYTES:
raise_business("截图超过 10 MB 限制")
if not _check_image_magic(data):
raise_business("截图文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
settings = get_settings()
ext = os.path.splitext(file.filename or "benchmark.jpg")[1] or ".jpg"
abs_dir = os.path.join(
settings.UPLOAD_ABS_ROOT,
"benchmarks",
str(workspace_id),
str(product_id),
)
os.makedirs(abs_dir, exist_ok=True)
save_path = os.path.join(abs_dir, f"{uuid.uuid4().hex}{ext}")
with open(save_path, "wb") as f:
f.write(data)
return save_path
# ── 标杆笔记 ──────────────────────────────────────────────── # ── 标杆笔记 ────────────────────────────────────────────────
@router.get("/products/{product_id}/benchmarks") @router.get("/products/{product_id}/benchmarks")
def list_benchmarks( def list_benchmarks(
@@ -81,12 +150,64 @@ def create_benchmark(
current_user: Annotated[CurrentUser, Depends(require_admin)] = None, current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
product = db.query(Product).filter(
Product.id == product_id,
Product.workspace_id == current_user.workspace_id,
).first()
if not product:
raise_not_found("产品不存在")
# 兼容旧前端:旧字段 screenshot_url 实际可能传的是外部原帖链接。
legacy_link = (body.screenshot_url or "").strip()
highlights = (body.highlights or "").strip()
link_url = _clean_link_url(body.link_url or legacy_link)
if not highlights and not link_url:
raise_business("请至少填写原始链接或亮点")
b = BenchmarkNote( b = BenchmarkNote(
workspace_id=current_user.workspace_id, workspace_id=current_user.workspace_id,
product_id=product_id, product_id=product_id,
screenshot_url=body.screenshot_url, screenshot_url=None,
highlights=body.highlights, highlights=highlights,
link_url=body.link_url, link_url=link_url,
)
db.add(b); db.commit(); db.refresh(b)
return ok(_fmt_benchmark(b))
@router.post("/products/{product_id}/benchmarks/upload")
async def upload_benchmark(
product_id: int,
screenshot: UploadFile | None = File(None),
highlights: str | None = Form(None),
link_url: str | None = Form(None),
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
product = db.query(Product).filter(
Product.id == product_id,
Product.workspace_id == current_user.workspace_id,
).first()
if not product:
raise_not_found("产品不存在")
clean_highlights = (highlights or "").strip()
clean_link_url = _clean_link_url(link_url)
if not screenshot and not clean_highlights and not clean_link_url:
raise_business("请至少上传截图、填写原始链接或填写亮点")
screenshot_path = None
if screenshot:
screenshot_path = await _save_benchmark_screenshot(
screenshot,
current_user.workspace_id,
product_id,
)
b = BenchmarkNote(
workspace_id=current_user.workspace_id,
product_id=product_id,
screenshot_url=screenshot_path,
highlights=clean_highlights,
link_url=clean_link_url,
) )
db.add(b); db.commit(); db.refresh(b) db.add(b); db.commit(); db.refresh(b)
return ok(_fmt_benchmark(b)) return ok(_fmt_benchmark(b))
@@ -117,17 +238,24 @@ async def analyze_benchmark_note(
).first() ).first()
if not b: if not b:
raise_not_found("标杆笔记不存在") raise_not_found("标杆笔记不存在")
if not b.screenshot_url and not b.highlights: if not b.screenshot_url and not b.highlights and not b.link_url:
raise_business("该标杆无截图也无手填亮点,无法分析") raise_business("该标杆无截图、原始链接和手填亮点,无法分析")
# 读截图字节screenshot_url 存的是上传后的绝对/相对路径) # 读截图字节:新数据存本地上传路径;旧数据可能误存外部 URL不能直接 open。
screenshot = None screenshot = None
if b.screenshot_url: if b.screenshot_url:
if b.screenshot_url.startswith(("http://", "https://")):
logger.info("标杆截图字段为外部 URL跳过本地读取 id=%s", benchmark_id)
elif _is_benchmark_upload_path(b.screenshot_url) and os.path.exists(b.screenshot_url):
try: try:
with open(b.screenshot_url, "rb") as f: with open(b.screenshot_url, "rb") as f:
screenshot = f.read() screenshot = f.read()
except Exception as e: except Exception as e:
logger.warning("标杆截图读取失败 id=%s: %s", benchmark_id, e) logger.warning("标杆截图读取失败 id=%s: %s", benchmark_id, e)
elif os.path.exists(b.screenshot_url):
logger.warning("标杆截图路径不在上传目录,跳过读取 id=%s path=%s", benchmark_id, b.screenshot_url)
else:
logger.warning("标杆截图路径不存在 id=%s path=%s", benchmark_id, b.screenshot_url)
api_key_row = db.query(UserApiKey).filter( api_key_row = db.query(UserApiKey).filter(
UserApiKey.user_id == current_user.user_id, UserApiKey.user_id == current_user.user_id,
@@ -137,12 +265,26 @@ async def analyze_benchmark_note(
if not api_key_row: if not api_key_row:
raise_business("未配置 API Key请先在设置中录入") raise_business("未配置 API Key请先在设置中录入")
plain_key = decrypt_key(api_key_row.encrypted_key) plain_key = decrypt_key(api_key_row.encrypted_key)
clients = build_ai_clients(plain_key)
# codeproxy 备用 key可选用户录入则用没录回落 env
alt_row = db.query(UserApiKey).filter(
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
UserApiKey.provider == "codeproxy",
).first()
alt_key = decrypt_key(alt_row.encrypted_key) if alt_row else None
clients = build_ai_clients(plain_key, alt_key=alt_key)
plain_key = None plain_key = None
alt_key = None
b.analyze_status = "analyzing"; db.commit() b.analyze_status = "analyzing"; db.commit()
try: try:
result = await analyze_benchmark(clients, screenshot, b.highlights) analysis_text_parts = []
if b.highlights:
analysis_text_parts.append(b.highlights)
if b.link_url:
analysis_text_parts.append(f"原始链接:{b.link_url}")
result = await analyze_benchmark(clients, screenshot, "\n".join(analysis_text_parts))
finally: finally:
await clients.aclose() await clients.aclose()

View File

@@ -1,8 +1,9 @@
""" """
app/api/v1/delivery.py — 交付包路由 app/api/v1/delivery.py — 交付包路由
POST /tasks/{id}/package — 生成达人素材交付包 POST /tasks/{id}/package — 生成达人素材交付包
GET /delivery-packages/{id}/download — 下载status: pending/ready/downloaded GET /delivery-packages/{id}/download — 查询下载状态(无副作用
POST /delivery-packages/{id}/download-token — 签发60s一次性下载令牌C1坑修复 POST /delivery-packages/{id}/download-token — 签发60s一次性下载令牌C1坑修复
POST /delivery-packages/{id}/mark-downloaded — 显式标记已下载
GET /delivery-packages/{id}/download-file?token= — 带令牌下载,前端可用 window.open GET /delivery-packages/{id}/download-file?token= — 带令牌下载,前端可用 window.open
""" """
@@ -78,7 +79,7 @@ def get_package_download(
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""下载交付包status: pending/ready/downloaded""" """查询交付包下载状态。GET 只读,实际下载由 download-file 完成"""
pkg = db.query(DeliveryPackage).filter( pkg = db.query(DeliveryPackage).filter(
DeliveryPackage.id == package_id, DeliveryPackage.id == package_id,
DeliveryPackage.workspace_id == current_user.workspace_id, DeliveryPackage.workspace_id == current_user.workspace_id,
@@ -86,11 +87,6 @@ def get_package_download(
if not pkg: if not pkg:
raise_not_found("交付包不存在") raise_not_found("交付包不存在")
if pkg.status == "ready" and pkg.download_url:
# 标记为 downloaded只能下一次防重复公开 URL
pkg.status = "downloaded"
db.commit()
return ok(_fmt_package(pkg)) return ok(_fmt_package(pkg))
@@ -124,6 +120,26 @@ def create_download_token(
return ok({"token": token, "expires_in": 60}) return ok({"token": token, "expires_in": 60})
@router.post("/delivery-packages/{package_id}/mark-downloaded")
def mark_package_downloaded(
package_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""显式标记交付包已下载。所有 GET 下载接口保持只读。"""
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":
pkg.status = "downloaded"
db.commit()
db.refresh(pkg)
return ok(_fmt_package(pkg))
@router.get("/delivery-packages/{package_id}/download-file") @router.get("/delivery-packages/{package_id}/download-file")
def download_package_file( def download_package_file(
package_id: int, package_id: int,

View File

@@ -15,6 +15,7 @@ from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage
from app.models.task import GenerationTask
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["products"]) router = APIRouter(tags=["products"])
@@ -121,6 +122,28 @@ def get_product(
return ok(_fmt_product(p)) return ok(_fmt_product(p))
@router.get("/products/{product_id}/preference/context")
def get_product_preference_context(
product_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""产品维度飞轮偏好上下文(新建任务页用,此时还没有 task_id
与 task 维度端点同口径,复用 flywheel_service.get_preference_context。"""
p = db.query(Product).filter(
Product.id == product_id, Product.workspace_id == current_user.workspace_id
).first()
if not p:
raise_not_found("产品不存在")
from app.services.flywheel_service import get_preference_context
product_dict = {
"custom_prompt": p.custom_prompt or "",
"style_tone": p.style_tone or "",
}
ctx = get_preference_context(db, current_user.workspace_id, product_id, product_dict)
return ok(ctx)
@router.put("/products/{product_id}") @router.put("/products/{product_id}")
def update_product( def update_product(
product_id: int, body: ProductCreate, product_id: int, body: ProductCreate,
@@ -148,12 +171,21 @@ def delete_product(
current_user: Annotated[CurrentUser, Depends(require_admin)] = None, current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""智能删除(仅管理员):没跑过任务的产品物理删干净(级联清图片/标杆),
已有历史任务的产品改软删保留历史,避免 generation_tasks 外键报错。"""
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first() p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
if not p: if not p:
raise_not_found("产品不存在") raise_not_found("产品不存在")
p.is_active = False # 软删 has_task = db.query(GenerationTask.id).filter(GenerationTask.product_id == product_id).first() is not None
if has_task:
p.is_active = False # 有历史任务→软删保留
db.commit() db.commit()
return ok({"deleted": product_id}) return ok({"deleted": product_id, "mode": "soft", "reason": "产品已有历史任务,已停用并保留记录"})
db.query(ProductImage).filter(ProductImage.product_id == product_id).delete()
db.query(BenchmarkNote).filter(BenchmarkNote.product_id == product_id).delete()
db.delete(p) # 无任务→物理删(已先显式清图片/标杆,不赌数据库级联)
db.commit()
return ok({"deleted": product_id, "mode": "hard"})
# ── 产品参考图上传 ────────────────────────────────────────── # ── 产品参考图上传 ──────────────────────────────────────────
@@ -337,8 +369,17 @@ async def analyze_product_image(
raise_business("未配置 API Key请先在设置中录入") raise_business("未配置 API Key请先在设置中录入")
plain_key = decrypt_key(api_key_row.encrypted_key) plain_key = decrypt_key(api_key_row.encrypted_key)
clients = build_ai_clients(plain_key) # codeproxy 备用 key可选用户录入则用没录回落 env不抛错
alt_row = db.query(UserApiKey).filter(
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
UserApiKey.provider == "codeproxy",
).first()
alt_key = decrypt_key(alt_row.encrypted_key) if alt_row else None
clients = build_ai_clients(plain_key, alt_key=alt_key)
plain_key = None # 立即清零不传出基石B plain_key = None # 立即清零不传出基石B
alt_key = None
try: try:
raw = await clients.gpt_vision_analyze(_VISION_PROMPT, [data]) raw = await clients.gpt_vision_analyze(_VISION_PROMPT, [data])

View File

@@ -53,28 +53,78 @@ def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
from app.api.v1.tasks import _score_array_to_obj from app.api.v1.tasks import _score_array_to_obj
product = db.query(Product).filter(Product.id == t.product_id).first() product = db.query(Product).filter(Product.id == t.product_id).first()
operator = db.query(User).filter(User.id == t.operator_id).first() operator = db.query(User).filter(User.id == t.operator_id).first()
selected_text = db.query(TextCandidate).filter( selected_texts = db.query(TextCandidate).filter(
TextCandidate.task_id == t.id, TextCandidate.is_selected == True TextCandidate.task_id == t.id, TextCandidate.is_selected == True
).first() ).order_by(TextCandidate.strategy.asc(), TextCandidate.id.asc()).all()
selected_image = db.query(ImageCandidate).filter( selected_images = db.query(ImageCandidate).filter(
ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True
).first() ).order_by(ImageCandidate.strategy.asc(), ImageCandidate.seq.asc()).all()
# content 列存 JSON dict解包取 title/content 分字段返回 selected_text = selected_texts[0] if selected_texts else None
selected_image = selected_images[0] if selected_images else None
def _fmt_selected_text(selected_text: TextCandidate | None) -> dict | None:
if not selected_text:
return None
text_parsed: dict = {} text_parsed: dict = {}
if selected_text and selected_text.content:
try: try:
text_parsed = json.loads(selected_text.content) text_parsed = json.loads(selected_text.content or "{}")
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
text_parsed = {"content": selected_text.content} text_parsed = {"content": selected_text.content}
# 质量分:解析 score_json 明细数组算总分+透传维度(绝不读 eval_score,设计留NULL)
text_score = None text_score = None
if selected_text and selected_text.score_json: if selected_text.score_json:
try: try:
text_score = _score_array_to_obj(json.loads(selected_text.score_json)) text_score = _score_array_to_obj(json.loads(selected_text.score_json))
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
text_score = None text_score = None
return {
"candidate_id": selected_text.id,
"strategy": selected_text.strategy,
"angle_label": selected_text.angle_label,
"title": text_parsed.get("title", ""),
"content": text_parsed.get("content", ""),
"tags": text_parsed.get("tags", []),
"score": text_score,
}
def _fmt_selected_image(selected_image: ImageCandidate | None) -> dict | None:
if not selected_image:
return None
return {
"candidate_id": selected_image.id,
"strategy": selected_image.strategy,
"url": selected_image.url,
"role": selected_image.role,
"seq": selected_image.seq,
}
images_by_strategy: dict[str, list[ImageCandidate]] = {}
for img in selected_images:
images_by_strategy.setdefault(img.strategy or "_", []).append(img)
selected_sets = []
for strategy in ("A", "B", "C"):
text = next((tc for tc in selected_texts if tc.strategy == strategy), None)
imgs = images_by_strategy.get(strategy, [])
if text or imgs:
selected_sets.append({
"strategy": strategy,
"selected_text": _fmt_selected_text(text),
"selected_images": [_fmt_selected_image(img) for img in imgs],
})
# 质量分:解析 score_json 明细数组算总分+透传维度(绝不读 eval_score,设计留NULL)
legacy_text = _fmt_selected_text(selected_text)
legacy_image = _fmt_selected_image(selected_image)
# 兼容旧前端字段selected_text/selected_image 仍返回第一套第一张。
if legacy_text:
legacy_text.pop("strategy", None)
if legacy_image:
legacy_image = {
"candidate_id": legacy_image["candidate_id"],
"strategy": legacy_image["strategy"],
"url": legacy_image["url"],
}
return { return {
"task_id": t.id, "task_id": t.id,
@@ -82,19 +132,9 @@ def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
"theme": t.theme, "theme": t.theme,
"submitted_at": t.updated_at.isoformat(), "submitted_at": t.updated_at.isoformat(),
"operator_name": operator.username if operator else None, "operator_name": operator.username if operator else None,
"selected_text": { "selected_text": legacy_text,
"candidate_id": selected_text.id, "selected_image": legacy_image,
"angle_label": selected_text.angle_label, "selected_sets": selected_sets,
"title": text_parsed.get("title", ""),
"content": text_parsed.get("content", ""), # 纯正文
"tags": text_parsed.get("tags", []),
"score": text_score, # 审核显分:总分+7维明细(C2)
} 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,
} }

View File

@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.response import ok, raise_not_found, raise_state_invalid from app.core.response import ok, raise_not_found, raise_state_invalid
from app.middleware.workspace_guard import CurrentUser, require_write_permission from app.middleware.workspace_guard import CurrentUser, require_write_permission, require_admin
from app.models.task import GenerationTask, ImageCandidate, TextCandidate from app.models.task import GenerationTask, ImageCandidate, TextCandidate
from app.api.v1.tasks import ( from app.api.v1.tasks import (
@@ -45,6 +45,12 @@ def select_text(
).first() ).first()
if not tc: if not tc:
raise_not_found("文案候选不存在") raise_not_found("文案候选不存在")
# A/B/C 同套单选:避免旧选择累积导致审核/打包拿错套别。
db.query(TextCandidate).filter(
TextCandidate.task_id == task_id,
TextCandidate.strategy == tc.strategy,
TextCandidate.id != tc.id,
).update({"is_selected": False})
tc.is_selected = True tc.is_selected = True
db.commit() db.commit()
record_signal(db, current_user, task, "text_select", candidate_id=tc.id, angle_label=tc.angle_label) record_signal(db, current_user, task, "text_select", candidate_id=tc.id, angle_label=tc.angle_label)
@@ -70,6 +76,13 @@ def select_image(
).first() ).first()
if not ic: if not ic:
raise_not_found("图片候选不存在") raise_not_found("图片候选不存在")
# 同套同角色只保留一张已选图;重生后选择新图会自动取消旧图。
db.query(ImageCandidate).filter(
ImageCandidate.task_id == task_id,
ImageCandidate.strategy == ic.strategy,
ImageCandidate.role == ic.role,
ImageCandidate.id != ic.id,
).update({"is_selected": False})
ic.is_selected = True ic.is_selected = True
db.commit() db.commit()
# R7 断点2选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨; # R7 断点2选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨;
@@ -89,18 +102,40 @@ def import_text(
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""轨B导入外部文案直接进候选池跳过 AI 生成洞2""" """轨B导入外部文案直接进候选池跳过 AI 生成洞2
_check_task_ownership( 按已导入条数 %3 轮转分配 strategy(A/B/C),让导入轨「去生图」时能出三套正交配图
(生图按 strategy 匹配文案NULL 套永远匹配不上)。
导入=客户实跑验证过的范本,记 text_import 飞轮信号(权重高于改稿/AI选稿,倩倩姐2026-06-26)。"""
from app.constants.enums import IMAGE_STRATEGY_ANGLE
from app.services.flywheel_service import record_signal
task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(), db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id, current_user.workspace_id,
) )
# 已导入条数 %3 → A/B/C 轮转第1条A 第2条B 第3条C 第4条A…三套均匀分布
existing = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).count()
strategy = ("A", "B", "C")[existing % 3]
tc = TextCandidate( tc = TextCandidate(
workspace_id=current_user.workspace_id, task_id=task_id, workspace_id=current_user.workspace_id, task_id=task_id,
source="import", content=body.content, angle_label=body.angle_label, source="import", content=body.content, angle_label=body.angle_label,
strategy=strategy,
) )
db.add(tc) db.add(tc)
db.commit() db.commit()
db.refresh(tc) db.refresh(tc)
# 飞轮信号:导入文案=最值得学习的范本。角度优先用户填的,缺则按套别兜底,让偏好聚合学到角度。
# 飞轮辅助不阻塞主路径:记信号失败只 log,导入文案(tc 已 commit)照常返回。
try:
record_signal(
db, current_user, task, "text_import",
candidate_id=tc.id,
angle_label=body.angle_label or IMAGE_STRATEGY_ANGLE.get(strategy),
)
except Exception:
logger.warning(
"导入文案飞轮信号记录失败(不阻塞导入): task_id=%s candidate_id=%s",
task_id, tc.id,
)
return ok(_fmt_text(tc)) return ok(_fmt_text(tc))
@@ -128,6 +163,18 @@ def regenerate(
current_user.workspace_id, current_user.workspace_id,
) )
body = body or RegenerateRequest() body = body or RegenerateRequest()
if str(task.status) == "generating" or getattr(task.status, "value", None) == "generating":
raise_state_invalid("任务正在生成中,请等待本轮完成后再重生")
import redis as redis_lib
from app.core.config import get_settings
try:
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
except Exception as exc:
logger.warning("检查重生锁失败,继续提交: task_id=%s err=%s", task_id, exc)
else:
if r.exists(f"pipeline:lock:{task_id}"):
raise_state_invalid("任务正在重生中,请等待本轮完成后再操作")
# role 必须配 strategy单张重生需知道是哪套的哪张 # role 必须配 strategy单张重生需知道是哪套的哪张
if body.role and not body.strategy: if body.role and not body.strategy:
raise_state_invalid("指定单张重生(role)时必须同时指定 strategy(套别)") raise_state_invalid("指定单张重生(role)时必须同时指定 strategy(套别)")
@@ -138,6 +185,39 @@ def regenerate(
return ok({"task_id": task_id, "status": "regenerating", "scope": scope}) return ok({"task_id": task_id, "status": "regenerating", "scope": scope})
@router.post("/{task_id}/generate-images")
def generate_images(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""导入轨「去生图」:复用库内导入文案,跳过文案生成,只生图(首次出图)。
复用 regenerate 的 generating 守卫 + redis 锁防重复烧钱。"""
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,
)
if str(task.status) == "generating" or getattr(task.status, "value", None) == "generating":
raise_state_invalid("任务正在生成中,请等待本轮完成")
# 必须库内有导入文案才放行生图,避免空语境生图(质量崩)
has_text = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).count()
if not has_text:
raise_state_invalid("无可用文案,请先导入文案再去生图")
import redis as redis_lib
from app.core.config import get_settings
try:
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
except Exception as exc:
logger.warning("检查生图锁失败,继续提交: task_id=%s err=%s", task_id, exc)
else:
if r.exists(f"pipeline:lock:{task_id}"):
raise_state_invalid("任务正在生成中,请等待本轮完成")
enqueue_generation(task.id, reuse_text=True)
return ok({"task_id": task_id, "status": "generating"})
@router.post("/{task_id}/submit-review") @router.post("/{task_id}/submit-review")
def submit_review( def submit_review(
task_id: int, task_id: int,
@@ -151,11 +231,40 @@ def submit_review(
) )
if task.status != "pending_selection": if task.status != "pending_selection":
raise_state_invalid("只有 pending_selection 状态可提审") raise_state_invalid("只有 pending_selection 状态可提审")
_validate_strategy_selection(db, task)
task.status = "pending_review" task.status = "pending_review"
db.commit() db.commit()
return ok({"task_id": task_id, "status": "pending_review"}) return ok({"task_id": task_id, "status": "pending_review"})
def _validate_strategy_selection(db: Session, task: GenerationTask) -> None:
"""提审前最低门槛:至少 1 条已选文案 + 至少 1 张已选图。
三套 A/B/C 是「挑着选」——用户可只要其中一套、丢弃某套、或某套重新生成,
不强制三套齐全、不强制每套选满(倩倩姐 2026-06-26 拍板)。
"""
from app.core.response import raise_business
selected_text_count = (
db.query(TextCandidate.id)
.filter(TextCandidate.task_id == task.id, TextCandidate.is_selected == True)
.count()
)
selected_image_count = (
db.query(ImageCandidate.id)
.filter(ImageCandidate.task_id == task.id, ImageCandidate.is_selected == True)
.count()
)
parts = []
if selected_text_count < 1:
parts.append("请至少选择 1 条文案")
if selected_image_count < 1:
parts.append("请至少选择 1 张图片")
if parts:
raise_business("".join(parts))
@router.get("/{task_id}/preference/context") @router.get("/{task_id}/preference/context")
def get_preference_context( def get_preference_context(
task_id: int, task_id: int,
@@ -211,3 +320,45 @@ def edit_text(
db.commit() db.commit()
record_signal(db, current_user, task, "text_edit", candidate_id=tc.id, angle_label=tc.angle_label) record_signal(db, current_user, task, "text_edit", candidate_id=tc.id, angle_label=tc.angle_label)
return ok({"candidate_id": candidate_id, "edited": True}) return ok({"candidate_id": candidate_id, "edited": True})
@router.delete("/{task_id}/text-candidates/{candidate_id}")
def delete_text_candidate(
task_id: int, candidate_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""删除单条文案候选仅管理员物理删倩倩姐2026-06-30拍板删除权限只给管理员"""
_check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
tc = db.query(TextCandidate).filter(
TextCandidate.id == candidate_id, TextCandidate.task_id == task_id
).first()
if not tc:
raise_not_found("文案候选不存在")
db.delete(tc)
db.commit()
return ok({"deleted": candidate_id})
@router.delete("/{task_id}/image-candidates/{candidate_id}")
def delete_image_candidate(
task_id: int, candidate_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""删除单张图片候选仅管理员物理删倩倩姐2026-06-30拍板删除权限只给管理员"""
_check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
ic = db.query(ImageCandidate).filter(
ImageCandidate.id == candidate_id, ImageCandidate.task_id == task_id
).first()
if not ic:
raise_not_found("图片候选不存在")
db.delete(ic)
db.commit()
return ok({"deleted": candidate_id})

View File

@@ -13,7 +13,11 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_write_permission from app.middleware.workspace_guard import (
CurrentUser,
require_admin,
require_write_permission,
)
from app.models.task import GenerationTask, ImageCandidate, TextCandidate from app.models.task import GenerationTask, ImageCandidate, TextCandidate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -227,3 +231,42 @@ def get_task(
"text_candidates": [_fmt_text(tc) for tc in texts], "text_candidates": [_fmt_text(tc) for tc in texts],
"image_candidates": [_fmt_image(ic) for ic in images], "image_candidates": [_fmt_image(ic) for ic in images],
}) })
@router.delete("/{task_id}")
def delete_task(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""删除整个图文任务仅管理员倩倩姐2026-06-26拍板
智能删:有成品(delivery_packages)→转归档保留(status=archived不进看板)
无成品(残次/废稿)→物理删(先清 ai_call_logs/preference_events 外键,候选 CASCADE
"""
from datetime import datetime
from app.models.flywheel import AiCallLog, PreferenceEvent
from app.models.task import DeliveryPackage
from app.constants.enums import TaskStatus
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
task = _check_task_ownership(task, current_user.workspace_id)
has_package = (
db.query(DeliveryPackage.id)
.filter(DeliveryPackage.task_id == task_id)
.first()
is not None
)
if has_package:
task.status = TaskStatus.ARCHIVED.value
task.archived_at = datetime.utcnow()
db.commit()
return ok({"task_id": task_id, "action": "archived"})
# 物理删:先清无 CASCADE 的外键引用(候选表 CASCADE 自动删)
db.query(AiCallLog).filter(AiCallLog.task_id == task_id).delete(synchronize_session=False)
db.query(PreferenceEvent).filter(PreferenceEvent.task_id == task_id).delete(synchronize_session=False)
db.delete(task)
db.commit()
return ok({"task_id": task_id, "action": "deleted"})

View File

@@ -0,0 +1,83 @@
"""
app/api/v1/users.py — 管理员账号管理路由
全部挂 require_admin路由层只做参数校验→调 service→格式化响应。
禁用=软删除可恢复(倩倩姐2026-06-30);不物理删除。
"""
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
from app.middleware.workspace_guard import CurrentUser, require_admin
from app.services import user_admin_service as svc
router = APIRouter(prefix="/users", tags=["users"])
class CreateUserRequest(BaseModel):
username: str
email: str
role: str
init_password: str
@field_validator("username", "email", "role", "init_password")
@classmethod
def not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("不能为空")
return v
class SetActiveRequest(BaseModel):
is_active: bool
@router.get("")
def list_users(
current_user: Annotated[CurrentUser, Depends(require_admin)],
db: Session = Depends(get_db),
):
"""列出本 workspace 成员(含已禁用)。"""
return ok(svc.list_workspace_users(db, current_user.workspace_id))
@router.post("")
def create_user(
body: CreateUserRequest,
current_user: Annotated[CurrentUser, Depends(require_admin)],
db: Session = Depends(get_db),
):
"""建号,强制首登改密。"""
return ok(svc.create_workspace_user(
db, current_user.workspace_id,
body.username, body.email, body.role, body.init_password,
))
@router.post("/{user_id}/active")
def set_active(
user_id: int,
body: SetActiveRequest,
current_user: Annotated[CurrentUser, Depends(require_admin)],
db: Session = Depends(get_db),
):
"""启用/禁用账号(软删除,可随时恢复)。"""
return ok(svc.set_user_active(
db, current_user.workspace_id, user_id, body.is_active, current_user.user_id,
))
@router.delete("/{user_id}")
def delete_user(
user_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)],
db: Session = Depends(get_db),
):
"""物理删除账号(不可恢复,与禁用区分)。"""
return ok(svc.delete_workspace_user(
db, current_user.workspace_id, user_id, current_user.user_id,
))

View File

@@ -39,5 +39,6 @@ def switch_workspace(
token = create_access_token(current_user.user_id, body.workspace_id, member.role) token = create_access_token(current_user.user_id, body.workspace_id, member.role)
return ok({ return ok({
"current_workspace_id": body.workspace_id, "current_workspace_id": body.workspace_id,
"role": member.role,
"token": token, "token": token,
}) })

View File

@@ -62,6 +62,7 @@ class SignalType(str, Enum):
REJECT_WITH_REASON = "reject_with_reason" REJECT_WITH_REASON = "reject_with_reason"
REGENERATE = "regenerate" REGENERATE = "regenerate"
TEXT_EDIT = "text_edit" # 改稿:用户真改了字=最强真实信号(倩倩姐2026-06-16拍板) TEXT_EDIT = "text_edit" # 改稿:用户真改了字=最强真实信号(倩倩姐2026-06-16拍板)
TEXT_IMPORT = "text_import" # 导入:客户实跑验证过跑得好的范本=最值得学习(倩倩姐2026-06-26拍板)
# ── 飞轮信号权重默认值(北哥可校准)───────────────────── # ── 飞轮信号权重默认值(北哥可校准)─────────────────────
@@ -72,6 +73,9 @@ SIGNAL_WEIGHTS: dict[str, int] = {
SignalType.REJECT_WITH_REASON: -3, SignalType.REJECT_WITH_REASON: -3,
SignalType.REGENERATE: -1, SignalType.REGENERATE: -1,
SignalType.TEXT_EDIT: 5, # 最强信号,≥选择类(倩倩姐2026-06-16拍板;先激进跑一周再校准) SignalType.TEXT_EDIT: 5, # 最强信号,≥选择类(倩倩姐2026-06-16拍板;先激进跑一周再校准)
# 导入文案=客户实跑验证过的范本,权重高于改稿/AI选稿(倩倩姐2026-06-26拍板)。
# 8=改稿5+AI选稿3之上拉开梯度,体现"最值得学习"又不极端碾压;待倩倩姐最终校准。
SignalType.TEXT_IMPORT: 8,
} }

View File

@@ -37,6 +37,7 @@ class Settings(BaseSettings):
# ── 应用配置 ────────────────────────────────────── # ── 应用配置 ──────────────────────────────────────
APP_ENV: str = "development" APP_ENV: str = "development"
CORS_ALLOW_ORIGINS: str = "http://localhost:3000"
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天 JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
CELERY_BROKER_URL: str = "" CELERY_BROKER_URL: str = ""
CELERY_RESULT_BACKEND: str = "" CELERY_RESULT_BACKEND: str = ""

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.response import raise_forbidden, raise_unauthorized from app.core.response import raise_forbidden, raise_unauthorized
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.models.user import User
from app.models.workspace import WorkspaceMember from app.models.workspace import WorkspaceMember
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -85,6 +86,9 @@ def require_write_permission(
current_user.workspace_id, current_user.workspace_id,
) )
raise_forbidden("无权限访问此 workspace") raise_forbidden("无权限访问此 workspace")
user = db.query(User).filter(User.id == current_user.user_id).first()
if user and getattr(user, "must_change_password", False):
raise_forbidden("首次登录必须先修改密码")
return current_user return current_user

View File

@@ -102,9 +102,9 @@ class BenchmarkNote(Base):
product_id: Mapped[int] = mapped_column( product_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False
) )
screenshot_url: Mapped[str | None] = mapped_column(String(512)) screenshot_url: Mapped[str | None] = mapped_column(Text)
highlights: Mapped[str | None] = mapped_column(Text) # 手填亮点 highlights: Mapped[str | None] = mapped_column(Text) # 手填亮点
link_url: Mapped[str | None] = mapped_column(String(512)) link_url: Mapped[str | None] = mapped_column(Text)
# 009: 第2环标杆分析字段 # 009: 第2环标杆分析字段
features_json: Mapped[str | None] = mapped_column(Text, comment="爆款8特征分析结果JSONAI解析后写入") features_json: Mapped[str | None] = mapped_column(Text, comment="爆款8特征分析结果JSONAI解析后写入")
analyze_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False, comment="AI分析状态: pending/analyzing/done/failed") analyze_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False, comment="AI分析状态: pending/analyzing/done/failed")

View File

@@ -22,6 +22,7 @@ class User(Base):
email: Mapped[str] = mapped_column(String(255), 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) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False DateTime, server_default=func.now(), nullable=False
) )

View File

@@ -1,9 +1,10 @@
""" """
_score_prompt.py — AI 评委 prompt让模型真读文案不机械找词 _score_prompt.py — AI 评委 prompt让模型真读文案不机械找词
评判标准忠于《富贵情绪营销理论》原文(口播一手来源),标实战补充出处。 评判标准忠于《富贵情绪营销理论》原文(口播一手来源),标实战补充出处。
6维满分分布倩倩姐2026-06-15拍板与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步): 7维满分分布倩倩姐2026-06-22拍板补北哥要害⑤标签精准·与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
痛点人群精准18 / 情绪张力18 / 买点转化18 / 开头钩子15 / 标题点击力13 / 真实感13 = 95 痛点人群精准16 / 情绪张力16 / 买点转化16 / 开头钩子13 / 标题点击力13 / 真实感11 / 标签精准度10 = 95
+ 合规5机械硬拦不进AI评委= 100 + 合规5机械硬拦不进AI评委= 100
合格线80不动新增"标签精准度"是补考核维度分从原6维各匀出总分仍95不是降门槛。
"真实感"替换旧"产品聚焦一件事(16)":富贵"很少提产品/前70%干货后30%植入"独立升维。 "真实感"替换旧"产品聚焦一件事(16)":富贵"很少提产品/前70%干货后30%植入"独立升维。
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,13 +19,13 @@ SCORER_PERSONA = """你是一位资深小红书内容操盘手,深谙富贵情
# ── 6 维评判标准按权重降序合规第7维由代码机械硬拦不在此──── # ── 6 维评判标准按权重降序合规第7维由代码机械硬拦不在此────
# 标准依据:括号内标注[富贵原文]或[实战补充],前者来自口播一手来源,权威最高。 # 标准依据:括号内标注[富贵原文]或[实战补充],前者来自口播一手来源,权威最高。
SCORING_DIMENSIONS = """ SCORING_DIMENSIONS = """
【维度1·痛点人群精准满分18)】[富贵原文] 【维度1·痛点人群精准满分16)】[富贵原文]
判断:"说的就是我"——文案描述的处境/困扰,目标用户读了会不会对号入座。 判断:"说的就是我"——文案描述的处境/困扰,目标用户读了会不会对号入座。
好:具体到某类人的某个真实生活瞬间,让人觉得被看穿,落在"我的大问题/我的处境/我的渴望"上。 好:具体到某类人的某个真实生活瞬间,让人觉得被看穿,落在"我的大问题/我的处境/我的渴望"上。
差:泛泛而谈谁都能套(如"适合所有想变美的女生"或PUA用户、拿别人的惨状吓唬人。 差:泛泛而谈谁都能套(如"适合所有想变美的女生"或PUA用户、拿别人的惨状吓唬人。
依据:富贵"我的大问题→处境→渴望"内容骨架;人群越具体穿透力越强;"用户被宠成爹你PUA他不好使" 依据:富贵"我的大问题→处境→渴望"内容骨架;人群越具体穿透力越强;"用户被宠成爹你PUA他不好使"
【维度2·情绪张力满分18)】[富贵原文·第一性原理] 【维度2·情绪张力满分16)】[富贵原文·第一性原理]
判断:有没有"成果/后果"双向情绪,而不是平铺直叙报卖点。 判断:有没有"成果/后果"双向情绪,而不是平铺直叙报卖点。
· 后果=过去没用它,产生了什么糟糕处境(勾起恐惧/懊悔) · 后果=过去没用它,产生了什么糟糕处境(勾起恐惧/懊悔)
· 成果=用了它之后,会变成什么样(给出期待/乐观) · 成果=用了它之后,会变成什么样(给出期待/乐观)
@@ -32,32 +33,43 @@ SCORING_DIMENSIONS = """
差:全程客观介绍产品、无情绪、像说明书;或只单向吓唬、或只空喊美好。 差:全程客观介绍产品、无情绪、像说明书;或只单向吓唬、或只空喊美好。
依据:富贵"营销第一性原理就是情绪,没有情绪什么内容都不转化""成果是未来、后果是过去,要做这两种情绪" 依据:富贵"营销第一性原理就是情绪,没有情绪什么内容都不转化""成果是未来、后果是过去,要做这两种情绪"
【维度3·买点转化满分18)】[富贵原文] 【维度3·买点转化满分16)】[富贵原文]
判断:产品卖点有没有翻译成用户能感知的场景化利益(人话),而不是品牌视角的功效/参数。 判断:产品卖点有没有翻译成用户能感知的场景化利益(人话),而不是品牌视角的功效/参数。
好:用户能想象到的使用场景和结果(如"出门前最后一步、同事问我今天气色真好")。 好:用户能想象到的使用场景和结果(如"出门前最后一步搞定、被身边人夸最近状态真好")。
差:品牌语言/参数罗列(如"采用XX技术""含XX成分"),用户无感。 差:品牌语言/参数罗列(如"采用XX技术""含XX成分"),用户无感。
依据:富贵"卖点是品牌视角、买点是用户视角""用户买的不是产品,是使用场景背后被解决的问题" 依据:富贵"卖点是品牌视角、买点是用户视角""用户买的不是产品,是使用场景背后被解决的问题"
【维度4·开头钩子满分15)】[富贵原文] 【维度4·开头钩子满分13)】[富贵原文]
判断:开头能不能让人停下来、想继续读。 判断:开头能不能让人停下来、想继续读。
好:开头第一句就直击用户的大问题/痛处/具体场景代入,每句都打要害。 好:开头第一句就直击用户的大问题/痛处/具体场景代入,每句都打要害。
差:开头是套话或铺垫半天不进正题,没有任何抓人的点。 差:开头是套话或铺垫半天不进正题,没有任何抓人的点。
依据:富贵"内容就是锋利的刀,一定要插用户的心窝子" 依据:富贵"内容就是锋利的刀,一定要插用户的心窝子"
【维度5·标题点击力满分13】[富贵原文+实战补充] 【维度5·标题点击力满分13】[富贵原文+实战补充+北哥要害①]
判断:标题有没有让目标用户想点的诱因。 判断:标题有没有让目标用户想点的诱因,且覆盖面够不够广
好:标题带具体人群/场景/痛点/情绪钩子,一眼觉得"和我有关、我想看",最好来自用户真实说法 好:标题带悬念/反差/疑问钩子让人想点,同时"广覆盖"罩住尽量多的人(不一上来就锁死单一窄人群),精准筛人交给标签层
差:只有产品名、平淡无钩子、太像广告。 差:只有产品名、平淡无钩子、太像广告;或标题一上来就锁死单一窄人群(如只喊某一类人把其他人挡门外),覆盖面太窄
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文]标题善用痛点/人群/效果/情绪词[实战补充]。 依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文]北哥实战"标题广覆盖+悬念钩子,精准靠标签不靠标题筛人"[要害①]。
【维度6·真实感满分13)】[富贵原文] 【维度6·真实感满分11)】[富贵原文]
判断:整条文案是不是像真人分享,而不是品牌广告或功效说明书。 判断:整条文案是不是像真人分享,而不是品牌广告或功效说明书。
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格。 好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格;种草借第三方视角(闺蜜/同事/被人问)而非自夸回购
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板而非真实人设 差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板;或全程自卖自夸"我回购了N次"广告味重
依据:富贵"我们很少提产品""前70%是价值/场景后30%才是产品"用户能感受到"这不像广告" 依据:富贵"我们很少提产品""前70%是价值/场景后30%才是产品"北哥要害③"第三方闺蜜真实背书胜过自夸"
【维度7·标签精准度满分10】[北哥要害⑤·出单命门]
判断tags 是不是"有明确购买意向的人才会搜"的精准埋词,而不是凑流量的泛词。
好:目标人群+品类+核心卖点/场景组合成的精准长尾标签搜它的人就是想买这类的人至少2-3个精准埋词。
差:清一色"#好物分享 #真实测评 #种草"这类谁都能用、购买意向弱的泛流量大词,搜来的人不精准。
扣分细则(必须逐个标签判定,不许整体放水):把每个 tag 归为"精准埋词""泛词"
泛词=品类大词/平台流量词/无人群无卖点的口号词,如 #好物分享 #平价好物 #种草 #真实测评 #必入清单。
精准埋词=含明确人群或场景或具体痛点卖点,搜它的人就是目标买家,如 #久坐党护腰好物 #通勤提神咖啡。
评分上限泛词占比≥50% 最高只能给6分占比1/3左右给7-8分几乎全是精准埋词才给9-10分。
reason 里必须点名哪几个是泛词、哪几个是精准埋词,不能只给结论。
依据:北哥实战"精准购买意向标签埋词是出单命门,泛词价值低、精准长尾才带来真买家"[要害⑤]。
""".strip() """.strip()
# ── 真实感已升为维度6满分13),此处保留空字符串避免调用方引用报错 ── # ── 真实感已升为维度6满分11),此处保留空字符串避免调用方引用报错 ──
REALNESS_NOTE = "" # 升为 SCORING_DIMENSIONS 维度6不再重复附加 REALNESS_NOTE = "" # 升为 SCORING_DIMENSIONS 维度6不再重复附加
# ── 输出格式约束 ────────────────────────────────────────── # ── 输出格式约束 ──────────────────────────────────────────
@@ -65,17 +77,20 @@ SCORER_OUTPUT_FORMAT = """
读完这条文案后严格返回纯JSON对象不要markdown代码块、不要多余文字格式 读完这条文案后严格返回纯JSON对象不要markdown代码块、不要多余文字格式
{ {
"dims": [ "dims": [
{"item":"痛点人群精准","score":<0-18整数>,"reason":"<针对本条的具体理由30字内>"}, {"item":"痛点人群精准","score":<0-16整数>,"reason":"<针对本条的具体理由30字内>"},
{"item":"情绪张力","score":<0-18>,"reason":"..."}, {"item":"情绪张力","score":<0-16>,"reason":"..."},
{"item":"买点转化","score":<0-18>,"reason":"..."}, {"item":"买点转化","score":<0-16>,"reason":"..."},
{"item":"开头钩子","score":<0-15>,"reason":"..."}, {"item":"开头钩子","score":<0-13>,"reason":"..."},
{"item":"标题点击力","score":<0-13>,"reason":"..."}, {"item":"标题点击力","score":<0-13>,"reason":"..."},
{"item":"真实感","score":<0-13>,"reason":"..."} {"item":"真实感","score":<0-11>,"reason":"..."},
{"item":"标签精准度","score":<0-10>,"reason":"..."}
], ],
"verdict":"<优秀|合格|不合格>", "verdict":"<优秀|合格|不合格>",
"summary":"<一句话总评,说清这条最大的优点和最该改的点>" "summary":"<一句话总评,说清这条最大的优点和最该改的点>"
} }
打分要敢拉开差距:平庸文案该给中低分,不要清一色高分;优秀的地方也别吝啬给高分。 打分要敢拉开差距:平庸文案该给中低分,不要清一色高分;优秀的地方也别吝啬给高分。
只评上面7个维度。合规/违禁词由系统机械检测,禁止你输出"合规性/违禁词/合规"等任何合规相关维度,
也不要因为你主观觉得某词敏感而压分——"不假白/没那么黄/提气色"这类是合规的感受描述,不是功效违禁词。
""".strip() """.strip()
# 合规维度满分(机械硬拦,不进 AI 评委) # 合规维度满分(机械硬拦,不进 AI 评委)
@@ -86,9 +101,11 @@ AI_DIMS_MAX = 95
def build_score_prompt(copy: dict, product: dict | None = None) -> str: def build_score_prompt(copy: dict, product: dict | None = None) -> str:
"""组装单条文案的评委 prompt。copy={title,content,...}product 提供品牌/品类语境。""" """组装单条文案的评委 prompt。copy={title,content,tags,...}product 提供品牌/品类语境。"""
title = str(copy.get("title", "")).strip() title = str(copy.get("title", "")).strip()
content = str(copy.get("content", "")).strip() content = str(copy.get("content", "")).strip()
tags_raw = copy.get("tags") or []
tags = " ".join(str(t).strip() for t in tags_raw if str(t).strip()) or "(本条未给标签)"
ctx = "" ctx = ""
if product: if product:
name = product.get("name") or product.get("title") or "" name = product.get("name") or product.get("title") or ""
@@ -99,6 +116,6 @@ def build_score_prompt(copy: dict, product: dict | None = None) -> str:
ctx = "【产品语境】\n" + "\n".join(bits) + "\n\n" ctx = "【产品语境】\n" + "\n".join(bits) + "\n\n"
return ( return (
f"{SCORING_DIMENSIONS}\n\n" f"{SCORING_DIMENSIONS}\n\n"
f"{ctx}【待评文案】\n标题:{title}\n正文:{content}\n\n" f"{ctx}【待评文案】\n标题:{title}\n正文:{content}\n标签:{tags}\n\n"
f"{SCORER_OUTPUT_FORMAT}" f"{SCORER_OUTPUT_FORMAT}"
) )

View File

@@ -35,7 +35,7 @@ def _cat_words(category: str) -> list[str]:
def score_title(title: str, cat_words: list[str], w: dict) -> dict: def score_title(title: str, cat_words: list[str], w: dict) -> dict:
ts = 0 ts = 0
if 6 <= len(title) <= 34: ts += 8 if 6 <= len(title) <= 34: ts += 8
if re.search(r"[0-9一二三四五六七八九十几]|最近|这|这个|每天|早八|学生党|新手|宝妈|懒人|伪素颜", title): ts += 5 if re.search(r"[0-9一二三四五六七八九十几]|最近|这|这个|每天|早八|学生党|新手|宝妈|懒人", title): ts += 5
if _has_any(title, _SCENE_WORDS): ts += 6 if _has_any(title, _SCENE_WORDS): ts += 6
if _has_any(title, cat_words): ts += 5 if _has_any(title, cat_words): ts += 5
if re.search(r"[!?]|救星|不踩雷|闭眼入|会回购|被问|惊喜|加分|常备|省心|实用|别乱选|放心|有气色", title): ts += 6 if re.search(r"[!?]|救星|不踩雷|闭眼入|会回购|被问|惊喜|加分|常备|省心|实用|别乱选|放心|有气色", title): ts += 6

View File

@@ -15,35 +15,38 @@ _PERSONA = """你是一个日常生活分享博主,不是品牌推广号。
比例70% 写生活场景和使用感受30% 提产品,绝不颠倒。 比例70% 写生活场景和使用感受30% 提产品,绝不颠倒。
收尾铁律:每条结尾方式必须不同,禁止使用"东西放这了/买不买跟我没关系"这类被用滥的固定句式。""" 收尾铁律:每条结尾方式必须不同,禁止使用"东西放这了/买不买跟我没关系"这类被用滥的固定句式。"""
# ── Q1 随机变量池 ABC反同质化核心每次随机抽组合N条不撞────────────── # ── Q1 叙事切入配方(反同质化核心·北哥四象限人群法,全品类通用不写死)────────
# 方法层:框架固定,内容可扩展,绝不写死 # 方法层:只给"切入维度类型",具体人群/场景由模型按本产品卖点+目标人群填充
_POOL_A_IDENTITY: list[str] = [ # 北哥四象限:场景/习惯/适配/时机——主人群维度按条轮转保证N条开篇不撞
"上班族早八妆前随手抹", _AUDIENCE_DIMENSIONS: list[str] = [
"宿舍懒人护肤三分钟搞定", "使用场景人群(按该产品最高频的使用场景圈定一类人)",
"敏感肌妈妈哄完娃才有五分钟", "使用习惯人群(懒人省心/进阶讲究等按习惯分的一类人)",
"学生党第一次用高价护肤品", "适配特质人群(该产品特别适配的某类特质或需求人群)",
"素颜出门前最后一步", "使用时机人群(按使用时间/阶段/契机圈定的一类人)",
"性价比人群(看重划算、对价格敏感的一类人)",
] ]
_POOL_B_EMOTION: list[str] = [ # 情绪触发类型(通用,模型按产品填具体内容)
"看到镜子里发现气色暗了一周", _EMOTION_TRIGGERS: list[str] = [
"闺蜜问你最近皮肤怎么这么好", "被身边人提醒或夸到才注意到的瞬间",
"出门被催快点根本来不及叠瓶", "自己用的时候突然意识到的变化",
"对比之前踩过的雷或走过的弯路",
] ]
_POOL_C_FLAW: list[str] = [ # 真实小波折类型(增可信,通用不写死品类)
"第一次用量太少了没啥感觉", _REAL_FLAWS: list[str] = [
"包装简单到以为是山寨", "一开始用法没掌握或用量不对",
"价格摆在那以为会很油很厚", "第一眼对包装或价格有点误解",
"起效没想象中快、用了阵子才感觉到",
] ]
def _pick_combo() -> dict[str, str]: def _pick_combo(index: int = 0) -> dict[str, str]:
"""随机抽一组 ABC 变量每次生成调用一次N条各不相同""" """抽一组通用叙事切入配方维度类型非写死内容主人群维度按index轮转N条不撞"""
return { return {
"identity": random.choice(_POOL_A_IDENTITY), "identity": _AUDIENCE_DIMENSIONS[index % len(_AUDIENCE_DIMENSIONS)],
"emotion": random.choice(_POOL_B_EMOTION), "emotion": random.choice(_EMOTION_TRIGGERS),
"flaw": random.choice(_POOL_C_FLAW), "flaw": random.choice(_REAL_FLAWS),
} }
@@ -61,30 +64,34 @@ _EMOJI_RULES = """
- 正文段落可点缀少量 emoji 烘托情绪(如 🥹 😭 🤍 💛每段最多1-2个不堆砌 - 正文段落可点缀少量 emoji 烘托情绪(如 🥹 😭 🤍 💛每段最多1-2个不堆砌
- 结尾话题标签前后带表情,如 "#好物分享 🛒" - 结尾话题标签前后带表情,如 "#好物分享 🛒"
- emoji 服务情绪和分点,不要每句都加;整条正文 emoji 总量控制在 6-12 个 - emoji 服务情绪和分点,不要每句都加;整条正文 emoji 总量控制在 6-12 个
- 常用小红书 emoji 池:✅✨🌿💧🪞🧴📦🔍💛🤍🥹😭🛒(按语义选,不乱用) - 常用小红书 emoji 池:✅✨🌿💧📦🔍💛🤍🥹😭🛒💯(按语义选,不乱用)
""".strip() """.strip()
# ── Q2 5步框架 + Q3 四段结构 ───────────────────────────────────────────── # ── Q2 5步框架 + Q3 四段结构 ─────────────────────────────────────────────
_STRUCTURE_RULES = """ _STRUCTURE_RULES = """
【5步框架必须严格遵循 【5步框架必须严格遵循
① Hook暴击低价/痛点:第一句戳中场景或价格锚点,吊足读者好奇 ① Hook暴击低价/痛点:第一句戳中场景或价格锚点,吊足读者好奇
② 痛点共鸣2-3句描写使用前的真实困境用上面抽到的起因情绪A·B ② 痛点共鸣2-3句描写使用前的真实困境结合本条的情绪触发,并按北哥要害②召集多类人
③ 救星登场:自然带出产品,口吻是"碰巧发现/朋友安利/囤货时顺手",不是"推荐给你" ③ 救星登场:自然带出产品,口吻是"碰巧发现/朋友安利/囤货时顺手",不是"推荐给你"
④ 卖点罗列每条加✅小标题3条以内卖点翻译成使用感受不是功效列表 ④ 卖点罗列每条加✅小标题3条以内卖点翻译成使用感受不是功效列表
⑤ 收尾(每条必须从下方策略池随机选一种,同批次不得重复同一种,禁止"东西放这了/买不买跟我没关系"此类固定句式): ⑤ 收尾(每条必须从下方策略池随机选一种,同批次不得重复同一种,禁止"东西放这了/买不买跟我没关系"此类固定句式):
【收尾策略池·每条选不同策略】 【收尾策略池·每条选不同策略】
A·留白式感受只说自己现在的状态不提买不买"反正现在素颜出门我不慌" A·留白式感受只说自己现在的状态不提买不买"反正现在出门我都带着它,心里踏实多"
B·反问读者把感受抛回给读者"你们护肤有没有那种一用就回不去的东西?" B·反问读者把感受抛回给读者"你们有没有那种一用就回不去的东西?"
C·场景延续把故事延伸到未来某个细节"下次同事再问我皮肤的事我就知道啥了" C·场景延续把故事延伸到未来某个细节"下次再有人问起,我就知道该推荐啥了"
D·克制回购暗示轻描淡写说自己行为"第一用完了,已经在备第二" D·克制回购暗示轻描淡写说自己行为"第一用完了,已经在备第二"
E·纯记录收笔像日记最后一句不引导不评价"大概就这样,记录一下" E·纯记录收笔像日记最后一句不引导不评价"大概就这样,记录一下"
F·引导搜索仅在有品牌词时使用自然提一句"感兴趣可以搜搜『{品牌词}",不催单 F·引导搜索仅在有品牌词时使用自然提一句"感兴趣可以搜搜『{品牌词}",不催单
【正文四段结构(必须)】 【正文叙事骨架(开篇方式由本条叙事主线决定,禁止三套同一开头)】
段1·痛点引入:描写使用前的困境/触发场景身份场景A·起因情绪B 段1·开篇分叉(按本条叙事主线三选一,这是三套拉开差异的关键):
段2·实测记录真实使用过程带上小缺点C真实感来源 - 痛点先行→开篇即戳使用前的困境/情绪(结合主切入人群维度,按要害②召集多类人
段3·种草核心产品带来的变化用感受描述而非功效声称 - 场景先行→开篇先白描一个真实高频使用场景,痛点在场景里自然带出,不喊口号
段4·引导收尾从收尾策略池随机选一种佛系口吻不强推末尾带1-2个相关话题标签 - 成分背书先行→开篇用成分原理/测评对比视角切入("为什么有用/亲测对比"),痛点后置
段2·实测记录真实使用过程带上本条的真实小波折真实感来源用第三方视角背书北哥要害③
段3·种草核心产品带来的变化用感受描述而非功效声称核心成分逐条对应痛点北哥要害④
段4·引导收尾从收尾策略池随机选一种佛系口吻不强推末尾带精准购买意向标签北哥要害⑤
※ 卖点罗列的措辞/顺序/emoji 三套之间禁止雷同;身边人种草/被夸被问这类桥段三套不得共用同一套。
【字数】正文350-400字不含标题tags3-5处段落空行增强可读性 【字数】正文350-400字不含标题tags3-5处段落空行增强可读性
""".strip() """.strip()
@@ -92,23 +99,46 @@ _STRUCTURE_RULES = """
# ── Q8 标题公式5类结构每批次覆盖不同类型不重复────────────────────── # ── Q8 标题公式5类结构每批次覆盖不同类型不重复──────────────────────
_TITLE_FORMULA = """ _TITLE_FORMULA = """
【标题公式5类每条用不同类型禁止同批重复 【标题公式5类每条用不同类型禁止同批重复
肤质型:「{肤质}+用了{产品}+{感受}」例→"油皮用了素颜霜整个夏天不脱妆" 特征型:「{适配特征}+用了{产品}+{感受}」例→"久坐党用了这把椅子,腰一整天不酸了"
价格型:「{价格锚点}+{产品}+{效感受}」例→"三位数买到大牌平替,用完第一罐回购第二罐" 价格型:「{价格锚点}+{产品}+{效感受}」例→"三位数买到大牌平替,用完第一份就回购"
功效型:「{使用场景}+{产品}+{可感知变化}」例→"早起素颜出门靠这罐,同事问我最近皮肤怎么" 场景型:「{使用场景}+{产品}+{可感知变化}」例→"通勤路上靠这杯,到工位整个人都醒"
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神奇产品,涂上去感觉素颜也能出门" 夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神仙东西,用一次就回不去"
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"人说皮肤变好了,没说的是我了它一个月" 标题党型:「{反常识/意外信息}+真相是{翻转}」例→"夸最近状态好,没说的是我了它一个月"
硬性约束标题≤20字禁止出现"绝绝子/YYDS/杀疯了";不直接写功效词(美白/祛斑等)。 硬性约束标题≤20字禁止出现"绝绝子/YYDS/杀疯了";不直接写功效词(美白/祛斑等)。
硬性约束·标题必须有具体锚点:禁止"有点反差/真的绝/太惊艳/有点东西"这类空泛无信息量的标题,
必须落到一个具体的人群/场景/数字/反差细节上(读者扫一眼就知道讲什么、关我什么事)。
""".strip() """.strip()
_ANTI_AI = f""" _ANTI_AI = f"""
【反AI味必须遵守】 【反AI味必须遵守】
- 禁止固定开篇套话:不许"姐妹们/宝子们/今天给大家分享"开头 - 禁止固定开篇套话:不许"姐妹们/宝子们/今天给大家分享"开头
- 禁止以下AI味词出现在正文{_NEGATIVE_WORDS} - 禁止以下AI味词出现在正文{_NEGATIVE_WORDS}
- 禁止人群重叠:{'{count}'}条文案中身份场景不能重复靠变量池ABC保证) - 禁止人群重叠:{'{count}'}条文案主切入人群维度不能重复靠Q1切入配方按条轮转保证)
- 禁止场景重复:同批次文案不能都是"早上上班"或都是"学生宿舍" - 禁止场景重复:同批次文案开篇场景不能雷同不能N条都挤在同一个使用场景
- 避免生硬推销词:按头安利/绝绝子/闭眼冲 不能出现 - 避免生硬推销词:按头安利/绝绝子/闭眼冲 不能出现
""".strip() """.strip()
# ── 系统 prompt合并Q2/Q3/Q4/Q7/Q8数据层走build_prompt动态注入────────── # ── 北哥出单强化层·五大要害(实测出单命门,与富贵框架叠加,冲突以本层为准)──
_BEIGE_FIVE = """
【北哥出单强化层·五大要害(最高优先级,与上面框架叠加,冲突时以本层为准)】
要害①·标题广覆盖+悬念钩子:标题要罩住尽量多的人,不要一上来就锁死单一窄人群
(只喊一类窄人群会把其他潜在人群挡在外面);用悬念/反差/疑问做钩子留个扣子让人想点进来;
精准筛人交给标签层,不靠标题筛人。
要害②·开篇召集痛点:正文要把这个产品能解决的痛点尽量多列几个、召集多类人
"不管你是…还是…"的覆盖感),让更多人对号入座,不要只写一类人的一个痛点。
注意:召集痛点不必都堆在第一句——开篇方式由本条的叙事主线决定(见下方叙事主线),
痛点先行的可开篇即戳痛点,场景先行/成分背书先行的则在叙事展开中自然召集多类人,
绝不能因为这条要害就把三套都写成同一个"赶时间/痛点"开头。
要害③·第三方闺蜜真实背书:种草借第三方视角(闺蜜/同事/家人说、被人问、别人推荐),
"别人替你证言"的真实感,不是自己反复夸自己回购多少次;自夸味=广告味=掉信任。
要害④·成分逐条对应痛点每个核心成分都明确对上它解决的那个痛点成分1→痛点1成分2→痛点2
逐条映射讲清"为什么有用",翻译成用户能感知的结果,不堆术语、不把成分名堆在一起不解释。
要害⑤·精准购买意向标签埋词tags 不要凑流量泛词(#好物分享#真实测评 这类谁都能用的大词价值低),
要埋"有明确购买意向的人才会搜"的精准词——目标人群+品类+核心卖点/场景组合成精准长尾标签,
至少2-3个精准埋词。
这5条是北哥实测出单的命门务必逐条命中。
""".strip()
# ── 系统 prompt合并Q2/Q3/Q4/Q7/Q8 + 北哥五段数据层走build_prompt动态注入──
COPY_SYSTEM = f"""{_PERSONA} COPY_SYSTEM = f"""{_PERSONA}
合规红线(全品类通用): 合规红线(全品类通用):
@@ -118,6 +148,8 @@ COPY_SYSTEM = f"""{_PERSONA}
{_ANTI_AI.format(count="N")} {_ANTI_AI.format(count="N")}
{_BEIGE_FIVE}
{_TITLE_FORMULA} {_TITLE_FORMULA}
{_EMOJI_RULES} {_EMOJI_RULES}
@@ -178,30 +210,42 @@ def build_prompt(product: dict, count: int, extra_rules: str = "",
custom = (product.get("custom_prompt") or "").strip() custom = (product.get("custom_prompt") or "").strip()
brand_kw = (product.get("brand_keyword") or "").strip() brand_kw = (product.get("brand_keyword") or "").strip()
# Q1每条抽一组随机变量,传给模型作角色约束 # Q1每条按index轮转主人群维度+随机情绪/波折,传给模型作切入约束
combos = [_pick_combo() for _ in range(count)] combos = [_pick_combo(i) for i in range(count)]
combos_text = "\n".join( combos_text = "\n".join(
f"{i+1}条:身份场景=「{c['identity']}」·起因情绪=「{c['emotion']}」·小缺点=「{c['flaw']}" f"{i+1}条:主切入人群维度=「{c['identity']}」·情绪触发=「{c['emotion']}」·真实小波折=「{c['flaw']}"
for i, c in enumerate(combos) for i, c in enumerate(combos)
) )
angle_hint = f"文案角度要覆盖:{''.join(angles)}(每条用不同角度)。" if angles else "" angle_hint = f"文案角度要覆盖:{''.join(angles)}(每条用不同角度)。" if angles else ""
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else "" brand_rule = (
f"每条正文中自然植入品牌词「{brand_kw}」至少一次(融入语境,不生硬)。"
f"标题以悬念/钩子优先(北哥要害①),品牌词不必硬塞进标题;"
f"若要进标题须自然不抢戏,禁止把品牌词堆在标题开头当详情页商品名(如'{brand_kw}{name}'式)。"
) if brand_kw else ""
benchmark_block = _build_benchmark_block(product.get("benchmark_refs") or []) benchmark_block = _build_benchmark_block(product.get("benchmark_refs") or [])
# strategy_narrative 是三套拉开差异的核心,加强标记顶到最高权重
narrative_block = (
f"\n========== 本套叙事主线(最高优先级·决定开篇与全文走向,禁止偏离)==========\n"
f"{strategy_narrative}\n"
f"==============================================================\n"
if strategy_narrative else ""
)
lines = [ lines = [
f"产品:{name}", f"产品:{name}",
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效词;翻译范例:'烟酰胺''熬夜后第二天脸不那么黄了''高保湿''涂上去一整天都没搓泥拔干'{selling}", f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效/参数词;翻译范例:技术参数'XX配方/XX材质''用起来的某个具体好处''大容量''出门一整天都不用补'{selling}",
f"风格调性:{style}", f"风格调性:{style}",
strategy_narrative, narrative_block,
angle_hint, angle_hint,
brand_rule, brand_rule,
custom, custom,
benchmark_block, benchmark_block,
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用", f"\n【Q1切入配方·每条主切入人群维度不同,模型按本产品把维度填成具体人群",
combos_text, combos_text,
extra_rules, extra_rules,
f"\n请严格按5步框架+四段结构生成 {count}每条350-400字返回纯JSON数组。", f"\n请严格按5步框架+叙事骨架生成 {count} 条,开篇方式必须服从上方叙事主线,每条350-400字返回纯JSON数组。",
] ]
return "\n".join(l for l in lines if l) return "\n".join(l for l in lines if l)
@@ -228,15 +272,15 @@ def build_local_drafts(product: dict, count: int) -> list[dict]:
_fallback_angles = ["生活场景型", "成分分析型", "使用感受型", "性价比型", "痛点切入型"] _fallback_angles = ["生活场景型", "成分分析型", "使用感受型", "性价比型", "痛点切入型"]
angles_pool = product.get("text_angles") or _fallback_angles angles_pool = product.get("text_angles") or _fallback_angles
for i in range(count): for i in range(count):
c = _pick_combo() c = _pick_combo(i)
# 循环取不同角度(角度相同的两条会被 dedupe_copies 过滤掉,所以必须不重复) # 循环取不同角度(角度相同的两条会被 dedupe_copies 过滤掉,所以必须不重复)
angle = angles_pool[i % len(angles_pool)] angle = angles_pool[i % len(angles_pool)]
yield { yield {
"title": f"发现一个{name}{angle}用法", "title": f"发现一个{name}{angle}用法",
"content": ( "content": (
f"{c['identity']}{c['emotion']}\n\n" f"最近开始用{name}{c['emotion']}\n\n"
f"用了一段时间,{points[i % len(points)]}这点最让我意外。\n\n" f"用了一段时间,{points[i % len(points)]}这点最让我意外。\n\n"
f"说个小缺点{c['flaw']},后来才摸到感觉。\n\n" f"说个小波折{c['flaw']},后来才摸到感觉。\n\n"
f"反正现在用顺手了。✅" f"反正现在用顺手了。✅"
), ),
"tags": [f"#{name}", "#真实测评", "#好物分享"], "tags": [f"#{name}", "#真实测评", "#好物分享"],

View File

@@ -0,0 +1,121 @@
"""
北哥醒图调色层去AI化·暖棕奶茶色调
来源:北哥 v2 教程 f_010 醒图参数倩倩姐2026-06-15录入Pillow 近似实现。
参数:色温+28(暖棕灵魂)/高光-35(压瓶身反光)/阴影+22/对比+9/饱和+6/暗角-10/HSL橙肤色。
开关:环境变量 BEIGE_COLOR_GRADE默认开(=1)off 则原样返回不调色。
诚实声明醒图是分区调整Pillow 只能做全局近似,方向一致、强度可参数化微调。
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
try:
from PIL import Image, ImageEnhance
_PILLOW_OK = True
except ImportError:
_PILLOW_OK = False
# 北哥醒图默认参数(归一化到 Pillow 可用范围±5 浮动由调用方覆盖)
# 数值含义见模块 docstring强度系数经验值落地后按实拍微调
BEIGE_PRESET: dict[str, float] = {
"warmth": 28.0, # 色温(暖棕灵魂,口诀+25~30) → R提/B压
"highlight": -35.0, # 高光(压瓶身反光,固定压暗高光区)
"shadow": 22.0, # 阴影(提暗部)
"contrast": 9.0, # 对比度
"saturation": 6.0, # 饱和(别高=廉价感)
"vignette": 10.0, # 暗角(聚焦中间,越大四角越暗)
}
def is_enabled() -> bool:
"""北哥调色开关,默认关(倩倩姐2026-06-22拍板)。设 BEIGE_COLOR_GRADE=1 重开。
关闭理由:北哥醒图 warmth=28 是给"手机翻拍真实照"去AI化用的
gpt-image-2 成片本身已渲染好色调,再叠暖色温→脏黄发闷(实测R-B黄偏11→14.2)。
去AI化靠 SynthID 破除+重编码即可调色这层在AI成片上帮倒忙。
本模块代码保留,需要时设 BEIGE_COLOR_GRADE=1 即可重开调参。
"""
return os.environ.get("BEIGE_COLOR_GRADE", "0") == "1"
def _apply_warmth(img: "Image.Image", warmth: float) -> "Image.Image":
"""色温暖棕奶茶调灵魂。R通道提、B通道压强度由 warmth 控制。"""
if abs(warmth) < 1:
return img
k = warmth / 100.0 # 28 → 0.28
r, g, b = img.split()
r = r.point(lambda v: min(255, int(v * (1 + 0.28 * k))))
b = b.point(lambda v: max(0, int(v * (1 - 0.30 * k))))
g = g.point(lambda v: min(255, int(v * (1 + 0.05 * k)))) # 微提绿保肤色不偏品红
return Image.merge("RGB", (r, g, b))
def _apply_high_shadow(img: "Image.Image", highlight: float, shadow: float) -> "Image.Image":
"""高光压暗(压反光) + 阴影提亮(提暗部),按像素亮度分区近似。"""
if abs(highlight) < 1 and abs(shadow) < 1:
return img
hl = highlight / 100.0 # -35 → -0.35
sh = shadow / 100.0 # 22 → 0.22
def _curve(v: int) -> int:
t = v / 255.0
# 高光区(t>0.6)按 hl 压暗;阴影区(t<0.4)按 sh 提亮;中间过渡
if t > 0.6:
v2 = t + hl * (t - 0.6) / 0.4 * t
elif t < 0.4:
v2 = t + sh * (0.4 - t) / 0.4 * (1 - t)
else:
v2 = t
return max(0, min(255, int(v2 * 255)))
lut = [_curve(i) for i in range(256)]
return img.point(lut * 3)
def _apply_vignette(img: "Image.Image", strength: float) -> "Image.Image":
"""暗角四角压暗聚焦中间。strength 越大越暗。"""
if strength < 1:
return img
w, h = img.size
s = strength / 100.0 # 10 → 0.10
mask = Image.new("L", (w, h), 0)
px = mask.load()
cx, cy = w / 2.0, h / 2.0
maxd = (cx ** 2 + cy ** 2) ** 0.5
# 降采样算遮罩省时每4px算一次
step = 4
for y in range(0, h, step):
for x in range(0, w, step):
d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 / maxd
darken = int(255 * s * (d ** 2))
for dy in range(step):
for dx in range(step):
if x + dx < w and y + dy < h:
px[x + dx, y + dy] = darken
black = Image.new("RGB", (w, h), (0, 0, 0))
return Image.composite(black, img, mask)
def grade(image_bytes: bytes, preset: dict | None = None) -> bytes:
"""对单张图施加北哥暖棕调色。开关关或Pillow缺失则原样返回失败降级返原图。"""
if not is_enabled() or not _PILLOW_OK:
return image_bytes
p = {**BEIGE_PRESET, **(preset or {})}
try:
img = Image.open(__import__("io").BytesIO(image_bytes)).convert("RGB")
img = _apply_warmth(img, p["warmth"])
img = _apply_high_shadow(img, p["highlight"], p["shadow"])
if p["contrast"]:
img = ImageEnhance.Contrast(img).enhance(1 + p["contrast"] / 100.0)
if p["saturation"]:
img = ImageEnhance.Color(img).enhance(1 + p["saturation"] / 100.0)
img = _apply_vignette(img, p["vignette"])
buf = __import__("io").BytesIO()
img.save(buf, format="JPEG", quality=100, subsampling=0, optimize=True)
return buf.getvalue()
except Exception as exc:
logger.warning("北哥调色失败,降级返回原图: %s", exc)
return image_bytes

View File

@@ -28,16 +28,17 @@ SCORE_WEIGHTS = {
"keyword": 20, "keyword": 20,
"compliance": 5, "compliance": 5,
} }
# ── AI 评委 7 维满分分布倩倩姐2026-06-15拍板·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)── # ── AI 评委 7 维满分分布倩倩姐2026-06-22拍板补标签精准·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
# 6维AI读分(痛点18+情绪18+买点18+钩子15+标题13+真实感13=95) + 合规5 = 100 # 7维AI读分(痛点16+情绪16+买点16+钩子13+标题13+真实感11+标签精准10=95) + 合规5 = 100
# "真实感"=富贵"很少提产品/前70%干货后30%植入"原则,替换旧机械维度"产品聚焦一件事(16)" # 标签精准度=北哥要害⑤出单命门(精准购买意向埋词)分从原6维各匀出总分仍95合格线80不动非降门槛
AI_DIM_WEIGHTS = { AI_DIM_WEIGHTS = {
"痛点人群精准": 18, "痛点人群精准": 16,
"情绪张力": 18, "情绪张力": 16,
"买点转化": 18, "买点转化": 16,
"开头钩子": 15, "开头钩子": 13,
"标题点击力": 13, "标题点击力": 13,
"真实感": 13, "真实感": 11,
"标签精准度": 10,
"compliance": 5, # 机械硬拦,不进 AI 评委 "compliance": 5, # 机械硬拦,不进 AI 评委
} }
# 过线分。倩倩姐2026-06-15拍板80是临时观察值(AI评委给分克制84文案实为合格)。 # 过线分。倩倩姐2026-06-15拍板80是临时观察值(AI评委给分克制84文案实为合格)。
@@ -54,13 +55,13 @@ DEDUP_TITLE_CONTENT_BODY = 0.72 # 标题+正文联合判重时的正文阈值
MAX_OPTIMIZE_ROUNDS = 2 # 最多重生成轮次 MAX_OPTIMIZE_ROUNDS = 2 # 最多重生成轮次
# ── storyboard 分镜角色(枚举不写死数量)──────────────── # ── storyboard 分镜角色(枚举不写死数量)────────────────
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单 # Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤效果证明(按品类) ⑥促单
PAGE_ROLES = [ PAGE_ROLES = [
{"role": "hook", "name": "封面痛点大字", "focus": "负责点击:强情绪大字标题压痛点,产品露出,真实生活场景,像用户主动分享,不像广告海报"}, {"role": "hook", "name": "封面痛点大字", "focus": "负责点击:强情绪大字标题压痛点,产品露出,真实生活场景,像用户主动分享,不像广告海报"},
{"role": "product_closeup", "name": "单品特写", "focus": "负责种草锚点:单品高清特写+品牌词自然植入第2/6张都带品牌词强化记忆"}, {"role": "product_closeup", "name": "单品特写", "focus": "负责种草锚点:单品高清特写+品牌词自然植入第2/6张都带品牌词强化记忆"},
{"role": "ingredient", "name": "成分拆解", "focus": "负责信任:核心成分信息、作用说明,避免医疗化和绝对化表达,信息清晰可信"}, {"role": "ingredient", "name": "成分拆解", "focus": "负责信任:核心成分信息、作用说明,避免医疗化和绝对化表达,信息清晰可信"},
{"role": "texture", "name": "质地展示", "focus": "负责种草:质地近景、涂抹过程、肤感说明,真实手部/桌面/日常光线"}, {"role": "texture", "name": "质地展示", "focus": "负责种草:质地近景、涂抹过程、肤感说明,真实手部/桌面/日常光线"},
{"role": "applied_proof", "name": "上脸对比", "focus": "负责证明:可感知上脸效果,展示涂抹前后质地变化(不做肤色变白/瑕疵消失等违规暗示第5张"}, {"role": "applied_proof", "name": "效果证明", "focus": "负责证明:可感知的真实使用效果,按品类展示使用过程/状态变化(护肤=上脸肤感/食品=入口口感/家居数码=使用顺手等,不做违规功效暗示第5张"},
{"role": "closer", "name": "促单收尾", "focus": "负责转化:转化句+品牌词引导搜索品牌词成交软性收尾不硬广第6张再带一次品牌词"}, {"role": "closer", "name": "促单收尾", "focus": "负责转化:转化句+品牌词引导搜索品牌词成交软性收尾不硬广第6张再带一次品牌词"},
# 扩展角色8张链路用 # 扩展角色8张链路用
{"role": "pain_scene", "name": "痛点共鸣", "focus": "负责共鸣:展示目标人群的真实困扰和使用前情境,但不做功效前后对比"}, {"role": "pain_scene", "name": "痛点共鸣", "focus": "负责共鸣:展示目标人群的真实困扰和使用前情境,但不做功效前后对比"},
@@ -79,7 +80,7 @@ ROLE_SCENE_PREFERENCE = {
"product_closeup": ["primary"], # 单品特写:白底主图 "product_closeup": ["primary"], # 单品特写:白底主图
"ingredient": ["ingredient", "primary"], # 成分拆解:成分/包装细节 "ingredient": ["ingredient", "primary"], # 成分拆解:成分/包装细节
"texture": ["texture", "primary"], # 质地展示:质地特写 "texture": ["texture", "primary"], # 质地展示:质地特写
"applied_proof": ["model", "scene", "primary"], # 上脸:上脸图/场景 "applied_proof": ["model", "scene", "primary"], # 效果证明:使用场景/上身/上脸图
"social_proof": ["scene", "primary"], # 社交背书:场景 "social_proof": ["scene", "primary"], # 社交背书:场景
"closer": ["primary"], # 促单收尾:主图 "closer": ["primary"], # 促单收尾:主图
"scenario": ["scene", "primary"], "scenario": ["scene", "primary"],
@@ -91,8 +92,8 @@ ROLE_SCENE_PREFERENCE = {
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行 # 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
STYLE_PROMPTS = { STYLE_PROMPTS = {
"xiaohongshu_cover": "小红书种草风独立3:4图文海报/素材图1024×1536构图明亮干净真实实拍质感醒目中文短标题文字在安全区内", "xiaohongshu_cover": "小红书种草风独立3:4图文海报/素材图1024×1536构图明亮干净真实实拍质感醒目中文短标题文字在安全区内",
"comparison": "小红书说明对比风独立3:4图文海报/素材图1024×1536构图质地/场景/肤感左右或上下对比,信息层级清晰", "comparison": "小红书说明对比风独立3:4图文海报/素材图1024×1536构图质地/场景/使用状态左右或上下对比,信息层级清晰",
"ingredient": "小红书成分科普风独立3:4图文海报/素材图1024×1536构图成分卡片布局浅色商务美妆风,避免医疗化表达", "ingredient": "小红书信息科普风独立3:4图文海报/素材图1024×1536构图成分/参数卡片布局,浅色干净商务风,避免医疗化表达",
} }
STYLE_DEFAULT = "xiaohongshu_cover" STYLE_DEFAULT = "xiaohongshu_cover"
@@ -100,8 +101,8 @@ STYLE_DEFAULT = "xiaohongshu_cover"
# 按图数告诉模型整组图的种草节奏,让每张各司其职不雷同 # 按图数告诉模型整组图的种草节奏,让每张各司其职不雷同
NARRATIVE_BY_COUNT = { NARRATIVE_BY_COUNT = {
3: "3张极速链路第1张负责点击第2张是按品类变化的核心证明页第3张负责软性转化。", 3: "3张极速链路第1张负责点击第2张是按品类变化的核心证明页第3张负责软性转化。",
6: "6张标准种草链路封面点击、单品特写带品牌词、成分信任、质地种草、上脸证明、促单转化,每张画面和文字各司其职不重复。", 6: "6张标准种草链路封面点击、单品特写带品牌词、成分/参数信任、质地/细节种草、按品类的真实效果证明、促单转化,每张画面和文字各司其职不重复。",
8: "8张沉浸测评链路点击、痛点共鸣、单品特写、成分、质地、上脸证明、背书、软性转化。", 8: "8张沉浸测评链路点击、痛点共鸣、单品特写、成分/参数、质地/细节、按品类的真实效果证明、背书、软性转化。",
} }
# ── 3套正交叙事策略倩倩姐2026-06-15起草北哥过目版────────────── # ── 3套正交叙事策略倩倩姐2026-06-15起草北哥过目版──────────────
@@ -109,19 +110,19 @@ NARRATIVE_BY_COUNT = {
# 每套叙事链路注入 base_prompt 叙事链路段,替换 NARRATIVE_BY_COUNT 默认值 # 每套叙事链路注入 base_prompt 叙事链路段,替换 NARRATIVE_BY_COUNT 默认值
NARRATIVE_BY_STRATEGY = { NARRATIVE_BY_STRATEGY = {
"A": ( "A": (
'【套A·痛点先行】整组基调紧迫感、强对比、情绪共鸣文字短促带感叹号直戳"脸黄显疲惫""素颜不敢出门"' '【套A·痛点先行】整组基调紧迫感、强对比、情绪共鸣文字短促带感叹号直戳产品要解决的核心痛点(痛点取自上文产品卖点/人群,不要套用别的品类)'
'6张链路①痛点暴击封面强情绪大字直击暗黄/素颜焦虑)→ ②暗黄脸实拍对比(感叹号+对比词制造紧迫感)' '6张链路①痛点暴击封面强情绪大字直击该产品核心痛点)→ ②痛点实拍对比(感叹号+对比词制造紧迫感)'
'→ ③单品特写+品牌词 → ④成分为什么能救暗黄(成分拆解+信任) → ⑤上脸提亮实证 → ⑥"别再顶着黄脸早八"软性促单。' '→ ③单品特写+品牌词 → ④核心成分为什么能解决该痛点(成分拆解+信任) → ⑤使用效果实证 → ⑥用痛点收束的软性促单。'
), ),
"B": ( "B": (
'【套B·场景先行】整组基调轻松、生活化、代入感突出"快/省时/伪素颜自由",点到性价比不堆砌。' '【套B·场景先行】整组基调轻松、生活化、代入感突出该产品的真实使用场景与"快/省时/省心"卖点,点到性价比不堆砌。'
'6张链路"早八来不及"场景封面(生活场景钩子) → ②手忙脚乱通勤场景代入早八焦虑' '6张链路真实使用场景封面(取该产品最高频的生活场景钩子) → ②场景代入(还原用户在该场景里的困扰'
'→ ③一抹搞定单品特写+品牌词 → ④养肤成分让你敢素颜 → ⑤30秒上脸效果 → ⑥"伪素颜自由+平价"软性促单。' '→ ③单品特写+品牌词 → ④核心成分支撑该场景卖点 → ⑤使用效果展示 → ⑥用场景化卖点收束的软性促单。'
), ),
"C": ( "C": (
'【套C·成分背书先行】整组基调专业、可信、真实测评感强调成分逻辑+前后对比+像有用户实证背书。' '【套C·成分背书先行】整组基调专业、可信、真实测评感强调成分逻辑+前后对比+像有用户实证背书。'
'6张链路①成分权威封面核心成分信息锚定信任 → ②核心成分图解(作用说明+清晰可信)' '6张链路①成分权威封面该产品核心成分信息锚定信任) → ②核心成分图解(作用说明+清晰可信)'
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实上脸细节 → ⑥"成分党闭眼入"软性促单。' '→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实使用细节 → ⑥用成分可信度收束的软性促单。'
), ),
} }
@@ -130,23 +131,37 @@ NARRATIVE_BY_STRATEGY = {
# 与 NARRATIVE_BY_STRATEGY图片侧同根套A文案痛点先行↔套A图也痛点先行同套内文图一致 # 与 NARRATIVE_BY_STRATEGY图片侧同根套A文案痛点先行↔套A图也痛点先行同套内文图一致
TEXT_NARRATIVE_BY_STRATEGY = { TEXT_NARRATIVE_BY_STRATEGY = {
"A": ( "A": (
"【本条叙事主线·痛点先行】开篇直戳用户困扰(脸黄显疲惫/素颜不敢出门/早八顶着黄脸)," "【本条叙事主线·痛点先行】开篇第一句必须直接说出'使用前的困扰/焦虑情绪'本身"
"情绪强、句子短促带感叹,痛点贯穿全文到种草,结尾用'别再顶着黄脸早八'这类痛点收束促单。" "(取自本产品要解决的那个具体痛点,以及试过很多没用的挫败感),先有情绪再有场景,"
"基调:紧迫感、强对比、情绪共鸣" "痛点取自上文产品卖点/目标人群、不要套用别品类;情绪强、句子短促带感叹"
"⚠️开篇禁止从'时间/场景'切入如一天里的某个时刻、赶时间、通勤那是套B的开法必须先抛情绪/痛点。"
"▶中段背书方式=自己反复挣扎后的转折(试错→死心→偶然回购/被一个生活细节打动),"
"靠自己使用前后的真实变化来自证,不要用'闺蜜/同事/家人种草'来背书。"
"▶卖点要带着'这个困扰被解决了'的情绪逐个讲emoji用情绪向😭🥹💛"
"▶收尾用该痛点的释然收束('现在不慌了'类),禁止用'被身边人夸/被问起'桥段那是套B的尾"
), ),
"B": ( "B": (
"【本条叙事主线·场景先行】用真实生活场景开篇(早八来不及/通勤手忙脚乱/赶时间出门)," "【本条叙事主线·场景先行】开篇用一个具体的生活使用场景白描切入"
"轻松代入感,突出'快/省时/伪素颜自由',点到平价性价比但不堆砌。" "(最真实高频的那个使用时刻,画面感优先),痛点藏在场景里自然带出、不喊口号,"
"基调:轻松、生活化、像朋友随手分享" "突出'快/省时/省心'类卖点,点到平价性价比但不堆砌"
"▶中段背书方式=身边人(同事/家人)在场景里的一句随口反馈,轻松不刻意。"
"▶成分融在使用场景里一两句带过即可不要单独列大段成分清单emoji用场景向☕🚇✨"
"▶收尾用场景延续('下次再有人问…我知道怎么答了'类)。"
), ),
"C": ( "C": (
"【本条叙事主线·成分背书先行】用成分原理或测评视角开篇(核心成分为什么有用/亲测对比)," "【本条叙事主线·专业背书先行】开篇必须从'成分/参数/原理/测评对比'等专业依据切入"
"专业可信,带使用前后时间线对比,像真实用户实证背书,结尾用'成分党闭眼入'收束。" "(按本产品品类对应:护肤食品讲成分配方、数码家居讲参数原理、服饰讲材质工艺,"
"基调:专业、可信、真实测评感" "如翻配料/参数表、对比实验、为什么这个设计/成分有用),先讲可信度再讲使用"
"⚠️开篇禁止从'时间/场景'切入如赶时间、通勤、被人问起那是套A/B的开法必须从专业依据入手。"
"▶中段背书方式=理性自证(参数/成分对比/和踩过的雷横向比/前后时间线实测/参考测评博主的拆解分析),"
"靠数据和亲测说话,不要用'闺蜜/同事/家人种草'来背书。"
"▶专业依据要讲得比A/B更深为什么这项关键/感知差异/和别家差在哪),是本套的差异优势,"
"emoji用理性向🔍💧🧪收尾用专业可信度收束。"
), ),
} }
IMAGE_RETRY_ATTEMPTS = 3 # 生图重试总次数(含首次)
IMAGE_RETRY_BACKOFF_BASE = 2.0 # 指数退避底数(秒) IMAGE_RETRY_BACKOFF_BASE = 2.0 # 指数退避底数(秒)
IMAGE_SIZE_DEFAULT = "1024x1536" IMAGE_SIZE_DEFAULT = "1024x1536"
@@ -185,6 +200,7 @@ FLYWHEEL_WEIGHTS = {
"text_select": 3, "text_select": 3,
"image_select": 3, "image_select": 3,
"text_edit": 5, # 改稿=用户真动手改字=最强真实意图,与approve同级(倩倩姐2026-06-16拍板) "text_edit": 5, # 改稿=用户真动手改字=最强真实意图,与approve同级(倩倩姐2026-06-16拍板)
"text_import": 8, # 导入=客户实跑验证过的范本,权重最重(倩倩姐2026-06-26拍板),与enums.SIGNAL_WEIGHTS对齐
"approve": 5, "approve": 5,
"reject_with_reason": -3, "reject_with_reason": -3,
"regenerate": -1, "regenerate": -1,

View File

@@ -137,7 +137,13 @@ def build_fission_prompt(
source_note: dict, product: dict, reference_level: str, source_note: dict, product: dict, reference_level: str,
note_count: int, image_count: int, dimensions: list[str] | None = None, note_count: int, image_count: int, dimensions: list[str] | None = None,
) -> str: ) -> str:
"""组装裂变 user prompt对齐上线版 handleContentSplit 的 prompt 变量拼装)。""" """组装裂变 user prompt对齐上线版 handleContentSplit 的 prompt 变量拼装)。
D3修复给每套分配正交叙事主线复用 TEXT_NARRATIVE_BY_STRATEGY A/B/C
套数超过3时按顺序循环套4=A/套5=B让各套开篇/背书/收尾正交拉开。
"""
from app.services.ai_engine.constants import TEXT_NARRATIVE_BY_STRATEGY
src = source_note or {} src = source_note or {}
prod = product or {} prod = product or {}
dims = dimensions or DEFAULT_DIMENSIONS dims = dimensions or DEFAULT_DIMENSIONS
@@ -149,9 +155,24 @@ def build_fission_prompt(
points = prod.get("selling_points", []) or [] points = prod.get("selling_points", []) or []
audience = prod.get("target_audience", "") or "未提供" audience = prod.get("target_audience", "") or "未提供"
keywords = prod.get("keywords", []) or [] keywords = prod.get("keywords", []) or []
kw_line = "".join(keywords) if keywords else "".join( # D2修复去掉"真实测评/好物分享"泛词兜底评分prompt明确泛词占≥50%最高6分自打自
[x for x in [name, audience, "真实测评", "好物分享"] if x and x != "未提供"] # 改用产品真实信息提炼精准词实在无keywords也不塞流量泛词
) if keywords:
kw_line = "".join(keywords)
else:
# 从产品卖点/人群/名称里取实质性词,不拼平台泛词
sp_words = [str(s).strip() for s in (points[:2] if points else []) if str(s).strip()]
kw_parts = [x for x in ([name] + sp_words + ([audience] if audience != "未提供" else [])) if x and x != "未提供"]
kw_line = "".join(kw_parts) if kw_parts else ""
# D3修复按套序循环分配 A/B/C 叙事主线
narrative_keys = list(TEXT_NARRATIVE_BY_STRATEGY.keys()) # ["A","B","C"]
narrative_lines = []
for i in range(note_count):
key = narrative_keys[i % len(narrative_keys)]
narrative_lines.append(f"{i+1}套叙事主线:{TEXT_NARRATIVE_BY_STRATEGY[key]}")
narrative_block = "\n".join(narrative_lines)
return f"""爆款参考: return f"""爆款参考:
标题:{title} 标题:{title}
正文:{content} 正文:{content}
@@ -166,7 +187,11 @@ def build_fission_prompt(
生成数量:{note_count} 生成数量:{note_count}
每套图片数量:{image_count} 每套图片数量:{image_count}
【各套正交叙事主线——必须严格执行,这是本次裂变不同质的关键】
{narrative_block}
请生成{note_count}套完整小红书图文笔记包。每套都必须含标题、正文、标签、点击钩子标题、{image_count}张图的imagePlan。 请生成{note_count}套完整小红书图文笔记包。每套都必须含标题、正文、标签、点击钩子标题、{image_count}张图的imagePlan。
每套必须严格按其指定叙事主线展开开篇/背书/收尾,不同套之间叙事结构必须正交、不重复。
imagePlan必须按叙事链路逐张递进。3张版第2张必须是按当前品类变化的核心证明页不能和第1张重复构图不得让第2张重复第1张标题。 imagePlan必须按叙事链路逐张递进。3张版第2张必须是按当前品类变化的核心证明页不能和第1张重复构图不得让第2张重复第1张标题。
正文必须像真实小红书种草笔记一样自然带2-5个emoji不要把图片规划/配图建议/内部审核建议写进正文。""" 正文必须像真实小红书种草笔记一样自然带2-5个emoji不要把图片规划/配图建议/内部审核建议写进正文。"""

View File

@@ -43,6 +43,15 @@ class AIClients:
_model_image: str = "gpt-image-2" _model_image: str = "gpt-image-2"
_model_text: str = "claude-opus-4-8" # 最强档(倩倩姐红线):Claude系一律4.8,绝不降级 _model_text: str = "claude-opus-4-8" # 最强档(倩倩姐红线):Claude系一律4.8,绝不降级
def has_alt_channel(self) -> bool:
"""是否真正配置了 codeproxy 备用通道(有独立 key 才算)。
🔴 没配 codeproxy key 时(用户未录 + env 未设),编排层据此跳过 codeproxy
避免每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次。
录了 key 就自动启用真双通道互备,逻辑不变。
"""
return bool(self._alt_token)
def _client(self) -> httpx.AsyncClient: def _client(self) -> httpx.AsyncClient:
"""主通道(apiports) client按当前事件循环缓存""" """主通道(apiports) client按当前事件循环缓存"""
return self._client_for(self._gpt_base, self._gpt_token) return self._client_for(self._gpt_base, self._gpt_token)
@@ -67,15 +76,33 @@ class AIClients:
# ── ImageClient 协议实现(供 image_gen.py 使用)──────── # ── ImageClient 协议实现(供 image_gen.py 使用)────────
def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]: def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]:
"""按 provider 选 (base, token, client)codeproxy 走备用站独立 base+key""" """按 provider 选 (base, token, client)codeproxy 走备用站独立 base+key
if provider == "codeproxy" and self._alt_token:
🔴 绝不静默降级(红线):显式点名 codeproxy 却没有 codeproxy 独立 key 时,
必须抛错让该通道明确失败(由上层 generate_one_image 记录并切下一通道),
绝不静默用 apiports 主 key 冒充 codeproxy——那等于"假双通道"且无声退化,
既骗了主备设计也违反红线。用户要真备份就得录入第二把 key。
"""
if provider == "codeproxy":
if not self._alt_token:
raise RuntimeError(
"codeproxy 通道未配置独立 key,拒绝用 apiports 主 key 冒充"
"(红线:绝不静默降级)。请为该用户录入 codeproxy key 以启用真双通道互备。"
)
base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/") 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) 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("/") 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) 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: async def gpt_edits(self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None) -> bytes:
"""GPT edits endpoint带产品参考图禁纯文生图""" """GPT edits endpoint带产品参考图禁纯文生图
codeproxy 走 OpenAI Images `/images/edits`apiports 实测图片编辑注册在
`/chat/completions` 多模态通道,不能复用 `/images/edits`。
"""
if provider == "apiports":
return await self._apiports_chat_image_edit(prompt, reference_images)
import io import io
files: list[tuple] = [("prompt", (None, prompt))] files: list[tuple] = [("prompt", (None, prompt))]
for i, img in enumerate(reference_images): for i, img in enumerate(reference_images):
@@ -87,6 +114,29 @@ class AIClients:
resp.raise_for_status() resp.raise_for_status()
return _extract_image_bytes(resp.json()) return _extract_image_bytes(resp.json())
async def _apiports_chat_image_edit(self, prompt: str, reference_images: list[bytes]) -> bytes:
"""apiports 图片编辑:走 /chat/completions 多模态,参考图用 data URL。"""
if not reference_images:
raise ValueError("apiports 图生图缺少参考图,拒绝纯文生图")
import base64
base, _, client = self._gpt_target("apiports")
content: list[dict] = [{"type": "text", "text": prompt}]
for img in reference_images:
b64 = base64.b64encode(img).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
})
payload = {
"model": self._model_image,
"messages": [{"role": "user", "content": content}],
}
resp = await client.post(f"{base}/chat/completions", json=payload, timeout=300.0)
resp.raise_for_status()
return _extract_chat_image_bytes(resp.json())
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes: async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes:
"""GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)""" """GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)"""
base, _, client = self._gpt_target(provider) base, _, client = self._gpt_target(provider)
@@ -100,14 +150,17 @@ class AIClients:
if not self._gemini_key: if not self._gemini_key:
raise RuntimeError("Gemini key 未初始化") raise RuntimeError("Gemini key 未初始化")
gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta") gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta")
url = f"{gemini_base}/models/{model}:generateContent?key={self._gemini_key}" # 安全红线key 走 header(x-goog-api-key)不进 URL query
# 否则 httpx 异常的 __str__ 含完整 URL会把明文 key 带进 WARNING 日志。
url = f"{gemini_base}/models/{model}:generateContent"
parts: list[dict] = [{"text": prompt}] parts: list[dict] = [{"text": prompt}]
for img in reference_images: for img in reference_images:
import base64 import base64
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}}) parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}})
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}} payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}}
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post(url, json=payload, timeout=120.0) resp = await client.post(url, json=payload,
headers={"x-goog-api-key": self._gemini_key}, timeout=120.0)
resp.raise_for_status() resp.raise_for_status()
return _extract_gemini_image(resp.json()) return _extract_gemini_image(resp.json())
@@ -126,9 +179,10 @@ class AIClients:
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
except (httpx.HTTPStatusError, httpx.TransportError, httpx.TimeoutException) as exc: except (httpx.HTTPStatusError, httpx.TransportError, httpx.TimeoutException) as exc:
# 仅 5xx(服务端过载)或网络层错误才回落4xx(参数/鉴权)回落也没用,直接抛。 # 回落判定网络层错误、5xx(过载)、以及 402(欠费)/429(限流) 都该切通道——
# 这几种都是"本通道暂时不行、换通道有救"。401/403(鉴权)/400(参数) 回落也没用,直接抛。
status = getattr(getattr(exc, "response", None), "status_code", None) status = getattr(getattr(exc, "response", None), "status_code", None)
retryable = status is None or status >= 500 retryable = status is None or status >= 500 or status in (402, 429)
if not (retryable and self._alt_token): if not (retryable and self._alt_token):
raise raise
alt_base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/") alt_base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
@@ -196,21 +250,26 @@ class AIClients:
self._gpt_client_loop_id = None self._gpt_client_loop_id = None
def build_ai_clients(plain_key: str, gemini_key: str | None = None) -> AIClients: def build_ai_clients(plain_key: str, gemini_key: str | None = None, alt_key: str | None = None) -> AIClients:
""" """
用解密后的明文 key 构建 AIClients。 用解密后的明文 key 构建 AIClients。
只在 Celery worker 函数体内调用plain_key 是局部变量。 只在 Celery worker 函数体内调用plain_key 是局部变量。
httpx client 不在此预创建(避免绑死到调用方 loop首次 await 时按 loop 懒建。 httpx client 不在此预创建(避免绑死到调用方 loop首次 await 时按 loop 懒建。
调用完成后 caller 负责 await clients.aclose()。 调用完成后 caller 负责 await clients.aclose()。
alt_key用户录入的 codeproxy 备用站 key基石Bworker 内查库解密后传入)。
倩倩姐红线「全做成自己的不要埋进系统」——codeproxy key 优先用用户录入的,
未录入才回落 env CODEPROXY_KEY向后兼容避免没录时备用通道直接失效
base 地址(中转站 URL非秘密仍走 env 配置。
""" """
gpt_base = ( gpt_base = (
os.environ.get("IMAGE_API_BASE") # 旧变量名 os.environ.get("IMAGE_API_BASE") # 旧变量名
or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名 or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名
or "" or ""
).rstrip("/") ).rstrip("/")
# 备用站 codeproxy系统级 key非用户录入apiports 503 时切过去保生图成功 # 备用站 codeproxy优先用户录入 key回落 envapiports 503 时切过去保生图成功
alt_base = (os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/") alt_base = (os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
alt_token = os.environ.get("CODEPROXY_KEY") or None alt_token = alt_key or os.environ.get("CODEPROXY_KEY") or None
return AIClients( return AIClients(
_gpt_token=plain_key, _gpt_token=plain_key,
_gpt_base=gpt_base or None, _gpt_base=gpt_base or None,
@@ -240,6 +299,95 @@ def _extract_image_bytes(resp_json: dict) -> bytes:
raise ValueError(f"无法解析图片响应:{list(item.keys())}") raise ValueError(f"无法解析图片响应:{list(item.keys())}")
def _decode_b64_image(value: str) -> bytes:
"""解 data URL 或裸 base64 图片。"""
import base64
if value.startswith("data:"):
value = value.split(",", 1)[1]
return base64.b64decode(value)
def _bytes_from_image_item(item: dict) -> bytes | None:
"""兼容 chat 图片 item 里的常见 url/base64 字段。"""
if not isinstance(item, dict):
return None
for key in ("b64_json", "base64", "data"):
val = item.get(key)
if isinstance(val, str) and val:
return _decode_b64_image(val)
url_val = None
if isinstance(item.get("image_url"), dict):
url_val = item["image_url"].get("url")
elif isinstance(item.get("image_url"), str):
url_val = item.get("image_url")
elif isinstance(item.get("url"), str):
url_val = item.get("url")
if isinstance(url_val, str) and url_val:
if url_val.startswith("data:"):
return _decode_b64_image(url_val)
resp = httpx.get(url_val, timeout=30.0)
resp.raise_for_status()
return resp.content
return None
def _bytes_from_content_string(text: str) -> bytes | None:
"""从 chat 返回的正文字符串里抠图data URL / markdown 图链 / 裸图片URL。
很多中转站把 gpt-image 结果以 ![](url) 或 data URL 嵌在 content 文本里,
不走 images[]/parts 结构化字段,这里兜住这几种常见形态(前面带说明文字也能抠出)。
"""
import re
m = re.search(r"data:image/[\w.+-]+;base64,[A-Za-z0-9+/=]+", text)
if m:
return _decode_b64_image(m.group(0))
m = re.search(r"!\[[^\]]*\]\((https?://[^\s)]+)\)", text) # markdown 图链
if m:
resp = httpx.get(m.group(1), timeout=30.0)
resp.raise_for_status()
return resp.content
m = re.search( # 裸图片URL限图片后缀避免误抓非图链接
r"https?://[^\s)\]\"']+\.(?:png|jpe?g|webp|gif)(?:\?[^\s)\]\"']*)?",
text, re.IGNORECASE,
)
if m:
resp = httpx.get(m.group(0), timeout=30.0)
resp.raise_for_status()
return resp.content
return None
def _extract_chat_image_bytes(resp_json: dict) -> bytes:
"""从 /chat/completions 图片响应中提取图片 bytes。"""
choices = resp_json.get("choices") or []
if not choices:
raise ValueError("chat 图片响应缺少 choices")
message = choices[0].get("message") or {}
images = message.get("images")
if isinstance(images, list):
for item in images:
found = _bytes_from_image_item(item)
if found:
return found
content = message.get("content")
if isinstance(content, list):
for part in content:
found = _bytes_from_image_item(part)
if found:
return found
elif isinstance(content, str) and content:
found = _bytes_from_content_string(content)
if found:
return found
raise ValueError(
"无法解析 chat 图片响应:"
f"top={list(resp_json.keys())}, message={list(message.keys())}"
)
def _extract_gemini_image(resp_json: dict) -> bytes: def _extract_gemini_image(resp_json: dict) -> bytes:
"""从 Gemini generateContent 响应提取图片 bytes""" """从 Gemini generateContent 响应提取图片 bytes"""
import base64 import base64

View File

@@ -26,21 +26,37 @@ def _pick_reference_for_role(
images_by_scene: dict[str, list[bytes]] | None, images_by_scene: dict[str, list[bytes]] | None,
fallback: list[bytes] | None, fallback: list[bytes] | None,
) -> tuple[list[bytes] | None, str]: ) -> tuple[list[bytes] | None, str]:
"""R5多图按分镜 role 选该场景的产品图。取不到回落主图 """R5多图把【全部】产品参考图都传给每张分镜,按 role 偏好排序
返回 (参考图bytes列表, 命中scene标签用于日志)。 🔴 产品内外一体,绝不按 role 只喂单张倩倩姐2026-06-26拍板纠正
旧逻辑命中偏好场景就 return 单张 → 质地特写分镜只拿到膏体微距、没有主图,
模型缺完整瓶身锚点 → 脑补瓶身走样+乱印字("3张好/4张每套坏一张"的根因)。
现改为每张分镜都把所有场景图一起传,只把本 role 最相关的场景排最前
(多模态对靠前的图权重更高),且主图(primary)始终在列表内做瓶身锚点。
返回 (排序后的全部参考图bytes列表, 排序说明用于日志)。
""" """
if images_by_scene: if images_by_scene:
for scene in ROLE_SCENE_PREFERENCE.get(role, ["primary"]): prefs = ROLE_SCENE_PREFERENCE.get(role, ["primary"])
ordered: list[bytes] = []
used_scenes: list[str] = []
# 1) 先按 role 偏好顺序排(本 role 最相关的视角靠前)
for scene in prefs:
imgs = images_by_scene.get(scene) imgs = images_by_scene.get(scene)
if imgs: if imgs:
return imgs, scene ordered.extend(imgs)
# 偏好全落空:用任意可用图兜底(仍优先 primary used_scenes.append(scene)
if images_by_scene.get("primary"): # 2) 主图始终保底进列表(即使不在偏好里,也要补做瓶身锚点)
return images_by_scene["primary"], "primary" if "primary" not in used_scenes and images_by_scene.get("primary"):
ordered.extend(images_by_scene["primary"])
used_scenes.append("primary")
# 3) 其余场景图也补进来:同一产品的所有视角都给模型看,防瓶身走样
for scene, imgs in images_by_scene.items(): for scene, imgs in images_by_scene.items():
if imgs: if scene not in used_scenes and imgs:
return imgs, f"{scene}(兜底)" ordered.extend(imgs)
used_scenes.append(scene)
if ordered:
return ordered, "+".join(used_scenes)
return fallback, "fallback" return fallback, "fallback"
@@ -53,17 +69,42 @@ class ImageClient(Protocol):
async def gemini_generate( async def gemini_generate(
self, prompt: str, reference_images: list[bytes], model: str self, prompt: str, reference_images: list[bytes], model: str
) -> bytes: ... ) -> bytes: ...
def has_alt_channel(self) -> bool: ...
def _image_provider_order() -> list[str]: def _image_provider_order(has_alt: bool = True) -> list[str]:
"""从环境变量读主备顺序(扒 imageProviderOrder""" """从环境变量读主备顺序(扒 imageProviderOrder
🔴 真双通道互备倩倩姐2026-06拍板codeproxy / apiports 是两个独立中转站,
各用自己的 base+key, 任一站上游挂(502/503)由另一站顶上 —— 这才是真备份。
若 .env 把主备都设成同一个站(如都 codeproxy), 去重后只剩一个 → 退化成"假备份"
(一站挂全挂)。故只要序列用到 GPT 中转站家族, 就自动补齐缺失的那个,
保证 codeproxy+apiports 双通道都在。"gpt" 是 apiports 的等价别名(同 base+主key),
归一化掉避免重复尝试同一站。codeproxy 走 /images/editsapiports 走
/chat/completions 多模态编辑;两者都必须带产品参考图,不碰纯文生图。
🔴 has_alt=False用户/env 都没配 codeproxy key绝不把 codeproxy 补进序列——
否则每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次拖慢整体
倩倩姐2026-06-30方案A。录了 keyhas_alt=True才补齐真双通道逻辑不变。
"""
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower() primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower()
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower() fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower()
seen: list[str] = [] order: list[str] = []
for p in [primary, fallback]: for p in [primary, fallback]:
if p and p not in seen: p = "apiports" if p == "gpt" else p
seen.append(p) # 没配 codeproxy key 时,显式点名 codeproxy 也跳过(避免空转重试)
return seen if p == "codeproxy" and not has_alt:
continue
if p and p not in order:
order.append(p)
if any(p in ("codeproxy", "apiports") for p in order):
for ch in ("codeproxy", "apiports"):
# codeproxy 仅在真有备用 key 时补齐
if ch == "codeproxy" and not has_alt:
continue
if ch not in order:
order.append(ch)
return order
def _gemini_models() -> list[str]: def _gemini_models() -> list[str]:
@@ -90,12 +131,20 @@ async def _retry(coro_fn, attempts: int = IMAGE_RETRY_ATTEMPTS, backoff: float =
async def _request_gpt(client: ImageClient, prompt: str, reference_images: list[bytes], provider: str | None = None) -> bytes: async def _request_gpt(client: ImageClient, prompt: str, reference_images: list[bytes], provider: str | None = None) -> bytes:
if reference_images: if reference_images:
return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider) return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider)
# 无产品参考图时降级为纯文生图(需 ALLOW_TEXT_ONLY_IMAGE=true 或 M2阶段 # 🔴 红线瓶身忠于参考图原样禁止纯文生图脑补产品倩倩姐2026-06-15拍板
allow_text_only = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "true").lower() == "true" # ALLOW_TEXT_ONLY_IMAGE 默认 false即使显式设为 true 也拒绝——纯文生图会让产品包装
if allow_text_only: # 完全由模型自由发挥,严重走样,不符合合规与真实双重要求。
logger.warning("无产品参考图,降级为纯文生图(可能产品跑偏,建议前端上传参考图)") # 若要解锁请联系产品负责人,不在代码层开放。
return await client.gpt_generate(prompt, IMAGE_SIZE_DEFAULT, provider) _env_val = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "false").lower()
raise ValueError("GPT 主通道缺产品图:禁止纯文生图以免产品跑偏(设 ALLOW_TEXT_ONLY_IMAGE=true 可解锁)") if _env_val == "true":
logger.warning(
"检测到 ALLOW_TEXT_ONLY_IMAGE=true但该选项已被锁定红线禁纯文生图"
"将强制抛出缺图异常,请为产品上传参考图。"
)
raise ValueError(
"缺少产品参考图,无法生成忠于实物的产品图,请先上传该产品的主图。"
"红线禁止纯文生图脑补产品包装ALLOW_TEXT_ONLY_IMAGE 已锁定为无效)"
)
async def _request_gemini(client: ImageClient, prompt: str, reference_images: list[bytes]) -> bytes: async def _request_gemini(client: ImageClient, prompt: str, reference_images: list[bytes]) -> bytes:
@@ -118,7 +167,9 @@ async def generate_one_image(
返回图片 bytesPNG/JPEG 返回图片 bytesPNG/JPEG
""" """
refs = reference_images or [] refs = reference_images or []
providers = _image_provider_order() # 没配 codeproxy 备用 key 时不把它补进尝试序列避免每张图空转重试方案A
has_alt = bool(getattr(client, "has_alt_channel", lambda: True)())
providers = _image_provider_order(has_alt)
errors: list[str] = [] errors: list[str] = []
for provider in providers: for provider in providers:
@@ -191,6 +242,9 @@ async def generate_storyboard_images(
f"素材使用={item.get('asset_use','')}" f"素材使用={item.get('asset_use','')}"
f"{brand_line}" f"{brand_line}"
f"禁止事项={item.get('forbidden','')}" f"禁止事项={item.get('forbidden','')}"
"参考图说明:随附的多张参考图都是【同一个产品】的不同视角(主图=产品/包装本体,"
"其余=质地/细节/场景特写产品外形、颜色、包装上原有文字必须100%忠于主图原样,"
"禁止重绘产品本体、禁止在包装上增删改任何文字(品牌词只放图上标题/贴纸等排版层,绝不印产品本体)。"
"排版要求独立小红书3:4图文海报画面完整标题只出现一次不与其他页重复" "排版要求独立小红书3:4图文海报画面完整标题只出现一次不与其他页重复"
"中文文字少而清晰,主标题+最多3个短点位可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌" "中文文字少而清晰,主标题+最多3个短点位可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌"
"不要生成App截图或笔记详情页界面。" "不要生成App截图或笔记详情页界面。"

View File

@@ -21,6 +21,9 @@ except ImportError:
_PILLOW_OK = False _PILLOW_OK = False
logger.warning("Pillow 未安装image_postprocessor 不可用") logger.warning("Pillow 未安装image_postprocessor 不可用")
# 北哥暖棕调色层A4自带开关与降级
from .beige_color_grade import grade as _beige_grade, is_enabled as _beige_enabled
# 比例映射表,对齐大卫 RATIO_MAP。key 为字符串如 '3:4' # 比例映射表,对齐大卫 RATIO_MAP。key 为字符串如 '3:4'
RATIO_MAP: dict[str, tuple[int, int]] = { RATIO_MAP: dict[str, tuple[int, int]] = {
"1:1": (1024, 1024), "1:1": (1024, 1024),
@@ -74,12 +77,27 @@ def process_image(
img = ImageOps.fit(img, (tw, th), method=Image.LANCZOS) 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) logger.debug("resize %dx%d%dx%d (ratio=%s)", actual_w, actual_h, tw, th, aspect_ratio)
# --- Step2: resample_strength 削像素水印(可选,默认轻采样--- # --- Step2+3: 去AI化破水印硬路默认开倩倩姐2026-06-26拍板对齐大卫xhs实测版---
# 硬路 = 缩2px裁1px边错位采样 + 亮度/饱和微调,专破 SynthID 像素水印。
# 「像素不能有任何压缩」红线大卫硬路只动边缘2px没有全图来回缩放
# Clover 原 Step2缩98%再放大)是额外的全图 LANCZOS 重采样、会软化全图像素,
# 故硬路开启时直接跳过 Step2只走硬路局部错位——既破水印又不压全图像素。
hard_mode = os.environ.get("SYNTHID_HARD_MODE", "1") != "0"
if hard_mode:
# 硬路只动边缘(缩2px裁1px),用图片当前尺寸即可,不依赖 RATIO_MAP
# 故非标准比例(target=None)也走硬路,绝不降级到全图 LANCZOS 有损重采样。
img = _apply_synthid_break(img)
else:
# 仅硬路显式关闭时,才用全图轻采样兜底破水印
img = _apply_resample(img, resample_strength) img = _apply_resample(img, resample_strength)
# --- Step3: SynthID 破除SYNTHID_HARD_MODE=1 才开,默认关--- # --- Step3.5: 北哥暖棕调色BEIGE_COLOR_GRADE 默认开去AI化核心---
if os.environ.get("SYNTHID_HARD_MODE") == "1" and target: # 在重编码前以 bytes 往返grade 内部自带开关/降级,关则原样透传
img = _apply_synthid_break(img, target) if _beige_enabled():
buf0 = io.BytesIO()
img.save(buf0, format="JPEG", quality=100, subsampling=0)
graded = _beige_grade(buf0.getvalue())
img = Image.open(io.BytesIO(graded)).convert("RGB")
# --- Step4: 高保真 JPEG 重编码,去所有元数据 --- # --- Step4: 高保真 JPEG 重编码,去所有元数据 ---
buf = io.BytesIO() buf = io.BytesIO()
@@ -118,19 +136,20 @@ def _apply_resample(img: "Image.Image", strength: int) -> "Image.Image":
return img return img
def _apply_synthid_break(img: "Image.Image", target: tuple[int, int]) -> "Image.Image": def _apply_synthid_break(img: "Image.Image") -> "Image.Image":
""" """
SynthID 破除(SYNTHID_HARD_MODE=1 时调用): SynthID 破除(硬路,默认开):对齐大卫 xhs 实测版。
对齐大卫逻辑 — 缩到(w-2,h-2)再裁掉1px边 + 亮度*1.005/饱和*0.998 顺序对齐大卫:先微调亮度/饱和(modulate) → 再缩2px裁1px边错位采样
只动边缘像素、按图片当前尺寸算,不依赖目标比例,不做全图重采样、不压全图像素。
诚实声明:只能削弱 SynthID不保证 100% 清除。 诚实声明:只能削弱 SynthID不保证 100% 清除。
""" """
tw, th = target # 先 modulate亮度*1.005/饱和*0.998),对齐大卫 modulate 在缩裁之前
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.Brightness(img).enhance(1.005)
img = ImageEnhance.Color(img).enhance(0.998) img = ImageEnhance.Color(img).enhance(0.998)
# 按当前尺寸缩2px再裁1px边错位采样破边缘水印残留w-4 × h-4
w, h = img.size
img = ImageOps.fit(img, (w - 2, h - 2), method=Image.LANCZOS)
img = img.crop((1, 1, w - 3, h - 3))
return img return img

View File

@@ -1,7 +1,8 @@
""" """
llm_scorer.py — AI 评委打分入口(让模型真读文案,替代机械找词) llm_scorer.py — AI 评委打分入口(让模型真读文案,替代机械找词)
合规第7维仍走机械硬拦(score_compliance)AI 读前6维给分+理由。 合规第7维仍走机械硬拦(score_compliance)AI 读前6维给分+理由。
任何异常/解析失败 → 回退旧机械 score_copy绝不卡链路。 B1(倩倩姐2026-06-26):评分两通道(apiports+codeproxy强档兜底)都挂时,
绝不静默回退机械分糊弄,抛 ScoringUnavailableError 由上层逐条接住、标"评分不可用"
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -12,17 +13,23 @@ from typing import Any
from .constants import BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, QUALITY_PASS_SCORE from .constants import BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, QUALITY_PASS_SCORE
from ._scoring_dims import score_compliance from ._scoring_dims import score_compliance
from .text_scoring import score_copy
from ._score_prompt import SCORER_PERSONA, build_score_prompt, COMPLIANCE_MAX from ._score_prompt import SCORER_PERSONA, build_score_prompt, COMPLIANCE_MAX
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 6 个 AI 维度满分倩倩姐2026-06-15拍板·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
# 痛点18+情绪18+买点18+钩子15+标题13+真实感13=95+合规5=100 class ScoringUnavailableError(RuntimeError):
# "真实感"替换旧"产品聚焦一件事",对齐富贵"很少提产品/前70%干货后30%植入"原则 """评分通道(apiports+codeproxy 强档兜底)均不可用。
按倩倩姐2026-06-26 B1拍板评分拿不到真AI分时绝不静默回退机械分糊弄
(机械分会误杀好文案/漏放烂文案,双破"质量过关"红线),明确抛错由上层逐条接住。"""
# 7 个 AI 维度满分倩倩姐2026-06-22拍板补标签精准·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
# 痛点16+情绪16+买点16+钩子13+标题13+真实感11+标签精准10=95+合规5=100
# "标签精准度"=北哥要害⑤(精准购买意向埋词)分从原6维各匀出总分仍95合格线80不动
_DIM_MAX = { _DIM_MAX = {
"痛点人群精准": 18, "情绪张力": 18, "买点转化": 18, "痛点人群精准": 16, "情绪张力": 16, "买点转化": 16,
"开头钩子": 15, "标题点击力": 13, "真实感": 13, "开头钩子": 13, "标题点击力": 13, "真实感": 11,
"标签精准度": 10,
} }
# 评委合规相关默认权重(仅供 score_compliance 复用其内部硬拦逻辑) # 评委合规相关默认权重(仅供 score_compliance 复用其内部硬拦逻辑)
_COMPLIANCE_W = {"compliance": COMPLIANCE_MAX} _COMPLIANCE_W = {"compliance": COMPLIANCE_MAX}
@@ -65,22 +72,29 @@ async def llm_score_copy(
) )
break break
except Exception as exc: # noqa: BLE001 — 含 httpx.HTTPStatusError 503/429 except Exception as exc: # noqa: BLE001 — 含 httpx.HTTPStatusError 503/429
# chat_complete 底层已含 apiports→codeproxy gpt-5.5 强档回落(含402欠费/429限流)。
# 走到这里说明两通道都失败。503/429 给瞬时过载留重试窗口,其余直接判通道不可用。
status = getattr(getattr(exc, "response", None), "status_code", 0) status = getattr(getattr(exc, "response", None), "status_code", 0)
if status in (503, 429) and attempt < 3: if status in (503, 429) and attempt < 3:
await asyncio.sleep(backoff[min(attempt, 2)]) await asyncio.sleep(backoff[min(attempt, 2)])
continue continue
logger.warning("AI评委调用失败回退机械打分: %s", exc) # B1红线绝不静默回退机械分糊弄明确抛错由调用方逐条接住、标记"评分不可用"
return score_copy(copy, source, banned_words, pass_score=pass_score) logger.error("AI评委两通道均失败判定评分不可用(不降级机械分): %s", exc)
raise ScoringUnavailableError(f"评分通道不可用: {exc}") from exc
verdict = _parse_verdict(raw) verdict = _parse_verdict(raw)
if not verdict: if not verdict:
logger.warning("AI评委输出解析失败回退机械打分。raw[:120]=%s", raw[:120]) # 拿到了回复但不是合法JSON模型偶发不听话。同样不静默机械分抛错让上层标记。
return score_copy(copy, source, banned_words, pass_score=pass_score) logger.error("AI评委输出解析失败(不降级机械分)。raw[:120]=%s", raw[:120])
raise ScoringUnavailableError("评分输出解析失败")
details: list[dict] = [] details: list[dict] = []
_COMPLIANCE_ALIASES = ("合规", "违禁", "禁用") # LLM 偶尔不听话多吐合规维度,显式剔除防污染机械合规分
for d in verdict["dims"]: for d in verdict["dims"]:
item = str(d.get("item", "")).strip() item = str(d.get("item", "")).strip()
if item not in _DIM_MAX: # 只收白名单6维模型偶尔多吐"总分"等噪声项,丢弃 if any(a in item for a in _COMPLIANCE_ALIASES):
continue # 合规只认机械 dim_comp丢弃 LLM 主观合规判定
if item not in _DIM_MAX: # 只收白名单7维模型偶尔多吐"总分"等噪声项,丢弃
continue continue
mx = _DIM_MAX[item] mx = _DIM_MAX[item]
sc = max(0, min(mx, int(round(float(d.get("score", 0)))))) sc = max(0, min(mx, int(round(float(d.get("score", 0))))))

View File

@@ -1,7 +1,7 @@
""" """
package_exporter.py — 达人素材交付包生成 package_exporter.py — 达人素材交付包生成
架构方案§五 1A步骤5按笔记分文件夹 + 图(01/02/03) + 文案.txt + 发布清单 + 合规说明 架构方案§五 1A步骤5按笔记分文件夹 + 图(01/02/03) + 文案.txt + 发布清单 + 合规说明
路径规则:uploads/packages/{workspace_id}/{task_id}/note_{n}/ 路径规则:{UPLOAD_ABS_ROOT}/packages/{workspace_id}/{task_id}/note_{n}/
""" """
from __future__ import annotations from __future__ import annotations
import json import json

View File

@@ -53,7 +53,8 @@ def aggregate_preference_context(
# text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板) # text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板)
# image_select(选图)按套别叙事角度计入,让选图偏好真正闭环回生图(R7断点2) # image_select(选图)按套别叙事角度计入,让选图偏好真正闭环回生图(R7断点2)
if sig_type in ("text_select", "approve", "text_edit", "image_select") and angle: # text_import(导入)=客户实跑验证过的范本,权重最高(8),角度计入让飞轮最重学习(倩倩姐2026-06-26)
if sig_type in ("text_select", "approve", "text_edit", "image_select", "text_import") and angle:
angle_counts[angle] += weight angle_counts[angle] += weight
elif sig_type == "reject_with_reason": elif sig_type == "reject_with_reason":
reason = str(e.get("reason") or "").strip() reason = str(e.get("reason") or "").strip()

View File

@@ -67,7 +67,7 @@ def get_narrative_roles(image_count: int = 6) -> list[dict]:
""" """
按图数返回分镜角色列表(扒 getNarrativeRolesQ6对齐北哥6张套路 按图数返回分镜角色列表(扒 getNarrativeRolesQ6对齐北哥6张套路
≤3 张:极速链路 hook / applied_proof / closer ≤3 张:极速链路 hook / applied_proof / closer
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单 ≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分/参数 ④质地/细节 ⑤效果证明(按品类) ⑥促单
>6 张:沉浸链路 + pain_scene / scenario / social_proof >6 张:沉浸链路 + pain_scene / scenario / social_proof
""" """
count = clamp_count(image_count) count = clamp_count(image_count)
@@ -102,14 +102,14 @@ def build_visual_system(product: dict, analysis: dict | None = None) -> dict:
"typography": identity.get("typographyStyle", "主标题清晰黑体或手写感标题,辅助文字便签/勾选标注,字重颜色保持同一体系"), "typography": identity.get("typographyStyle", "主标题清晰黑体或手写感标题,辅助文字便签/勾选标注,字重颜色保持同一体系"),
"sticker": identity.get("stickerLanguage", "少量箭头、放大镜、勾选、小表情、便签,不使用促销按钮"), "sticker": identity.get("stickerLanguage", "少量箭头、放大镜、勾选、小表情、便签,不使用促销按钮"),
"layout": identity.get("layoutStyle", "同一组图片保持色调、光线、产品露出方式一致,每张图承担不同叙事角色"), "layout": identity.get("layoutStyle", "同一组图片保持色调、光线、产品露出方式一致,每张图承担不同叙事角色"),
"texture": identity.get("materialTexture", "产品包装、质地、手背/上脸肤感要真实自然"), "texture": identity.get("materialTexture", "产品包装、材质、质地/细节要真实自然,符合本品类的真实使用质感"),
"package_details": identity.get("packageDetails", "如果提供产品图,必须还原包装颜色、瓶身形状、标签方向和主视觉"), "package_details": identity.get("packageDetails", "如果提供产品图,必须还原包装颜色、瓶身形状、标签方向和主视觉"),
"xhs_style_preset": identity.get("xhsStylePreset", "真实测评风/手写安利风/清单便签风"), "xhs_style_preset": identity.get("xhsStylePreset", "真实测评风/手写安利风/清单便签风"),
"symbol_system": identity.get("symbolSystem", "中等密度小红书种草符号:✅ ✨ 🌿 💧 🪞 🧴 📦 🔍 💛每张最多2-4个"), "symbol_system": identity.get("symbolSystem", "中等密度小红书种草符号:✅ ✨ 🌿 💧 🪞 🧴 📦 🔍 💛每张最多2-4个"),
"quality_rules": [ "quality_rules": [
"同组图片字体体系相对一致,但不要像固定模板", "同组图片字体体系相对一致,但不要像固定模板",
"每张压图文字必须服务当前叙事角色,不能重复封面标题", "每张压图文字必须服务当前叙事角色,不能重复封面标题",
"护肤品优先出现手背涂抹质地微距自然上脸局部真实生活场景", "效果证明页按品类出真实使用证据:护肤=手背涂抹/质地微距/自然上脸局部,食品=冲泡入口,家居数码=使用过程,服饰=上身材质,均为真实生活场景",
"人物真实自然有轻微皮肤纹理和生活感不要AI精修美女", "人物真实自然有轻微皮肤纹理和生活感不要AI精修美女",
"禁止乱码、错别字、App底栏、Like评论分享、硬广价格牌、虚假功效before/after", "禁止乱码、错别字、App底栏、Like评论分享、硬广价格牌、虚假功效before/after",
], ],

View File

@@ -18,11 +18,11 @@ import re
# ── sanitize扒 sanitizeImagePlanText防违禁视觉词进 prompt # ── sanitize扒 sanitizeImagePlanText防违禁视觉词进 prompt
_SANITIZE_RULES: list[tuple[str, str]] = [ _SANITIZE_RULES: list[tuple[str, str]] = [
(r"before\s*&\s*after", "质地与肤感说明"), (r"before\s*&\s*after", "质地与使用状态说明"),
(r"before\s*/?\s*after", "质地与肤感说明"), (r"before\s*/?\s*after", "质地与使用状态说明"),
(r"\bbefore\b", "质地状态"), (r"\bbefore\b", "使用前状态"),
(r"\bafter\b", "上脸肤感"), (r"\bafter\b", "使用后状态"),
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"), (r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/使用状态说明"),
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"), (r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"), (r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"), (r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
@@ -42,33 +42,33 @@ ROLE_STORYBOARD_TPL: dict[str, dict] = {
"hook": { "hook": {
"goal": "{audience}因为{pain}停下划走,产生点开欲", "goal": "{audience}因为{pain}停下划走,产生点开欲",
"overlay": "{hook}", "overlay": "{hook}",
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实肤感/手部细节像iPhone随手实拍的封面不是海报", "visual": "自然光生活场景,手持产品或产品在桌面前景,真实使用细节/手部细节像iPhone随手实拍的封面不是海报",
"basis": "来自选中文案标题、人群{audience}、痛点{pain}", "basis": "来自选中文案标题、人群{audience}、痛点{pain}",
"forbidden": "不要价格、不要重复后续卖点、不要App界面、不要广告海报感", "forbidden": "不要价格、不要重复后续卖点、不要App界面、不要广告海报感",
}, },
"product_closeup": { "product_closeup": {
"goal": "建立单品记忆锚点,让用户记住是哪个产品", "goal": "建立单品记忆锚点,让用户记住是哪个产品",
"overlay": "{brand}", "overlay": "{brand}",
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读,品牌词自然出现在画面或瓶身", "visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读且忠于参考图原样,品牌词出现在图上标题/贴纸等文字排版层,绝不印到瓶身",
"basis": "来自产品名和品牌词第2张和第6张都要带品牌词强化记忆", "basis": "来自产品名和品牌词第2张和第6张都要带品牌词强化记忆",
"forbidden": "不要堆多个产品、不要花哨背景抢主体、不要改包装文字", "forbidden": "不要堆多个产品、不要花哨背景抢主体、不要改包装文字",
}, },
"ingredient": { "ingredient": {
"goal": "用成分/配方信息建立信任,但不医疗化", "goal": "用成分/配方/参数信息建立信任,但不夸大不医疗化",
"overlay": "看清{point}", "overlay": "看清{point}",
"visual": "成分卡片式布局,产品+成分图标/短说明,浅色商务美妆风,信息层级清楚", "visual": "信息卡片式布局,产品+成分/参数图标+短说明,浅色干净商务风,信息层级清楚(护肤食品讲成分配方,数码家居讲参数原理,服饰讲材质工艺)",
"basis": "来自卖点里的成分/功效点,理性表达不夸大", "basis": "来自卖点里的成分/参数/工艺点,理性表达不夸大",
"forbidden": "不要治疗/改善疾病承诺、不要医生背书、不要绝对化", "forbidden": "不要治疗/改善疾病承诺、不要医生背书、不要绝对化",
}, },
"texture": { "texture": {
"goal": "让用户看到{point}的真实质感证据", "goal": "让用户看到{point}的真实质感证据",
"overlay": "{point}看得见", "overlay": "{point}看得见",
"visual": "手背或指尖涂抹质地微距,产品放在旁边,自然光,保留真实皮肤纹理,能看清延展肤感", "visual": "质地/材质/细节微距,产品放在旁边,自然光,保留真实纹理(护肤=涂抹延展肤感,食品=色泽质地,服饰=面料织纹,数码家居=做工细节),能看清{point}",
"basis": "来自卖点里的质地/肤感描述", "basis": "来自卖点里的质地/材质/做工描述",
"forbidden": "不要生成变白效果、不要医疗化对比、不要和封面同构图", "forbidden": "不要生成变白效果、不要医疗化对比、不要和封面同构图",
}, },
"applied_proof": { "applied_proof": {
"goal": "用可感知的上脸/使用证据证明{point}", "goal": "用可感知的真实使用证据证明{point}",
"overlay": "{proof_overlay}", "overlay": "{proof_overlay}",
"visual": "{proof_visual}", "visual": "{proof_visual}",
"basis": "来自核心卖点{point}和用户对效果的关注", "basis": "来自核心卖点{point}和用户对效果的关注",

View File

@@ -14,7 +14,7 @@ from typing import Any
from .constants import MAX_OPTIMIZE_ROUNDS from .constants import MAX_OPTIMIZE_ROUNDS
from ._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array, build_local_drafts from ._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array, build_local_drafts
from .text_scoring import score_copy, dedupe_copies from .text_scoring import score_copy, dedupe_copies
from .llm_scorer import llm_score_copy from .llm_scorer import llm_score_copy, ScoringUnavailableError
from .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult from .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -69,6 +69,35 @@ async def _call_llm(client: Any, prompt: str, max_tokens: int = 8192) -> str:
# 故分批:每批最多 4 条,串行调用合并。批大小可经 TEXT_BATCH_SIZE 调。 # 故分批:每批最多 4 条,串行调用合并。批大小可经 TEXT_BATCH_SIZE 调。
TEXT_BATCH_SIZE = int(os.environ.get("TEXT_BATCH_SIZE", "4")) TEXT_BATCH_SIZE = int(os.environ.get("TEXT_BATCH_SIZE", "4"))
# 评分有限并发:评分无状态、逐条独立可并发,但仍限并发数防 apiports 限流(生成层串行
# 不动task45 实测全并发雪崩)。默认3=保守值(评分 max_tokens仅1500远轻于生成8192
# 且评分已带 503/429 重试兜底),可经 SCORE_CONCURRENCY 调。
_SCORE_CONCURRENCY = int(os.environ.get("SCORE_CONCURRENCY", "3"))
def _build_cross_set_avoidance(previous: list[dict]) -> str:
"""从已生成的其他套文案提炼回避约束,喂进生成 prompt 防三套撞车。
覆盖三个最易同质化的点:已用标签、背书人物桥段、收尾句式。"""
if not previous:
return ""
used_tags: list[str] = []
used_titles: list[str] = []
for c in previous:
used_titles.append(c.get("title", ""))
for t in (c.get("tags") or []):
tag = str(t).replace("🛒", "").strip()
if tag and tag not in used_tags:
used_tags.append(tag)
lines = ["【跨套去重·硬约束(本条务必与已发布的其它套拉开,禁止换皮重复)】"]
if used_titles:
lines.append(f"- 已用标题(禁止同主题/同句式):{ ''.join(t for t in used_titles if t) }")
if used_tags:
lines.append(f"- 已用标签(本条至少换掉一半,改用不同人群/场景/成分组合的精准长尾,扩大触达不抢同批流量):{ ''.join(used_tags) }")
lines.append("- 标签禁止与已用标签共用同一核心搜索词(别套已用的核心词,本条标签就别再都堆这个词,换成本套独有的人群/场景/卖点关键词分流)。")
lines.append("- 背书角色/桥段禁止与上面套雷同:若别套用了'闺蜜种草',本条换成自证/家人/同事/自己反复试错等不同来源;")
lines.append("- 卖点罗列的措辞顺序与 emoji 禁止与别套一致;同一个具体种草桥段全局只许出现一次。")
return "\n".join(lines)
async def _generate_one_batch(llm_client: Any, product: dict, batch_n: int, extra: str, async def _generate_one_batch(llm_client: Any, product: dict, batch_n: int, extra: str,
strategy_narrative: str = "") -> list[dict]: strategy_narrative: str = "") -> list[dict]:
@@ -119,25 +148,53 @@ async def generate_text_variants(
贯穿首批生成与优化轮,确保同套内文案同一叙事不串味。""" 贯穿首批生成与优化轮,确保同套内文案同一叙事不串味。"""
banned_entries = build_entries_from_db(banned_word_rows or []) banned_entries = build_entries_from_db(banned_word_rows or [])
extra = flywheel_context extra = flywheel_context
# 跨套去重:把已生成套的标签/背书桥段提炼成回避约束喂进生成 prompt
# 防止三套撞车(仅事后 dedupe 删不掉"同用闺蜜背书/同套标签"的同质化)
avoid = _build_cross_set_avoidance(previous_copies or [])
if avoid:
extra = f"{extra}\n{avoid}" if extra else avoid
copies: list[dict] = await _generate_in_batches( copies: list[dict] = await _generate_in_batches(
llm_client, product, count, extra, strategy_narrative) llm_client, product, count, extra, strategy_narrative)
if not copies: if not copies:
copies = list(build_local_drafts(product, count)) # generator → list copies = list(build_local_drafts(product, count)) # generator → list
candidates: list[dict] = [] # 评分有限并发逐条独立无状态可并发sem 限并发数防 apiports 限流(生成层串行不动)。
for c in copies: # 每条语义与原串行完全一致:违禁词机械检查(同步,放 sem 外不占并发额度)→AI评分→
# 评分不可用则标记不计合格不炸整批auto_fixed 回写修正文案。gather 保序=候选顺序不变。
sem = asyncio.Semaphore(_SCORE_CONCURRENCY)
async def _score_one(c: dict) -> dict:
ban: CheckResult = check_and_fix( ban: CheckResult = check_and_fix(
f"{c.get('title','')} {c.get('content','')}", f"{c.get('title','')} {c.get('content','')}",
banned_entries or None, banned_entries or None,
) )
try:
async with sem:
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries]) scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
except ScoringUnavailableError as exc:
# B1评分两通道都挂→不打机械分糊弄这条标"评分不可用·不计合格",不炸整批
logger.warning("文案评分不可用,标记不计合格(非质量问题): %s", exc)
c.update({"source": "ai", "score": 0,
"score_detail": [{"item": "评分不可用", "score": 0, "max": 100,
"note": "评分通道(apiports+codeproxy)暂不可用,非文案质量问题"}],
"passed": False, "banned_word_status": ban.status,
"scoring_unavailable": True, "verdict": "", "summary": "评分服务暂不可用"})
return c
c.update({"source": "ai", "score": scored["score"], "score_detail": scored["score_detail"], c.update({"source": "ai", "score": scored["score"], "score_detail": scored["score_detail"],
"passed": scored["passed"], "banned_word_status": ban.status, "passed": scored["passed"], "banned_word_status": ban.status,
"verdict": scored.get("verdict", ""), "summary": scored.get("summary", "")}) "verdict": scored.get("verdict", ""), "summary": scored.get("summary", "")})
if ban.status == "auto_fixed" and ban.fixed_text: if ban.status == "auto_fixed" and ban.fixed_text:
c["content"] = ban.fixed_text c["content"] = ban.fixed_text
candidates.append(c) return c
candidates: list[dict] = list(await asyncio.gather(*(_score_one(c) for c in copies)))
scoring_failures = sum(1 for c in candidates if c.get("scoring_unavailable"))
# 整批评分全因通道不可用而失败 → 抛错让上层透传"评分服务不可用",区别于"质量不合格"
if copies and scoring_failures == len(copies):
raise ScoringUnavailableError(
f"本套 {len(copies)} 条文案评分全部失败:评分通道(apiports+codeproxy)均不可用")
failed = [c for c in candidates if not c["passed"] and c["banned_word_status"] != "hard_block"] failed = [c for c in candidates if not c["passed"] and c["banned_word_status"] != "hard_block"]
# 优化轮默认关闭apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503 # 优化轮默认关闭apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503
@@ -164,7 +221,11 @@ async def generate_text_variants(
logger.warning("文案优化轮 LLM 失败,沿用原始候选不再重试") logger.warning("文案优化轮 LLM 失败,沿用原始候选不再重试")
break break
for nc in parse_json_array(raw2): for nc in parse_json_array(raw2):
try:
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries]) sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
except ScoringUnavailableError as exc:
logger.warning("优化轮评分不可用,跳过该条不计入: %s", exc)
continue
nc.update({"source": "ai", "score": sc2["score"], "score_detail": sc2["score_detail"], nc.update({"source": "ai", "score": sc2["score"], "score_detail": sc2["score_detail"],
"passed": sc2["passed"], "banned_word_status": "pass", "passed": sc2["passed"], "banned_word_status": "pass",
"verdict": sc2.get("verdict", ""), "summary": sc2.get("summary", "")}) "verdict": sc2.get("verdict", ""), "summary": sc2.get("summary", "")})

View File

@@ -9,6 +9,7 @@ import logging
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.response import raise_unauthorized from app.core.response import raise_unauthorized
from app.middleware.workspace_guard import CurrentUser from app.middleware.workspace_guard import CurrentUser
from app.models.user import User from app.models.user import User
@@ -16,6 +17,7 @@ from app.models.workspace import WorkspaceMember
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
DEFAULT_SEED_PASSWORD = "Clover2026!"
def hash_password(plain: str) -> str: def hash_password(plain: str) -> str:
@@ -38,6 +40,8 @@ def authenticate_user(
).first() ).first()
if not user or not verify_password(password, user.hashed_password): if not user or not verify_password(password, user.hashed_password):
raise_unauthorized("用户名或密码错误") raise_unauthorized("用户名或密码错误")
if get_settings().APP_ENV == "production" and password == DEFAULT_SEED_PASSWORD:
raise_unauthorized("默认密码已禁用,请联系管理员重置密码")
# 取用户所在的第一个 workspace手动建账号场景只有一个 # 取用户所在的第一个 workspace手动建账号场景只有一个
member = ( member = (
@@ -68,4 +72,20 @@ def build_user_response(user: User, workspace_id: int, role: str) -> dict:
"email": user.email, "email": user.email,
"current_workspace_id": workspace_id, "current_workspace_id": workspace_id,
"role": role, "role": role,
"must_change_password": bool(getattr(user, "must_change_password", False)),
} }
def change_password(db: Session, user_id: int, current_password: str, new_password: str) -> None:
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user or not verify_password(current_password, user.hashed_password):
raise_unauthorized("当前密码错误")
if len(new_password) < 8:
from app.core.response import raise_param_error
raise_param_error("新密码至少 8 位")
if new_password == DEFAULT_SEED_PASSWORD:
from app.core.response import raise_param_error
raise_param_error("不能使用默认密码")
user.hashed_password = hash_password(new_password)
user.must_change_password = False
db.commit()

View File

@@ -33,6 +33,13 @@ def _parse_fission_response(
build_fallback_image_plan, build_fallback_notes, build_fallback_image_plan, build_fallback_notes,
) )
def _fallback(reason: str) -> list[dict]:
notes = build_fallback_notes(source_note, product, note_count, image_count)
for note in notes:
note["is_fallback"] = True
note["fallback_reason"] = reason
return notes
notes = notes_array_from_parsed(parse_json_array(raw)) notes = notes_array_from_parsed(parse_json_array(raw))
if not notes: if not notes:
# parse_json_array 只认数组;再试整段当对象解析(容错单对象返回) # parse_json_array 只认数组;再试整段当对象解析(容错单对象返回)
@@ -42,7 +49,7 @@ def _parse_fission_response(
notes = [] notes = []
if not notes: if not notes:
logger.warning("裂变 LLM 返回不可解析启用品类兜底。raw[:120]=%s", str(raw)[:120]) logger.warning("裂变 LLM 返回不可解析启用品类兜底。raw[:120]=%s", str(raw)[:120])
return build_fallback_notes(source_note, product, note_count, image_count) return _fallback("llm_parse_failed")
out: list[dict] = [] out: list[dict] = []
for n in notes: for n in notes:
@@ -53,17 +60,21 @@ def _parse_fission_response(
if not isinstance(plan, list) or len(plan) != image_count: if not isinstance(plan, list) or len(plan) != image_count:
n["imagePlan"] = build_fallback_image_plan(n, image_count) n["imagePlan"] = build_fallback_image_plan(n, image_count)
out.append(n) out.append(n)
return out or build_fallback_notes(source_note, product, note_count, image_count) return out or _fallback("llm_empty_notes")
def _score_notes(clients, notes: list[dict], source_note: dict, banned: list[str]) -> None: def _score_notes(
clients, notes: list[dict], source_note: dict, banned: list[str],
product: dict | None = None,
) -> None:
"""对每套笔记 LLM 评分限2并发结果就地写回 note['_score']/_passed/_block。 """对每套笔记 LLM 评分限2并发结果就地写回 note['_score']/_passed/_block。
Celery 同步环境:用 asyncio.run 跑一个内部 gather信号量限2并发 D1修复product 传给 llm_score_copy → build_score_prompt 组装【产品语境】
对齐生图限流,避免中转站 429 评委不再盲评,买点转化/痛点人群精准两维能基于产品信息给分
Celery 同步环境:用 asyncio.run 跑一个内部 gather信号量限2并发
""" """
import asyncio import asyncio
from app.services.ai_engine.llm_scorer import llm_score_copy from app.services.ai_engine.llm_scorer import llm_score_copy, ScoringUnavailableError
from app.services.ai_engine.constants import QUALITY_PASS_SCORE from app.services.ai_engine.constants import QUALITY_PASS_SCORE
async def _run() -> None: async def _run() -> None:
@@ -78,8 +89,17 @@ def _score_notes(clients, notes: list[dict], source_note: dict, banned: list[str
async with sem: async with sem:
try: try:
r = await llm_score_copy( r = await llm_score_copy(
clients, copy, source_note, banned, pass_score=QUALITY_PASS_SCORE, clients, copy, product or source_note, banned,
pass_score=QUALITY_PASS_SCORE,
) )
except ScoringUnavailableError as exc:
# B1红线对齐评分两通道都挂≠质量差明确标记不可用绝不静默记0分糊弄
logger.error("裂变评分通道不可用(非质量问题): %s", exc)
note["_scoring_unavailable"] = True
note["_score"] = 0
note["_block"] = False
note["_passed"] = False
return
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("裂变评分异常记0分不达标: %s", exc) logger.warning("裂变评分异常记0分不达标: %s", exc)
r = {"score": 0, "passed": False, "banned_words_found": []} r = {"score": 0, "passed": False, "banned_words_found": []}
@@ -91,6 +111,11 @@ def _score_notes(clients, notes: list[dict], source_note: dict, banned: list[str
await asyncio.gather(*(_one(n) for n in notes)) await asyncio.gather(*(_one(n) for n in notes))
asyncio.run(_run()) asyncio.run(_run())
# 整批评分全因通道不可用而失败 → 抛错让上层(execute_fission_pipeline)感知,
# 标记任务"评分服务不可用"而非误判成完成/质量差
if notes and all(n.get("_scoring_unavailable") for n in notes):
raise ScoringUnavailableError(
f"裂变 {len(notes)} 套笔记评分全部失败:评分通道(apiports+codeproxy)均不可用")
def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_id: int) -> dict: def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_id: int) -> dict:
@@ -147,7 +172,8 @@ def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_
logger.warning("裂变 LLM 调用失败,启用品类兜底: %s", exc) logger.warning("裂变 LLM 调用失败,启用品类兜底: %s", exc)
notes = _parse_fission_response(raw, source_note, product, n, image_count) notes = _parse_fission_response(raw, source_note, product, n, image_count)
_score_notes(clients, notes, source_note, banned) # D1修复传 product 给评委,不再盲评(买点转化/痛点人群精准需要产品语境)
_score_notes(clients, notes, source_note, banned, product=product)
# 排序:过线优先,再按分降序;取前 N 套(不足用兜底草稿补齐) # 排序:过线优先,再按分降序;取前 N 套(不足用兜底草稿补齐)
notes.sort(key=lambda x: (x.get("_passed", False), x.get("_score", 0)), reverse=True) notes.sort(key=lambda x: (x.get("_passed", False), x.get("_score", 0)), reverse=True)
@@ -175,5 +201,9 @@ def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_
from app.services.fission_images import generate_fission_images from app.services.fission_images import generate_fission_images
generate_fission_images(db, clients, ft, product, image_count, saved_ids) generate_fission_images(db, clients, ft, product, image_count, saved_ids)
ft.status = "completed"; db.commit() has_fallback = any(bool(note.get("is_fallback")) for note in chosen)
return {"fission_id": fission_id, "status": "completed", "note_ids": saved_ids} # status 字段是 varchar(20)"completed_with_fallback"(23字符)会撑爆触发 DataError 1406
# 故用短码 "completed_fb"(倩倩姐2026-06-30修);语义=完成但含降级草稿。
ft.status = "completed_fb" if has_fallback else "completed"
db.commit()
return {"fission_id": fission_id, "status": ft.status, "note_ids": saved_ids}

View File

@@ -23,6 +23,39 @@ logger = logging.getLogger(__name__)
MAX_FANOUT = 5 # 每用户并发上限5(红线);裂变 N 套直出受同等约束 MAX_FANOUT = 5 # 每用户并发上限5(红线);裂变 N 套直出受同等约束
def _source_copy_from_payload(payload) -> str:
"""从候选文案 JSON 中提取可裂变的正文,避免把整段 JSON 当 prompt。"""
if isinstance(payload, str):
raw = payload.strip()
if not raw:
return ""
try:
parsed = json.loads(raw)
except Exception:
return raw
return _source_copy_from_payload(parsed)
if isinstance(payload, dict):
parts: list[str] = []
for key in ("title", "content", "body", "note", "text", "opening", "selling_points", "tags"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
parts.append(value.strip())
elif isinstance(value, list):
items = [str(item).strip() for item in value if str(item).strip()]
if items:
parts.append(" / ".join(items))
if parts:
return "\n".join(parts)
return json.dumps(payload, ensure_ascii=False)
if isinstance(payload, list):
items = [_source_copy_from_payload(item) for item in payload]
return "\n".join(item for item in items if item)
return str(payload or "")
def create_fission( def create_fission(
db: Session, current_user: CurrentUser, db: Session, current_user: CurrentUser,
source_task_id: int, reference_level: str, fanout_count: int, source_task_id: int, reference_level: str, fanout_count: int,
@@ -73,5 +106,5 @@ def _extract_source_copy(db: Session, src: GenerationTask) -> str:
.order_by(TextCandidate.is_selected.desc(), TextCandidate.id.asc()) .order_by(TextCandidate.is_selected.desc(), TextCandidate.id.asc())
.first()) .first())
if c and c.content: if c and c.content:
return c.content return _source_copy_from_payload(c.content)
return src.theme or "" return src.theme or ""

View File

@@ -8,7 +8,7 @@ preference_aggregator查最近50条 → 最常选角度 + 打回原因近3条
import logging import logging
from typing import Any from typing import Any
from sqlalchemy import desc from sqlalchemy import desc, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership
@@ -69,6 +69,20 @@ def record_signal(
raise raise
def count_signals(db: Session, workspace_id: int, product_id: int) -> int:
"""该产品累计偏好信号总数(按 workspace+product走联合索引
供前端"飞轮已积累N条信号"累积感知用,体现越用越多。"""
return (
db.query(func.count(PreferenceEvent.id))
.filter(
PreferenceEvent.workspace_id == workspace_id,
PreferenceEvent.product_id == product_id,
)
.scalar()
or 0
)
def get_preference_context( def get_preference_context(
db: Session, workspace_id: int, product_id: int, db: Session, workspace_id: int, product_id: int,
product_dict: dict[str, Any] | None = None, product_dict: dict[str, Any] | None = None,
@@ -105,4 +119,5 @@ def get_preference_context(
"recent_preference": ctx.get("recent_preference", ""), "recent_preference": ctx.get("recent_preference", ""),
"reject_reasons": ctx.get("reject_reasons", []), "reject_reasons": ctx.get("reject_reasons", []),
"injected_count": ctx.get("injected_count", 0), "injected_count": ctx.get("injected_count", 0),
"signal_count": count_signals(db, workspace_id, product_id),
} }

View File

@@ -68,17 +68,19 @@ def create_generation_task(
# 已生成完等挑选/审核的不占 worker。红线=每用户5个(可配置)。 # 已生成完等挑选/审核的不占 worker。红线=每用户5个(可配置)。
_check_concurrency_limit(db, current_user.user_id, current_user.workspace_id) _check_concurrency_limit(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 from app.models.product import Product
product = db.query(Product).filter( product = db.query(Product).filter(
Product.id == body.product_id, Product.id == body.product_id,
Product.workspace_id == current_user.workspace_id, Product.workspace_id == current_user.workspace_id,
).first() ).first()
if not product: if not product:
raise_business("产品不存在") raise_business("产品不存在")
# 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
need_img = getattr(body, "need_product_image", True)
if need_img:
if not (product.image_path or "").strip(): if not (product.image_path or "").strip():
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关") raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关")
@@ -111,10 +113,14 @@ def create_generation_task(
def enqueue_generation(task_id: int, regen_strategy: str | None = None, def enqueue_generation(task_id: int, regen_strategy: str | None = None,
regen_role: str | None = None, custom_prompt: str | None = None) -> None: regen_role: str | None = None, custom_prompt: str | None = None,
reuse_text: bool = False) -> None:
"""只推 task_id+可选重生参数)入队,绝不推 key基石B """只推 task_id+可选重生参数)入队,绝不推 key基石B
regen_strategy/regen_role/custom_prompt 仅 R2 单张/单套重生时传,常规生成留 None。""" regen_strategy/regen_role/custom_prompt 仅 R2 单张/单套重生时传,常规生成留 None。
reuse_text=True 仅导入轨「去生图」时传:首次出图但复用库内导入文案,跳过文案生成。"""
from app.workers.tasks import run_generation_pipeline from app.workers.tasks import run_generation_pipeline
run_generation_pipeline.delay(task_id, regen_strategy=regen_strategy, run_generation_pipeline.delay(task_id, regen_strategy=regen_strategy,
regen_role=regen_role, custom_prompt=custom_prompt) regen_role=regen_role, custom_prompt=custom_prompt,
logger.info("Enqueued task_id=%s regen=%s/%s", task_id, regen_strategy, regen_role) reuse_text=reuse_text)
logger.info("Enqueued task_id=%s regen=%s/%s reuse_text=%s",
task_id, regen_strategy, regen_role, reuse_text)

View File

@@ -0,0 +1,137 @@
"""
app/services/user_admin_service.py — 管理员账号管理 service
仅 admin 可调(路由层 require_admin 守卫)。
建号/列号/启禁用,全部限定在调用者所在 workspace 内。
禁用=软删除(is_active=False),可随时恢复(倩倩姐2026-06-30拍板);不物理删除。
"""
import logging
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.constants.enums import UserRole
from app.core.response import raise_business, raise_not_found, raise_param_error
from app.models.user import LoginRecord, User
from app.models.workspace import WorkspaceMember
from app.services.auth_service import DEFAULT_SEED_PASSWORD, hash_password
logger = logging.getLogger(__name__)
_VALID_ROLES = {r.value for r in UserRole}
def list_workspace_users(db: Session, workspace_id: int) -> list[dict]:
"""列出本 workspace 的所有成员(含禁用),带角色与最后登录时间。"""
rows = (
db.query(User, WorkspaceMember.role)
.join(WorkspaceMember, WorkspaceMember.user_id == User.id)
.filter(WorkspaceMember.workspace_id == workspace_id)
.order_by(User.id)
.all()
)
result: list[dict] = []
for user, role in rows:
last = (
db.query(func.max(LoginRecord.created_at))
.filter(LoginRecord.user_id == user.id)
.scalar()
)
# role 可能是 UserRole 枚举对象,统一取字符串值给前端匹配 ROLE_LABELS
role_str = role.value if hasattr(role, "value") else str(role)
result.append({
"id": user.id,
"username": user.username,
"email": user.email,
"role": role_str,
"is_active": bool(user.is_active),
"must_change_password": bool(user.must_change_password),
"last_login_at": last.isoformat() if last else None,
})
return result
def create_workspace_user(
db: Session, workspace_id: int, username: str, email: str,
role: str, init_password: str,
) -> dict:
"""管理员建号:建 User + 绑 WorkspaceMember强制首登改密。"""
username, email = username.strip(), email.strip().lower()
if not username or not email:
raise_param_error("用户名和邮箱不能为空")
if role not in _VALID_ROLES:
raise_param_error(f"角色非法,仅支持 {sorted(_VALID_ROLES)}")
if len(init_password) < 8:
raise_param_error("初始密码至少 8 位")
if init_password == DEFAULT_SEED_PASSWORD:
raise_param_error("初始密码不能使用系统默认密码")
if db.query(User).filter(User.username == username).first():
raise_business("用户名已存在")
if db.query(User).filter(User.email == email).first():
raise_business("邮箱已被占用")
user = User(
username=username, email=email,
hashed_password=hash_password(init_password),
is_active=True, must_change_password=True,
)
db.add(user)
db.flush()
db.add(WorkspaceMember(workspace_id=workspace_id, user_id=user.id, role=role))
db.commit()
logger.info("admin created user=%s role=%s in ws=%s", username, role, workspace_id)
return {"id": user.id, "username": user.username, "role": role}
def set_user_active(
db: Session, workspace_id: int, target_user_id: int,
is_active: bool, operator_user_id: int,
) -> dict:
"""启用/禁用账号(软删除)。禁止管理员禁用自己。"""
if target_user_id == operator_user_id and not is_active:
raise_business("不能禁用当前登录的管理员账号")
member = (
db.query(WorkspaceMember)
.filter(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.user_id == target_user_id,
)
.first()
)
if not member:
raise_not_found("该账号不在当前 workspace")
user = db.query(User).filter(User.id == target_user_id).first()
if not user:
raise_not_found("账号不存在")
user.is_active = is_active
db.commit()
logger.info("admin set user=%s active=%s", target_user_id, is_active)
return {"id": user.id, "is_active": is_active}
def delete_workspace_user(
db: Session, workspace_id: int, target_user_id: int, operator_user_id: int,
) -> dict:
"""物理删除账号:先清关联(login_record+workspace_member)再删 user。禁止删自己。"""
if target_user_id == operator_user_id:
raise_business("不能删除当前登录的管理员账号")
member = (
db.query(WorkspaceMember)
.filter(
WorkspaceMember.workspace_id == workspace_id,
WorkspaceMember.user_id == target_user_id,
)
.first()
)
if not member:
raise_not_found("该账号不在当前 workspace")
user = db.query(User).filter(User.id == target_user_id).first()
if not user:
raise_not_found("账号不存在")
username = user.username
db.query(LoginRecord).filter(LoginRecord.user_id == target_user_id).delete()
db.query(WorkspaceMember).filter(WorkspaceMember.user_id == target_user_id).delete()
db.delete(user)
db.commit()
logger.info("admin DELETED user=%s(%s) from ws=%s", target_user_id, username, workspace_id)
return {"id": target_user_id, "deleted": True}

View File

@@ -5,6 +5,7 @@ build_delivery_package查已选文案+图片 → package_exporter → 存路
import json import json
import logging import logging
import os
from app.workers.celery_app import celery_app from app.workers.celery_app import celery_app
@@ -40,7 +41,7 @@ def build_delivery_package(self, package_id: int) -> dict:
from app.core.config import get_settings from app.core.config import get_settings
settings = get_settings() settings = get_settings()
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/") upload_root = settings.UPLOAD_ABS_ROOT.rstrip("/")
# A8 多套打包:按 strategy(A/B/C 三套正交叙事)分组,每套成一篇独立 note。 # A8 多套打包:按 strategy(A/B/C 三套正交叙事)分组,每套成一篇独立 note。
# 北哥拿到的交付包含完整 3 套note_01/02/03不再只打第 1 条文案。 # 北哥拿到的交付包含完整 3 套note_01/02/03不再只打第 1 条文案。
@@ -59,9 +60,15 @@ def build_delivery_package(self, package_id: int) -> dict:
def _read_image(ic) -> dict: def _read_image(ic) -> dict:
img_bytes = b"" img_bytes = b""
if ic.url: if ic.url:
# url 已含 uploads 前缀;工作目录 /applstrip 当相对路径读,勿再拼 base(防 uploads/uploads) path = ic.url
if os.path.isabs(path):
pass
elif path.startswith("/uploads/"):
path = f"{upload_root}/{path.removeprefix('/uploads/')}"
elif path.startswith("uploads/"):
path = f"{upload_root}/{path.removeprefix('uploads/')}"
try: try:
with open(ic.url.lstrip("/"), "rb") as f: with open(path, "rb") as f:
img_bytes = f.read() img_bytes = f.read()
except OSError as e: except OSError as e:
logger.warning("图片读取失败,跳过:%s %s", ic.url, e) logger.warning("图片读取失败,跳过:%s %s", ic.url, e)
@@ -115,8 +122,8 @@ def build_delivery_package(self, package_id: int) -> dict:
raise ValueError("无图片候选,无法打包") raise ValueError("无图片候选,无法打包")
from app.services.ai_engine.package_exporter import build_delivery_package as do_build from app.services.ai_engine.package_exporter import build_delivery_package as do_build
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开 # 打包产物放持久化卷 /app/uploads/packages/,与图片目录 /app/uploads/{ws}/{task}/ 分开
packages_base = f"{upload_base}/packages" packages_base = os.path.join(upload_root, "packages")
zip_path = do_build(workspace_id, task_id, notes, base_path=packages_base) zip_path = do_build(workspace_id, task_id, notes, base_path=packages_base)
pkg.package_path = zip_path pkg.package_path = zip_path

View File

@@ -32,15 +32,17 @@ def _resolve_image_path(img_path: str) -> str:
def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment: str, 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]: push_fn, workspace_id: int, seq_start: int) -> tuple[list, int, bool, str | None, int]:
""" """
Step5: 调 generate_text_variants → 存 TextCandidate → 推 SSE → 写 ai_call_logs。 Step5: 调 generate_text_variants → 存 TextCandidate → 推 SSE → 写 ai_call_logs。
S1: 存库前过滤——只存 passed且score>=QUALITY_PASS_SCORE(80,红线)且banned_word_status!='hard_block' 的文案。 S1: 存库前过滤——只存 passed且score>=QUALITY_PASS_SCORE(80,红线)且banned_word_status!='hard_block' 的文案。
合格数 < task.text_count 时 needs_replenish=True由主任务发起后台补充子任务 合格数 < task.text_count 时 needs_replenish=True由主任务发起后台补充子任务
返回 (candidates_raw, next_seq, needs_replenish)。 返回 (candidates_raw, next_seq, needs_replenish, text_fail_reason, saved_count)。
text_fail_reason: None|scoring_unavailable|generation_failed|quality_filtered|replenishing(B1透传0条原因)。
""" """
import time import time
from app.services.ai_engine.text_variants import generate_text_variants from app.services.ai_engine.text_variants import generate_text_variants
from app.services.ai_engine.llm_scorer import ScoringUnavailableError
from app.models.product import BannedWord from app.models.product import BannedWord
from app.models.task import TextCandidate from app.models.task import TextCandidate
from app.models.flywheel import AiCallLog from app.models.flywheel import AiCallLog
@@ -70,6 +72,7 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
t0 = time.monotonic() t0 = time.monotonic()
llm_success = True llm_success = True
scoring_unavailable = False # B1评分通道(apiports+codeproxy)整套都挂,用于区分"评分不可用"vs"质量不合格"
candidates_raw: list = [] candidates_raw: list = []
for s in _strategies: for s in _strategies:
n = _per[s] n = _per[s]
@@ -85,6 +88,12 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
flywheel_context=flywheel_fragment, flywheel_context=flywheel_fragment,
strategy_narrative=TEXT_NARRATIVE_BY_STRATEGY.get(s, ""), strategy_narrative=TEXT_NARRATIVE_BY_STRATEGY.get(s, ""),
)) ))
except ScoringUnavailableError as exc:
# B1评分两通道均不可用——明确记号绝不当"质量不合格"糊弄用户
scoring_unavailable = True
llm_success = False
logger.error("generate_text_variants(套%s) 评分通道不可用: %s", s, exc)
part = []
except Exception as exc: except Exception as exc:
llm_success = False llm_success = False
logger.error("generate_text_variants(套%s) 失败: %s", s, exc) logger.error("generate_text_variants(套%s) 失败: %s", s, exc)
@@ -115,10 +124,13 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
# S1: 存库前过滤——只存合格文案passed + score>=QUALITY_PASS_SCORE(80) + 非hard_block # S1: 存库前过滤——只存合格文案passed + score>=QUALITY_PASS_SCORE(80) + 非hard_block
seq = seq_start seq = seq_start
saved_count = 0 saved_count = 0
partial_scoring_unavailable = False # 部分条目评分通道挂(整套没全挂故没抛异常),用于精确归因
for i, c in enumerate(candidates_raw): for i, c in enumerate(candidates_raw):
score = c.get("score", 0) score = c.get("score", 0)
passed = c.get("passed", False) passed = c.get("passed", False)
bw_status = c.get("banned_word_status", "pass") bw_status = c.get("banned_word_status", "pass")
if c.get("scoring_unavailable"):
partial_scoring_unavailable = True
if not (passed and score >= QUALITY_PASS_SCORE and bw_status != "hard_block"): if not (passed and score >= QUALITY_PASS_SCORE and bw_status != "hard_block"):
logger.info( logger.info(
"文案[%d] 过滤丢弃: passed=%s score=%s banned=%s", "文案[%d] 过滤丢弃: passed=%s score=%s banned=%s",
@@ -159,22 +171,40 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
"文案合格数不足: task_id=%s 目标=%s 实得=%s,将后台异步补充", "文案合格数不足: task_id=%s 目标=%s 实得=%s,将后台异步补充",
task.id, task.text_count, saved_count, task.id, task.text_count, saved_count,
) )
return candidates_raw, seq, needs_replenish # 整套全挂(scoring_unavailable)或部分条目评分挂(partial)都按"评分不可用"归因,
# 不被 quality_filtered 掩盖——评分通道问题≠文案质量问题(B1红线)
scoring_unavailable = scoring_unavailable or partial_scoring_unavailable
# B1透传 0 条的真实原因,前端据此区分提示,不再把"评分挂了"显示成"没有合格文案"
if saved_count == 0 and scoring_unavailable:
text_fail_reason = "scoring_unavailable" # 评分服务不可用
elif saved_count == 0 and not llm_success:
text_fail_reason = "generation_failed" # 文案生成本身失败(非评分)
elif saved_count == 0:
text_fail_reason = "quality_filtered" # 生成成功但全部未达标
elif needs_replenish:
text_fail_reason = "replenishing" # 有产出但不足,补充中
else:
text_fail_reason = None # 正常足量
return candidates_raw, seq, needs_replenish, text_fail_reason, saved_count
def run_image_generation(db, clients, task, product_dict: dict, def run_image_generation(db, clients, task, product_dict: dict,
push_fn, workspace_id: int, seq_start: int, push_fn, workspace_id: int, seq_start: int,
first_copy: dict, upload_base_path: str, notes_by_strategy: dict[str, dict], upload_base_path: str,
regen_strategy: str | None = None, regen_strategy: str | None = None,
regen_role: str | None = None, regen_role: str | None = None,
custom_prompt: str | None = None, custom_prompt: str | None = None,
flywheel_fragment: str | None = None) -> int: flywheel_fragment: str | None = None,
reuse_text: bool = False) -> int:
""" """
Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。 Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。
返回 next_seq。 返回 next_seq。
R2 局部重生(均 None=全量A/B/C)regen_strategy 限定只跑该套regen_role 配合限定该套该张; R2 局部重生(均 None=全量A/B/C)regen_strategy 限定只跑该套regen_role 配合限定该套该张;
custom_prompt 人工追加提示词。重生产出 is_regen=True 新增不删旧。 custom_prompt 人工追加提示词。重生产出 is_regen=True 新增不删旧。
reuse_text=True(导入轨):只遍历库内真有导入文案的套(notes_by_strategy 的键)
导入几套出几套,未导入的套不刷 batch_failed 噪声。
""" """
import time import time
from app.services.ai_engine.image_gen import generate_storyboard_images from app.services.ai_engine.image_gen import generate_storyboard_images
@@ -236,28 +266,66 @@ def run_image_generation(db, clients, task, product_dict: dict,
# 主图始终保底进 primary多图表为空或主图未入表时仍可用 # 主图始终保底进 primary多图表为空或主图未入表时仍可用
if reference_images and not images_by_scene.get("primary"): if reference_images and not images_by_scene.get("primary"):
images_by_scene.setdefault("primary", []).extend(reference_images) images_by_scene.setdefault("primary", []).extend(reference_images)
# 主图缺失告警:无 scene=primary 入表时,所有 primary 角色只能靠 image_path 兜底,
# 若用户把瓶身误标成 texture主图角色会取不到真瓶身 → 提前暴露在日志(不硬拦,纯测试场景仍放行)
if not images_by_scene.get("primary"):
logger.warning(
"task_id=%s 无 scene=primary 产品图,主图角色将无瓶身锚点,"
"请确认产品已正确标注主图(瓶身本体)。", task.id
)
if images_by_scene: if images_by_scene:
logger.info("R5多图已加载%s", {k: len(v) for k, v in images_by_scene.items()}) logger.info("R5多图已加载%s", {k: len(v) for k, v in images_by_scene.items()})
seq = seq_start seq = seq_start
# R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事 # R2: 限定重生套别(regen_strategy)则只跑该套
_strategies = (regen_strategy,) if regen_strategy else ("A", "B", "C") # reuse_text(导入轨): 只跑库内真有导入文案的套(按 A/B/C 顺序),导入几套出几套;
# 否则全量 A/B/C 三套正交叙事。
if regen_strategy:
_strategies = (regen_strategy,)
elif reuse_text:
_strategies = tuple(s for s in ("A", "B", "C") if notes_by_strategy.get(s))
# 存量导入文案 strategy 可能为 NULL(归到键"_")A/B/C 全匹配不上→_strategies 空。
# 必须显式报错,否则循环静默跳过=零图产出但任务不报错,极难排查。
if not _strategies:
logger.error(
"导入文案均未分配套别(A/B/C),无法生图: task_id=%s keys=%s",
task.id, list(notes_by_strategy.keys()),
)
push_fn(task.id, workspace_id, "error", {
"code": 40003,
"message": "导入文案未分配套别(A/B/C),请重新导入文案后再去生图",
}, seq + 1)
return seq + 1
else:
_strategies = ("A", "B", "C")
_is_regen = bool(regen_strategy or regen_role) _is_regen = bool(regen_strategy or regen_role)
# 进度总数:单张重生=1单套=image_count全量=image_count×3 # 进度总数:单张重生=1单套=image_count全量/导入=image_count×实际套数
if regen_role: if regen_role:
_img_total = 1 _img_total = 1
elif regen_strategy: elif regen_strategy:
_img_total = task.image_count _img_total = task.image_count
else: else:
_img_total = task.image_count * 3 _img_total = task.image_count * len(_strategies)
for si, strategy in enumerate(_strategies): for si, strategy in enumerate(_strategies):
t0 = time.monotonic() t0 = time.monotonic()
img_success = True img_success = True
img_error_code = None img_error_code = None
try: try:
note_for_strategy = notes_by_strategy.get(strategy)
if not note_for_strategy:
logger.error("%s缺少合格文案,拒绝复用其他套文案生图: task_id=%s", strategy, task.id)
seq += 1
push_fn(task.id, workspace_id, "batch_failed", {
"batch": f"{strategy}_missing_text",
"reason": f"{strategy}缺少合格文案,未生成该套图片",
"strategy": strategy,
"retryable": False,
}, seq)
continue
image_results = asyncio.run(generate_storyboard_images( image_results = asyncio.run(generate_storyboard_images(
client=clients, client=clients,
note=first_copy, note=note_for_strategy,
product=product_dict, product=product_dict,
image_count=task.image_count, image_count=task.image_count,
reference_images=reference_images or None, reference_images=reference_images or None,

View File

@@ -46,19 +46,37 @@ def decrypt_user_key(db, operator_id: int, workspace_id: int) -> str:
return decrypt_key(api_key_row.encrypted_key) return decrypt_key(api_key_row.encrypted_key)
def build_clients_and_clear_key(plain_key: str): def decrypt_codeproxy_key(db, operator_id: int, workspace_id: int) -> str | None:
"""
查用户录入的 codeproxy 备用站 key → Fernet 解密,返回 plain_key 或 None。
备用通道:用户没录则返回 None不抛错build_ai_clients 会回落 env
主生图流程绝不因没录备用 key 而中断apiports 主通道才是必需)。
"""
from app.models.workspace import UserApiKey
from app.utils.fernet_utils import decrypt_key
row = db.query(UserApiKey).filter(
UserApiKey.user_id == operator_id,
UserApiKey.workspace_id == workspace_id,
UserApiKey.provider == "codeproxy",
).first()
return decrypt_key(row.encrypted_key) if row else None
def build_clients_and_clear_key(plain_key: str, alt_key: str | None = None):
""" """
Step3: 构建 AIClientsplain_key 传入后立即由调用方置 None。 Step3: 构建 AIClientsplain_key 传入后立即由调用方置 None。
alt_key用户录入的 codeproxy 备用 key可选同样由调用方查库解密后传入。
返回 clients 对象。 返回 clients 对象。
""" """
from app.services.ai_engine.gemini_factory import build_ai_clients from app.services.ai_engine.gemini_factory import build_ai_clients
return build_ai_clients(plain_key) return build_ai_clients(plain_key, alt_key=alt_key)
def build_product_dict(product) -> dict: def build_product_dict(product) -> dict:
"""把 ORM product 转成 AI 引擎所需的 dict不含任何 key""" """把 ORM product 转成 AI 引擎所需的 dict不含任何 key"""
return { return {
"id": product.id, "id": getattr(product, "id", None),
"name": product.name, "name": product.name,
"category": product.category or "通用好物", "category": product.category or "通用好物",
"selling_points": json.loads(product.selling_points or "[]"), "selling_points": json.loads(product.selling_points or "[]"),
@@ -133,4 +151,7 @@ def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict:
for e in recent for e in recent
] ]
ctx = aggregate_preference_context(events_dicts, product_dict, workspace_id, product_id) ctx = aggregate_preference_context(events_dicts, product_dict, workspace_id, product_id)
# 累积感知:补该产品累计信号总数(前端"飞轮已积累N条信号"),与展示端同口径
from app.services.flywheel_service import count_signals
ctx["signal_count"] = count_signals(db, workspace_id, product_id)
return ctx.get("prompt_fragment", ""), ctx return ctx.get("prompt_fragment", ""), ctx

View File

@@ -34,6 +34,7 @@ def replenish_text_candidates(self, task_id: int) -> dict:
from app.workers.pipeline_steps import ( from app.workers.pipeline_steps import (
load_task_and_product, load_task_and_product,
decrypt_user_key, decrypt_user_key,
decrypt_codeproxy_key,
build_clients_and_clear_key, build_clients_and_clear_key,
build_product_dict, build_product_dict,
load_flywheel_context, load_flywheel_context,
@@ -57,8 +58,10 @@ def replenish_text_candidates(self, task_id: int) -> dict:
workspace_id = task.workspace_id workspace_id = task.workspace_id
plain_key = decrypt_user_key(db, task.operator_id, workspace_id) plain_key = decrypt_user_key(db, task.operator_id, workspace_id)
clients = build_clients_and_clear_key(plain_key) alt_key = decrypt_codeproxy_key(db, task.operator_id, workspace_id)
clients = build_clients_and_clear_key(plain_key, alt_key)
plain_key = None # 用完即清除基石B plain_key = None # 用完即清除基石B
alt_key = None
product_dict = build_product_dict(product) product_dict = build_product_dict(product)
flywheel_fragment, _ = load_flywheel_context( flywheel_fragment, _ = load_flywheel_context(
db, workspace_id, task.product_id, product_dict db, workspace_id, task.product_id, product_dict
@@ -71,7 +74,7 @@ def replenish_text_candidates(self, task_id: int) -> dict:
current = existing current = existing
while current < target and rounds < MAX_REPLENISH_ROUNDS: while current < target and rounds < MAX_REPLENISH_ROUNDS:
rounds += 1 rounds += 1
run_text_generation( _, _, _, fail_reason, _ = run_text_generation(
db, clients, task, product_dict, flywheel_fragment, db, clients, task, product_dict, flywheel_fragment,
_push_event_sync, workspace_id, seq, _push_event_sync, workspace_id, seq,
) )
@@ -85,6 +88,11 @@ def replenish_text_candidates(self, task_id: int) -> dict:
"补充轮 %d: task_id=%s 库内合格=%d/%d", "补充轮 %d: task_id=%s 库内合格=%d/%d",
rounds, task_id, current, target, rounds, task_id, current, target,
) )
# 评分通道不可用时再补也是空烧 token(每轮还各 35s backoff)——立即停,
# 等通道恢复用户手动重试。区别于"质量不达标"(那个值得多补几轮)。
if fail_reason == "scoring_unavailable":
logger.error("补充中止: task_id=%s 评分通道不可用,停止空跑重试", task_id)
break
return { return {
"task_id": task_id, "task_id": task_id,

View File

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

View File

@@ -18,6 +18,22 @@ logger = logging.getLogger(__name__)
settings = get_settings() settings = get_settings()
def _cors_origins() -> list[str]:
origins = [
item.strip()
for item in (settings.CORS_ALLOW_ORIGINS or "").split(",")
if item.strip()
]
if settings.APP_ENV == "production":
return [origin for origin in origins if origin != "*"]
dev_origins = set(origins or ["http://localhost:3000"])
if "http://localhost:3000" in dev_origins:
dev_origins.add("http://127.0.0.1:3000")
if "http://127.0.0.1:3000" in dev_origins:
dev_origins.add("http://localhost:3000")
return sorted(dev_origins)
def create_app() -> FastAPI: def create_app() -> FastAPI:
app = FastAPI( app = FastAPI(
title="Clover API", title="Clover API",
@@ -29,7 +45,7 @@ def create_app() -> FastAPI:
# ── CORS ────────────────────────────────────────── # ── CORS ──────────────────────────────────────────
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # 生产环境收窄到前端域名 allow_origins=_cors_origins(),
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -60,7 +76,7 @@ def create_app() -> FastAPI:
_register_routers(app) _register_routers(app)
# ── 静态文件G4坑修复图片 /uploads 路由)────── # ── 静态文件G4坑修复图片 /uploads 路由)──────
uploads_dir = os.path.join(os.path.dirname(__file__), "uploads") uploads_dir = settings.UPLOAD_ABS_ROOT
os.makedirs(uploads_dir, exist_ok=True) os.makedirs(uploads_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads") app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
@@ -69,6 +85,7 @@ def create_app() -> FastAPI:
def _register_routers(app: FastAPI) -> None: def _register_routers(app: FastAPI) -> None:
from app.api.v1.auth import router as auth_router from app.api.v1.auth import router as auth_router
from app.api.v1.users import router as users_router
from app.api.v1.api_keys import router as api_keys_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.products import router as products_router
from app.api.v1.product_images import router as product_images_router from app.api.v1.product_images import router as product_images_router
@@ -84,6 +101,7 @@ def _register_routers(app: FastAPI) -> None:
prefix = "/api/v1" prefix = "/api/v1"
app.include_router(auth_router, prefix=prefix) app.include_router(auth_router, prefix=prefix)
app.include_router(users_router, prefix=prefix)
app.include_router(api_keys_router, prefix=prefix) app.include_router(api_keys_router, prefix=prefix)
# 导出 router 必须在 products_router 之前:否则 /products/export 被 /products/{product_id} 吞掉 # 导出 router 必须在 products_router 之前:否则 /products/export 被 /products/{product_id} 吞掉
app.include_router(exports_router, prefix=prefix) app.include_router(exports_router, prefix=prefix)

View File

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

View File

@@ -0,0 +1,69 @@
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name _;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
client_max_body_size 20m;
location = /health {
proxy_pass http://api:8000/health;
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 X-Forwarded-Proto https;
}
location ~ ^/api/v1/tasks/[0-9]+/stream$ {
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 X-Forwarded-Proto https;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
add_header X-Accel-Buffering no;
}
location /api/ {
proxy_pass http://api:8000/api/;
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 X-Forwarded-Proto https;
}
location /uploads/ {
proxy_pass http://api:8000/uploads/;
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 X-Forwarded-Proto https;
}
location / {
proxy_pass http://frontend:3000;
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 X-Forwarded-Proto https;
}
}

4
backend/pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -q

27
backend/scripts/backup.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env sh
set -eu
BACKUP_DIR="${BACKUP_DIR:-./backups/$(date +%Y%m%d-%H%M%S)}"
MYSQL_CONTAINER="${MYSQL_CONTAINER:-clover-mysql-1}"
MONGO_CONTAINER="${MONGO_CONTAINER:-clover-mongo-1}"
UPLOADS_DIR="${UPLOADS_DIR:-/app/uploads}"
mkdir -p "$BACKUP_DIR"
if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ] || [ -z "${MYSQL_DB:-}" ]; then
echo "MYSQL_USER/MYSQL_PASSWORD/MYSQL_DB must be set" >&2
exit 1
fi
echo "[1/3] dump mysql -> $BACKUP_DIR/mysql.sql.gz"
docker exec "$MYSQL_CONTAINER" sh -c "mysqldump -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DB" | gzip > "$BACKUP_DIR/mysql.sql.gz"
gzip -t "$BACKUP_DIR/mysql.sql.gz"
echo "[2/3] dump mongo -> $BACKUP_DIR/mongo.archive.gz"
docker exec "$MONGO_CONTAINER" sh -c "mongodump --archive --gzip --db clover_trace" > "$BACKUP_DIR/mongo.archive.gz"
gzip -t "$BACKUP_DIR/mongo.archive.gz" || true
echo "[3/3] archive uploads -> $BACKUP_DIR/uploads.tgz"
tar -czf "$BACKUP_DIR/uploads.tgz" "$UPLOADS_DIR"
echo "backup completed: $BACKUP_DIR"

View File

@@ -16,7 +16,7 @@ from app.core.database import SessionLocal # noqa: E402
from app.models.user import User # noqa: E402 from app.models.user import User # noqa: E402
from app.models.workspace import Workspace, WorkspaceMember # noqa: E402 from app.models.workspace import Workspace, WorkspaceMember # noqa: E402
from app.models.product import Product, BannedWord # noqa: E402 from app.models.product import Product, BannedWord # noqa: E402
from app.services.auth_service import hash_password # noqa: E402 from app.services.auth_service import DEFAULT_SEED_PASSWORD, hash_password # noqa: E402
get_settings() # 触发 .env 校验,缺变量早报错 get_settings() # 触发 .env 校验,缺变量早报错
@@ -24,10 +24,18 @@ get_settings() # 触发 .env 校验,缺变量早报错
WORKSPACE_NAME = "北哥小红书车间" WORKSPACE_NAME = "北哥小红书车间"
WORKSPACE_SLUG = "beige-xhs" WORKSPACE_SLUG = "beige-xhs"
SEED_PASSWORDS = {
"admin": os.environ.get("CLOVER_SEED_PASSWORD_ADMIN", os.environ.get("CLOVER_SEED_PASSWORD", DEFAULT_SEED_PASSWORD)),
"supervisor": os.environ.get("CLOVER_SEED_PASSWORD_SUPERVISOR", os.environ.get("CLOVER_SEED_PASSWORD", DEFAULT_SEED_PASSWORD)),
"operator": os.environ.get("CLOVER_SEED_PASSWORD_OPERATOR", os.environ.get("CLOVER_SEED_PASSWORD", DEFAULT_SEED_PASSWORD)),
}
if get_settings().APP_ENV == "production" and any(p == DEFAULT_SEED_PASSWORD for p in SEED_PASSWORDS.values()):
raise RuntimeError("Production seed requires CLOVER_SEED_PASSWORD; default password is forbidden")
USERS = [ USERS = [
{"username": "admin", "email": "admin@clover.local", "password": "Clover2026!", "role": "admin"}, {"username": "admin", "email": "admin@clover.local", "password": SEED_PASSWORDS["admin"], "role": "admin"},
{"username": "supervisor", "email": "supervisor@clover.local", "password": "Clover2026!", "role": "supervisor"}, {"username": "supervisor", "email": "supervisor@clover.local", "password": SEED_PASSWORDS["supervisor"], "role": "supervisor"},
{"username": "operator", "email": "operator@clover.local", "password": "Clover2026!", "role": "operator"}, {"username": "operator", "email": "operator@clover.local", "password": SEED_PASSWORDS["operator"], "role": "operator"},
] ]
PRODUCT = { PRODUCT = {
@@ -72,6 +80,7 @@ def run():
email=u_spec["email"], email=u_spec["email"],
hashed_password=hash_password(u_spec["password"]), hashed_password=hash_password(u_spec["password"]),
is_active=True, is_active=True,
must_change_password=True,
) )
db.add(user) db.add(user)
db.flush() db.flush()
@@ -109,38 +118,9 @@ def run():
else: else:
print(f"[=] banned_word 已存在: {bw_spec['word']}") print(f"[=] banned_word 已存在: {bw_spec['word']}")
# ── user_api_keysR3明文key只在加密瞬间用不打印不落明文)── # ── user_api_keys不再预置倩倩姐2026-06-12拍板系统埋的key彻底拔干净)──
apiports_key = os.environ.get("APIPORTS_KEY", "").strip() # 每个用户登录后到「设置 → API Key」自录自用录一次即记住。
if apiports_key: # 种子不再从 APIPORTS_KEY 环境变量批量塞 key避免"免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() db.commit()
print("\n种子数据初始化完成。") print("\n种子数据初始化完成。")

70
backend/scripts/watch.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# watch.sh — Clover 全站实时监控
# 用途: 倩倩姐操作页面时, 实时显示哪个请求出错(4xx/5xx)、哪里抛异常。
# 用法: bash backend/scripts/watch.sh # 只看错误(默认, 干净)
# bash backend/scripts/watch.sh --all # 看全部请求(含正常)
# 退出: Ctrl-C
set -u
MODE="${1:-errors}" # errors(默认只看错误) | --all(全部)
# 颜色
R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[36m'; DIM=$'\033[2m'; N=$'\033[0m'
echo "${B}========================================${N}"
echo "${B} Clover 实时监控 (Ctrl-C 退出)${N}"
if [ "$MODE" = "--all" ]; then
echo " 模式: 全部请求"
else
echo " 模式: 只看错误 (加 --all 看全部)"
fi
echo "${B}========================================${N}"
echo "${DIM}盯着: clover_api + clover_worker. 现在去浏览器操作, 出错会标红.${N}"
echo ""
# 同时跟随 api 和 worker 日志, 每行打上来源标签
{
docker logs -f --tail 0 clover_api 2>&1 | sed "s/^/[API] /" &
API_PID=$!
docker logs -f --tail 0 clover_worker 2>&1 | sed "s/^/[WRK] /" &
WRK_PID=$!
trap "kill $API_PID $WRK_PID 2>/dev/null" EXIT INT TERM
wait
} | while IFS= read -r line; do
# 过滤纯噪音: 健康检查 + SQLAlchemy 回显
case "$line" in
*"/health"*) continue ;;
*"sqlalchemy"*|*"SELECT "*|*"FROM "*|*"WHERE "*|*"LIMIT "*|*"INSERT "*|*"UPDATE "*|*"BEGIN "*|*"COMMIT"*|*"ROLLBACK"*) continue ;;
esac
ts=$(date '+%H:%M:%S')
# 1) HTTP 请求行: 提取状态码
if echo "$line" | grep -qE '"(GET|POST|PUT|DELETE|PATCH) '; then
code=$(echo "$line" | grep -oE 'HTTP/1\.1" [0-9]{3}' | grep -oE '[0-9]{3}$')
req=$(echo "$line" | grep -oE '"(GET|POST|PUT|DELETE|PATCH) [^"]*"')
src=$(echo "$line" | grep -oE '^\[[A-Z]+\]')
case "$code" in
2*) [ "$MODE" = "--all" ] && echo "${DIM}${ts} ${src} ${G}${code}${N}${DIM} ${req}${N}" ;;
4*) echo "${ts} ${src} ${Y}${code} ⚠ 客户端错误${N} ${req}" ;;
5*) echo "${ts} ${src} ${R}${code} ✖ 服务端错误${N} ${req}" ;;
*) [ "$MODE" = "--all" ] && echo "${ts} ${src} ${code} ${req}" ;;
esac
continue
fi
# 2) 异常/报错/堆栈: 永远显示并标红
if echo "$line" | grep -qiE "error|exception|traceback|critical|失败|拒绝|unauthorized|forbidden|raise"; then
echo "${ts} ${R}${line}${N}"
continue
fi
# 3) WARNING 级: 黄色提示
if echo "$line" | grep -qE "WARNING|warn"; then
echo "${ts} ${Y}${line}${N}"
continue
fi
# 4) 其余行仅在 --all 模式显示
[ "$MODE" = "--all" ] && echo "${DIM}${ts} ${line}${N}"
done

View File

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

View File

@@ -0,0 +1,106 @@
import json
from pathlib import Path
import pytest
from app.api.v1 import task_actions
from app.core.response import CloverHTTPException
from app.services.fission_service import _source_copy_from_payload
class _Query:
def __init__(self, rows):
self._rows = rows
def filter(self, *args, **kwargs):
return self
def all(self):
return self._rows
class _Db:
def __init__(self, text_rows, image_rows):
self._queries = [_Query(text_rows), _Query(image_rows)]
def query(self, *args, **kwargs):
return self._queries.pop(0)
class _Task:
id = 7
image_count = 3
def test_submit_review_requires_each_strategy_to_select_full_image_count():
db = _Db(
text_rows=[("A",), ("B",), ("C",)],
image_rows=[("A", 1), ("A", 2), ("A", 3), ("B", 4), ("C", 5), ("C", 6), ("C", 7)],
)
with pytest.raises(CloverHTTPException) as exc:
task_actions._validate_strategy_selection(db, _Task())
assert "图片未选满B(1/3)" in exc.value.biz_message
def test_submit_review_passes_when_all_strategies_have_text_and_full_images():
db = _Db(
text_rows=[("A",), ("B",), ("C",)],
image_rows=[
("A", 1), ("A", 2), ("A", 3),
("B", 4), ("B", 5), ("B", 6),
("C", 7), ("C", 8), ("C", 9),
],
)
task_actions._validate_strategy_selection(db, _Task())
def test_fission_source_extracts_copy_body_from_json_candidate():
payload = json.dumps({
"title": "早八伪素颜",
"content": "通勤前快速提亮,不卡粉。",
"selling_points": ["黄皮友好", "妆感轻"],
}, ensure_ascii=False)
source = _source_copy_from_payload(payload)
assert "早八伪素颜" in source
assert "通勤前快速提亮" in source
assert "selling_points" not in source
assert "{" not in source
def test_delivery_status_get_is_read_only_contract():
from app.api.v1 import delivery
assert "commit" not in delivery.get_package_download.__code__.co_names
assert "commit" not in delivery.download_package_file.__code__.co_names
assert "commit" in delivery.mark_package_downloaded.__code__.co_names
def test_workspace_switch_response_includes_role_contract():
from app.api.v1 import workspaces
constants = workspaces.switch_workspace.__code__.co_consts
assert ("current_workspace_id", "role", "token") in constants
def test_production_cors_filters_wildcard():
source = Path("main.py").read_text()
assert "CORS_ALLOW_ORIGINS" in Path("app/core/config.py").read_text()
assert 'origin != "*"' in source
assert "allow_origins=_cors_origins()" in source
def test_production_rejects_default_seed_password_contract():
source = Path("app/services/auth_service.py").read_text()
seed_source = Path("scripts/seed_data.py").read_text()
assert 'DEFAULT_SEED_PASSWORD = "Clover2026!"' in source
assert 'APP_ENV == "production"' in source
assert "默认密码已禁用" in source
assert "Production seed requires CLOVER_SEED_PASSWORD" in seed_source

View File

@@ -0,0 +1,64 @@
import asyncio
import base64
from app.services.ai_engine.gemini_factory import AIClients
class _Resp:
def __init__(self, payload):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
class _Client:
def __init__(self, payload):
self.payload = payload
self.calls = []
async def post(self, url, **kwargs):
self.calls.append({"url": url, **kwargs})
return _Resp(self.payload)
def test_apiports_image_edit_uses_chat_completions(monkeypatch):
img = base64.b64encode(b"image-bytes").decode()
fake = _Client({
"choices": [{
"message": {
"images": [{"b64_json": img}],
},
}],
})
client = AIClients(_gpt_token="apiports-token", _gpt_base="https://apiports.example")
monkeypatch.setattr(client, "_gpt_target", lambda provider: ("https://apiports.example", "apiports-token", fake))
out = asyncio.run(client.gpt_edits("keep bottle", [b"ref"], "1024x1536", provider="apiports"))
assert out == b"image-bytes"
assert fake.calls[0]["url"] == "https://apiports.example/chat/completions"
payload = fake.calls[0]["json"]
assert payload["model"] == client._model_image
content = payload["messages"][0]["content"]
assert content[0]["type"] == "text"
assert content[1]["type"] == "image_url"
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
def test_codeproxy_image_edit_keeps_images_edits(monkeypatch):
img = base64.b64encode(b"codeproxy-image").decode()
fake = _Client({"data": [{"b64_json": img}]})
client = AIClients(_alt_token="codeproxy-token", _alt_base="https://codeproxy.example")
monkeypatch.setattr(client, "_gpt_target", lambda provider: ("https://codeproxy.example", "codeproxy-token", fake))
out = asyncio.run(client.gpt_edits("keep bottle", [b"ref"], "1024x1536", provider="codeproxy"))
assert out == b"codeproxy-image"
assert fake.calls[0]["url"] == "https://codeproxy.example/images/edits"
files = fake.calls[0]["files"]
assert any(part[0] == "image[]" for part in files)
assert any(part[0] == "prompt" for part in files)

9
docker-compose.https.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
nginx:
ports:
- "80:80"
- "443:443"
volumes:
- ./backend/nginx/nginx.https.conf:/etc/nginx/conf.d/default.conf:ro
- uploads:/app/uploads:ro
- ./certs:/etc/nginx/certs:ro

144
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,144 @@
services:
api:
build:
context: ./backend
dockerfile: Dockerfile
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
APP_ENV: production
FERNET_KEY: ${FERNET_KEY}
JWT_SECRET: ${JWT_SECRET}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS}
IMAGE_PROVIDER_PRIMARY: ${IMAGE_PROVIDER_PRIMARY}
IMAGE_PROVIDER_FALLBACK: ${IMAGE_PROVIDER_FALLBACK}
IMAGE_MODEL: ${IMAGE_MODEL}
MODEL_IMAGE: ${MODEL_IMAGE}
MODEL_TEXT: ${MODEL_TEXT}
APIPORTS_BASE_URL: ${APIPORTS_BASE_URL}
APIPORTS_KEY: ${APIPORTS_KEY}
CODEPROXY_BASE_URL: ${CODEPROXY_BASE_URL}
CODEPROXY_KEY: ${CODEPROXY_KEY}
GEMINI_API_URL: ${GEMINI_API_URL}
MAX_CONCURRENT_TASKS_PER_USER: ${MAX_CONCURRENT_TASKS_PER_USER}
UPLOAD_BASE_PATH: ${UPLOAD_BASE_PATH}
UPLOAD_ABS_ROOT: ${UPLOAD_ABS_ROOT}
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
mongo:
condition: service_healthy
volumes:
- uploads:/app/uploads
expose:
- "8000"
command: sh -c "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
worker:
build:
context: ./backend
dockerfile: Dockerfile
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
APP_ENV: production
FERNET_KEY: ${FERNET_KEY}
JWT_SECRET: ${JWT_SECRET}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS}
IMAGE_PROVIDER_PRIMARY: ${IMAGE_PROVIDER_PRIMARY}
IMAGE_PROVIDER_FALLBACK: ${IMAGE_PROVIDER_FALLBACK}
IMAGE_MODEL: ${IMAGE_MODEL}
MODEL_IMAGE: ${MODEL_IMAGE}
MODEL_TEXT: ${MODEL_TEXT}
APIPORTS_BASE_URL: ${APIPORTS_BASE_URL}
APIPORTS_KEY: ${APIPORTS_KEY}
CODEPROXY_BASE_URL: ${CODEPROXY_BASE_URL}
CODEPROXY_KEY: ${CODEPROXY_KEY}
GEMINI_API_URL: ${GEMINI_API_URL}
MAX_CONCURRENT_TASKS_PER_USER: ${MAX_CONCURRENT_TASKS_PER_USER}
UPLOAD_BASE_PATH: ${UPLOAD_BASE_PATH}
UPLOAD_ABS_ROOT: ${UPLOAD_ABS_ROOT}
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
mongo:
condition: service_healthy
volumes:
- uploads:/app/uploads
command: celery -A app.workers.celery_app worker -Q generation,packaging -c 2 -l info
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_SSE_BASE_URL: ${NEXT_PUBLIC_SSE_BASE_URL}
depends_on:
api:
condition: service_healthy
expose:
- "3000"
command: npm run start
nginx:
image: nginx:1.27-alpine
depends_on:
api:
condition: service_healthy
frontend:
condition: service_started
ports:
- "80:80"
volumes:
- ./backend/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- uploads:/app/uploads:ro
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DB}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
mongo:
image: mongo:7.0
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7.4-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
mysql_data:
mongo_data:
uploads:

34
docs/ops/fernet.md Normal file
View File

@@ -0,0 +1,34 @@
# FERNET_KEY 管理
`FERNET_KEY` 用于加密客户在工作台录入的 API Key。它不是普通配置必须单独备份。
## 规则
- 每个生产环境生成唯一 `FERNET_KEY`
- 不要复用本地测试环境的 key。
- 不要提交到 git。
- 不要发在聊天记录里。
- 备份时和数据库备份分开放置。
## 丢失后果
如果 `FERNET_KEY` 丢失,数据库里的客户 API Key 密文无法解密,客户需要重新录入 key。
## 生成方式
```bash
python3 - <<'PY'
from cryptography.fernet import Fernet
print(Fernet.generate_key().decode())
PY
```
## 轮换方式
一期不做密文在线重加密。需要轮换时:
1. 通知客户暂停生成。
2. 清空 `user_api_keys` 中旧密文,或让客户在设置页逐个覆盖录入。
3. 更新服务器 `.env``FERNET_KEY`
4. 重启 api/worker。
5. 让客户重新录入 API Key。

74
docs/ops/runbook.md Normal file
View File

@@ -0,0 +1,74 @@
# Clover 运维手册
## 部署
1. 复制 `.env.example``.env`
2. 填写 `FERNET_KEY``JWT_SECRET`、数据库密码、域名、AI provider base URL。
3. 如果前端和 API 走同一个 nginx 域名,`NEXT_PUBLIC_API_BASE_URL``NEXT_PUBLIC_SSE_BASE_URL` 保持空;如果前后端分域,填公网 API 域名,并且每次修改后必须重新 build 前端镜像。
4. 内测 HTTP 启动:
```bash
docker compose -f docker-compose.prod.yml --env-file .env up -d --build
```
5. 正式 HTTPS 启动前置条件:
- 域名已经解析到服务器公网 IP。
- 证书文件放在 `certs/fullchain.pem``certs/privkey.pem`
- `.env``CORS_ALLOW_ORIGINS` 填真实 HTTPS 域名。
```bash
docker compose -f docker-compose.prod.yml -f docker-compose.https.yml --env-file .env up -d --build
```
6. 查看状态:
```bash
docker compose -f docker-compose.prod.yml ps
curl http://127.0.0.1/health
```
HTTPS 模式验证:
```bash
docker compose -f docker-compose.prod.yml -f docker-compose.https.yml ps
curl -I https://<domain>/health
```
## 更新
```bash
git fetch --tags
git checkout <delivery-tag>
docker compose -f docker-compose.prod.yml --env-file .env up -d --build
```
## 回滚
```bash
git checkout <previous-delivery-tag>
docker compose -f docker-compose.prod.yml --env-file .env up -d --build
```
## 备份
```bash
cd backend
MYSQL_USER=clover MYSQL_PASSWORD='<password>' MYSQL_DB=clover sh scripts/backup.sh
```
备份内容包括 MySQL、MongoDB、uploads。
## 密钥
- `FERNET_KEY` 必须备份,丢失后客户 API Key 需要重新录入。
- `JWT_SECRET` 轮换会让所有登录态失效。
- 服务器 `.env` 可放测试公共 key客户自购 key 应在工作台由客户自己录入。
## 日志
```bash
docker compose -f docker-compose.prod.yml logs -f api
docker compose -f docker-compose.prod.yml logs -f worker
docker compose -f docker-compose.prod.yml logs -f nginx
```

View File

@@ -0,0 +1,52 @@
# Clover 排障手册
## 服务不可访问
```bash
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs --tail=100 nginx
curl -I http://127.0.0.1/
```
## 后端异常
```bash
curl http://127.0.0.1/health
docker compose -f docker-compose.prod.yml logs --tail=200 api
docker compose -f docker-compose.prod.yml logs --tail=200 worker
```
## SSE 没有实时进度
检查 nginx 是否关闭缓冲:
```bash
curl -N -H "Accept: text/event-stream" "http://127.0.0.1/api/v1/tasks/<task_id>/stream?ticket=<ticket>"
```
应能持续收到 named event。nginx 配置必须包含 `proxy_buffering off``X-Accel-Buffering: no`
## 图片显示 404
```bash
docker compose -f docker-compose.prod.yml exec api ls -lah /app/uploads
docker compose -f docker-compose.prod.yml exec worker ls -lah /app/uploads
curl -I http://127.0.0.1/uploads/<path>
```
api 和 worker 必须共享同一个 `uploads` volume。
## 生成失败
常见原因:
- 客户未录入 API Key。
- provider key 余额不足或无权限。
- 上游中转站 429/503。
- 产品入镜但未上传产品图。
查看 worker 日志:
```bash
docker compose -f docker-compose.prod.yml logs --tail=200 worker
```

197
docs/user/使用说明.md Normal file
View File

@@ -0,0 +1,197 @@
# Clover 用户操作手册
适用对象:运营、组长、管理员。
访问地址:由管理员提供;本机预览地址为 `http://localhost:3001`
## 1. 登录与首次改密
1. 打开平台地址。
2. 输入管理员提供的账号和初始密码。
3. 首次登录会进入「修改密码」页面。
4. 输入当前密码、新密码、确认新密码。
5. 修改成功后,系统会按角色进入对应页面。
角色入口:
- 管理员:进入「系统管理」,可维护产品、标杆笔记、违禁词。
- 组长:进入「审核台」,负责通过或打回内容。
- 运营:进入「开新任务」,负责生成内容和提交审核。
## 2. 录入自己的 API Key
进入「工作配置」或「设置」→「API Key」。
1. 选择 Provider。
2. 粘贴自己的 API Key。
3. 点击「测试」。
4. 提示测试通过后,点击「保存」。
Provider 说明:
- 主通道 apiports必填用于文案和图片生成。
- 备用通道 codeproxy选填主通道繁忙时自动兜底。
- Gemini选填作为备用能力。
注意:
- 系统只显示 Key 后 4 位,不展示完整 Key。
- 每个账号使用自己录入的 Key。
- 后续换 Key 时,点击对应通道的「修改」,重新填写后保存即可覆盖。
## 3. 新建或维护产品
进入「开新任务」。
1. 在产品区域选择已有产品,或点击新建产品。
2. 填写产品名称、品牌词、卖点。
3. 上传产品参考图。
4. 如果要让产品出现在生成图片中,必须至少上传一张清晰产品图。
5. 可给产品图选择场景角色,例如主图、细节、使用场景等。
产品图建议:
- 图片清晰,瓶身或包装完整。
- 不要使用过度裁切、遮挡、强滤镜图片。
- 同一个产品可上传多张图,方便系统按分镜自动选择参考图。
## 4. 创建生成任务
进入「开新任务」。
1. 选择产品。
2. 填写本次内容主题。
3. 选择是否「产品入镜」。
4. 设置每套图片数量。
5. 点击生成。
系统会生成 A/B/C 三套不同叙事:
- A痛点先行。
- B场景先行。
- C成分背书。
生成过程中不要关闭页面。若页面刷新,可回到「历史归档」或当前任务继续查看。
## 5. 选择文案
进入文案选择页后:
1. 分别查看 A/B/C 三套文案。
2. 每套选择 1 条文案。
3. 注意查看质量分和合规提示。
4. 三套都选完后,进入图片选择。
如果文案不满意,可以重新生成。
## 6. 选择图片
进入图片选择页后:
1. 图片按 A/B/C 三套分组展示。
2. 每套需要选满任务设置的图片数量。
3. 点击图片可放大查看。
4. 不满意的图片可重生单张或整套。
5. 如果某批图片失败,页面会显示失败原因,可点击重试。
选图完成后,进入确认预览页。
## 7. 提交审核
在确认预览页:
1. 检查已选文案、图片、标签和标题。
2. 确认无误后点击「提交审核」。
3. 任务会进入「待审核」状态。
被打回后:
1. 在任务页查看打回原因。
2. 按组长意见调整文案或图片。
3. 再次提交审核。
## 8. 组长审核
组长或管理员进入「审核台」。
1. 查看待审核任务。
2. 检查 A/B/C 三套内容、图片和质量分。
3. 通过:内容进入可打包状态。
4. 打回:必须填写打回原因,运营会在任务页看到原因。
## 9. 下载交付包
进入「历史归档」。
1. 找到已通过审核的任务。
2. 点击生成或下载交付包。
3. 下载 zip 文件。
交付包通常包含:
- 每套笔记文案。
- 已选图片。
- 发布清单。
- 合规说明。
下载后请抽查压缩包内容是否完整。
## 10. 裂变扩散
进入「裂变扩散」。
1. 选择一个已经通过审核的源任务。
2. 选择参考程度。
3. 设置裂变套数。
4. 点击开始裂变。
5. 完成后进入裂变结果页查看多套笔记包。
如果系统提示使用了兜底草稿,需要人工复核标题、正文和分镜后再交付。
## 11. 常见问题
提示未配置 API Key
- 到「设置」→「API Key」录入并测试 Key。
API Key 测试失败:
- 检查 Key 是否复制完整。
- 检查中转站余额和模型权限。
- 检查是否选择了正确 Provider。
产品入镜失败:
- 检查产品是否上传参考图。
- 换更清晰、无遮挡的产品图后重试。
生成进度不动:
- 先刷新页面或回到「历史归档」查看任务状态。
- 若仍无变化,联系管理员检查 worker、SSE 和上游中转站。
图片不符合预期:
- 优先更换更清晰的产品图。
- 对单张图片使用重生。
- 必要时调整主题表达后重新生成。
被组长打回:
- 查看打回原因。
- 按原因修改后重新提交审核。
下载包打不开或内容缺失:
- 重新生成交付包。
- 若仍失败,联系管理员检查 uploads 持久化目录和打包任务日志。
## 12. 客户交付前检查
正式交付客户使用前,管理员需要确认:
- 客户访问域名可打开。
- HTTPS 证书正常。
- 三类账号可登录。
- 首次登录会强制改密。
- 客户账号可以录入并测试 API Key。
- 能完整跑通一条任务:建产品 → 生成 → 选择 → 审核 → 下载交付包。

9
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.env
.env.*
!.env.example
node_modules/
.next/
dist/
build/
*.log
.DS_Store

4
frontend/.env.example Normal file
View File

@@ -0,0 +1,4 @@
# Same-origin nginx deployment can keep these empty.
# These are build-time variables. After changing them, rebuild the frontend image.
NEXT_PUBLIC_API_BASE_URL=
NEXT_PUBLIC_SSE_BASE_URL=

28
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_SSE_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_SSE_BASE_URL=$NEXT_PUBLIC_SSE_BASE_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json package-lock.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.js ./next.config.js
RUN npm prune --omit=dev
EXPOSE 3000
CMD ["npm", "run", "start"]

View File

@@ -1,6 +1,6 @@
# Clover Frontend # Clover Frontend
Next.jsReact5屏骨架 Next.jsReact运营端
## 目录规划 ## 目录规划
``` ```
@@ -11,14 +11,15 @@ frontend/src/
types/ # TypeScript 类型 types/ # TypeScript 类型
``` ```
## 5屏 ## 主要页面
1. 管理配置台 — 产品档案/标杆笔记/违禁词/API Key录入 当前 App Router 生产构建包含 13 个路由:首页、看板、配置、仪表盘、裂变、历史、登录、审核、设置、任务创建、文案选择、图片选择、确认预览。
2. 开任务 — 选产品+标杆+今天主题+设定数量,发起生成
3. 挑文案+挑图 — SSE实时看生成进度8选3文案/挑图,显示"本次已注入"
4. 确认预览 — 提交审核
5. 审核台(组长) — 待审队列/通过/打回写原因
## 前端红线(不得违反) ## 前端红线(不得违反)
- 不展示 Token 余额/用量 - 不展示 Token 余额/用量
- 不做积分中心/billing 页 - 不做积分中心/billing 页
- 飞轮偏好信号不暴露独立 POST 给前端(防伪造) - 飞轮偏好信号不暴露独立 POST 给前端(防伪造)
## 环境变量
- `NEXT_PUBLIC_API_BASE_URL`API 基地址;同域 nginx 部署可留空。
- `NEXT_PUBLIC_SSE_BASE_URL`SSE 后端地址;同域 nginx 部署可留空。
- 这两个变量是构建期变量,修改后必须重新 build 前端镜像。

View File

@@ -0,0 +1,18 @@
import nextVitals from 'eslint-config-next/core-web-vitals';
const config = [
...nextVitals,
{
ignores: ['.next/**', 'node_modules/**'],
},
{
rules: {
'react-hooks/immutability': 'off',
'react-hooks/purity': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
},
},
];
export default config;

View File

@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,8 +1,16 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
turbopack: {
root: __dirname,
},
images: { images: {
domains: ['localhost'], remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
},
],
}, },
async rewrites() { async rewrites() {
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'; const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,13 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "clsx": "^2.1.1",
"next": "14.2.0", "next": "^16.2.9",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
@@ -21,10 +21,13 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^8.57.0", "eslint": "^9.39.4",
"eslint-config-next": "14.2.0", "eslint-config-next": "^16.2.9",
"postcss": "^8.4.38", "postcss": "^8.5.15",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"typescript": "^5.4.5" "typescript": "^5.4.5"
},
"overrides": {
"postcss": "^8.5.15"
} }
} }

1
frontend/public/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -5,6 +5,7 @@
* 复用 history 的状态色 + Link 跳转风格,保持视觉一致不割裂。 * 复用 history 的状态色 + Link 跳转风格,保持视觉一致不割裂。
*/ */
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { TaskListItem } from '@/types'; import { TaskListItem } from '@/types';
@@ -18,8 +19,11 @@ export interface ColumnDef {
export const BOARD_COLUMNS: ColumnDef[] = [ export const BOARD_COLUMNS: ColumnDef[] = [
{ key: 'generating', label: '生成中', { key: 'generating', label: '生成中',
statuses: ['pending', 'generating', 'pending_selection'], statuses: ['pending', 'generating'],
accent: 'text-blue-600 bg-blue-50' }, accent: 'text-blue-600 bg-blue-50' },
{ key: 'pending_selection', label: '待提交',
statuses: ['pending_selection'],
accent: 'text-indigo-600 bg-indigo-50' },
{ key: 'review', label: '待审核', { key: 'review', label: '待审核',
statuses: ['pending_review'], accent: 'text-amber-600 bg-amber-50' }, statuses: ['pending_review'], accent: 'text-amber-600 bg-amber-50' },
{ key: 'approved', label: '已通过', { key: 'approved', label: '已通过',
@@ -28,23 +32,27 @@ export const BOARD_COLUMNS: ColumnDef[] = [
statuses: ['rejected'], accent: 'text-red-500 bg-red-50' }, statuses: ['rejected'], accent: 'text-red-500 bg-red-50' },
]; ];
// 卡片点击目标:生成中/待审核进 confirm 看文案,其余进 images 排图/查看 // 卡片点击目标:除「待提交(pending_selection)」进 images 排图外,其余都进 confirm
// (生成中/待审核=看文案进度,已通过/已打回=只读查看文案+图)
function cardHref(task: TaskListItem): string { function cardHref(task: TaskListItem): string {
if (['pending', 'generating', 'pending_selection', 'pending_review'].includes(task.status)) { if (task.status === 'pending_selection') {
return `/tasks/${task.id}/confirm`;
}
return `/tasks/${task.id}/images`; return `/tasks/${task.id}/images`;
} }
return `/tasks/${task.id}/confirm`;
}
// --- TaskCard --- 单个任务卡,点击进详情 // --- TaskCard --- 单个任务卡,点击进详情
export function TaskCard({ task }: { task: TaskListItem }) { // onDelete 可选:仅管理员且非「待审核」列传入 → 卡右上角出删除按钮(先确认再删)
export function TaskCard({ task, onDelete }: { task: TaskListItem; onDelete?: (id: number) => void }) {
const [confirming, setConfirming] = useState(false);
return ( return (
<div className="relative">
<Link <Link
href={cardHref(task)} href={cardHref(task)}
aria-label={`任务 ${task.theme}`} aria-label={`任务 ${task.theme}`}
className="block rounded-lg border border-border-default bg-white p-3 hover:border-brand-orange hover:shadow-sm transition-all" className="block rounded-lg border border-border-default bg-white p-3 hover:border-brand-orange hover:shadow-sm transition-all"
> >
<p className="text-sm font-medium text-text-primary line-clamp-2">{task.theme}</p> <p className="text-sm font-medium text-text-primary line-clamp-2 pr-6">{task.theme}</p>
<p className="mt-1 text-xs text-text-secondary">{task.product_name ?? '—'}</p> <p className="mt-1 text-xs text-text-secondary">{task.product_name ?? '—'}</p>
<div className="mt-2 flex items-center gap-2 text-xs text-text-tertiary"> <div className="mt-2 flex items-center gap-2 text-xs text-text-tertiary">
<span>{task.text_count} </span> <span>{task.text_count} </span>
@@ -59,11 +67,43 @@ export function TaskCard({ task }: { task: TaskListItem }) {
</p> </p>
)} )}
</Link> </Link>
{/* 删除按钮(管理员,非待审核列):浮在 Link 外层,点击不触发跳转 */}
{onDelete && !confirming && (
<button
onClick={() => setConfirming(true)}
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded text-text-tertiary hover:bg-red-50 hover:text-red-500"
aria-label="删除任务"
title="删除任务"
>
</button>
)}
{onDelete && confirming && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-white/95 p-2 text-center">
<p className="text-xs text-text-primary"></p>
<div className="flex gap-2">
<button
onClick={() => setConfirming(false)}
className="rounded border border-border-default px-2 py-0.5 text-xs text-text-secondary hover:bg-surface-secondary"
>
</button>
<button
onClick={() => { onDelete(task.id); }}
className="rounded bg-red-500 px-2 py-0.5 text-xs text-white hover:bg-red-600"
>
</button>
</div>
</div>
)}
</div>
); );
} }
// --- BoardColumn --- 状态列:列头 + 计数 + 卡片堆叠 // --- BoardColumn --- 状态列:列头 + 计数 + 卡片堆叠
export function BoardColumn({ col, tasks }: { col: ColumnDef; tasks: TaskListItem[] }) { // onDelete 可选传入则该列卡片显删除按钮page 仅对管理员+非待审核列传)
export function BoardColumn({ col, tasks, onDelete }: { col: ColumnDef; tasks: TaskListItem[]; onDelete?: (id: number) => void }) {
return ( return (
<div className="flex flex-col min-w-[240px] flex-1 rounded-xl bg-surface-secondary p-3"> <div className="flex flex-col min-w-[240px] flex-1 rounded-xl bg-surface-secondary p-3">
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
@@ -76,7 +116,7 @@ export function BoardColumn({ col, tasks }: { col: ColumnDef; tasks: TaskListIte
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<p className="py-8 text-center text-xs text-text-tertiary"></p> <p className="py-8 text-center text-xs text-text-tertiary"></p>
) : ( ) : (
tasks.map((t) => <TaskCard key={t.id} task={t} />) tasks.map((t) => <TaskCard key={t.id} task={t} onDelete={onDelete} />)
)} )}
</div> </div>
</div> </div>

View File

@@ -8,6 +8,7 @@
*/ */
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import { TaskListItem, PaginatedResponse } from '@/types'; import { TaskListItem, PaginatedResponse } from '@/types';
import { getErrorAction } from '@/types/errors'; import { getErrorAction } from '@/types/errors';
import { BOARD_COLUMNS, BoardColumn, BoardSkeleton } from './components'; import { BOARD_COLUMNS, BoardColumn, BoardSkeleton } from './components';
@@ -20,6 +21,8 @@ export default function BoardPage() {
const [tasks, setTasks] = useState<TaskListItem[]>([]); const [tasks, setTasks] = useState<TaskListItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [productId, setProductId] = useState<number | 'all'>('all'); // 顶部产品筛选
const isAdmin = useAuthStore((s) => s.role) === 'admin'; // 删除按钮仅管理员可见
const fetchTasks = useCallback(async () => { const fetchTasks = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -39,10 +42,27 @@ export default function BoardPage() {
useEffect(() => { fetchTasks(); }, [fetchTasks]); useEffect(() => { fetchTasks(); }, [fetchTasks]);
// 按列分组:每列收自己 statuses 命中的任务 // 删除整个任务(仅管理员):后端智能删(有成品→归档保留,残次→物理删),成功后本地移除卡片
const handleDelete = useCallback(async (id: number) => {
try {
await api.delete(`/api/v1/tasks/${id}`);
setTasks((prev) => prev.filter((t) => t.id !== id));
} catch (e: unknown) {
const ae = e as { code?: number };
setError(getErrorAction(ae.code ?? 50001).message);
}
}, []);
// 任务里出现过的产品去重,喂给顶部下拉(数据已带 product_id/product_name不改后端
const products = Array.from(
new Map(tasks.map((t) => [t.product_id, t.product_name ?? `产品#${t.product_id}`])).entries(),
).map(([id, name]) => ({ id, name }));
// 先按选中产品过滤,再按列分组
const visibleTasks = productId === 'all' ? tasks : tasks.filter((t) => t.product_id === productId);
const byColumn = BOARD_COLUMNS.map((col) => ({ const byColumn = BOARD_COLUMNS.map((col) => ({
col, col,
items: tasks.filter((t) => col.statuses.includes(t.status)), items: visibleTasks.filter((t) => col.statuses.includes(t.status)),
})); }));
return ( return (
@@ -58,6 +78,21 @@ export default function BoardPage() {
</button> </button>
</div> </div>
<div className="flex items-center gap-2">
<label htmlFor="board-product" className="text-sm text-text-secondary"></label>
<select
id="board-product"
value={productId}
onChange={(e) => setProductId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-primary focus:outline-none focus:border-brand-orange"
>
<option value="all"></option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{error && ( {error && (
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600"> <div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error} {error}
@@ -70,7 +105,13 @@ export default function BoardPage() {
) : ( ) : (
<div className="flex gap-4 flex-1 overflow-x-auto"> <div className="flex gap-4 flex-1 overflow-x-auto">
{byColumn.map(({ col, items }) => ( {byColumn.map(({ col, items }) => (
<BoardColumn key={col.key} col={col} tasks={items} /> <BoardColumn
key={col.key}
col={col}
tasks={items}
/* 删除按钮:仅管理员,且「待审核」列不放(审核中不允许删) */
onDelete={isAdmin && col.key !== 'review' ? handleDelete : undefined}
/>
))} ))}
</div> </div>
)} )}

View File

@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import { ApiError } from '@/types';
const INPUT_CLS = 'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange transition-colors';
export default function ChangePasswordPage() {
const router = useRouter();
const { user, fetchMe } = useAuthStore();
const mustChange = !!user?.must_change_password;
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (newPassword.length < 8) {
setError('新密码至少 8 位');
return;
}
if (newPassword !== confirmPassword) {
setError('两次输入的新密码不一致');
return;
}
setSaving(true);
setError('');
try {
await api.post('/api/v1/auth/change-password', {
current_password: currentPassword,
new_password: newPassword,
});
await fetchMe();
if (mustChange) {
router.replace(user?.role === 'admin' ? '/config' : user?.role === 'supervisor' ? '/review' : '/tasks/new');
} else {
router.back();
}
} catch (err) {
const apiErr = err as ApiError;
setError(apiErr?.message || '修改密码失败');
} finally {
setSaving(false);
}
}
return (
<div className="flex items-center justify-center min-h-[calc(100vh-56px)] bg-brand-cream">
<div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-8">
<h1 className="text-xl font-bold text-text-primary mb-2">
{mustChange ? '首次登录请修改密码' : '修改密码'}
</h1>
<p className="text-sm text-text-secondary mb-6">
{mustChange
? '为了保护客户数据,初始密码只能用于首次登录。'
: '建议定期更换密码,新密码至少 8 位。'}
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label htmlFor="current-password" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="current-password" type="password" autoComplete="current-password" value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label htmlFor="new-password" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="new-password" type="password" autoComplete="new-password" value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} className={INPUT_CLS} />
</div>
{error && <div role="alert" className="text-status-error text-sm bg-red-50 px-3 py-2 rounded-lg">{error}</div>}
<button type="submit" disabled={saving}
className="w-full bg-brand-orange text-white rounded-lg py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50">
{saving ? '保存中…' : '修改密码'}
</button>
{!mustChange && (
<button type="button" onClick={() => router.back()}
className="w-full text-text-secondary rounded-lg py-2 text-sm hover:text-text-primary transition-colors">
</button>
)}
</form>
</div>
</div>
);
}

View File

@@ -7,12 +7,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { BenchmarksTab } from '@/components/config/BenchmarksTab'; import { BenchmarksTab } from '@/components/config/BenchmarksTab';
import { BannedWordsTab } from '@/components/config/BannedWordsTab'; import { BannedWordsTab } from '@/components/config/BannedWordsTab';
import { UserManageTab } from '@/components/config/UserManageTab';
type TabKey = 'benchmarks' | 'banned_words'; type TabKey = 'benchmarks' | 'banned_words' | 'users';
const TABS: { key: TabKey; label: string }[] = [ const TABS: { key: TabKey; label: string }[] = [
{ key: 'benchmarks', label: '标杆笔记' }, { key: 'benchmarks', label: '标杆笔记' },
{ key: 'banned_words', label: '违禁词库' }, { key: 'banned_words', label: '违禁词库' },
{ key: 'users', label: '账号管理' },
]; ];
export default function ConfigPage() { export default function ConfigPage() {
@@ -46,6 +48,7 @@ export default function ConfigPage() {
<div id={`panel-${activeTab}`} role="tabpanel"> <div id={`panel-${activeTab}`} role="tabpanel">
{activeTab === 'benchmarks' && <BenchmarksTab />} {activeTab === 'benchmarks' && <BenchmarksTab />}
{activeTab === 'banned_words' && <BannedWordsTab />} {activeTab === 'banned_words' && <BannedWordsTab />}
{activeTab === 'users' && <UserManageTab />}
</div> </div>
</div> </div>
); );

View File

@@ -28,8 +28,9 @@ export default function DashboardPage() {
<DashCard href="/review" icon="✅" label="审核台" desc="查看待审笔记,通过或打回" /> <DashCard href="/review" icon="✅" label="审核台" desc="查看待审笔记,通过或打回" />
)} )}
{role === 'admin' && ( {role === 'admin' && (
<DashCard href="/config" icon="⚙️" label="配置中心" desc="管理产品、标杆笔记、违禁词和 API Key" /> <DashCard href="/config" icon="⚙️" label="系统管理" desc="标杆笔记、违禁词库、账号管理" />
)} )}
<DashCard href="/settings" icon="🔧" label="工作配置" desc="产品档案、API Key" />
<DashCard href="/history" icon="🗂️" label="历史归档" desc="查看历史任务和成品库" /> <DashCard href="/history" icon="🗂️" label="历史归档" desc="查看历史任务和成品库" />
</div> </div>
</div> </div>

View File

@@ -47,6 +47,7 @@ function DownloadButton({ taskId }: { taskId: number }) {
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
await api.post(`/api/v1/delivery-packages/${id}/mark-downloaded`);
setState('done'); setState('done');
return; return;
} }
@@ -55,7 +56,11 @@ function DownloadButton({ taskId }: { taskId: number }) {
} catch { setState('error'); } } catch { setState('error'); }
} }
if (state === 'done') return <span className="text-xs text-green-600"></span>; if (state === 'done') return (
<span className="text-xs text-green-600" title="文件已保存到浏览器默认下载目录(通常是「下载」文件夹)">
</span>
);
if (state === 'error') return <span className="text-xs text-red-500"></span>; if (state === 'error') return <span className="text-xs text-red-500"></span>;
return ( return (
<button <button

View File

@@ -5,6 +5,7 @@
* 数据GET /api/v1/tasks?status=approved,archived,rejected * 数据GET /api/v1/tasks?status=approved,archived,rejected
*/ */
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { TaskListItem, PaginatedResponse } from '@/types'; import { TaskListItem, PaginatedResponse } from '@/types';
import { getErrorAction } from '@/types/errors'; import { getErrorAction } from '@/types/errors';
@@ -19,6 +20,8 @@ const STATUS_LABELS: Record<string, string> = {
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
export default function HistoryPage() { export default function HistoryPage() {
const searchParams = useSearchParams();
const isImageView = searchParams.get('view') === 'images';
const [tasks, setTasks] = useState<TaskListItem[]>([]); const [tasks, setTasks] = useState<TaskListItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -58,10 +61,18 @@ export default function HistoryPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-text-primary"></h1> <h1 className="text-xl font-semibold text-text-primary">
{isImageView ? '图片排版' : '历史归档'}
</h1>
<span className="text-sm text-text-tertiary"> {total} </span> <span className="text-sm text-text-tertiary"> {total} </span>
</div> </div>
{isImageView && (
<p className="text-sm text-text-secondary -mt-2">
</p>
)}
{/* 状态筛选 */} {/* 状态筛选 */}
<div className="flex gap-2" role="group" aria-label="状态筛选"> <div className="flex gap-2" role="group" aria-label="状态筛选">
{[{ key: 'all', label: '全部' }, ...HISTORY_STATUSES.map((s) => ({ {[{ key: 'all', label: '全部' }, ...HISTORY_STATUSES.map((s) => ({

View File

@@ -4,7 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
import { Sidebar } from '@/components/layout/Sidebar'; import { Sidebar } from '@/components/layout/Sidebar';
export const metadata: Metadata = { export const metadata: Metadata = {
title: '龙石小红书内容生产平台', title: 'Clover小红书内容生产平台',
description: '小红书内容生产工具', description: '小红书内容生产工具',
}; };
@@ -21,7 +21,7 @@ export default function RootLayout({
{/* 顶部 Header */} {/* 顶部 Header */}
<header className="h-14 bg-brand-orange flex items-center px-6 justify-between flex-shrink-0"> <header className="h-14 bg-brand-orange flex items-center px-6 justify-between flex-shrink-0">
<h1 className="text-white font-bold text-base"> <h1 className="text-white font-bold text-base">
Clover小红书内容生产平
</h1> </h1>
<UserBadge /> <UserBadge />
</header> </header>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
// /login — 登录页按角色跳转首页admin→/config, supervisor→/review, operator→/tasks/new // /login — 登录页按角色跳转首页admin→/config, supervisor→/review, operator→/tasks/new
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -15,6 +15,14 @@ export default function LoginPage() {
const [form, setForm] = useState<LoginRequest>({ username: '', password: '' }); const [form, setForm] = useState<LoginRequest>({ username: '', password: '' });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPwd, setShowPwd] = useState(false);
// 被 token 失效踢回登录页时给出明确提示api.ts 跳转会带 ?reason=expired
const [notice, setNotice] = useState('');
useEffect(() => {
if (typeof window === 'undefined') return;
const reason = new URLSearchParams(window.location.search).get('reason');
if (reason === 'expired') setNotice('登录状态已过期,请重新登录后再操作。');
}, []);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -27,6 +35,10 @@ export default function LoginPage() {
try { try {
const data = await api.post<LoginResponse>('/api/v1/auth/login', form); const data = await api.post<LoginResponse>('/api/v1/auth/login', form);
login(data.token, data.user); login(data.token, data.user);
if (data.user.must_change_password) {
router.replace('/change-password');
return;
}
// 按角色跳对应首页 // 按角色跳对应首页
const dest = const dest =
data.user.role === 'admin' data.user.role === 'admin'
@@ -49,11 +61,16 @@ export default function LoginPage() {
<div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-8"> <div className="w-96 bg-surface-primary rounded-2xl shadow-lg p-8">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="text-4xl mb-2" aria-hidden="true">🌿</div> <div className="text-4xl mb-2" aria-hidden="true">🌿</div>
<h1 className="text-xl font-bold text-text-primary"></h1> <h1 className="text-xl font-bold text-text-primary">Clover内容生产平</h1>
<p className="text-text-secondary text-sm mt-1"></p> <p className="text-text-secondary text-sm mt-1"></p>
</div> </div>
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4"> <form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
{notice && (
<div role="status" className="text-sm bg-amber-50 text-amber-700 border border-amber-200 px-3 py-2 rounded-lg">
{notice}
</div>
)}
<div> <div>
<label htmlFor="username" className="block text-sm font-medium text-text-primary mb-1"></label> <label htmlFor="username" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="username" type="text" autoComplete="username" value={form.username} <input id="username" type="text" autoComplete="username" value={form.username}
@@ -63,9 +80,16 @@ export default function LoginPage() {
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-text-primary mb-1"></label> <label htmlFor="password" className="block text-sm font-medium text-text-primary mb-1"></label>
<input id="password" type="password" autoComplete="current-password" value={form.password} <div className="relative">
<input id="password" type={showPwd ? 'text' : 'password'} autoComplete="current-password" value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
className={INPUT_CLS} aria-required="true" /> className={INPUT_CLS + ' pr-10'} aria-required="true" />
<button type="button" onClick={() => setShowPwd((v) => !v)}
aria-label={showPwd ? '隐藏密码' : '显示密码'}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-secondary hover:text-text-primary text-base p-1">
{showPwd ? '👁️' : '🙈'}
</button>
</div>
</div> </div>
{error && ( {error && (

View File

@@ -5,7 +5,7 @@
* 🔴 红线key 各人自录自用各算各的;只录不显余额 * 🔴 红线key 各人自录自用各算各的;只录不显余额
* 拆分自原 /configB方案admin 专属的标杆/违禁词留在 /config「系统管理」 * 拆分自原 /configB方案admin 专属的标杆/违禁词留在 /config「系统管理」
*/ */
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { ProductsTab } from '@/components/config/ProductsTab'; import { ProductsTab } from '@/components/config/ProductsTab';
import { ApiKeysTab } from '@/components/config/ApiKeysTab'; import { ApiKeysTab } from '@/components/config/ApiKeysTab';
@@ -18,6 +18,12 @@ const TABS: { key: TabKey; label: string }[] = [
export default function SettingsPage() { export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<TabKey>('api_keys'); const [activeTab, setActiveTab] = useState<TabKey>('api_keys');
// 支持 ?tab=products 直达(开任务页「编辑配置」跳来)。
// 客户端挂载后再读 URL避免 SSR(无 window) 与客户端初值不一致引发 hydration mismatch。
useEffect(() => {
const t = new URLSearchParams(window.location.search).get('tab');
if (t === 'products') setActiveTab('products');
}, []);
return ( return (
<div className="p-6 max-w-5xl"> <div className="p-6 max-w-5xl">

View File

@@ -3,21 +3,82 @@
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar'; import { ProgressBar } from '@/components/layout/ProgressBar';
import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar'; import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar';
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages'; import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages';
import { ImageLightbox } from '@/components/tasks/ImageLightbox';
import { TextLightbox } from '@/components/tasks/TextLightbox';
import { useTaskStore } from '@/stores/taskStore'; import { useTaskStore } from '@/stores/taskStore';
import { useGenerationStore } from '@/stores/generationStore'; import { useGenerationStore } from '@/stores/generationStore';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useState } from 'react'; import { TextCandidate, ImageCandidate } from '@/types/index';
import { useState, useEffect } from 'react';
export default function ConfirmPage() { export default function ConfirmPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const taskId = params?.id ? Number(params.id) : null; const taskId = params?.id ? Number(params.id) : null;
const router = useRouter(); const router = useRouter();
const { selectedTextIds, selectedImageIds } = useTaskStore(); const { selectedTextIds, selectedImageIds, setSelections } = useTaskStore();
// imageCandidates 平铺(契约 image_candidate 事件无 batch 字段) // imageCandidates 平铺(契约 image_candidate 事件无 batch 字段)
const { textCandidates, imageCandidates } = useGenerationStore(); const { textCandidates, imageCandidates, hydrateFromTask } = useGenerationStore();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [regenerating, setRegenerating] = useState(false); const [regenerating, setRegenerating] = useState(false);
const [hydrating, setHydrating] = useState(false);
const [hydrateError, setHydrateError] = useState(false); // C1: 拉取失败时给重试入口
const [submitError, setSubmitError] = useState(''); // 提审被后端拒绝时显式提示,不再吞错
// 飞轮节点反馈:提交审核/打回后短暂绿芽提示
const [flywheelMsg, setFlywheelMsg] = useState<string | null>(null);
// 失败任务查看不能空白(#11):记录任务状态+失败原因failed 时显眼提示
const [taskMeta, setTaskMeta] = useState<{ status: string; failReason: string } | null>(null);
// 已通过/已打回 = 只读查看态倩倩姐2026-06-26不提交审核文案点看全文、图点放大
const isReadonly = taskMeta?.status === 'approved' || taskMeta?.status === 'rejected';
const [zoomImage, setZoomImage] = useState<ImageCandidate | null>(null);
const [zoomText, setZoomText] = useState<TextCandidate | null>(null);
// store 无持久化:从历史归档「查看」直达本页时 store 是空的 → 「暂无选中文案」。
// 挂载时拉 GET /tasks/{id}:候选为空则回填+据 is_selected 还原已选;
// 同时记录任务状态/失败原因,失败任务不再空白展示(#11)。
const loadTask = (id: number) => {
// 跨任务切换修复倩倩姐2026-06-26报bugSPA 路由切到不同任务卡片时,
// 上个任务数据仍在 store → storeEmpty=false → 旧逻辑直接 return 显示上一个任务,
// 手动刷新才好(刷新=重挂载清空 store。这里按 currentTaskId 判定是否换了任务,
// 换了就先清两个 store让本次走"空 store 全量回填"路径,根治"显示上个任务/暂无选中"。
const ts = useTaskStore.getState();
if (ts.currentTaskId !== id) {
useGenerationStore.getState().reset();
ts.reset();
ts.setCurrentTask(id, 'pending_selection');
}
const storeEmpty =
useGenerationStore.getState().textCandidates.length === 0 &&
useGenerationStore.getState().imageCandidates.length === 0;
if (storeEmpty) setHydrating(true);
setHydrateError(false);
api.get<{
status: string; reject_reason?: string | null;
text_candidates: TextCandidate[]; image_candidates: ImageCandidate[];
}>(`/api/v1/tasks/${id}`)
.then((data) => {
setTaskMeta({ status: data.status, failReason: data.reject_reason || '' });
if (!storeEmpty) return;
const texts = data.text_candidates || [];
const images = data.image_candidates || [];
hydrateFromTask({ textCandidates: texts, imageCandidates: images });
// hydrateFromTask 内已据 is_selected 写 taskStore但 storeEmpty 路径
// generationStore 可能比 taskStore setSelections 先跑,兜底再调一次
setSelections(
texts.filter((c) => c.is_selected).map((c) => c.candidate_id),
images.filter((c) => c.is_selected).map((c) => c.candidate_id),
);
})
.catch(() => { setHydrateError(true); }) // C1: 不再静默,给重试入口
.finally(() => setHydrating(false));
};
useEffect(() => {
if (!taskId) return;
loadTask(taskId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]);
const selectedTexts = textCandidates.filter((c) => selectedTextIds.includes(c.candidate_id)); const selectedTexts = textCandidates.filter((c) => selectedTextIds.includes(c.candidate_id));
const selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id)); const selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id));
@@ -25,9 +86,17 @@ export default function ConfirmPage() {
async function handleSubmitReview() { async function handleSubmitReview() {
if (!taskId) return; if (!taskId) return;
setSubmitting(true); setSubmitting(true);
setSubmitError('');
try { try {
await api.post(`/api/v1/tasks/${taskId}/submit-review`); await api.post(`/api/v1/tasks/${taskId}/submit-review`);
setFlywheelMsg('🌿 已学习你的选择,下次更懂你');
setTimeout(() => {
setFlywheelMsg(null);
router.push('/review'); router.push('/review');
}, 2500);
} catch (e) {
// 后端校验失败(如未选满)以前被 finally 吞掉 → 用户看着像"点了没反应"
setSubmitError(e instanceof Error ? e.message : '提交审核失败,请重试');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -38,7 +107,11 @@ export default function ConfirmPage() {
setRegenerating(true); setRegenerating(true);
try { try {
await api.post(`/api/v1/tasks/${taskId}/regenerate`); await api.post(`/api/v1/tasks/${taskId}/regenerate`);
setFlywheelMsg('🌿 已记录你的顾虑,下次会避开');
setTimeout(() => {
setFlywheelMsg(null);
router.push(`/tasks/${taskId}/text`); router.push(`/tasks/${taskId}/text`);
}, 2500);
} finally { } finally {
setRegenerating(false); setRegenerating(false);
} }
@@ -46,27 +119,66 @@ export default function ConfirmPage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<ProgressBar currentStep={4} /> {/* 已通过/已打回=只读查看不显示步骤进度条倩倩姐2026-06-26 */}
{!isReadonly && <ProgressBar currentStep={4} />}
<div className="p-6 flex-1 overflow-auto max-w-4xl"> <div className="p-6 flex-1 overflow-auto max-w-4xl">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2> <div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-bold text-text-primary">
{isReadonly ? '查看笔记' : '确认预览'}
</h2>
{taskId && <DeleteTaskButton taskId={taskId} />}
</div>
{/* C1: 拉取失败给重新加载入口,避免永久灰死 */}
{hydrateError && (
<div className="mb-6 rounded-lg bg-yellow-50 border border-yellow-200 p-4" role="alert">
<p className="text-sm text-yellow-700"></p>
<button
onClick={() => taskId && loadTask(taskId)}
className="mt-2 text-sm bg-brand-orange text-white rounded-lg px-4 py-1.5 hover:bg-orange-600"
>
</button>
</div>
)}
{taskMeta?.status === 'failed' && (
<div className="mb-6 rounded-lg bg-red-50 border border-red-200 p-4" role="alert">
<p className="text-sm font-semibold text-red-600 mb-1"> </p>
<p className="text-sm text-red-600 whitespace-pre-wrap">
{taskMeta.failReason || '生成过程中出错,未产出可用内容。可点下方「重新生成」重试。'}
</p>
<button
onClick={handleRegenerate}
disabled={regenerating}
className="mt-3 text-sm bg-brand-orange text-white rounded-lg px-4 py-2 hover:bg-orange-600 disabled:opacity-50"
>
{regenerating ? '重新生成中…' : '重新生成'}
</button>
</div>
)}
<div className="grid grid-cols-2 gap-6"> <div className="grid grid-cols-2 gap-6">
{/* 已选文案 */} {/* 已选文案 */}
<div> <div>
<h3 className="text-sm font-semibold text-text-secondary mb-3"></h3> <h3 className="text-sm font-semibold text-text-secondary mb-3"></h3>
{selectedTexts.length === 0 ? ( {hydrating ? (
<p className="text-text-tertiary text-sm"></p>
) : selectedTexts.length === 0 ? (
<p className="text-text-tertiary text-sm"></p> <p className="text-text-tertiary text-sm"></p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{selectedTexts.map((c) => ( {selectedTexts.map((c) => (
<div key={c.candidate_id} <div key={c.candidate_id}
className="border border-border-default rounded-xl p-4 bg-surface-primary"> onClick={isReadonly ? () => setZoomText(c) : undefined}
className={`border border-border-default rounded-xl p-4 bg-surface-primary ${isReadonly ? 'cursor-pointer hover:border-brand-orange' : ''}`}>
<span className="text-xs text-brand-orange font-medium">{c.angle_label}</span> <span className="text-xs text-brand-orange font-medium">{c.angle_label}</span>
<p className="text-sm text-text-primary mt-1 whitespace-pre-wrap line-clamp-6"> <p className={`text-sm text-text-primary mt-1 whitespace-pre-wrap ${isReadonly ? '' : 'line-clamp-6'}`}>
{c.content} {c.content}
</p> </p>
<span className="text-xs text-text-tertiary mt-1 block"> {c.score.total}</span> <span className="text-xs text-text-tertiary mt-1 block">
{c.score.total}{isReadonly ? ' · 点击看全文' : ''}
</span>
</div> </div>
))} ))}
</div> </div>
@@ -74,18 +186,55 @@ export default function ConfirmPage() {
</div> </div>
{/* 已选图 */} {/* 已选图 */}
<ConfirmPreviewImages images={selectedImages} /> <ConfirmPreviewImages images={selectedImages} onZoom={isReadonly ? setZoomImage : undefined} />
</div> </div>
{/* 操作栏 */} {/* 提审被拒:显式红框,不再静默吞错 */}
{submitError && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3" role="alert">
<p className="text-sm text-red-600">{submitError}</p>
</div>
)}
{/* 飞轮节点反馈:提交成功后短暂绿芽提示 */}
{flywheelMsg && (
<div role="status" aria-live="polite"
className="mb-4 rounded-lg bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark">
{flywheelMsg}
</div>
)}
{/* 操作栏:只读态(已通过/已打回)不提交审核,只给只读说明;否则正常确认操作 */}
{isReadonly ? (
<div className="mt-6 rounded-lg bg-gray-50 border border-border-default px-4 py-3 text-sm text-text-secondary">
{taskMeta?.status === 'approved'
? '本笔记已通过审核,当前为查看模式。点击文案看全文、点击图片放大查看。'
: '本笔记已被打回,当前为查看模式。点击文案看全文、点击图片放大查看。'}
</div>
) : (
<ConfirmActionBar <ConfirmActionBar
submitting={submitting} submitting={submitting}
regenerating={regenerating} regenerating={regenerating}
canSubmit={selectedTextIds.length > 0} hydrating={hydrating}
canSubmit={selectedTextIds.length > 0 && selectedImageIds.length > 0}
onSubmit={handleSubmitReview} onSubmit={handleSubmitReview}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
/> />
)}
</div> </div>
{/* 只读查看:文案全文 / 图片放大 */}
<TextLightbox
text={zoomText?.content ?? null}
label={zoomText?.angle_label}
score={zoomText?.score.total}
onClose={() => setZoomText(null)}
/>
<ImageLightbox
url={zoomImage?.url ?? null}
caption={zoomImage?.role}
onClose={() => setZoomImage(null)}
/>
</div> </div>
); );
} }

View File

@@ -1,16 +1,18 @@
'use client'; 'use client';
// 屏4 挑图0/N异步电影 + 按A/B/C分组选图 + 点图放大 + 单批重试 + 能离开 // 屏4 挑图0/N异步电影 + 按A/B/C分组选图 + 点图放大 + 单批重试 + 能离开
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar'; import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner'; import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard'; import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard';
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader'; import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
import { ImageActionBar } from '@/components/tasks/ImageActionBar'; import { ImageActionBar } from '@/components/tasks/ImageActionBar';
import { ImageLightbox } from '@/components/tasks/ImageLightbox'; import { ImageLightbox } from '@/components/tasks/ImageLightbox';
import { ImageStrategyGroup, ROLE_LABEL } from '@/components/tasks/ImageStrategyGroup'; import { ImageStrategyGroup, ROLE_LABEL } from '@/components/tasks/ImageStrategyGroup';
import { RegenControls, RegenTarget } from '@/components/tasks/RegenControls'; import { RegenControls, RegenTarget } from '@/components/tasks/RegenControls';
import { SseStatusBar } from '@/components/tasks/SseStatusBar'; import { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { StageProgress } from '@/components/tasks/StageProgress';
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner'; import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner'; import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
import { useSse } from '@/hooks/useSse'; import { useSse } from '@/hooks/useSse';
@@ -24,11 +26,14 @@ export default function ImageSelectionPage() {
const taskId = params?.id ? Number(params.id) : null; const taskId = params?.id ? Number(params.id) : null;
const router = useRouter(); const router = useRouter();
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore(); const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected, removeImageCandidate } = useGenerationStore();
const { selectedImageIds, toggleImageSelection } = useTaskStore(); const { selectedImageIds, toggleImageSelection } = useTaskStore();
const { connectionStatus, reconnectAttempt } = useSse(taskId); const { connectionStatus, reconnectAttempt } = useSse(taskId);
const [zoom, setZoom] = useState<ImageCandidate | null>(null); const [zoom, setZoom] = useState<ImageCandidate | null>(null);
const [regenTarget, setRegenTarget] = useState<RegenTarget | null>(null); const [regenTarget, setRegenTarget] = useState<RegenTarget | null>(null);
const [regenPending, setRegenPending] = useState<Record<string, { label: string; minId: number }>>({});
const [regenNotice, setRegenNotice] = useState<string | null>(null);
const [justSavedFlywheel, setJustSavedFlywheel] = useState(false);
const pendingCount = imageTotal - imageDone; const pendingCount = imageTotal - imageDone;
const allDone = imageTotal > 0 && pendingCount === 0; const allDone = imageTotal > 0 && pendingCount === 0;
@@ -45,6 +50,29 @@ export default function ImageSelectionPage() {
const groups: Array<['A' | 'B' | 'C', ImageCandidate[]]> = (['A', 'B', 'C'] as const).map( const groups: Array<['A' | 'B' | 'C', ImageCandidate[]]> = (['A', 'B', 'C'] as const).map(
(s) => [s, latestByRole(imageCandidates.filter((c) => c.strategy === s))] (s) => [s, latestByRole(imageCandidates.filter((c) => c.strategy === s))]
); );
const regeneratingKeys = useMemo(() => new Set(Object.keys(regenPending)), [regenPending]);
useEffect(() => {
setRegenPending((pending) => {
let changed = false;
const next = { ...pending };
for (const [key, meta] of Object.entries(pending)) {
const [strategy, role] = key.split(':');
const finished = imageCandidates.some((c) =>
c.strategy === strategy &&
(role === '*' || c.role === role) &&
c.candidate_id > meta.minId &&
c.is_regen
);
if (finished) {
delete next[key];
changed = true;
setRegenNotice(`${meta.label} 已重生完成,已展示最新图片`);
}
}
return changed ? next : pending;
});
}, [imageCandidates]);
async function handleRetry(_batchRole: string) { async function handleRetry(_batchRole: string) {
// 一期 regenerate = 整体重生R2 已扩展按角色单批重生,见 handleRegen // 一期 regenerate = 整体重生R2 已扩展按角色单批重生,见 handleRegen
@@ -55,10 +83,23 @@ export default function ImageSelectionPage() {
// R2 单张/单套重生 + 可选人工提示词 // R2 单张/单套重生 + 可选人工提示词
async function handleRegen(target: RegenTarget, customPrompt: string) { async function handleRegen(target: RegenTarget, customPrompt: string) {
if (!taskId) return; if (!taskId) return;
const key = `${target.strategy}:${target.role ?? '*'}`;
if (regenPending[key]) return;
const minId = imageCandidates.reduce((max, c) => Math.max(max, c.candidate_id), 0);
setRegenPending((prev) => ({ ...prev, [key]: { label: target.label, minId } }));
setRegenNotice(`${target.label} 已提交重生,生成完成后会自动替换为最新图片`);
await api.post(`/api/v1/tasks/${taskId}/regenerate`, { await api.post(`/api/v1/tasks/${taskId}/regenerate`, {
strategy: target.strategy, strategy: target.strategy,
role: target.role, role: target.role,
custom_prompt: customPrompt || undefined, custom_prompt: customPrompt || undefined,
}).catch((err) => {
setRegenPending((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
setRegenNotice(err?.message || '重生提交失败,请稍后重试');
throw err;
}); });
} }
@@ -66,6 +107,19 @@ export default function ImageSelectionPage() {
setRegenTarget({ strategy: strategy as 'A' | 'B' | 'C', role, label }); setRegenTarget({ strategy: strategy as 'A' | 'B' | 'C', role, label });
} }
async function handleDeleteImage(candidateId: number) {
if (!taskId) return;
await api.delete(`/api/v1/tasks/${taskId}/image-candidates/${candidateId}`);
removeImageCandidate(candidateId);
}
// 选图即时飞轮反馈点选后绿芽提示4秒
function handleToggleImage(candidateId: number) {
toggleImageSelection(candidateId);
setJustSavedFlywheel(true);
setTimeout(() => setJustSavedFlywheel(false), 4000);
}
async function handleProceedToConfirm() { async function handleProceedToConfirm() {
if (!taskId || selectedImageIds.length === 0) return; if (!taskId || selectedImageIds.length === 0) return;
for (const candidateId of selectedImageIds) { for (const candidateId of selectedImageIds) {
@@ -82,11 +136,36 @@ export default function ImageSelectionPage() {
<FlywheelBannerFromSse <FlywheelBannerFromSse
recentPreference={flywheelInjected.recentPreference} recentPreference={flywheelInjected.recentPreference}
rejectReasons={flywheelInjected.rejectReasons} rejectReasons={flywheelInjected.rejectReasons}
signalCount={flywheelInjected.signalCount}
/> />
)} )}
{justSavedFlywheel && (
<p role="status" aria-live="polite"
className="px-4 py-2 text-sm text-brand-green-dark bg-brand-green-light border border-brand-green/30">
🌿
</p>
)}
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} /> <SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
<div className="p-6 flex-1 overflow-auto"> <div className="p-6 flex-1 overflow-auto">
{taskId && (
<div className="mb-4 flex justify-end">
<DeleteTaskButton taskId={taskId} />
</div>
)}
<FatalErrorBanner /> <FatalErrorBanner />
{regenNotice && (
<div className="mb-4 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-700">
<span>{regenNotice}</span>
<button
onClick={() => setRegenNotice(null)}
className="ml-4 text-xs text-amber-700 underline"
>
</button>
</div>
)}
{/* #4 阶段进度:配图全程阶段(分析→文案→配图→完成) */}
{!allDone && <StageProgress scope="image" />}
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} /> <ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
{imageCandidates.length === 0 ? ( {imageCandidates.length === 0 ? (
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6"> <div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
@@ -100,9 +179,11 @@ export default function ImageSelectionPage() {
strategy={s} strategy={s}
candidates={list} candidates={list}
selectedImageIds={selectedImageIds} selectedImageIds={selectedImageIds}
onToggle={toggleImageSelection} onToggle={handleToggleImage}
onZoom={setZoom} onZoom={setZoom}
onRegen={openRegen} onRegen={openRegen}
regeneratingKeys={regeneratingKeys}
onDelete={handleDeleteImage}
/> />
) : null ) : null
) )
@@ -110,13 +191,20 @@ export default function ImageSelectionPage() {
{/* 失败批次提示 */} {/* 失败批次提示 */}
{failedBatches.length > 0 && allDone && ( {failedBatches.length > 0 && allDone && (
<div role="alert" className="mt-4 bg-red-50 border border-status-error rounded-xl px-4 py-3 text-sm text-status-error"> <div role="alert" className="mt-4 bg-red-50 border border-status-error rounded-xl px-4 py-3 text-sm text-status-error">
{failedBatches.length} <p className="font-medium"> {failedBatches.length} </p>
{failedBatches.filter((b) => b.retryable).map((b) => ( <ul className="mt-2 space-y-1">
<button key={b.batch} onClick={() => handleRetry(b.batch)} {failedBatches.map((b) => (
className="ml-2 underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}> <li key={b.batch} className="flex flex-wrap items-center gap-2">
{ROLE_LABEL[b.batch] || b.batch} <span>{ROLE_LABEL[b.batch] || b.batch}{b.reason || '中转站暂时无响应'}</span>
{b.retryable && (
<button onClick={() => handleRetry(b.batch)}
className="underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
</button> </button>
)}
</li>
))} ))}
</ul>
</div> </div>
)} )}
{imageCandidates.length > 0 && ( {imageCandidates.length > 0 && (

View File

@@ -1,17 +1,21 @@
'use client'; 'use client';
// 屏3 挑文案SSE实时+五维分+多选+飞轮隐形 // 屏3 挑文案SSE实时+五维分+多选+飞轮隐形
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { ProgressBar } from '@/components/layout/ProgressBar'; import { ProgressBar } from '@/components/layout/ProgressBar';
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner'; import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
import { FlywheelBanner, FlywheelBannerFromSse } from '@/components/FlywheelBanner';
import { TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard'; import { TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
import { TextStrategyGroup, groupByStrategy } from '@/components/tasks/TextStrategyGroup'; import { TextStrategyGroup, groupByStrategy } from '@/components/tasks/TextStrategyGroup';
import { SseStatusBar } from '@/components/tasks/SseStatusBar'; import { SseStatusBar } from '@/components/tasks/SseStatusBar';
import { StageProgress } from '@/components/tasks/StageProgress';
import { TextOutcomeBanner } from '@/components/tasks/TextOutcomeBanner';
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner'; import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner'; import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
import { useSse } from '@/hooks/useSse'; import { useSse } from '@/hooks/useSse';
import { useGenerationStore } from '@/stores/generationStore'; import { useGenerationStore } from '@/stores/generationStore';
import { useTaskStore } from '@/stores/taskStore'; import { useTaskStore } from '@/stores/taskStore';
import { usePreferenceStore } from '@/stores/preferenceStore';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
export default function TextSelectionPage() { export default function TextSelectionPage() {
@@ -19,14 +23,32 @@ export default function TextSelectionPage() {
const taskId = params?.id ? Number(params.id) : null; const taskId = params?.id ? Number(params.id) : null;
const router = useRouter(); const router = useRouter();
const { textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent } = useGenerationStore(); const { stage, textCandidates, textDone, textTotal, flywheelInjected, patchTextCandidateContent, removeTextCandidate } = useGenerationStore();
const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore(); const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore();
const { context, fetchContext } = usePreferenceStore();
const { connectionStatus, reconnectAttempt } = useSse(taskId); const { connectionStatus, reconnectAttempt } = useSse(taskId);
const [proceedError, setProceedError] = useState('');
const [track, setTrack] = useState<'ai' | 'import' | null>(null);
useEffect(() => { useEffect(() => {
if (taskId) setCurrentTask(taskId, 'generating'); if (taskId) setCurrentTask(taskId, 'generating');
}, [taskId]); }, [taskId]);
// 历史回填:无 SSE 活跃飞轮数据时,拉 task 维度 context 兜底展示
useEffect(() => {
if (!taskId || flywheelInjected) return;
fetchContext(taskId);
}, [taskId, flywheelInjected]);
// 拉取 track导入轨「去生图」要先触发生图(reuse_text)再跳页AI轨直接跳页。
// track=null 表示未拉到,按钮禁用,避免竞态误把导入轨当 AI 轨跳过生图触发。
useEffect(() => {
if (!taskId) return;
api.get<{ track?: 'ai' | 'import' }>(`/api/v1/tasks/${taskId}`)
.then((t) => setTrack(t.track === 'import' ? 'import' : 'ai'))
.catch(() => setTrack('ai'));
}, [taskId]);
async function handleSaveEdit(candidateId: number, content: string) { async function handleSaveEdit(candidateId: number, content: string) {
if (!taskId) return; if (!taskId) return;
// D1 改稿=飞轮最强信号 text_edit后端回写 + record_signal // D1 改稿=飞轮最强信号 text_edit后端回写 + record_signal
@@ -36,43 +58,80 @@ export default function TextSelectionPage() {
patchTextCandidateContent(candidateId, content); patchTextCandidateContent(candidateId, content);
} }
async function handleDeleteText(candidateId: number) {
if (!taskId) return;
await api.delete(`/api/v1/tasks/${taskId}/text-candidates/${candidateId}`);
removeTextCandidate(candidateId);
}
async function handleProceedToImages() { async function handleProceedToImages() {
if (!taskId || selectedTextIds.length === 0) return; if (!taskId || selectedTextIds.length === 0) return;
setProceedError('');
const failed: number[] = [];
for (const candidateId of selectedTextIds) { for (const candidateId of selectedTextIds) {
try {
await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId }); await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId });
} catch {
// 不再静默吞错:残留/失效id会被后端拒(404),要让用户知道而非"以为都生效了"
failed.push(candidateId);
}
}
if (failed.length > 0) {
setProceedError(`${failed.length} 条文案选中失败(可能已失效),请重新选择后再试。`);
return;
}
// 导入轨:选完文案点「去生图」→ 触发只生图(复用导入文案),再跳生图页看进度。
// AI轨生图已在建任务时随文案一并跑完直接跳页即可。
if (track === 'import') {
try {
await api.post(`/api/v1/tasks/${taskId}/generate-images`, {});
} catch {
setProceedError('触发生图失败,请稍后重试。');
return;
}
} }
router.push(`/tasks/${taskId}/images`); router.push(`/tasks/${taskId}/images`);
} }
const isGenerating = textDone < textTotal || textTotal === 0; // task_done(stage=done)/failed 后生成已结束——绝不再显示"生成中"骨架屏装在跑,
// 否则就是图17那种"完成了却永远转圈/0条还显完成"。
const finished = stage === 'done' || stage === 'failed';
const isGenerating = !finished && (textDone < textTotal || textTotal === 0);
const showSkeleton = textCandidates.length === 0 && isGenerating; const showSkeleton = textCandidates.length === 0 && isGenerating;
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<ProgressBar currentStep={2} /> <ProgressBar currentStep={2} />
<RejectReasonBanner taskId={taskId} /> <RejectReasonBanner taskId={taskId} />
{flywheelInjected && ( {flywheelInjected ? (
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} /> <FlywheelBannerFromSse
)} recentPreference={flywheelInjected.recentPreference}
rejectReasons={flywheelInjected.rejectReasons}
signalCount={flywheelInjected.signalCount}
/>
) : context ? (
<FlywheelBanner context={context} />
) : null}
<div className="p-6 flex-1 overflow-auto"> <div className="p-6 flex-1 overflow-auto">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-text-primary"> <h2 className="text-xl font-bold text-text-primary">
{textTotal > 0 && <span className="text-sm font-normal text-text-secondary ml-2"> {textDone}/{textTotal} </span>} {textTotal > 0 && <span className="text-sm font-normal text-text-secondary ml-2"> {textDone}/{textTotal} </span>}
</h2> </h2>
<div className="flex items-center gap-3">
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} /> <SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
{taskId && <DeleteTaskButton taskId={taskId} />}
</div>
</div> </div>
{/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */} {/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */}
<FatalErrorBanner /> <FatalErrorBanner />
{/* SSE 进度提示(生成中且无内容时) */} {/* B1 文案产出结果提示:完成但没出文案/补充中/评分不可用,明确告知真实原因 */}
{showSkeleton && ( <TextOutcomeBanner />
<div className="mb-4 bg-brand-cream border border-brand-orange/20 rounded-xl px-4 py-3 text-sm text-text-secondary"
role="status" aria-live="polite"> {/* #3 阶段进度:替代静态"预计15-30秒",展示 分析竞品→生成文案 实时阶段 */}
15-30 {isGenerating && <StageProgress scope="text" />}
</div>
)}
{/* 文案卡:按 A/B/C 三套分组展示(让运营一眼看出三套不同角度);生成中显示骨架 */} {/* 文案卡:按 A/B/C 三套分组展示(让运营一眼看出三套不同角度);生成中显示骨架 */}
{showSkeleton ? ( {showSkeleton ? (
@@ -88,6 +147,7 @@ export default function TextSelectionPage() {
selectedTextIds={selectedTextIds} selectedTextIds={selectedTextIds}
onToggle={toggleTextSelection} onToggle={toggleTextSelection}
onSaveEdit={handleSaveEdit} onSaveEdit={handleSaveEdit}
onDelete={handleDeleteText}
/> />
)) ))
)} )}
@@ -97,10 +157,11 @@ export default function TextSelectionPage() {
<div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4"> <div className="sticky bottom-0 bg-surface-primary border-t border-border-default px-6 py-4 flex items-center justify-between mt-4">
<span className="text-sm text-text-secondary"> <span className="text-sm text-text-secondary">
<strong className="text-text-primary">{selectedTextIds.length}</strong> <strong className="text-text-primary">{selectedTextIds.length}</strong>
{proceedError && <span className="ml-3 text-red-600">{proceedError}</span>}
</span> </span>
<button <button
onClick={handleProceedToImages} onClick={handleProceedToImages}
disabled={selectedTextIds.length === 0} disabled={selectedTextIds.length === 0 || track === null}
aria-label={`用选中的 ${selectedTextIds.length} 条文案去生图`} aria-label={`用选中的 ${selectedTextIds.length} 条文案去生图`}
className="bg-brand-orange text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50" className="bg-brand-orange text-white rounded-lg px-6 py-2.5 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"
> >

View File

@@ -20,7 +20,7 @@ import { ApiError } from '@/types/index';
export default function NewTaskPage() { export default function NewTaskPage() {
const router = useRouter(); const router = useRouter();
const { context } = usePreferenceStore(); const { context, fetchContextByProduct } = usePreferenceStore();
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]); const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
@@ -35,6 +35,7 @@ export default function NewTaskPage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState(false); // 提升:供右侧「编辑配置」(运营/组长)触发就地编辑
useEffect(() => { loadProducts(); }, []); useEffect(() => { loadProducts(); }, []);
@@ -47,6 +48,12 @@ export default function NewTaskPage() {
.catch(() => setBenchmarks([])); .catch(() => setBenchmarks([]));
}, [selectedProduct]); }, [selectedProduct]);
// 选品变化:拉该产品飞轮偏好上下文,驱动顶部 FlywheelBanner
useEffect(() => {
if (!selectedProduct) return;
fetchContextByProduct(selectedProduct.id);
}, [selectedProduct?.id]);
async function loadProducts() { async function loadProducts() {
try { try {
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products'); const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
@@ -57,6 +64,12 @@ export default function NewTaskPage() {
} }
} }
// #1 内联建品把新产品并入列表顶部并选中QuickProductCreate 已含上传产品图)
function handleProductCreated(p: Product) {
setProducts((list) => [p, ...list.filter((x) => x.id !== p.id)]);
setSelectedProduct(p);
}
function validate(): boolean { function validate(): boolean {
if (!selectedProduct) { setError('请选择产品'); return false; } if (!selectedProduct) { setError('请选择产品'); return false; }
if (!form.theme.trim()) { setError('请填写今天主题'); return false; } if (!form.theme.trim()) { setError('请填写今天主题'); return false; }
@@ -135,6 +148,9 @@ export default function NewTaskPage() {
products={products} products={products}
selectedProduct={selectedProduct} selectedProduct={selectedProduct}
onSelectProduct={setSelectedProduct} onSelectProduct={setSelectedProduct}
onProductCreated={handleProductCreated}
editingProduct={editingProduct}
setEditingProduct={setEditingProduct}
benchmarks={benchmarks} benchmarks={benchmarks}
form={form} form={form}
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))} onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
@@ -147,7 +163,7 @@ export default function NewTaskPage() {
</div> </div>
{/* 右侧配置预览面板 */} {/* 右侧配置预览面板 */}
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />} {selectedProduct && <ConfigPreviewPanel product={selectedProduct} onEditInline={() => setEditingProduct(true)} />}
</div> </div>
<ImportTextModal <ImportTextModal

View File

@@ -37,7 +37,7 @@ interface AuthGuardProps {
export function AuthGuard({ children }: AuthGuardProps) { export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { isAuthenticated, role, isLoading, fetchMe, endLoading } = useAuthStore(); const { isAuthenticated, role, user, isLoading, fetchMe, endLoading } = useAuthStore();
useEffect(() => { useEffect(() => {
// 首屏校验:有 token 且 store 未初始化 → 拉 me无 token → 结束 loading 让守卫放行判断 // 首屏校验:有 token 且 store 未初始化 → 拉 me无 token → 结束 loading 让守卫放行判断
@@ -60,11 +60,20 @@ export function AuthGuard({ children }: AuthGuardProps) {
return; return;
} }
if (user?.must_change_password && pathname !== '/change-password') {
router.replace('/change-password');
return;
}
if (!user?.must_change_password && pathname === '/change-password') {
router.replace('/dashboard');
return;
}
const required = getRequiredRoles(pathname); const required = getRequiredRoles(pathname);
if (required && role && !required.includes(role)) { if (required && role && !required.includes(role)) {
router.replace('/dashboard'); router.replace('/dashboard');
} }
}, [isAuthenticated, role, isLoading, pathname, router]); }, [isAuthenticated, role, user?.must_change_password, isLoading, pathname, router]);
if (isLoading) { if (isLoading) {
return ( return (

View File

@@ -11,14 +11,23 @@ interface FlywheelBannerProps {
} }
export function FlywheelBanner({ context }: FlywheelBannerProps) { export function FlywheelBanner({ context }: FlywheelBannerProps) {
if (!context.injected_count && !context.recent_preference) return null; const signalCount = context.signal_count ?? 0;
if (!context.injected_count && !context.recent_preference && !signalCount) return null;
return ( return (
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex items-start gap-2" className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex flex-col gap-1"
> >
{signalCount > 0 && (
<div className="flex items-start gap-2">
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
<span className="font-medium"> {signalCount} </span>
</div>
)}
{(context.recent_preference || (context.reject_reasons?.length ?? 0) > 0) && (
<div className="flex items-start gap-2">
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span> <span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
<div> <div>
<span className="font-medium"></span> <span className="font-medium"></span>
@@ -32,6 +41,8 @@ export function FlywheelBanner({ context }: FlywheelBannerProps) {
)} )}
</div> </div>
</div> </div>
)}
</div>
); );
} }
@@ -41,15 +52,24 @@ export function FlywheelBanner({ context }: FlywheelBannerProps) {
interface FlywheelBannerFromSseProps { interface FlywheelBannerFromSseProps {
recentPreference: string; recentPreference: string;
rejectReasons: string[]; rejectReasons: string[];
signalCount?: number;
} }
export function FlywheelBannerFromSse({ recentPreference, rejectReasons }: FlywheelBannerFromSseProps) { export function FlywheelBannerFromSse({ recentPreference, rejectReasons, signalCount }: FlywheelBannerFromSseProps) {
const count = signalCount ?? 0;
return ( return (
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex items-start gap-2" className="bg-brand-green-light border border-brand-green/30 px-4 py-2 text-sm text-brand-green-dark flex flex-col gap-1"
> >
{count > 0 && (
<div className="flex items-start gap-2">
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
<span className="font-medium"> {count} </span>
</div>
)}
<div className="flex items-start gap-2">
<span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span> <span aria-hidden="true" className="mt-0.5 flex-shrink-0">🌿</span>
<div> <div>
<span className="font-medium"></span> <span className="font-medium"></span>
@@ -61,5 +81,6 @@ export function FlywheelBannerFromSse({ recentPreference, rejectReasons }: Flywh
)} )}
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -0,0 +1,158 @@
'use client';
/**
* StageLoadingPanel — 通用多阶段加载面板A1/A2/A3 复用)
* - 阶段列表:待完成灰 / 当前橙色 pulse 点 / 已完成绿勾
* - 线性进度条(公式可配)+ ETA 胶囊
* - 图片骨架屏 shimmer可选
*/
import { useEffect, useRef, useState } from 'react';
import { clsx } from 'clsx';
export interface StageItem {
label: string;
}
interface Props {
stages: StageItem[];
/** 当前激活阶段索引0-based-1 = 未开始;>= stages.length = 全完成 */
activeIdx: number;
/** ETA 目标秒数(用于线性估算) */
targetSec: number;
/** 任务启动时间戳ms用于推算进度和 ETA */
startedAt: number | null;
/** 进度条已完成比例0-100若提供则直接用不再用时间公式 */
realPct?: number;
/** 标题(左上) */
title: string;
/** 图片骨架数量0 = 不显示骨架屏) */
skeletonCount?: number;
className?: string;
}
// 线性估算进度公式min(96, base + elapsed * rate)
function calcPct(elapsed: number, targetSec: number): number {
const rate = 96 / (targetSec * 0.85); // 85% 时间跑到 96%
return Math.min(96, Math.round(elapsed * rate));
}
// 进度条与步骤耦合:时间驱动平滑动画,但被「当前步骤」的区间钳住——
// 步骤不往前走,进度条就不会冲过这一步的上限边界。
// 杜绝倩倩姐反馈的"进度条快满了、下面步骤还卡在第一步、看起来像卡死"。
// floor=当前步起点(真实进度,步骤前进时立刻顶上来)ceil-gap=当前步上限(留余量表示"未完成")。
function stepBoundedPct(
elapsed: number, targetSec: number, activeIdx: number, total: number,
): number {
const timed = calcPct(elapsed, targetSec);
if (total <= 0) return timed;
const idx = Math.max(0, activeIdx); // -1(未开始)按第0步对待条停在最左
const floor = (idx / total) * 100;
const ceil = ((idx + 1) / total) * 100;
const gap = Math.min(3, ceil - floor) ; // 距上限留点空隙,肉眼可见"这步还在跑"
return Math.round(Math.min(ceil - gap, Math.max(floor, timed)));
}
function formatEta(remainSec: number): string {
if (remainSec <= 0) return '即将完成';
if (remainSec < 60) return `${Math.ceil(remainSec)}`;
return `${Math.ceil(remainSec / 60)} 分钟`;
}
export function StageLoadingPanel({
stages, activeIdx, targetSec, startedAt, realPct,
title, skeletonCount = 0, className,
}: Props) {
const [elapsed, setElapsed] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!startedAt) return;
const tick = () => setElapsed(Math.floor((Date.now() - startedAt) / 1000));
tick();
timerRef.current = setInterval(tick, 1000);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [startedAt]);
const pct = realPct !== undefined
? realPct
: (startedAt ? stepBoundedPct(elapsed, targetSec, activeIdx, stages.length) : 0);
const remainSec = Math.max(0, targetSec - elapsed);
// 超时提示
let hint = '';
if (elapsed > 120) hint = '耗时较长,可降低生成数量后重试';
else if (elapsed > 60) hint = 'AI 仍在生成,请保持页面打开';
const allDone = activeIdx >= stages.length;
return (
<div className={clsx('rounded-xl border border-brand-orange/20 bg-brand-cream p-4', className)}>
{/* 标题行 + ETA 胶囊 */}
<div className="flex items-center justify-between mb-3">
<span className="font-semibold text-text-primary text-sm">{title}</span>
{!allDone && startedAt && (
<span className="text-xs bg-orange-100 text-brand-orange px-2.5 py-0.5 rounded-full font-medium">
{hint || formatEta(remainSec)}
</span>
)}
{allDone && (
<span className="text-xs bg-green-100 text-green-600 px-2.5 py-0.5 rounded-full font-medium">
</span>
)}
</div>
{/* 进度条 */}
<div
className="mb-3 h-1.5 bg-surface-tertiary rounded-full overflow-hidden"
role="progressbar" aria-valuenow={allDone ? 100 : pct} aria-valuemin={0} aria-valuemax={100}
>
<div
className={clsx(
'h-full rounded-full transition-all duration-1000',
allDone ? 'bg-green-500' : 'bg-brand-orange',
)}
style={{ width: `${allDone ? 100 : pct}%` }}
/>
</div>
{/* 阶段列表 */}
<ol className="space-y-1.5">
{stages.map((s, i) => {
const isDone = i < activeIdx || allDone;
const isActive = i === activeIdx && !allDone;
return (
<li key={i} className="flex items-center gap-2 text-sm">
{/* 状态指示器 */}
{isDone ? (
<span className="w-4 h-4 flex-shrink-0 rounded-full bg-green-500 flex items-center justify-center text-white text-[10px]"></span>
) : isActive ? (
<span className="w-4 h-4 flex-shrink-0 rounded-full bg-brand-orange animate-pulse" aria-hidden="true" />
) : (
<span className="w-4 h-4 flex-shrink-0 rounded-full bg-gray-200" aria-hidden="true" />
)}
<span className={clsx(
isDone ? 'text-text-secondary' :
isActive ? 'text-brand-orange font-medium' : 'text-text-tertiary',
)}>
{s.label}
</span>
</li>
);
})}
</ol>
{/* 骨架屏占位(图片未到时展示 shimmer */}
{skeletonCount > 0 && !allDone && (
<div className="mt-4 grid grid-cols-3 gap-2">
{Array.from({ length: skeletonCount }).map((_, i) => (
<div
key={i}
className="aspect-[3/4] rounded-lg bg-gradient-to-r from-gray-100 via-gray-200 to-gray-100 bg-[length:200%_100%] animate-[shimmer_1.5s_infinite]"
aria-hidden="true"
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
// ApiKeysTab — API Key 录入只录不显余额CLAUDE.md红线 // ApiKeysTab — API Key 录入只录不显余额CLAUDE.md红线
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { ApiKey, CreateApiKeyRequest, PaginatedResponse, ApiError } from '@/types/index'; import { ApiKey, CreateApiKeyRequest, PaginatedResponse, ApiError } from '@/types/index';
import { getErrorAction } from '@/types/errors'; import { getErrorAction } from '@/types/errors';
@@ -10,7 +10,13 @@ export function ApiKeysTab() {
const [form, setForm] = useState<CreateApiKeyRequest>({ provider: 'openai', api_key: '' }); const [form, setForm] = useState<CreateApiKeyRequest>({ provider: 'openai', api_key: '' });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const keyInputRef = useRef<HTMLInputElement>(null);
// 当前选中 provider 是否已录过 → 决定是「保存」还是「更新覆盖」
const isOverwrite = keys.some((k) => k.provider === form.provider);
useEffect(() => { loadKeys(); }, []); useEffect(() => { loadKeys(); }, []);
@@ -22,10 +28,18 @@ export function ApiKeysTab() {
} catch { /* silent */ } finally { setLoading(false); } } catch { /* silent */ } finally { setLoading(false); }
} }
// 点列表「修改」:把该 provider 填回表单、清空 key 待重录、聚焦输入框(覆盖式改 key
function handleEdit(provider: string) {
setForm({ provider, api_key: '' });
setError('');
setSuccess('');
keyInputRef.current?.focus();
}
async function handleAdd(e: React.FormEvent) { async function handleAdd(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!form.api_key.trim()) { setError('请填写 API Key'); return; } if (!form.api_key.trim()) { setError('请填写 API Key'); return; }
setSaving(true); setError(''); setSaving(true); setError(''); setSuccess('');
try { try {
await api.post('/api/v1/api-keys', form); await api.post('/api/v1/api-keys', form);
setForm({ provider: 'openai', api_key: '' }); setForm({ provider: 'openai', api_key: '' });
@@ -36,6 +50,18 @@ export function ApiKeysTab() {
} finally { setSaving(false); } } finally { setSaving(false); }
} }
async function handleTest() {
if (!form.api_key.trim()) { setError('请填写 API Key'); return; }
setTesting(true); setError(''); setSuccess('');
try {
await api.post('/api/v1/api-keys/test', form);
setSuccess('测试通过,可以保存');
} catch (err) {
const apiErr = err as ApiError;
setError(apiErr?.message || '测试失败,请检查 Key');
} finally { setTesting(false); }
}
async function handleDelete(id: number) { async function handleDelete(id: number) {
if (!confirm('确认删除此 Key')) return; if (!confirm('确认删除此 Key')) return;
try { try {
@@ -47,33 +73,41 @@ export function ApiKeysTab() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<p className="text-sm text-text-secondary bg-brand-cream-dark px-4 py-3 rounded-lg"> <p className="text-sm text-text-secondary bg-brand-cream-dark px-4 py-3 rounded-lg">
API Key API Key key
</p> </p>
<form onSubmit={handleAdd} className="flex gap-3 items-end"> <form onSubmit={handleAdd} className="flex gap-3 items-end">
<div> <div>
<label htmlFor="provider" className="block text-sm font-medium text-text-primary mb-1">Provider</label> <label htmlFor="provider" className="block text-sm font-medium text-text-primary mb-1">Provider</label>
<select id="provider" value={form.provider} <select id="provider" value={form.provider}
onChange={(e) => setForm((f) => ({ ...f, provider: e.target.value }))} onChange={(e) => { setError(''); setSuccess(''); setForm((f) => ({ ...f, provider: e.target.value })); }}
className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"> className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange">
<option value="openai">OpenAI (gpt-image-2)</option> <option value="openai"> apiports+</option>
<option value="gemini">Gemini</option> <option value="codeproxy"> codeproxyapiports </option>
<option value="gemini">Gemini</option>
</select> </select>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<label htmlFor="api-key-input" className="block text-sm font-medium text-text-primary mb-1">API Key</label> <label htmlFor="api-key-input" className="block text-sm font-medium text-text-primary mb-1">
<input id="api-key-input" type="password" value={form.api_key} API Key{isOverwrite && <span className="text-brand-orange ml-1"></span>}
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} </label>
<input id="api-key-input" ref={keyInputRef} type="password" value={form.api_key}
onChange={(e) => { setError(''); setSuccess(''); setForm((f) => ({ ...f, api_key: e.target.value })); }}
placeholder="sk-..." placeholder="sk-..."
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange" className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
aria-label="输入 API Key" aria-label="输入 API Key"
/> />
</div> </div>
<button type="submit" disabled={saving} aria-label="保存 API Key" <button type="button" onClick={handleTest} disabled={testing || saving}
className="bg-brand-orange text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50"> className="border border-border-default text-text-secondary rounded-lg px-4 py-2 text-sm font-medium hover:border-brand-orange hover:text-brand-orange disabled:opacity-50 whitespace-nowrap">
{saving ? '保存中…' : '保存'} {testing ? '测试中…' : '测试'}
</button>
<button type="submit" disabled={saving || testing} aria-label={isOverwrite ? '更新 API Key' : '保存 API Key'}
className="bg-brand-orange text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-orange-600 disabled:opacity-50 whitespace-nowrap">
{saving ? '保存中…' : isOverwrite ? '更新覆盖' : '保存'}
</button> </button>
</form> </form>
{error && <p role="alert" className="text-status-error text-sm">{error}</p>} {error && <p role="alert" className="text-status-error text-sm">{error}</p>}
{success && <p role="status" className="text-green-700 text-sm">{success}</p>}
{loading ? ( {loading ? (
<div className="text-text-secondary text-sm"></div> <div className="text-text-secondary text-sm"></div>
) : ( ) : (
@@ -84,8 +118,12 @@ export function ApiKeysTab() {
<span className="font-medium">{k.provider}</span> <span className="font-medium">{k.provider}</span>
<span className="text-text-secondary ml-2">{k.key_last4}</span> <span className="text-text-secondary ml-2">{k.key_last4}</span>
</span> </span>
<span className="flex items-center gap-3">
<button onClick={() => handleEdit(k.provider)} aria-label={`修改 ${k.provider} Key`}
className="text-brand-orange text-xs hover:underline"></button>
<button onClick={() => handleDelete(k.id)} aria-label={`删除 ${k.provider} Key`} <button onClick={() => handleDelete(k.id)} aria-label={`删除 ${k.provider} Key`}
className="text-status-error text-xs hover:underline"></button> className="text-status-error text-xs hover:underline"></button>
</span>
</li> </li>
))} ))}
{keys.length === 0 && ( {keys.length === 0 && (

View File

@@ -4,7 +4,6 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { CreateBenchmarkRequest } from '@/types/index';
interface BenchmarkFormProps { interface BenchmarkFormProps {
productId: number; productId: number;
@@ -13,18 +12,33 @@ interface BenchmarkFormProps {
} }
export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormProps) { export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormProps) {
const [form, setForm] = useState<CreateBenchmarkRequest>({ const [linkUrl, setLinkUrl] = useState('');
screenshot_url: '', highlights: '', link_url: null, const [highlights, setHighlights] = useState('');
}); const [screenshot, setScreenshot] = useState<File | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function handleSave(e: React.FormEvent) { async function handleSave(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!form.screenshot_url || !form.highlights) return; if (!screenshot && !linkUrl.trim() && !highlights.trim()) {
setError('请至少上传截图、填写原始链接或填写亮点');
return;
}
if (screenshot && screenshot.size > 10 * 1024 * 1024) {
setError('截图超过 10 MB');
return;
}
setSaving(true); setSaving(true);
setError('');
try { try {
await api.post(`/api/v1/products/${productId}/benchmarks`, form); const formData = new FormData();
if (screenshot) formData.append('screenshot', screenshot);
formData.append('highlights', highlights.trim());
formData.append('link_url', linkUrl.trim());
await api.postForm(`/api/v1/products/${productId}/benchmarks/upload`, formData);
onSaved(); onSaved();
} catch (err) {
setError((err as Error)?.message || '保存失败,请重试');
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -35,22 +49,32 @@ export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormPro
className="border border-border-default rounded-xl p-6 bg-surface-primary space-y-4"> className="border border-border-default rounded-xl p-6 bg-surface-primary space-y-4">
<h3 className="font-medium text-text-primary"></h3> <h3 className="font-medium text-text-primary"></h3>
<div> <div>
<label htmlFor="bm-url" className="block text-sm font-medium mb-1"> URL</label> <label htmlFor="bm-link" className="block text-sm font-medium mb-1"></label>
<input id="bm-url" value={form.screenshot_url} <input id="bm-link" value={linkUrl}
onChange={(e) => setForm((f) => ({ ...f, screenshot_url: e.target.value }))} onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..." placeholder="https://www.xiaohongshu.com/..."
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange" className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
required aria-required="true"
/> />
<p className="mt-1 text-xs text-text-tertiary"></p>
</div> </div>
<div> <div>
<label htmlFor="bm-highlights" className="block text-sm font-medium mb-1"></label> <label htmlFor="bm-screenshot" className="block text-sm font-medium mb-1"></label>
<textarea id="bm-highlights" value={form.highlights} rows={3} <input id="bm-screenshot" type="file" accept="image/jpeg,image/png,image/webp"
onChange={(e) => setForm((f) => ({ ...f, highlights: e.target.value }))} onChange={(e) => setScreenshot(e.target.files?.[0] ?? null)}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
/>
{screenshot && (
<p className="mt-1 text-xs text-text-secondary truncate">{screenshot.name}</p>
)}
</div>
<div>
<label htmlFor="bm-highlights" className="block text-sm font-medium mb-1"></label>
<textarea id="bm-highlights" value={highlights} rows={3}
onChange={(e) => setHighlights(e.target.value)}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange resize-none" className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange resize-none"
required aria-required="true"
/> />
</div> </div>
{error && <p role="alert" className="text-sm text-status-error">{error}</p>}
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button type="button" onClick={onCancel} <button type="button" onClick={onCancel}
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary"> className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">

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