上线版: 产品表单统一+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:
21
.dockerignore
Normal file
21
.dockerignore
Normal 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
43
.env.example
Normal 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
51
.github/workflows/ci.yml
vendored
Normal 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
6
.gitignore
vendored
@@ -22,9 +22,15 @@ build/
|
||||
logs/
|
||||
*.log
|
||||
.DS_Store
|
||||
verify_output/
|
||||
**/verify_output/
|
||||
output_e2e/
|
||||
**/output_e2e/
|
||||
screenshot-*.png
|
||||
|
||||
# Celery / Redis 本地
|
||||
celerybeat-schedule
|
||||
dump.rdb
|
||||
.env.bak
|
||||
*.env.bak
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
- ①**改稿进飞轮(D1,最强信号,必做)**:运营改AI稿→diff喂回飞轮。**入飞轮时机=先激进(改了就入)跑一周,北哥用后按真实信号质量校准是否加"过审才入"闸门**(细案E18/E19)。需补 SignalType.text_edit + TextCandidate.edited字段 + 改稿端点(走task_actions不碰review.py)。
|
||||
- ②**AI评图分进飞轮(E12,做扎实版)**:AI评图分**只做"生成时筛选+前端展示"**,前端要呈现"这张图AI评了X分+已被选入飞轮"。**真正影响下次生成的飞轮权重只认真实信号(选了哪张/改了什么/过审与否),不拿AI主观分当权重**——守住eval_score留NULL铁律,AI分是展示层不是权重层。
|
||||
- ③**前端呈现=轻量化关键节点反馈(不做独立飞轮主视图大页E01)**:在生成/选择/改稿/审核节点即时提示"已学习你的偏好""本次参考了你上周改的稿""飞轮已积累N条信号",嵌现有流程,纵切片友好。
|
||||
- ④🔴 **导入文案飞轮权重要更重(倩倩姐2026-06-26拍板)**:客户导入的外部文案=客户实跑验证过、跑得好的样本,是**最值得学习的范本**,飞轮信号权重要高于普通 AI 选稿信号。需补 SignalType(如 text_import)并给更高权重;与"导入轨去生图"链路配套。守住 eval_score 留 NULL 铁律不变。
|
||||
- 🔴 **竞品笔记数据监控=不做(倩倩姐2026-06-16拍板)**:E04竞品数据全监控/反推关键词=自动抓小红书,命中合规反爬红线,直接砍,不进任何期。
|
||||
- 发布:不自动发小红书,产出"达人素材交付包"人工发。
|
||||
- 数据归属:原始数据(输入+产出+素材包)归客户可导出;飞轮偏好归平台。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 1. 产品定位
|
||||
- 一句话:北哥团队的小红书达人素材"中央厨房"(多租户SaaS)
|
||||
- 界面名:龙石小红书内容生产平台 ; 项目代号:Clover🍀
|
||||
- 界面名:Clover小红书内容生产平台 ; 项目代号:Clover🍀
|
||||
- 核心差异化 = **偏好飞轮**(越用越懂你的"老师傅")
|
||||
|
||||
## 2. 目标用户与三角色
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Clover PRD-前端(界面名:龙石小红书内容生产平台)
|
||||
# Clover PRD-前端(界面名:Clover小红书内容生产平台)
|
||||
|
||||
> 前端技术框架。**承接层**:5屏画面金标准见 `../../前端交互设计.md`(经倩倩姐确认),视觉见 `../../前端设计参考图.png`。本文①承接5屏②补工程细节③对接后端契约——**唯一冻结源 = `../启动包/API契约.md`**(端点§1 / SSE事件§2 / 错误码§0),不看 PRD-后端。
|
||||
> 技术栈:Next.js+React+TS+Tailwind+Zustand(范式扒banana/frontend,全新重建)。PC优先,最小宽度1280px,不做移动端。
|
||||
@@ -21,7 +21,7 @@
|
||||
- 🔴 角色路由守卫:/review只组长+管理员;/config只管理员;越权重定向工作台
|
||||
|
||||
## 2. 全局布局(对齐设计图)
|
||||
- 顶部:标题"龙石小红书内容生产平台"+右上角当前用户(运营-小李)
|
||||
- 顶部:标题"Clover小红书内容生产平台"+右上角当前用户(运营-小李)
|
||||
- 5步流程条(1开新任务/2选择文案/3选择图片/4确认文案/5成片发布)——熟手常驻;组长/管理员登录后不走此条(各进自己首页)
|
||||
- 左侧8项导航(工作台/开新任务/选择文案/选择图片/建立任务/任务列表/历史归档/配置中心)
|
||||
- 主区 + 右侧配置预览面板
|
||||
|
||||
72
README.md
Normal file
72
README.md
Normal 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
15
backend/.dockerignore
Normal 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
26
backend/.env.example
Normal 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
|
||||
@@ -14,15 +14,16 @@ backend/
|
||||
constants/ # 策略命名中心文件,消除打架
|
||||
utils/ # Fernet加密、SSE工具等
|
||||
alembic/
|
||||
versions/ # 001-004 migration 脚本
|
||||
versions/ # 001-022 migration 脚本
|
||||
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
|
||||
|
||||
24
backend/alembic/versions/022_user_must_change_password.py
Normal file
24
backend/alembic/versions/022_user_must_change_password.py
Normal 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")
|
||||
@@ -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,
|
||||
)
|
||||
35
backend/alembic/versions/024_signal_type_text_import.py
Normal file
35
backend/alembic/versions/024_signal_type_text_import.py
Normal 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}")
|
||||
@@ -43,6 +43,10 @@ class CreateApiKeyRequest(BaseModel):
|
||||
# 注:不接收 url 字段(token站固定自家站,基石B)
|
||||
|
||||
|
||||
class TestApiKeyRequest(CreateApiKeyRequest):
|
||||
pass
|
||||
|
||||
|
||||
def _format_key(k: UserApiKey) -> dict:
|
||||
"""只显 provider + key_last4,不暴露余额/用量/encrypted_key(红线)。"""
|
||||
return {
|
||||
@@ -77,8 +81,13 @@ def create_api_key(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""录入 API Key(Fernet 加密存储,只保存后4位明文用于展示)。"""
|
||||
from app.core.response import raise_business
|
||||
"""录入/更新 API Key(Fernet 加密存储,只保存后4位明文用于展示)。
|
||||
|
||||
覆盖式(倩倩姐2026-06-23拍板):同 user+workspace+provider 已存在则直接更新,
|
||||
不报错——「切换/修改 key」无需先删再录,前端点「修改」重录即覆盖。
|
||||
"""
|
||||
encrypted = encrypt_api_key(body.api_key)
|
||||
last4 = mask_api_key(body.api_key)
|
||||
# 检查同 user+workspace+provider 是否已有
|
||||
existing = (
|
||||
db.query(UserApiKey)
|
||||
@@ -90,15 +99,19 @@ def create_api_key(
|
||||
.first()
|
||||
)
|
||||
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(
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=current_user.workspace_id,
|
||||
provider=body.provider,
|
||||
encrypted_key=encrypted,
|
||||
key_last4=mask_api_key(body.api_key),
|
||||
key_last4=last4,
|
||||
)
|
||||
db.add(key_obj)
|
||||
db.commit()
|
||||
@@ -107,6 +120,48 @@ def create_api_key(
|
||||
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}")
|
||||
def delete_api_key(
|
||||
key_id: int,
|
||||
|
||||
@@ -34,6 +34,11 @@ class LoginRequest(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
# ── 路由 ───────────────────────────────────────────────────
|
||||
@router.post("/login")
|
||||
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")
|
||||
def get_me(
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
@@ -63,6 +80,7 @@ def get_me(
|
||||
"email": user.email,
|
||||
"current_workspace_id": current_user.workspace_id,
|
||||
"role": current_user.role,
|
||||
"must_change_password": bool(getattr(user, "must_change_password", False)),
|
||||
"workspace": {
|
||||
"id": ws.id, "name": ws.name, "slug": ws.slug,
|
||||
} if ws else None,
|
||||
|
||||
@@ -4,16 +4,19 @@ app/api/v1/benchmarks.py — 标杆笔记 + 违禁词路由(管理员)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, 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.models.product import BannedWord, BenchmarkNote
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["products"])
|
||||
@@ -45,6 +48,8 @@ def _fmt_benchmark(b: BenchmarkNote) -> dict:
|
||||
"screenshot_url": b.screenshot_url,
|
||||
"highlights": b.highlights, "link_url": b.link_url,
|
||||
"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(),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
def list_benchmarks(
|
||||
@@ -81,12 +150,64 @@ def create_benchmark(
|
||||
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("产品不存在")
|
||||
# 兼容旧前端:旧字段 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(
|
||||
workspace_id=current_user.workspace_id,
|
||||
product_id=product_id,
|
||||
screenshot_url=body.screenshot_url,
|
||||
highlights=body.highlights,
|
||||
link_url=body.link_url,
|
||||
screenshot_url=None,
|
||||
highlights=highlights,
|
||||
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)
|
||||
return ok(_fmt_benchmark(b))
|
||||
@@ -117,17 +238,24 @@ async def analyze_benchmark_note(
|
||||
).first()
|
||||
if not b:
|
||||
raise_not_found("标杆笔记不存在")
|
||||
if not b.screenshot_url and not b.highlights:
|
||||
raise_business("该标杆无截图也无手填亮点,无法分析")
|
||||
if not b.screenshot_url and not b.highlights and not b.link_url:
|
||||
raise_business("该标杆无截图、原始链接和手填亮点,无法分析")
|
||||
|
||||
# 读截图字节(screenshot_url 存的是上传后的绝对/相对路径)
|
||||
# 读截图字节:新数据存本地上传路径;旧数据可能误存外部 URL,不能直接 open。
|
||||
screenshot = None
|
||||
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:
|
||||
with open(b.screenshot_url, "rb") as f:
|
||||
screenshot = f.read()
|
||||
except Exception as 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(
|
||||
UserApiKey.user_id == current_user.user_id,
|
||||
@@ -137,12 +265,26 @@ async def analyze_benchmark_note(
|
||||
if not api_key_row:
|
||||
raise_business("未配置 API 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
|
||||
alt_key = None
|
||||
|
||||
b.analyze_status = "analyzing"; db.commit()
|
||||
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:
|
||||
await clients.aclose()
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
app/api/v1/delivery.py — 交付包路由
|
||||
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}/mark-downloaded — 显式标记已下载
|
||||
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,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""下载交付包(status: pending/ready/downloaded)。"""
|
||||
"""查询交付包下载状态。GET 只读,实际下载由 download-file 完成。"""
|
||||
pkg = db.query(DeliveryPackage).filter(
|
||||
DeliveryPackage.id == package_id,
|
||||
DeliveryPackage.workspace_id == current_user.workspace_id,
|
||||
@@ -86,11 +87,6 @@ def get_package_download(
|
||||
if not pkg:
|
||||
raise_not_found("交付包不存在")
|
||||
|
||||
if pkg.status == "ready" and pkg.download_url:
|
||||
# 标记为 downloaded(只能下一次,防重复公开 URL)
|
||||
pkg.status = "downloaded"
|
||||
db.commit()
|
||||
|
||||
return ok(_fmt_package(pkg))
|
||||
|
||||
|
||||
@@ -124,6 +120,26 @@ def create_download_token(
|
||||
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")
|
||||
def download_package_file(
|
||||
package_id: int,
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.core.database import get_db
|
||||
from app.core.response import ok, paginate, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage
|
||||
from app.models.task import GenerationTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["products"])
|
||||
@@ -121,6 +122,28 @@ def get_product(
|
||||
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}")
|
||||
def update_product(
|
||||
product_id: int, body: ProductCreate,
|
||||
@@ -148,12 +171,21 @@ def delete_product(
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""智能删除(仅管理员):没跑过任务的产品物理删干净(级联清图片/标杆),
|
||||
已有历史任务的产品改软删保留历史,避免 generation_tasks 外键报错。"""
|
||||
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
p.is_active = False # 软删
|
||||
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()
|
||||
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,请先在设置中录入")
|
||||
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)
|
||||
alt_key = None
|
||||
|
||||
try:
|
||||
raw = await clients.gpt_vision_analyze(_VISION_PROMPT, [data])
|
||||
|
||||
@@ -53,28 +53,78 @@ def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
|
||||
from app.api.v1.tasks import _score_array_to_obj
|
||||
product = db.query(Product).filter(Product.id == t.product_id).first()
|
||||
operator = db.query(User).filter(User.id == t.operator_id).first()
|
||||
selected_text = db.query(TextCandidate).filter(
|
||||
selected_texts = db.query(TextCandidate).filter(
|
||||
TextCandidate.task_id == t.id, TextCandidate.is_selected == True
|
||||
).first()
|
||||
selected_image = db.query(ImageCandidate).filter(
|
||||
).order_by(TextCandidate.strategy.asc(), TextCandidate.id.asc()).all()
|
||||
selected_images = db.query(ImageCandidate).filter(
|
||||
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 = {}
|
||||
if selected_text and selected_text.content:
|
||||
try:
|
||||
text_parsed = json.loads(selected_text.content)
|
||||
text_parsed = json.loads(selected_text.content or "{}")
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
text_parsed = {"content": selected_text.content}
|
||||
|
||||
# 质量分:解析 score_json 明细数组算总分+透传维度(绝不读 eval_score,设计留NULL)
|
||||
text_score = None
|
||||
if selected_text and selected_text.score_json:
|
||||
if selected_text.score_json:
|
||||
try:
|
||||
text_score = _score_array_to_obj(json.loads(selected_text.score_json))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
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 {
|
||||
"task_id": t.id,
|
||||
@@ -82,19 +132,9 @@ def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
|
||||
"theme": t.theme,
|
||||
"submitted_at": t.updated_at.isoformat(),
|
||||
"operator_name": operator.username if operator else None,
|
||||
"selected_text": {
|
||||
"candidate_id": selected_text.id,
|
||||
"angle_label": selected_text.angle_label,
|
||||
"title": text_parsed.get("title", ""),
|
||||
"content": text_parsed.get("content", ""), # 纯正文
|
||||
"tags": text_parsed.get("tags", []),
|
||||
"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,
|
||||
"selected_text": legacy_text,
|
||||
"selected_image": legacy_image,
|
||||
"selected_sets": selected_sets,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_not_found, raise_state_invalid
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission, require_admin
|
||||
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
|
||||
from app.api.v1.tasks import (
|
||||
@@ -45,6 +45,12 @@ def select_text(
|
||||
).first()
|
||||
if not tc:
|
||||
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
|
||||
db.commit()
|
||||
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()
|
||||
if not ic:
|
||||
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
|
||||
db.commit()
|
||||
# R7 断点2:选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨;
|
||||
@@ -89,18 +102,40 @@ def import_text(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""轨B:导入外部文案直接进候选池,跳过 AI 生成(洞2)。"""
|
||||
_check_task_ownership(
|
||||
"""轨B:导入外部文案直接进候选池,跳过 AI 生成(洞2)。
|
||||
按已导入条数 %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(),
|
||||
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(
|
||||
workspace_id=current_user.workspace_id, task_id=task_id,
|
||||
source="import", content=body.content, angle_label=body.angle_label,
|
||||
strategy=strategy,
|
||||
)
|
||||
db.add(tc)
|
||||
db.commit()
|
||||
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))
|
||||
|
||||
|
||||
@@ -128,6 +163,18 @@ def regenerate(
|
||||
current_user.workspace_id,
|
||||
)
|
||||
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(单张重生需知道是哪套的哪张)
|
||||
if body.role and not body.strategy:
|
||||
raise_state_invalid("指定单张重生(role)时必须同时指定 strategy(套别)")
|
||||
@@ -138,6 +185,39 @@ def regenerate(
|
||||
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")
|
||||
def submit_review(
|
||||
task_id: int,
|
||||
@@ -151,11 +231,40 @@ def submit_review(
|
||||
)
|
||||
if task.status != "pending_selection":
|
||||
raise_state_invalid("只有 pending_selection 状态可提审")
|
||||
_validate_strategy_selection(db, task)
|
||||
task.status = "pending_review"
|
||||
db.commit()
|
||||
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")
|
||||
def get_preference_context(
|
||||
task_id: int,
|
||||
@@ -211,3 +320,45 @@ def edit_text(
|
||||
db.commit()
|
||||
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})
|
||||
|
||||
|
||||
@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})
|
||||
|
||||
@@ -13,7 +13,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, paginate, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.middleware.workspace_guard import (
|
||||
CurrentUser,
|
||||
require_admin,
|
||||
require_write_permission,
|
||||
)
|
||||
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -227,3 +231,42 @@ def get_task(
|
||||
"text_candidates": [_fmt_text(tc) for tc in texts],
|
||||
"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"})
|
||||
|
||||
83
backend/app/api/v1/users.py
Normal file
83
backend/app/api/v1/users.py
Normal 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,
|
||||
))
|
||||
@@ -39,5 +39,6 @@ def switch_workspace(
|
||||
token = create_access_token(current_user.user_id, body.workspace_id, member.role)
|
||||
return ok({
|
||||
"current_workspace_id": body.workspace_id,
|
||||
"role": member.role,
|
||||
"token": token,
|
||||
})
|
||||
|
||||
@@ -62,6 +62,7 @@ class SignalType(str, Enum):
|
||||
REJECT_WITH_REASON = "reject_with_reason"
|
||||
REGENERATE = "regenerate"
|
||||
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.REGENERATE: -1,
|
||||
SignalType.TEXT_EDIT: 5, # 最强信号,≥选择类(倩倩姐2026-06-16拍板;先激进跑一周再校准)
|
||||
# 导入文案=客户实跑验证过的范本,权重高于改稿/AI选稿(倩倩姐2026-06-26拍板)。
|
||||
# 8=改稿5+AI选稿3之上拉开梯度,体现"最值得学习"又不极端碾压;待倩倩姐最终校准。
|
||||
SignalType.TEXT_IMPORT: 8,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# ── 应用配置 ──────────────────────────────────────
|
||||
APP_ENV: str = "development"
|
||||
CORS_ALLOW_ORIGINS: str = "http://localhost:3000"
|
||||
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
|
||||
CELERY_BROKER_URL: str = ""
|
||||
CELERY_RESULT_BACKEND: str = ""
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.response import raise_forbidden, raise_unauthorized
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.user import User
|
||||
from app.models.workspace import WorkspaceMember
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -85,6 +86,9 @@ def require_write_permission(
|
||||
current_user.workspace_id,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -102,9 +102,9 @@ class BenchmarkNote(Base):
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
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) # 手填亮点
|
||||
link_url: Mapped[str | None] = mapped_column(String(512))
|
||||
link_url: Mapped[str | None] = mapped_column(Text)
|
||||
# 009: 第2环标杆分析字段
|
||||
features_json: Mapped[str | None] = mapped_column(Text, comment="爆款8特征分析结果(JSON),AI解析后写入")
|
||||
analyze_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False, comment="AI分析状态: pending/analyzing/done/failed")
|
||||
|
||||
@@ -22,6 +22,7 @@ class User(Base):
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
_score_prompt.py — AI 评委 prompt(让模型真读文案,不机械找词)
|
||||
评判标准忠于《富贵情绪营销理论》原文(口播一手来源),标实战补充出处。
|
||||
6维满分分布(倩倩姐2026-06-15拍板,与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
|
||||
痛点人群精准18 / 情绪张力18 / 买点转化18 / 开头钩子15 / 标题点击力13 / 真实感13 = 95
|
||||
7维满分分布(倩倩姐2026-06-22拍板补北哥要害⑤标签精准·与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
|
||||
痛点人群精准16 / 情绪张力16 / 买点转化16 / 开头钩子13 / 标题点击力13 / 真实感11 / 标签精准度10 = 95
|
||||
+ 合规5(机械硬拦,不进AI评委)= 100
|
||||
合格线80不动;新增"标签精准度"是补考核维度,分从原6维各匀出,总分仍95,不是降门槛。
|
||||
"真实感"替换旧"产品聚焦一件事(16)":富贵"很少提产品/前70%干货后30%植入"独立升维。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -18,13 +19,13 @@ SCORER_PERSONA = """你是一位资深小红书内容操盘手,深谙富贵情
|
||||
# ── 6 维评判标准(按权重降序;合规第7维由代码机械硬拦,不在此)────
|
||||
# 标准依据:括号内标注[富贵原文]或[实战补充],前者来自口播一手来源,权威最高。
|
||||
SCORING_DIMENSIONS = """
|
||||
【维度1·痛点人群精准(满分18)】[富贵原文]
|
||||
【维度1·痛点人群精准(满分16)】[富贵原文]
|
||||
判断:"说的就是我"——文案描述的处境/困扰,目标用户读了会不会对号入座。
|
||||
好:具体到某类人的某个真实生活瞬间,让人觉得被看穿,落在"我的大问题/我的处境/我的渴望"上。
|
||||
差:泛泛而谈谁都能套(如"适合所有想变美的女生");或PUA用户、拿别人的惨状吓唬人。
|
||||
依据:富贵"我的大问题→处境→渴望"内容骨架;人群越具体穿透力越强;"用户被宠成爹,你PUA他不好使"。
|
||||
|
||||
【维度2·情绪张力(满分18)】[富贵原文·第一性原理]
|
||||
【维度2·情绪张力(满分16)】[富贵原文·第一性原理]
|
||||
判断:有没有"成果/后果"双向情绪,而不是平铺直叙报卖点。
|
||||
· 后果=过去没用它,产生了什么糟糕处境(勾起恐惧/懊悔)
|
||||
· 成果=用了它之后,会变成什么样(给出期待/乐观)
|
||||
@@ -32,32 +33,43 @@ SCORING_DIMENSIONS = """
|
||||
差:全程客观介绍产品、无情绪、像说明书;或只单向吓唬、或只空喊美好。
|
||||
依据:富贵"营销第一性原理就是情绪,没有情绪什么内容都不转化";"成果是未来、后果是过去,要做这两种情绪"。
|
||||
|
||||
【维度3·买点转化(满分18)】[富贵原文]
|
||||
【维度3·买点转化(满分16)】[富贵原文]
|
||||
判断:产品卖点有没有翻译成用户能感知的场景化利益(人话),而不是品牌视角的功效/参数。
|
||||
好:用户能想象到的使用场景和结果(如"出门前最后一步、同事问我今天气色真好")。
|
||||
好:用户能想象到的使用场景和结果(如"出门前最后一步搞定、被身边人夸最近状态真好")。
|
||||
差:品牌语言/参数罗列(如"采用XX技术""含XX成分"),用户无感。
|
||||
依据:富贵"卖点是品牌视角、买点是用户视角";"用户买的不是产品,是使用场景背后被解决的问题"。
|
||||
|
||||
【维度4·开头钩子(满分15)】[富贵原文]
|
||||
【维度4·开头钩子(满分13)】[富贵原文]
|
||||
判断:开头能不能让人停下来、想继续读。
|
||||
好:开头第一句就直击用户的大问题/痛处/具体场景代入,每句都打要害。
|
||||
差:开头是套话或铺垫半天不进正题,没有任何抓人的点。
|
||||
依据:富贵"内容就是锋利的刀,一定要插用户的心窝子"。
|
||||
|
||||
【维度5·标题点击力(满分13)】[富贵原文+实战补充]
|
||||
判断:标题有没有让目标用户想点的诱因。
|
||||
好:标题带具体人群/场景/痛点/情绪钩子,一眼觉得"和我有关、我想看",最好来自用户真实说法。
|
||||
差:只有产品名、平淡无钩子、或太像广告。
|
||||
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文];标题善用痛点/人群/效果/情绪词[实战补充]。
|
||||
【维度5·标题点击力(满分13)】[富贵原文+实战补充+北哥要害①]
|
||||
判断:标题有没有让目标用户想点的诱因,且覆盖面够不够广。
|
||||
好:标题带悬念/反差/疑问钩子让人想点,同时"广覆盖"罩住尽量多的人(不一上来就锁死单一窄人群),精准筛人交给标签层。
|
||||
差:只有产品名、平淡无钩子、太像广告;或标题一上来就锁死单一窄人群(如只喊某一类人把其他人挡门外),覆盖面太窄。
|
||||
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文];北哥实战"标题广覆盖+悬念钩子,精准靠标签不靠标题筛人"[要害①]。
|
||||
|
||||
【维度6·真实感(满分13)】[富贵原文]
|
||||
【维度6·真实感(满分11)】[富贵原文]
|
||||
判断:整条文案是不是像真人分享,而不是品牌广告或功效说明书。
|
||||
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格。
|
||||
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板而非真实人设。
|
||||
依据:富贵"我们很少提产品";"前70%是价值/场景,后30%才是产品";用户能感受到"这不像广告"。
|
||||
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格;种草借第三方视角(闺蜜/同事/被人问)而非自夸回购。
|
||||
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板;或全程自卖自夸"我回购了N次"广告味重。
|
||||
依据:富贵"我们很少提产品";"前70%是价值/场景,后30%才是产品";北哥要害③"第三方闺蜜真实背书胜过自夸"。
|
||||
|
||||
【维度7·标签精准度(满分10)】[北哥要害⑤·出单命门]
|
||||
判断:tags 是不是"有明确购买意向的人才会搜"的精准埋词,而不是凑流量的泛词。
|
||||
好:目标人群+品类+核心卖点/场景组合成的精准长尾标签(搜它的人就是想买这类的人),至少2-3个精准埋词。
|
||||
差:清一色"#好物分享 #真实测评 #种草"这类谁都能用、购买意向弱的泛流量大词,搜来的人不精准。
|
||||
扣分细则(必须逐个标签判定,不许整体放水):把每个 tag 归为"精准埋词"或"泛词"。
|
||||
泛词=品类大词/平台流量词/无人群无卖点的口号词,如 #好物分享 #平价好物 #种草 #真实测评 #必入清单。
|
||||
精准埋词=含明确人群或场景或具体痛点卖点,搜它的人就是目标买家,如 #久坐党护腰好物 #通勤提神咖啡。
|
||||
评分上限:泛词占比≥50% 最高只能给6分;占比1/3左右给7-8分;几乎全是精准埋词才给9-10分。
|
||||
reason 里必须点名哪几个是泛词、哪几个是精准埋词,不能只给结论。
|
||||
依据:北哥实战"精准购买意向标签埋词是出单命门,泛词价值低、精准长尾才带来真买家"[要害⑤]。
|
||||
""".strip()
|
||||
|
||||
# ── 真实感已升为维度6(满分13),此处保留空字符串避免调用方引用报错 ──
|
||||
# ── 真实感已升为维度6(满分11),此处保留空字符串避免调用方引用报错 ──
|
||||
REALNESS_NOTE = "" # 升为 SCORING_DIMENSIONS 维度6,不再重复附加
|
||||
|
||||
# ── 输出格式约束 ──────────────────────────────────────────
|
||||
@@ -65,17 +77,20 @@ SCORER_OUTPUT_FORMAT = """
|
||||
读完这条文案后,严格返回纯JSON对象(不要markdown代码块、不要多余文字),格式:
|
||||
{
|
||||
"dims": [
|
||||
{"item":"痛点人群精准","score":<0-18整数>,"reason":"<针对本条的具体理由,30字内>"},
|
||||
{"item":"情绪张力","score":<0-18>,"reason":"..."},
|
||||
{"item":"买点转化","score":<0-18>,"reason":"..."},
|
||||
{"item":"开头钩子","score":<0-15>,"reason":"..."},
|
||||
{"item":"痛点人群精准","score":<0-16整数>,"reason":"<针对本条的具体理由,30字内>"},
|
||||
{"item":"情绪张力","score":<0-16>,"reason":"..."},
|
||||
{"item":"买点转化","score":<0-16>,"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":"<优秀|合格|不合格>",
|
||||
"summary":"<一句话总评,说清这条最大的优点和最该改的点>"
|
||||
}
|
||||
打分要敢拉开差距:平庸文案该给中低分,不要清一色高分;优秀的地方也别吝啬给高分。
|
||||
⚠️只评上面7个维度。合规/违禁词由系统机械检测,禁止你输出"合规性/违禁词/合规"等任何合规相关维度,
|
||||
也不要因为你主观觉得某词敏感而压分——"不假白/没那么黄/提气色"这类是合规的感受描述,不是功效违禁词。
|
||||
""".strip()
|
||||
|
||||
# 合规维度满分(机械硬拦,不进 AI 评委)
|
||||
@@ -86,9 +101,11 @@ AI_DIMS_MAX = 95
|
||||
|
||||
|
||||
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()
|
||||
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 = ""
|
||||
if product:
|
||||
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"
|
||||
return (
|
||||
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}"
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ def _cat_words(category: str) -> list[str]:
|
||||
def score_title(title: str, cat_words: list[str], w: dict) -> dict:
|
||||
ts = 0
|
||||
if 6 <= len(title) <= 34: ts += 8
|
||||
if re.search(r"[0-9一二三四五六七八九十几]|最近|这支|这个|每天|早八|学生党|新手|宝妈|懒人|伪素颜", title): ts += 5
|
||||
if re.search(r"[0-9一二三四五六七八九十几]|最近|这款|这个|每天|早八|学生党|新手|宝妈|懒人", title): ts += 5
|
||||
if _has_any(title, _SCENE_WORDS): ts += 6
|
||||
if _has_any(title, cat_words): ts += 5
|
||||
if re.search(r"[!!??]|救星|不踩雷|闭眼入|会回购|被问|惊喜|加分|常备|省心|实用|别乱选|放心|有气色", title): ts += 6
|
||||
|
||||
@@ -15,35 +15,38 @@ _PERSONA = """你是一个日常生活分享博主,不是品牌推广号。
|
||||
比例:70% 写生活场景和使用感受,30% 提产品,绝不颠倒。
|
||||
收尾铁律:每条结尾方式必须不同,禁止使用"东西放这了/买不买跟我没关系"这类被用滥的固定句式。"""
|
||||
|
||||
# ── Q1 随机变量池 ABC(反同质化核心,每次随机抽组合,N条不撞)──────────────
|
||||
# 方法层:框架固定,内容可扩展,绝不写死
|
||||
_POOL_A_IDENTITY: list[str] = [
|
||||
"上班族早八妆前随手抹",
|
||||
"宿舍懒人护肤三分钟搞定",
|
||||
"敏感肌妈妈哄完娃才有五分钟",
|
||||
"学生党第一次用高价护肤品",
|
||||
"素颜出门前最后一步",
|
||||
# ── Q1 叙事切入配方(反同质化核心·北哥四象限人群法,全品类通用不写死)────────
|
||||
# 方法层:只给"切入维度类型",具体人群/场景由模型按本产品卖点+目标人群填充
|
||||
# 北哥四象限:场景/习惯/适配/时机——主人群维度按条轮转,保证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]:
|
||||
"""随机抽一组 ABC 变量(每次生成调用一次,N条各不相同)"""
|
||||
def _pick_combo(index: int = 0) -> dict[str, str]:
|
||||
"""抽一组通用叙事切入配方(维度类型非写死内容);主人群维度按index轮转,N条不撞"""
|
||||
return {
|
||||
"identity": random.choice(_POOL_A_IDENTITY),
|
||||
"emotion": random.choice(_POOL_B_EMOTION),
|
||||
"flaw": random.choice(_POOL_C_FLAW),
|
||||
"identity": _AUDIENCE_DIMENSIONS[index % len(_AUDIENCE_DIMENSIONS)],
|
||||
"emotion": random.choice(_EMOTION_TRIGGERS),
|
||||
"flaw": random.choice(_REAL_FLAWS),
|
||||
}
|
||||
|
||||
|
||||
@@ -61,30 +64,34 @@ _EMOJI_RULES = """
|
||||
- 正文段落可点缀少量 emoji 烘托情绪(如 🥹 😭 🤍 💛),每段最多1-2个,不堆砌
|
||||
- 结尾话题标签前后带表情,如 "#好物分享 🛒"
|
||||
- emoji 服务情绪和分点,不要每句都加;整条正文 emoji 总量控制在 6-12 个
|
||||
- 常用小红书 emoji 池:✅✨🌿💧🪞🧴📦🔍💛🤍🥹😭🛒(按语义选,不乱用)
|
||||
- 常用小红书 emoji 池:✅✨🌿💧📦🔍💛🤍🥹😭🛒💯(按语义选,不乱用)
|
||||
""".strip()
|
||||
|
||||
# ── Q2 5步框架 + Q3 四段结构 ─────────────────────────────────────────────
|
||||
_STRUCTURE_RULES = """
|
||||
【5步框架(必须严格遵循)】
|
||||
① Hook暴击低价/痛点:第一句戳中场景或价格锚点,吊足读者好奇
|
||||
② 痛点共鸣:2-3句描写使用前的真实困境(用上面抽到的起因情绪A·B)
|
||||
② 痛点共鸣:2-3句描写使用前的真实困境(结合本条的情绪触发,并按北哥要害②召集多类人)
|
||||
③ 救星登场:自然带出产品,口吻是"碰巧发现/朋友安利/囤货时顺手",不是"推荐给你"
|
||||
④ 卖点罗列(每条加✅小标题):3条以内,卖点翻译成使用感受,不是功效列表
|
||||
⑤ 收尾(每条必须从下方策略池随机选一种,同批次不得重复同一种,禁止"东西放这了/买不买跟我没关系"此类固定句式):
|
||||
【收尾策略池·每条选不同策略】
|
||||
A·留白式感受:只说自己现在的状态,不提买不买,如"反正现在素颜出门我不慌了"
|
||||
B·反问读者:把感受抛回给读者,如"你们护肤有没有那种一用就回不去的东西?"
|
||||
C·场景延续:把故事延伸到未来某个细节,如"下次同事再问我皮肤的事我就知道说啥了"
|
||||
D·克制回购暗示:轻描淡写说自己行为,如"第一罐用完了,已经在备第二罐"
|
||||
A·留白式感受:只说自己现在的状态,不提买不买,如"反正现在出门我都带着它,心里踏实多了"
|
||||
B·反问读者:把感受抛回给读者,如"你们有没有那种一用就回不去的小东西?"
|
||||
C·场景延续:把故事延伸到未来某个细节,如"下次再有人问起,我就知道该推荐啥了"
|
||||
D·克制回购暗示:轻描淡写说自己行为,如"第一份用完了,已经在备第二份"
|
||||
E·纯记录收笔:像日记最后一句,不引导不评价,如"大概就这样,记录一下"
|
||||
F·引导搜索(仅在有品牌词时使用):自然提一句,如"感兴趣可以搜搜『{品牌词}』",不催单
|
||||
|
||||
【正文四段结构(必须)】
|
||||
段1·痛点引入:描写使用前的困境/触发场景(身份场景A·起因情绪B)
|
||||
段2·实测记录:真实使用过程,带上小缺点C(真实感来源)
|
||||
段3·种草核心:产品带来的变化,用感受描述而非功效声称
|
||||
段4·引导收尾:从收尾策略池随机选一种,佛系口吻,不强推,末尾带1-2个相关话题标签
|
||||
【正文叙事骨架(开篇方式由本条叙事主线决定,禁止三套同一开头)】
|
||||
段1·开篇分叉(按本条叙事主线三选一,这是三套拉开差异的关键):
|
||||
- 痛点先行→开篇即戳使用前的困境/情绪(结合主切入人群维度,按要害②召集多类人)
|
||||
- 场景先行→开篇先白描一个真实高频使用场景,痛点在场景里自然带出,不喊口号
|
||||
- 成分背书先行→开篇用成分原理/测评对比视角切入("为什么有用/亲测对比"),痛点后置
|
||||
段2·实测记录:真实使用过程,带上本条的真实小波折(真实感来源),用第三方视角背书(北哥要害③)
|
||||
段3·种草核心:产品带来的变化,用感受描述而非功效声称,核心成分逐条对应痛点(北哥要害④)
|
||||
段4·引导收尾:从收尾策略池随机选一种,佛系口吻,不强推,末尾带精准购买意向标签(北哥要害⑤)
|
||||
※ 卖点罗列的措辞/顺序/emoji 三套之间禁止雷同;身边人种草/被夸被问这类桥段三套不得共用同一套。
|
||||
|
||||
【字数】正文350-400字(不含标题tags),3-5处段落空行增强可读性
|
||||
""".strip()
|
||||
@@ -92,23 +99,46 @@ _STRUCTURE_RULES = """
|
||||
# ── Q8 标题公式(5类结构,每批次覆盖不同类型,不重复)──────────────────────
|
||||
_TITLE_FORMULA = """
|
||||
【标题公式(5类,每条用不同类型,禁止同批重复)】
|
||||
肤质型:「{肤质}+用了{产品}+{感受}」例→"油皮用了素颜霜整个夏天不脱妆"
|
||||
价格型:「{价格锚点}+{产品}+{功效感受}」例→"三位数买到大牌平替,用完第一罐回购第二罐"
|
||||
功效型:「{使用场景}+{产品}+{可感知变化}」例→"早起素颜出门靠这罐,同事问我最近皮肤怎么了"
|
||||
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神奇产品,涂上去感觉素颜也能出门了"
|
||||
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"被人说皮肤变好了,没说的是我用了它一个月"
|
||||
特征型:「{适配特征}+用了{产品}+{感受}」例→"久坐党用了这把椅子,腰一整天不酸了"
|
||||
价格型:「{价格锚点}+{产品}+{效果感受}」例→"三位数买到大牌平替,用完第一份就回购"
|
||||
场景型:「{使用场景}+{产品}+{可感知变化}」例→"通勤路上靠这杯,到工位整个人都醒了"
|
||||
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神仙东西,用一次就回不去了"
|
||||
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"被夸最近状态好,没说的是我换了它一个月"
|
||||
硬性约束:标题≤20字;禁止出现"绝绝子/YYDS/杀疯了";不直接写功效词(美白/祛斑等)。
|
||||
硬性约束·标题必须有具体锚点:禁止"有点反差/真的绝/太惊艳/有点东西"这类空泛无信息量的标题,
|
||||
必须落到一个具体的人群/场景/数字/反差细节上(读者扫一眼就知道讲什么、关我什么事)。
|
||||
""".strip()
|
||||
_ANTI_AI = f"""
|
||||
【反AI味必须遵守】
|
||||
- 禁止固定开篇套话:不许"姐妹们/宝子们/今天给大家分享"开头
|
||||
- 禁止以下AI味词出现在正文:{_NEGATIVE_WORDS}
|
||||
- 禁止人群重叠:{'{count}'}条文案中身份场景不能重复(靠变量池ABC保证)
|
||||
- 禁止场景重复:同批次文案不能都是"早上上班"或都是"学生宿舍"
|
||||
- 禁止人群重叠:{'{count}'}条文案主切入人群维度不能重复(靠Q1切入配方按条轮转保证)
|
||||
- 禁止场景重复:同批次文案开篇场景不能雷同(不能N条都挤在同一个使用场景)
|
||||
- 避免生硬推销词:按头安利/绝绝子/闭眼冲 不能出现
|
||||
""".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}
|
||||
|
||||
合规红线(全品类通用):
|
||||
@@ -118,6 +148,8 @@ COPY_SYSTEM = f"""{_PERSONA}
|
||||
|
||||
{_ANTI_AI.format(count="N")}
|
||||
|
||||
{_BEIGE_FIVE}
|
||||
|
||||
{_TITLE_FORMULA}
|
||||
|
||||
{_EMOJI_RULES}
|
||||
@@ -178,30 +210,42 @@ def build_prompt(product: dict, count: int, extra_rules: str = "",
|
||||
custom = (product.get("custom_prompt") or "").strip()
|
||||
brand_kw = (product.get("brand_keyword") or "").strip()
|
||||
|
||||
# Q1:每条抽一组随机变量,传给模型作角色约束
|
||||
combos = [_pick_combo() for _ in range(count)]
|
||||
# Q1:每条按index轮转主人群维度+随机情绪/波折,传给模型作切入约束
|
||||
combos = [_pick_combo(i) for i in range(count)]
|
||||
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)
|
||||
)
|
||||
|
||||
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 [])
|
||||
|
||||
# strategy_narrative 是三套拉开差异的核心,加强标记顶到最高权重
|
||||
narrative_block = (
|
||||
f"\n========== 本套叙事主线(最高优先级·决定开篇与全文走向,禁止偏离)==========\n"
|
||||
f"{strategy_narrative}\n"
|
||||
f"==============================================================\n"
|
||||
if strategy_narrative else ""
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"产品:{name}",
|
||||
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效词;翻译范例:'烟酰胺'→'熬夜后第二天脸不那么黄了','高保湿'→'涂上去一整天都没搓泥拔干'):{selling}",
|
||||
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效/参数词;翻译范例:技术参数'XX配方/XX材质'→'用起来的某个具体好处','大容量'→'出门一整天都不用补'):{selling}",
|
||||
f"风格调性:{style}",
|
||||
strategy_narrative,
|
||||
narrative_block,
|
||||
angle_hint,
|
||||
brand_rule,
|
||||
custom,
|
||||
benchmark_block,
|
||||
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】",
|
||||
f"\n【Q1切入配方·每条主切入人群维度不同,模型按本产品把维度填成具体人群】",
|
||||
combos_text,
|
||||
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)
|
||||
|
||||
@@ -228,15 +272,15 @@ def build_local_drafts(product: dict, count: int) -> list[dict]:
|
||||
_fallback_angles = ["生活场景型", "成分分析型", "使用感受型", "性价比型", "痛点切入型"]
|
||||
angles_pool = product.get("text_angles") or _fallback_angles
|
||||
for i in range(count):
|
||||
c = _pick_combo()
|
||||
c = _pick_combo(i)
|
||||
# 循环取不同角度(角度相同的两条会被 dedupe_copies 过滤掉,所以必须不重复)
|
||||
angle = angles_pool[i % len(angles_pool)]
|
||||
yield {
|
||||
"title": f"发现一个{name}的{angle}用法",
|
||||
"content": (
|
||||
f"{c['identity']},{c['emotion']}。\n\n"
|
||||
f"最近开始用{name},{c['emotion']}。\n\n"
|
||||
f"用了一段时间,{points[i % len(points)]}这点最让我意外。\n\n"
|
||||
f"说个小缺点:{c['flaw']},后来才摸到感觉。\n\n"
|
||||
f"说个小波折:{c['flaw']},后来才摸到感觉。\n\n"
|
||||
f"反正现在用顺手了。✅"
|
||||
),
|
||||
"tags": [f"#{name}", "#真实测评", "#好物分享"],
|
||||
|
||||
121
backend/app/services/ai_engine/beige_color_grade.py
Normal file
121
backend/app/services/ai_engine/beige_color_grade.py
Normal 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
|
||||
@@ -28,16 +28,17 @@ SCORE_WEIGHTS = {
|
||||
"keyword": 20,
|
||||
"compliance": 5,
|
||||
}
|
||||
# ── AI 评委 7 维满分分布(倩倩姐2026-06-15拍板·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
|
||||
# 6维AI读分(痛点18+情绪18+买点18+钩子15+标题13+真实感13=95) + 合规5 = 100
|
||||
# "真实感"=富贵"很少提产品/前70%干货后30%植入"原则,替换旧机械维度"产品聚焦一件事(16)"
|
||||
# ── AI 评委 7 维满分分布(倩倩姐2026-06-22拍板补标签精准·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
|
||||
# 7维AI读分(痛点16+情绪16+买点16+钩子13+标题13+真实感11+标签精准10=95) + 合规5 = 100
|
||||
# 标签精准度=北哥要害⑤出单命门(精准购买意向埋词),分从原6维各匀出,总分仍95,合格线80不动非降门槛
|
||||
AI_DIM_WEIGHTS = {
|
||||
"痛点人群精准": 18,
|
||||
"情绪张力": 18,
|
||||
"买点转化": 18,
|
||||
"开头钩子": 15,
|
||||
"痛点人群精准": 16,
|
||||
"情绪张力": 16,
|
||||
"买点转化": 16,
|
||||
"开头钩子": 13,
|
||||
"标题点击力": 13,
|
||||
"真实感": 13,
|
||||
"真实感": 11,
|
||||
"标签精准度": 10,
|
||||
"compliance": 5, # 机械硬拦,不进 AI 评委
|
||||
}
|
||||
# 过线分。倩倩姐2026-06-15拍板:80是临时观察值(AI评委给分克制,84文案实为合格)。
|
||||
@@ -54,13 +55,13 @@ DEDUP_TITLE_CONTENT_BODY = 0.72 # 标题+正文联合判重时的正文阈值
|
||||
MAX_OPTIMIZE_ROUNDS = 2 # 最多重生成轮次
|
||||
|
||||
# ── storyboard 分镜角色(枚举不写死数量)────────────────
|
||||
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
|
||||
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤效果证明(按品类) ⑥促单
|
||||
PAGE_ROLES = [
|
||||
{"role": "hook", "name": "封面痛点大字", "focus": "负责点击:强情绪大字标题压痛点,产品露出,真实生活场景,像用户主动分享,不像广告海报"},
|
||||
{"role": "product_closeup", "name": "单品特写", "focus": "负责种草锚点:单品高清特写+品牌词自然植入,第2/6张都带品牌词,强化记忆"},
|
||||
{"role": "ingredient", "name": "成分拆解", "focus": "负责信任:核心成分信息、作用说明,避免医疗化和绝对化表达,信息清晰可信"},
|
||||
{"role": "texture", "name": "质地展示", "focus": "负责种草:质地近景、涂抹过程、肤感说明,真实手部/桌面/日常光线"},
|
||||
{"role": "applied_proof", "name": "上脸对比", "focus": "负责证明:可感知上脸效果,展示涂抹前后质地变化(不做肤色变白/瑕疵消失等违规暗示),第5张"},
|
||||
{"role": "applied_proof", "name": "效果证明", "focus": "负责证明:可感知的真实使用效果,按品类展示使用过程/状态变化(护肤=上脸肤感/食品=入口口感/家居数码=使用顺手等,不做违规功效暗示),第5张"},
|
||||
{"role": "closer", "name": "促单收尾", "focus": "负责转化:转化句+品牌词,引导搜索品牌词成交,软性收尾不硬广,第6张再带一次品牌词"},
|
||||
# 扩展角色(8张链路用)
|
||||
{"role": "pain_scene", "name": "痛点共鸣", "focus": "负责共鸣:展示目标人群的真实困扰和使用前情境,但不做功效前后对比"},
|
||||
@@ -79,7 +80,7 @@ ROLE_SCENE_PREFERENCE = {
|
||||
"product_closeup": ["primary"], # 单品特写:白底主图
|
||||
"ingredient": ["ingredient", "primary"], # 成分拆解:成分/包装细节
|
||||
"texture": ["texture", "primary"], # 质地展示:质地特写
|
||||
"applied_proof": ["model", "scene", "primary"], # 上脸:上脸图/场景
|
||||
"applied_proof": ["model", "scene", "primary"], # 效果证明:使用场景/上身/上脸图
|
||||
"social_proof": ["scene", "primary"], # 社交背书:场景
|
||||
"closer": ["primary"], # 促单收尾:主图
|
||||
"scenario": ["scene", "primary"],
|
||||
@@ -91,8 +92,8 @@ ROLE_SCENE_PREFERENCE = {
|
||||
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
|
||||
STYLE_PROMPTS = {
|
||||
"xiaohongshu_cover": "小红书种草风,独立3:4图文海报/素材图,1024×1536构图,明亮干净,真实实拍质感,醒目中文短标题,文字在安全区内",
|
||||
"comparison": "小红书说明对比风,独立3:4图文海报/素材图,1024×1536构图,质地/场景/肤感左右或上下对比,信息层级清晰",
|
||||
"ingredient": "小红书成分科普风,独立3:4图文海报/素材图,1024×1536构图,成分卡片布局,浅色商务美妆风,避免医疗化表达",
|
||||
"comparison": "小红书说明对比风,独立3:4图文海报/素材图,1024×1536构图,质地/场景/使用状态左右或上下对比,信息层级清晰",
|
||||
"ingredient": "小红书信息科普风,独立3:4图文海报/素材图,1024×1536构图,成分/参数卡片布局,浅色干净商务风,避免医疗化表达",
|
||||
}
|
||||
STYLE_DEFAULT = "xiaohongshu_cover"
|
||||
|
||||
@@ -100,8 +101,8 @@ STYLE_DEFAULT = "xiaohongshu_cover"
|
||||
# 按图数告诉模型整组图的种草节奏,让每张各司其职不雷同
|
||||
NARRATIVE_BY_COUNT = {
|
||||
3: "3张极速链路:第1张负责点击,第2张是按品类变化的核心证明页,第3张负责软性转化。",
|
||||
6: "6张标准种草链路:封面点击、单品特写带品牌词、成分信任、质地种草、上脸证明、促单转化,每张画面和文字各司其职不重复。",
|
||||
8: "8张沉浸测评链路:点击、痛点共鸣、单品特写、成分、质地、上脸证明、背书、软性转化。",
|
||||
6: "6张标准种草链路:封面点击、单品特写带品牌词、成分/参数信任、质地/细节种草、按品类的真实效果证明、促单转化,每张画面和文字各司其职不重复。",
|
||||
8: "8张沉浸测评链路:点击、痛点共鸣、单品特写、成分/参数、质地/细节、按品类的真实效果证明、背书、软性转化。",
|
||||
}
|
||||
|
||||
# ── 3套正交叙事策略(倩倩姐2026-06-15起草,北哥过目版)──────────────
|
||||
@@ -109,19 +110,19 @@ NARRATIVE_BY_COUNT = {
|
||||
# 每套叙事链路注入 base_prompt 叙事链路段,替换 NARRATIVE_BY_COUNT 默认值
|
||||
NARRATIVE_BY_STRATEGY = {
|
||||
"A": (
|
||||
'【套A·痛点先行】整组基调:紧迫感、强对比、情绪共鸣,文字短促带感叹号,直戳"脸黄显疲惫""素颜不敢出门"。'
|
||||
'6张链路:①痛点暴击封面(强情绪大字直击暗黄/素颜焦虑)→ ②暗黄脸实拍对比(感叹号+对比词制造紧迫感)'
|
||||
'→ ③单品特写+品牌词 → ④成分为什么能救暗黄(成分拆解+信任) → ⑤上脸提亮实证 → ⑥"别再顶着黄脸早八"软性促单。'
|
||||
'【套A·痛点先行】整组基调:紧迫感、强对比、情绪共鸣,文字短促带感叹号,直戳产品要解决的核心痛点(痛点取自上文产品卖点/人群,不要套用别的品类)。'
|
||||
'6张链路:①痛点暴击封面(强情绪大字直击该产品核心痛点)→ ②痛点实拍对比(感叹号+对比词制造紧迫感)'
|
||||
'→ ③单品特写+品牌词 → ④核心成分为什么能解决该痛点(成分拆解+信任) → ⑤使用效果实证 → ⑥用痛点收束的软性促单。'
|
||||
),
|
||||
"B": (
|
||||
'【套B·场景先行】整组基调:轻松、生活化、代入感,突出"快/省时/伪素颜自由",点到性价比不堆砌。'
|
||||
'6张链路:①"早八来不及"场景封面(生活场景钩子) → ②手忙脚乱通勤场景(代入早八焦虑)'
|
||||
'→ ③一抹搞定单品特写+品牌词 → ④养肤成分让你敢素颜 → ⑤30秒上脸效果 → ⑥"伪素颜自由+平价"软性促单。'
|
||||
'【套B·场景先行】整组基调:轻松、生活化、代入感,突出该产品的真实使用场景与"快/省时/省心"卖点,点到性价比不堆砌。'
|
||||
'6张链路:①真实使用场景封面(取该产品最高频的生活场景做钩子) → ②场景代入(还原用户在该场景里的困扰)'
|
||||
'→ ③单品特写+品牌词 → ④核心成分支撑该场景卖点 → ⑤使用效果展示 → ⑥用场景化卖点收束的软性促单。'
|
||||
),
|
||||
"C": (
|
||||
'【套C·成分背书先行】整组基调:专业、可信、真实测评感,强调成分逻辑+前后对比+像有用户实证背书。'
|
||||
'6张链路:①成分权威封面(核心成分信息锚定信任) → ②核心成分图解(作用说明+清晰可信)'
|
||||
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实上脸细节 → ⑥"成分党闭眼入"软性促单。'
|
||||
'6张链路:①成分权威封面(该产品核心成分信息锚定信任) → ②核心成分图解(作用说明+清晰可信)'
|
||||
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实使用细节 → ⑥用成分可信度收束的软性促单。'
|
||||
),
|
||||
}
|
||||
|
||||
@@ -130,23 +131,37 @@ NARRATIVE_BY_STRATEGY = {
|
||||
# 与 NARRATIVE_BY_STRATEGY(图片侧)同根:套A文案痛点先行↔套A图也痛点先行,同套内文图一致
|
||||
TEXT_NARRATIVE_BY_STRATEGY = {
|
||||
"A": (
|
||||
"【本条叙事主线·痛点先行】开篇直戳用户困扰(脸黄显疲惫/素颜不敢出门/早八顶着黄脸),"
|
||||
"情绪强、句子短促带感叹,痛点贯穿全文到种草,结尾用'别再顶着黄脸早八'这类痛点收束促单。"
|
||||
"基调:紧迫感、强对比、情绪共鸣。"
|
||||
"【本条叙事主线·痛点先行】开篇第一句必须直接说出'使用前的困扰/焦虑情绪'本身"
|
||||
"(取自本产品要解决的那个具体痛点,以及试过很多没用的挫败感),先有情绪再有场景,"
|
||||
"痛点取自上文产品卖点/目标人群、不要套用别品类;情绪强、句子短促带感叹。"
|
||||
"⚠️开篇禁止从'时间/场景'切入(如一天里的某个时刻、赶时间、通勤,那是套B的开法),必须先抛情绪/痛点。"
|
||||
"▶中段背书方式=自己反复挣扎后的转折(试错→死心→偶然回购/被一个生活细节打动),"
|
||||
"靠自己使用前后的真实变化来自证,不要用'闺蜜/同事/家人种草'来背书。"
|
||||
"▶卖点要带着'这个困扰被解决了'的情绪逐个讲,emoji用情绪向(😭🥹💛)。"
|
||||
"▶收尾用该痛点的释然收束('现在不慌了'类),禁止用'被身边人夸/被问起'桥段(那是套B的尾)。"
|
||||
),
|
||||
"B": (
|
||||
"【本条叙事主线·场景先行】用真实生活场景开篇(早八来不及/通勤手忙脚乱/赶时间出门),"
|
||||
"轻松代入感,突出'快/省时/伪素颜自由',点到平价性价比但不堆砌。"
|
||||
"基调:轻松、生活化、像朋友随手分享。"
|
||||
"【本条叙事主线·场景先行】开篇用一个具体的生活使用场景白描切入"
|
||||
"(最真实高频的那个使用时刻,画面感优先),痛点藏在场景里自然带出、不喊口号,"
|
||||
"突出'快/省时/省心'类卖点,点到平价性价比但不堆砌。"
|
||||
"▶中段背书方式=身边人(同事/家人)在场景里的一句随口反馈,轻松不刻意。"
|
||||
"▶成分融在使用场景里一两句带过即可,不要单独列大段成分清单,emoji用场景向(☕🚇✨)。"
|
||||
"▶收尾用场景延续('下次再有人问…我知道怎么答了'类)。"
|
||||
),
|
||||
"C": (
|
||||
"【本条叙事主线·成分背书先行】用成分原理或测评视角开篇(核心成分为什么有用/亲测对比),"
|
||||
"专业可信,带使用前后时间线对比,像真实用户实证背书,结尾用'成分党闭眼入'收束。"
|
||||
"基调:专业、可信、真实测评感。"
|
||||
"【本条叙事主线·专业背书先行】开篇必须从'成分/参数/原理/测评对比'等专业依据切入"
|
||||
"(按本产品品类对应:护肤食品讲成分配方、数码家居讲参数原理、服饰讲材质工艺,"
|
||||
"如翻配料/参数表、对比实验、为什么这个设计/成分有用),先讲可信度再讲使用。"
|
||||
"⚠️开篇禁止从'时间/场景'切入(如赶时间、通勤、被人问起,那是套A/B的开法),必须从专业依据入手。"
|
||||
"▶中段背书方式=理性自证(参数/成分对比/和踩过的雷横向比/前后时间线实测/参考测评博主的拆解分析),"
|
||||
"靠数据和亲测说话,不要用'闺蜜/同事/家人种草'来背书。"
|
||||
"▶专业依据要讲得比A/B更深(为什么这项关键/感知差异/和别家差在哪),是本套的差异优势,"
|
||||
"emoji用理性向(🔍💧🧪),收尾用专业可信度收束。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
IMAGE_RETRY_ATTEMPTS = 3 # 生图重试总次数(含首次)
|
||||
IMAGE_RETRY_BACKOFF_BASE = 2.0 # 指数退避底数(秒)
|
||||
IMAGE_SIZE_DEFAULT = "1024x1536"
|
||||
|
||||
@@ -185,6 +200,7 @@ FLYWHEEL_WEIGHTS = {
|
||||
"text_select": 3,
|
||||
"image_select": 3,
|
||||
"text_edit": 5, # 改稿=用户真动手改字=最强真实意图,与approve同级(倩倩姐2026-06-16拍板)
|
||||
"text_import": 8, # 导入=客户实跑验证过的范本,权重最重(倩倩姐2026-06-26拍板),与enums.SIGNAL_WEIGHTS对齐
|
||||
"approve": 5,
|
||||
"reject_with_reason": -3,
|
||||
"regenerate": -1,
|
||||
|
||||
@@ -137,7 +137,13 @@ def build_fission_prompt(
|
||||
source_note: dict, product: dict, reference_level: str,
|
||||
note_count: int, image_count: int, dimensions: list[str] | None = None,
|
||||
) -> 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 {}
|
||||
prod = product or {}
|
||||
dims = dimensions or DEFAULT_DIMENSIONS
|
||||
@@ -149,9 +155,24 @@ def build_fission_prompt(
|
||||
points = prod.get("selling_points", []) or []
|
||||
audience = prod.get("target_audience", "") or "未提供"
|
||||
keywords = prod.get("keywords", []) or []
|
||||
kw_line = "、".join(keywords) if keywords else "、".join(
|
||||
[x for x in [name, audience, "真实测评", "好物分享"] if x and x != "未提供"]
|
||||
)
|
||||
# D2修复:去掉"真实测评/好物分享"泛词兜底(评分prompt明确泛词占≥50%最高6分,自打自)
|
||||
# 改用产品真实信息提炼精准词;实在无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"""爆款参考:
|
||||
标题:{title}
|
||||
正文:{content}
|
||||
@@ -166,7 +187,11 @@ def build_fission_prompt(
|
||||
生成数量:{note_count}套
|
||||
每套图片数量:{image_count}张
|
||||
|
||||
【各套正交叙事主线——必须严格执行,这是本次裂变不同质的关键】
|
||||
{narrative_block}
|
||||
|
||||
请生成{note_count}套完整小红书图文笔记包。每套都必须含标题、正文、标签、点击钩子标题、{image_count}张图的imagePlan。
|
||||
每套必须严格按其指定叙事主线展开开篇/背书/收尾,不同套之间叙事结构必须正交、不重复。
|
||||
imagePlan必须按叙事链路逐张递进。3张版第2张必须是按当前品类变化的核心证明页,不能和第1张重复构图,不得让第2张重复第1张标题。
|
||||
正文必须像真实小红书种草笔记一样自然带2-5个emoji;不要把图片规划/配图建议/内部审核建议写进正文。"""
|
||||
|
||||
|
||||
@@ -43,6 +43,15 @@ class AIClients:
|
||||
_model_image: str = "gpt-image-2"
|
||||
_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:
|
||||
"""主通道(apiports) client,按当前事件循环缓存"""
|
||||
return self._client_for(self._gpt_base, self._gpt_token)
|
||||
@@ -67,15 +76,33 @@ class AIClients:
|
||||
# ── ImageClient 协议实现(供 image_gen.py 使用)────────
|
||||
|
||||
def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]:
|
||||
"""按 provider 选 (base, token, client);codeproxy 走备用站独立 base+key"""
|
||||
if provider == "codeproxy" and self._alt_token:
|
||||
"""按 provider 选 (base, token, client);codeproxy 走备用站独立 base+key。
|
||||
|
||||
🔴 绝不静默降级(红线):显式点名 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("/")
|
||||
return base, self._alt_token, self._client_for(base, self._alt_token)
|
||||
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
|
||||
return base, self._gpt_token, self._client_for(self._gpt_base, self._gpt_token)
|
||||
|
||||
async def gpt_edits(self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None) -> bytes:
|
||||
"""GPT edits endpoint(带产品参考图,禁纯文生图)"""
|
||||
"""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
|
||||
files: list[tuple] = [("prompt", (None, prompt))]
|
||||
for i, img in enumerate(reference_images):
|
||||
@@ -87,6 +114,29 @@ class AIClients:
|
||||
resp.raise_for_status()
|
||||
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:
|
||||
"""GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)"""
|
||||
base, _, client = self._gpt_target(provider)
|
||||
@@ -100,14 +150,17 @@ class AIClients:
|
||||
if not self._gemini_key:
|
||||
raise RuntimeError("Gemini key 未初始化")
|
||||
gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta")
|
||||
url = f"{gemini_base}/models/{model}:generateContent?key={self._gemini_key}"
|
||||
# 安全红线: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}]
|
||||
for img in reference_images:
|
||||
import base64
|
||||
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}})
|
||||
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=payload, timeout=120.0)
|
||||
resp = await client.post(url, json=payload,
|
||||
headers={"x-goog-api-key": self._gemini_key}, timeout=120.0)
|
||||
resp.raise_for_status()
|
||||
return _extract_gemini_image(resp.json())
|
||||
|
||||
@@ -126,9 +179,10 @@ class AIClients:
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
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)
|
||||
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):
|
||||
raise
|
||||
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
|
||||
|
||||
|
||||
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。
|
||||
只在 Celery worker 函数体内调用,plain_key 是局部变量。
|
||||
httpx client 不在此预创建(避免绑死到调用方 loop),首次 await 时按 loop 懒建。
|
||||
调用完成后 caller 负责 await clients.aclose()。
|
||||
|
||||
alt_key:用户录入的 codeproxy 备用站 key(基石B:worker 内查库解密后传入)。
|
||||
倩倩姐红线「全做成自己的,不要埋进系统」——codeproxy key 优先用用户录入的,
|
||||
未录入才回落 env CODEPROXY_KEY(向后兼容,避免没录时备用通道直接失效)。
|
||||
base 地址(中转站 URL)非秘密,仍走 env 配置。
|
||||
"""
|
||||
gpt_base = (
|
||||
os.environ.get("IMAGE_API_BASE") # 旧变量名
|
||||
or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名
|
||||
or ""
|
||||
).rstrip("/")
|
||||
# 备用站 codeproxy:系统级 key(非用户录入),apiports 503 时切过去保生图成功
|
||||
# 备用站 codeproxy:优先用户录入 key,回落 env;apiports 503 时切过去保生图成功
|
||||
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(
|
||||
_gpt_token=plain_key,
|
||||
_gpt_base=gpt_base or None,
|
||||
@@ -240,6 +299,95 @@ def _extract_image_bytes(resp_json: dict) -> bytes:
|
||||
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 结果以  或 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:
|
||||
"""从 Gemini generateContent 响应提取图片 bytes"""
|
||||
import base64
|
||||
|
||||
@@ -26,21 +26,37 @@ def _pick_reference_for_role(
|
||||
images_by_scene: dict[str, list[bytes]] | None,
|
||||
fallback: list[bytes] | None,
|
||||
) -> 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:
|
||||
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)
|
||||
if imgs:
|
||||
return imgs, scene
|
||||
# 偏好全落空:用任意可用图兜底(仍优先 primary)
|
||||
if images_by_scene.get("primary"):
|
||||
return images_by_scene["primary"], "primary"
|
||||
ordered.extend(imgs)
|
||||
used_scenes.append(scene)
|
||||
# 2) 主图始终保底进列表(即使不在偏好里,也要补做瓶身锚点)
|
||||
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():
|
||||
if imgs:
|
||||
return imgs, f"{scene}(兜底)"
|
||||
if scene not in used_scenes and imgs:
|
||||
ordered.extend(imgs)
|
||||
used_scenes.append(scene)
|
||||
if ordered:
|
||||
return ordered, "+".join(used_scenes)
|
||||
return fallback, "fallback"
|
||||
|
||||
|
||||
@@ -53,17 +69,42 @@ class ImageClient(Protocol):
|
||||
async def gemini_generate(
|
||||
self, prompt: str, reference_images: list[bytes], model: str
|
||||
) -> bytes: ...
|
||||
def has_alt_channel(self) -> bool: ...
|
||||
|
||||
|
||||
def _image_provider_order() -> list[str]:
|
||||
"""从环境变量读主备顺序(扒 imageProviderOrder)"""
|
||||
def _image_provider_order(has_alt: bool = True) -> list[str]:
|
||||
"""从环境变量读主备顺序(扒 imageProviderOrder)。
|
||||
|
||||
🔴 真双通道互备(倩倩姐2026-06拍板):codeproxy / apiports 是两个独立中转站,
|
||||
各用自己的 base+key, 任一站上游挂(502/503)由另一站顶上 —— 这才是真备份。
|
||||
若 .env 把主备都设成同一个站(如都 codeproxy), 去重后只剩一个 → 退化成"假备份"
|
||||
(一站挂全挂)。故只要序列用到 GPT 中转站家族, 就自动补齐缺失的那个,
|
||||
保证 codeproxy+apiports 双通道都在。"gpt" 是 apiports 的等价别名(同 base+主key),
|
||||
归一化掉避免重复尝试同一站。codeproxy 走 /images/edits,apiports 走
|
||||
/chat/completions 多模态编辑;两者都必须带产品参考图,不碰纯文生图。
|
||||
|
||||
🔴 has_alt=False(用户/env 都没配 codeproxy key)时,绝不把 codeproxy 补进序列——
|
||||
否则每张图都白白尝试 codeproxy → 撞"绝不静默降级"红线 → 空转重试 3 次拖慢整体
|
||||
(倩倩姐2026-06-30方案A)。录了 key(has_alt=True)才补齐,真双通道逻辑不变。
|
||||
"""
|
||||
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower()
|
||||
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower()
|
||||
seen: list[str] = []
|
||||
order: list[str] = []
|
||||
for p in [primary, fallback]:
|
||||
if p and p not in seen:
|
||||
seen.append(p)
|
||||
return seen
|
||||
p = "apiports" if p == "gpt" else p
|
||||
# 没配 codeproxy key 时,显式点名 codeproxy 也跳过(避免空转重试)
|
||||
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]:
|
||||
@@ -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:
|
||||
if reference_images:
|
||||
return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider)
|
||||
# 无产品参考图时降级为纯文生图(需 ALLOW_TEXT_ONLY_IMAGE=true 或 M2阶段)
|
||||
allow_text_only = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "true").lower() == "true"
|
||||
if allow_text_only:
|
||||
logger.warning("无产品参考图,降级为纯文生图(可能产品跑偏,建议前端上传参考图)")
|
||||
return await client.gpt_generate(prompt, IMAGE_SIZE_DEFAULT, provider)
|
||||
raise ValueError("GPT 主通道缺产品图:禁止纯文生图以免产品跑偏(设 ALLOW_TEXT_ONLY_IMAGE=true 可解锁)")
|
||||
# 🔴 红线:瓶身忠于参考图原样,禁止纯文生图脑补产品(倩倩姐2026-06-15拍板)。
|
||||
# ALLOW_TEXT_ONLY_IMAGE 默认 false,即使显式设为 true 也拒绝——纯文生图会让产品包装
|
||||
# 完全由模型自由发挥,严重走样,不符合合规与真实双重要求。
|
||||
# 若要解锁请联系产品负责人,不在代码层开放。
|
||||
_env_val = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "false").lower()
|
||||
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:
|
||||
@@ -118,7 +167,9 @@ async def generate_one_image(
|
||||
返回图片 bytes(PNG/JPEG)。
|
||||
"""
|
||||
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] = []
|
||||
|
||||
for provider in providers:
|
||||
@@ -191,6 +242,9 @@ async def generate_storyboard_images(
|
||||
f"素材使用={item.get('asset_use','')}。"
|
||||
f"{brand_line}"
|
||||
f"禁止事项={item.get('forbidden','')}。"
|
||||
"参考图说明:随附的多张参考图都是【同一个产品】的不同视角(主图=产品/包装本体,"
|
||||
"其余=质地/细节/场景特写);产品外形、颜色、包装上原有文字必须100%忠于主图原样,"
|
||||
"禁止重绘产品本体、禁止在包装上增删改任何文字(品牌词只放图上标题/贴纸等排版层,绝不印产品本体)。"
|
||||
"排版要求:独立小红书3:4图文海报,画面完整;标题只出现一次,不与其他页重复;"
|
||||
"中文文字少而清晰,主标题+最多3个短点位;可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌;"
|
||||
"不要生成App截图或笔记详情页界面。"
|
||||
|
||||
@@ -21,6 +21,9 @@ except ImportError:
|
||||
_PILLOW_OK = False
|
||||
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: dict[str, tuple[int, int]] = {
|
||||
"1:1": (1024, 1024),
|
||||
@@ -74,12 +77,27 @@ def process_image(
|
||||
img = ImageOps.fit(img, (tw, th), method=Image.LANCZOS)
|
||||
logger.debug("resize %dx%d → %dx%d (ratio=%s)", actual_w, actual_h, tw, th, aspect_ratio)
|
||||
|
||||
# --- Step2: resample_strength 削像素水印(可选,默认轻采样)---
|
||||
# --- 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)
|
||||
|
||||
# --- Step3: SynthID 破除(SYNTHID_HARD_MODE=1 才开,默认关)---
|
||||
if os.environ.get("SYNTHID_HARD_MODE") == "1" and target:
|
||||
img = _apply_synthid_break(img, target)
|
||||
# --- Step3.5: 北哥暖棕调色(BEIGE_COLOR_GRADE 默认开,去AI化核心)---
|
||||
# 在重编码前以 bytes 往返:grade 内部自带开关/降级,关则原样透传
|
||||
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 重编码,去所有元数据 ---
|
||||
buf = io.BytesIO()
|
||||
@@ -118,19 +136,20 @@ def _apply_resample(img: "Image.Image", strength: int) -> "Image.Image":
|
||||
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 时调用):
|
||||
对齐大卫逻辑 — 缩到(w-2,h-2)再裁掉1px边 + 亮度*1.005/饱和*0.998。
|
||||
SynthID 破除(硬路,默认开):对齐大卫 xhs 实测版。
|
||||
顺序对齐大卫:先微调亮度/饱和(modulate) → 再缩2px裁1px边错位采样。
|
||||
只动边缘像素、按图片当前尺寸算,不依赖目标比例,不做全图重采样、不压全图像素。
|
||||
诚实声明:只能削弱 SynthID,不保证 100% 清除。
|
||||
"""
|
||||
tw, th = target
|
||||
img = ImageOps.fit(img, (tw - 2, th - 2), method=Image.LANCZOS)
|
||||
# 裁掉1px边,消除边缘水印残留
|
||||
img = img.crop((1, 1, tw - 3, th - 3))
|
||||
# 微调亮度/饱和(对齐大卫 modulate brightness/saturation)
|
||||
# 先 modulate(亮度*1.005/饱和*0.998),对齐大卫 modulate 在缩裁之前
|
||||
img = ImageEnhance.Brightness(img).enhance(1.005)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
llm_scorer.py — AI 评委打分入口(让模型真读文案,替代机械找词)
|
||||
合规第7维仍走机械硬拦(score_compliance);AI 读前6维给分+理由。
|
||||
任何异常/解析失败 → 回退旧机械 score_copy,绝不卡链路。
|
||||
B1(倩倩姐2026-06-26):评分两通道(apiports+codeproxy强档兜底)都挂时,
|
||||
绝不静默回退机械分糊弄,抛 ScoringUnavailableError 由上层逐条接住、标"评分不可用"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
@@ -12,17 +13,23 @@ from typing import Any
|
||||
|
||||
from .constants import BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, QUALITY_PASS_SCORE
|
||||
from ._scoring_dims import score_compliance
|
||||
from .text_scoring import score_copy
|
||||
from ._score_prompt import SCORER_PERSONA, build_score_prompt, COMPLIANCE_MAX
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 6 个 AI 维度满分(倩倩姐2026-06-15拍板·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
|
||||
# 痛点18+情绪18+买点18+钩子15+标题13+真实感13=95,+合规5=100
|
||||
# "真实感"替换旧"产品聚焦一件事",对齐富贵"很少提产品/前70%干货后30%植入"原则
|
||||
|
||||
class ScoringUnavailableError(RuntimeError):
|
||||
"""评分通道(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 = {
|
||||
"痛点人群精准": 18, "情绪张力": 18, "买点转化": 18,
|
||||
"开头钩子": 15, "标题点击力": 13, "真实感": 13,
|
||||
"痛点人群精准": 16, "情绪张力": 16, "买点转化": 16,
|
||||
"开头钩子": 13, "标题点击力": 13, "真实感": 11,
|
||||
"标签精准度": 10,
|
||||
}
|
||||
# 评委合规相关默认权重(仅供 score_compliance 复用其内部硬拦逻辑)
|
||||
_COMPLIANCE_W = {"compliance": COMPLIANCE_MAX}
|
||||
@@ -65,22 +72,29 @@ async def llm_score_copy(
|
||||
)
|
||||
break
|
||||
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)
|
||||
if status in (503, 429) and attempt < 3:
|
||||
await asyncio.sleep(backoff[min(attempt, 2)])
|
||||
continue
|
||||
logger.warning("AI评委调用失败,回退机械打分: %s", exc)
|
||||
return score_copy(copy, source, banned_words, pass_score=pass_score)
|
||||
# B1红线:绝不静默回退机械分糊弄,明确抛错由调用方逐条接住、标记"评分不可用"
|
||||
logger.error("AI评委两通道均失败,判定评分不可用(不降级机械分): %s", exc)
|
||||
raise ScoringUnavailableError(f"评分通道不可用: {exc}") from exc
|
||||
|
||||
verdict = _parse_verdict(raw)
|
||||
if not verdict:
|
||||
logger.warning("AI评委输出解析失败,回退机械打分。raw[:120]=%s", raw[:120])
|
||||
return score_copy(copy, source, banned_words, pass_score=pass_score)
|
||||
# 拿到了回复但不是合法JSON:模型偶发不听话。同样不静默机械分,抛错让上层标记。
|
||||
logger.error("AI评委输出解析失败(不降级机械分)。raw[:120]=%s", raw[:120])
|
||||
raise ScoringUnavailableError("评分输出解析失败")
|
||||
|
||||
details: list[dict] = []
|
||||
_COMPLIANCE_ALIASES = ("合规", "违禁", "禁用") # LLM 偶尔不听话多吐合规维度,显式剔除防污染机械合规分
|
||||
for d in verdict["dims"]:
|
||||
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
|
||||
mx = _DIM_MAX[item]
|
||||
sc = max(0, min(mx, int(round(float(d.get("score", 0))))))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
package_exporter.py — 达人素材交付包生成
|
||||
架构方案§五 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
|
||||
import json
|
||||
|
||||
@@ -53,7 +53,8 @@ def aggregate_preference_context(
|
||||
|
||||
# text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板)
|
||||
# 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
|
||||
elif sig_type == "reject_with_reason":
|
||||
reason = str(e.get("reason") or "").strip()
|
||||
|
||||
@@ -67,7 +67,7 @@ def get_narrative_roles(image_count: int = 6) -> list[dict]:
|
||||
"""
|
||||
按图数返回分镜角色列表(扒 getNarrativeRoles,Q6对齐北哥6张套路)
|
||||
≤3 张:极速链路 hook / applied_proof / closer
|
||||
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
|
||||
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分/参数 ④质地/细节 ⑤效果证明(按品类) ⑥促单
|
||||
>6 张:沉浸链路 + pain_scene / scenario / social_proof
|
||||
"""
|
||||
count = clamp_count(image_count)
|
||||
@@ -102,14 +102,14 @@ def build_visual_system(product: dict, analysis: dict | None = None) -> dict:
|
||||
"typography": identity.get("typographyStyle", "主标题清晰黑体或手写感标题,辅助文字便签/勾选标注,字重颜色保持同一体系"),
|
||||
"sticker": identity.get("stickerLanguage", "少量箭头、放大镜、勾选、小表情、便签,不使用促销按钮"),
|
||||
"layout": identity.get("layoutStyle", "同一组图片保持色调、光线、产品露出方式一致,每张图承担不同叙事角色"),
|
||||
"texture": identity.get("materialTexture", "产品包装、质地、手背/上脸肤感要真实自然"),
|
||||
"texture": identity.get("materialTexture", "产品包装、材质、质地/细节要真实自然,符合本品类的真实使用质感"),
|
||||
"package_details": identity.get("packageDetails", "如果提供产品图,必须还原包装颜色、瓶身形状、标签方向和主视觉"),
|
||||
"xhs_style_preset": identity.get("xhsStylePreset", "真实测评风/手写安利风/清单便签风"),
|
||||
"symbol_system": identity.get("symbolSystem", "中等密度小红书种草符号:✅ ✨ 🌿 💧 🪞 🧴 📦 🔍 💛,每张最多2-4个"),
|
||||
"quality_rules": [
|
||||
"同组图片字体体系相对一致,但不要像固定模板",
|
||||
"每张压图文字必须服务当前叙事角色,不能重复封面标题",
|
||||
"护肤品优先出现手背涂抹、质地微距、自然上脸局部或真实生活场景",
|
||||
"效果证明页按品类出真实使用证据:护肤=手背涂抹/质地微距/自然上脸局部,食品=冲泡入口,家居数码=使用过程,服饰=上身材质,均为真实生活场景",
|
||||
"人物真实自然,有轻微皮肤纹理和生活感,不要AI精修美女",
|
||||
"禁止乱码、错别字、App底栏、Like评论分享、硬广价格牌、虚假功效before/after",
|
||||
],
|
||||
|
||||
@@ -18,11 +18,11 @@ import re
|
||||
|
||||
# ── sanitize(扒 sanitizeImagePlanText,防违禁视觉词进 prompt)
|
||||
_SANITIZE_RULES: list[tuple[str, str]] = [
|
||||
(r"before\s*&\s*after", "质地与肤感说明"),
|
||||
(r"before\s*/?\s*after", "质地与肤感说明"),
|
||||
(r"\bbefore\b", "质地状态"),
|
||||
(r"\bafter\b", "上脸肤感"),
|
||||
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"),
|
||||
(r"before\s*&\s*after", "质地与使用状态说明"),
|
||||
(r"before\s*/?\s*after", "质地与使用状态说明"),
|
||||
(r"\bbefore\b", "使用前状态"),
|
||||
(r"\bafter\b", "使用后状态"),
|
||||
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/使用状态说明"),
|
||||
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
|
||||
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
|
||||
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
|
||||
@@ -42,33 +42,33 @@ ROLE_STORYBOARD_TPL: dict[str, dict] = {
|
||||
"hook": {
|
||||
"goal": "让{audience}因为{pain}停下划走,产生点开欲",
|
||||
"overlay": "{hook}",
|
||||
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实肤感/手部细节,像iPhone随手实拍的封面,不是海报",
|
||||
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实使用细节/手部细节,像iPhone随手实拍的封面,不是海报",
|
||||
"basis": "来自选中文案标题、人群{audience}、痛点{pain}",
|
||||
"forbidden": "不要价格、不要重复后续卖点、不要App界面、不要广告海报感",
|
||||
},
|
||||
"product_closeup": {
|
||||
"goal": "建立单品记忆锚点,让用户记住是哪个产品",
|
||||
"overlay": "{brand}",
|
||||
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读,品牌词自然出现在画面或瓶身",
|
||||
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读且忠于参考图原样,品牌词只出现在图上标题/贴纸等文字排版层,绝不印到瓶身上",
|
||||
"basis": "来自产品名和品牌词,第2张和第6张都要带品牌词强化记忆",
|
||||
"forbidden": "不要堆多个产品、不要花哨背景抢主体、不要改包装文字",
|
||||
},
|
||||
"ingredient": {
|
||||
"goal": "用成分/配方信息建立信任,但不医疗化",
|
||||
"goal": "用成分/配方/参数信息建立信任,但不夸大不医疗化",
|
||||
"overlay": "看清{point}",
|
||||
"visual": "成分卡片式布局,产品+成分图标/短说明,浅色商务美妆风,信息层级清楚",
|
||||
"basis": "来自卖点里的成分/功效点,理性表达不夸大",
|
||||
"visual": "信息卡片式布局,产品+成分/参数图标+短说明,浅色干净商务风,信息层级清楚(护肤食品讲成分配方,数码家居讲参数原理,服饰讲材质工艺)",
|
||||
"basis": "来自卖点里的成分/参数/工艺点,理性表达不夸大",
|
||||
"forbidden": "不要治疗/改善疾病承诺、不要医生背书、不要绝对化",
|
||||
},
|
||||
"texture": {
|
||||
"goal": "让用户看到{point}的真实质感证据",
|
||||
"overlay": "{point}看得见",
|
||||
"visual": "手背或指尖涂抹质地微距,产品放在旁边,自然光,保留真实皮肤纹理,能看清延展和肤感",
|
||||
"basis": "来自卖点里的质地/肤感描述",
|
||||
"visual": "质地/材质/细节微距,产品放在旁边,自然光,保留真实纹理(护肤=涂抹延展肤感,食品=色泽质地,服饰=面料织纹,数码家居=做工细节),能看清{point}",
|
||||
"basis": "来自卖点里的质地/材质/做工描述",
|
||||
"forbidden": "不要生成变白效果、不要医疗化对比、不要和封面同构图",
|
||||
},
|
||||
"applied_proof": {
|
||||
"goal": "用可感知的上脸/使用证据证明{point}",
|
||||
"goal": "用可感知的真实使用证据证明{point}",
|
||||
"overlay": "{proof_overlay}",
|
||||
"visual": "{proof_visual}",
|
||||
"basis": "来自核心卖点{point}和用户对效果的关注",
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Any
|
||||
from .constants import MAX_OPTIMIZE_ROUNDS
|
||||
from ._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array, build_local_drafts
|
||||
from .text_scoring import score_copy, dedupe_copies
|
||||
from .llm_scorer import llm_score_copy
|
||||
from .llm_scorer import llm_score_copy, ScoringUnavailableError
|
||||
from .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult
|
||||
|
||||
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 调。
|
||||
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,
|
||||
strategy_narrative: str = "") -> list[dict]:
|
||||
@@ -119,25 +148,53 @@ async def generate_text_variants(
|
||||
贯穿首批生成与优化轮,确保同套内文案同一叙事不串味。"""
|
||||
banned_entries = build_entries_from_db(banned_word_rows or [])
|
||||
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(
|
||||
llm_client, product, count, extra, strategy_narrative)
|
||||
if not copies:
|
||||
copies = list(build_local_drafts(product, count)) # generator → list
|
||||
|
||||
candidates: list[dict] = []
|
||||
for c in copies:
|
||||
# 评分有限并发:逐条独立无状态可并发,sem 限并发数防 apiports 限流(生成层串行不动)。
|
||||
# 每条语义与原串行完全一致:违禁词机械检查(同步,放 sem 外不占并发额度)→AI评分→
|
||||
# 评分不可用则标记不计合格不炸整批;auto_fixed 回写修正文案。gather 保序=候选顺序不变。
|
||||
sem = asyncio.Semaphore(_SCORE_CONCURRENCY)
|
||||
|
||||
async def _score_one(c: dict) -> dict:
|
||||
ban: CheckResult = check_and_fix(
|
||||
f"{c.get('title','')} {c.get('content','')}",
|
||||
banned_entries or None,
|
||||
)
|
||||
try:
|
||||
async with sem:
|
||||
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"],
|
||||
"passed": scored["passed"], "banned_word_status": ban.status,
|
||||
"verdict": scored.get("verdict", ""), "summary": scored.get("summary", "")})
|
||||
if ban.status == "auto_fixed" and ban.fixed_text:
|
||||
c["content"] = ban.fixed_text
|
||||
candidates.append(c)
|
||||
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"]
|
||||
# 优化轮默认关闭:apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503,
|
||||
@@ -164,7 +221,11 @@ async def generate_text_variants(
|
||||
logger.warning("文案优化轮 LLM 失败,沿用原始候选不再重试")
|
||||
break
|
||||
for nc in parse_json_array(raw2):
|
||||
try:
|
||||
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"],
|
||||
"passed": sc2["passed"], "banned_word_status": "pass",
|
||||
"verdict": sc2.get("verdict", ""), "summary": sc2.get("summary", "")})
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.response import raise_unauthorized
|
||||
from app.middleware.workspace_guard import CurrentUser
|
||||
from app.models.user import User
|
||||
@@ -16,6 +17,7 @@ from app.models.workspace import WorkspaceMember
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
DEFAULT_SEED_PASSWORD = "Clover2026!"
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
@@ -38,6 +40,8 @@ def authenticate_user(
|
||||
).first()
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
raise_unauthorized("用户名或密码错误")
|
||||
if get_settings().APP_ENV == "production" and password == DEFAULT_SEED_PASSWORD:
|
||||
raise_unauthorized("默认密码已禁用,请联系管理员重置密码")
|
||||
|
||||
# 取用户所在的第一个 workspace(手动建账号场景只有一个)
|
||||
member = (
|
||||
@@ -68,4 +72,20 @@ def build_user_response(user: User, workspace_id: int, role: str) -> dict:
|
||||
"email": user.email,
|
||||
"current_workspace_id": workspace_id,
|
||||
"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()
|
||||
|
||||
@@ -33,6 +33,13 @@ def _parse_fission_response(
|
||||
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))
|
||||
if not notes:
|
||||
# parse_json_array 只认数组;再试整段当对象解析(容错单对象返回)
|
||||
@@ -42,7 +49,7 @@ def _parse_fission_response(
|
||||
notes = []
|
||||
if not notes:
|
||||
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] = []
|
||||
for n in notes:
|
||||
@@ -53,17 +60,21 @@ def _parse_fission_response(
|
||||
if not isinstance(plan, list) or len(plan) != image_count:
|
||||
n["imagePlan"] = build_fallback_image_plan(n, image_count)
|
||||
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。
|
||||
|
||||
Celery 同步环境:用 asyncio.run 跑一个内部 gather(信号量限2并发,
|
||||
对齐生图限流,避免中转站 429)。
|
||||
D1修复:product 传给 llm_score_copy → build_score_prompt 组装【产品语境】,
|
||||
评委不再盲评,买点转化/痛点人群精准两维能基于产品信息给分。
|
||||
Celery 同步环境:用 asyncio.run 跑一个内部 gather(信号量限2并发)。
|
||||
"""
|
||||
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
|
||||
|
||||
async def _run() -> None:
|
||||
@@ -78,8 +89,17 @@ def _score_notes(clients, notes: list[dict], source_note: dict, banned: list[str
|
||||
async with sem:
|
||||
try:
|
||||
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
|
||||
logger.warning("裂变评分异常,记0分不达标: %s", exc)
|
||||
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))
|
||||
|
||||
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:
|
||||
@@ -147,7 +172,8 @@ def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_
|
||||
logger.warning("裂变 LLM 调用失败,启用品类兜底: %s", exc)
|
||||
|
||||
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 套(不足用兜底草稿补齐)
|
||||
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
|
||||
generate_fission_images(db, clients, ft, product, image_count, saved_ids)
|
||||
|
||||
ft.status = "completed"; db.commit()
|
||||
return {"fission_id": fission_id, "status": "completed", "note_ids": saved_ids}
|
||||
has_fallback = any(bool(note.get("is_fallback")) for note in chosen)
|
||||
# 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}
|
||||
|
||||
@@ -23,6 +23,39 @@ logger = logging.getLogger(__name__)
|
||||
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(
|
||||
db: Session, current_user: CurrentUser,
|
||||
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())
|
||||
.first())
|
||||
if c and c.content:
|
||||
return c.content
|
||||
return _source_copy_from_payload(c.content)
|
||||
return src.theme or ""
|
||||
|
||||
@@ -8,7 +8,7 @@ preference_aggregator:查最近50条 → 最常选角度 + 打回原因近3条
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership
|
||||
@@ -69,6 +69,20 @@ def record_signal(
|
||||
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(
|
||||
db: Session, workspace_id: int, product_id: int,
|
||||
product_dict: dict[str, Any] | None = None,
|
||||
@@ -105,4 +119,5 @@ def get_preference_context(
|
||||
"recent_preference": ctx.get("recent_preference", ""),
|
||||
"reject_reasons": ctx.get("reject_reasons", []),
|
||||
"injected_count": ctx.get("injected_count", 0),
|
||||
"signal_count": count_signals(db, workspace_id, product_id),
|
||||
}
|
||||
|
||||
@@ -68,17 +68,19 @@ def create_generation_task(
|
||||
# 已生成完等挑选/审核的不占 worker。红线=每用户5个(可配置)。
|
||||
_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
|
||||
|
||||
product = db.query(Product).filter(
|
||||
Product.id == body.product_id,
|
||||
Product.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not product:
|
||||
raise_business("产品不存在")
|
||||
|
||||
# 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
|
||||
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
|
||||
need_img = getattr(body, "need_product_image", True)
|
||||
if need_img:
|
||||
if not (product.image_path or "").strip():
|
||||
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关")
|
||||
|
||||
@@ -111,10 +113,14 @@ def create_generation_task(
|
||||
|
||||
|
||||
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)。
|
||||
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
|
||||
run_generation_pipeline.delay(task_id, regen_strategy=regen_strategy,
|
||||
regen_role=regen_role, custom_prompt=custom_prompt)
|
||||
logger.info("Enqueued task_id=%s regen=%s/%s", task_id, regen_strategy, regen_role)
|
||||
regen_role=regen_role, custom_prompt=custom_prompt,
|
||||
reuse_text=reuse_text)
|
||||
logger.info("Enqueued task_id=%s regen=%s/%s reuse_text=%s",
|
||||
task_id, regen_strategy, regen_role, reuse_text)
|
||||
|
||||
137
backend/app/services/user_admin_service.py
Normal file
137
backend/app/services/user_admin_service.py
Normal 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}
|
||||
@@ -5,6 +5,7 @@ build_delivery_package:查已选文案+图片 → package_exporter → 存路
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
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
|
||||
settings = get_settings()
|
||||
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
|
||||
upload_root = settings.UPLOAD_ABS_ROOT.rstrip("/")
|
||||
|
||||
# A8 多套打包:按 strategy(A/B/C 三套正交叙事)分组,每套成一篇独立 note。
|
||||
# 北哥拿到的交付包含完整 3 套(note_01/02/03),不再只打第 1 条文案。
|
||||
@@ -59,9 +60,15 @@ def build_delivery_package(self, package_id: int) -> dict:
|
||||
def _read_image(ic) -> dict:
|
||||
img_bytes = b""
|
||||
if ic.url:
|
||||
# url 已含 uploads 前缀;工作目录 /app,lstrip 当相对路径读,勿再拼 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:
|
||||
with open(ic.url.lstrip("/"), "rb") as f:
|
||||
with open(path, "rb") as f:
|
||||
img_bytes = f.read()
|
||||
except OSError as e:
|
||||
logger.warning("图片读取失败,跳过:%s %s", ic.url, e)
|
||||
@@ -115,8 +122,8 @@ def build_delivery_package(self, package_id: int) -> dict:
|
||||
raise ValueError("无图片候选,无法打包")
|
||||
|
||||
from app.services.ai_engine.package_exporter import build_delivery_package as do_build
|
||||
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开
|
||||
packages_base = f"{upload_base}/packages"
|
||||
# 打包产物放持久化卷 /app/uploads/packages/,与图片目录 /app/uploads/{ws}/{task}/ 分开。
|
||||
packages_base = os.path.join(upload_root, "packages")
|
||||
zip_path = do_build(workspace_id, task_id, notes, base_path=packages_base)
|
||||
|
||||
pkg.package_path = zip_path
|
||||
|
||||
@@ -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,
|
||||
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。
|
||||
S1: 存库前过滤——只存 passed且score>=QUALITY_PASS_SCORE(80,红线)且banned_word_status!='hard_block' 的文案。
|
||||
合格数 < 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
|
||||
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.task import TextCandidate
|
||||
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()
|
||||
llm_success = True
|
||||
scoring_unavailable = False # B1:评分通道(apiports+codeproxy)整套都挂,用于区分"评分不可用"vs"质量不合格"
|
||||
candidates_raw: list = []
|
||||
for s in _strategies:
|
||||
n = _per[s]
|
||||
@@ -85,6 +88,12 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
|
||||
flywheel_context=flywheel_fragment,
|
||||
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:
|
||||
llm_success = False
|
||||
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)
|
||||
seq = seq_start
|
||||
saved_count = 0
|
||||
partial_scoring_unavailable = False # 部分条目评分通道挂(整套没全挂故没抛异常),用于精确归因
|
||||
for i, c in enumerate(candidates_raw):
|
||||
score = c.get("score", 0)
|
||||
passed = c.get("passed", False)
|
||||
bw_status = c.get("banned_word_status", "pass")
|
||||
if c.get("scoring_unavailable"):
|
||||
partial_scoring_unavailable = True
|
||||
if not (passed and score >= QUALITY_PASS_SCORE and bw_status != "hard_block"):
|
||||
logger.info(
|
||||
"文案[%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, 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,
|
||||
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_role: 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。
|
||||
返回 next_seq。
|
||||
|
||||
R2 局部重生(均 None=全量A/B/C):regen_strategy 限定只跑该套;regen_role 配合限定该套该张;
|
||||
custom_prompt 人工追加提示词。重生产出 is_regen=True 新增不删旧。
|
||||
|
||||
reuse_text=True(导入轨):只遍历库内真有导入文案的套(notes_by_strategy 的键),
|
||||
导入几套出几套,未导入的套不刷 batch_failed 噪声。
|
||||
"""
|
||||
import time
|
||||
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(多图表为空或主图未入表时仍可用)
|
||||
if reference_images and not images_by_scene.get("primary"):
|
||||
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:
|
||||
logger.info("R5多图已加载:%s", {k: len(v) for k, v in images_by_scene.items()})
|
||||
|
||||
seq = seq_start
|
||||
# R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事
|
||||
_strategies = (regen_strategy,) if regen_strategy else ("A", "B", "C")
|
||||
# R2: 限定重生套别(regen_strategy)则只跑该套;
|
||||
# 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)
|
||||
# 进度总数:单张重生=1,单套=image_count,全量=image_count×3
|
||||
# 进度总数:单张重生=1,单套=image_count,全量/导入=image_count×实际套数
|
||||
if regen_role:
|
||||
_img_total = 1
|
||||
elif regen_strategy:
|
||||
_img_total = task.image_count
|
||||
else:
|
||||
_img_total = task.image_count * 3
|
||||
_img_total = task.image_count * len(_strategies)
|
||||
for si, strategy in enumerate(_strategies):
|
||||
t0 = time.monotonic()
|
||||
img_success = True
|
||||
img_error_code = None
|
||||
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(
|
||||
client=clients,
|
||||
note=first_copy,
|
||||
note=note_for_strategy,
|
||||
product=product_dict,
|
||||
image_count=task.image_count,
|
||||
reference_images=reference_images or None,
|
||||
|
||||
@@ -46,19 +46,37 @@ def decrypt_user_key(db, operator_id: int, workspace_id: int) -> str:
|
||||
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: 构建 AIClients,plain_key 传入后立即由调用方置 None。
|
||||
alt_key:用户录入的 codeproxy 备用 key(可选),同样由调用方查库解密后传入。
|
||||
返回 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:
|
||||
"""把 ORM product 转成 AI 引擎所需的 dict(不含任何 key)。"""
|
||||
return {
|
||||
"id": product.id,
|
||||
"id": getattr(product, "id", None),
|
||||
"name": product.name,
|
||||
"category": product.category 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
|
||||
]
|
||||
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
|
||||
|
||||
@@ -34,6 +34,7 @@ def replenish_text_candidates(self, task_id: int) -> dict:
|
||||
from app.workers.pipeline_steps import (
|
||||
load_task_and_product,
|
||||
decrypt_user_key,
|
||||
decrypt_codeproxy_key,
|
||||
build_clients_and_clear_key,
|
||||
build_product_dict,
|
||||
load_flywheel_context,
|
||||
@@ -57,8 +58,10 @@ def replenish_text_candidates(self, task_id: int) -> dict:
|
||||
|
||||
workspace_id = task.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
|
||||
alt_key = None
|
||||
product_dict = build_product_dict(product)
|
||||
flywheel_fragment, _ = load_flywheel_context(
|
||||
db, workspace_id, task.product_id, product_dict
|
||||
@@ -71,7 +74,7 @@ def replenish_text_candidates(self, task_id: int) -> dict:
|
||||
current = existing
|
||||
while current < target and rounds < MAX_REPLENISH_ROUNDS:
|
||||
rounds += 1
|
||||
run_text_generation(
|
||||
_, _, _, fail_reason, _ = run_text_generation(
|
||||
db, clients, task, product_dict, flywheel_fragment,
|
||||
_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",
|
||||
rounds, task_id, current, target,
|
||||
)
|
||||
# 评分通道不可用时再补也是空烧 token(每轮还各 35s backoff)——立即停,
|
||||
# 等通道恢复用户手动重试。区别于"质量不达标"(那个值得多补几轮)。
|
||||
if fail_reason == "scoring_unavailable":
|
||||
logger.error("补充中止: task_id=%s 评分通道不可用,停止空跑重试", task_id)
|
||||
break
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
|
||||
@@ -27,6 +27,26 @@ def _json_loads_safe(raw: str | None) -> dict:
|
||||
return {"content": raw}
|
||||
|
||||
|
||||
def _load_notes_by_strategy(db, task_id: int, target_strategy: str | None = None) -> dict[str, dict]:
|
||||
"""按 A/B/C 套别取已入库文案,优先已选,其次本套最早一条。"""
|
||||
from app.models.task import TextCandidate
|
||||
|
||||
q = db.query(TextCandidate).filter(TextCandidate.task_id == task_id)
|
||||
if target_strategy:
|
||||
q = q.filter(TextCandidate.strategy == target_strategy)
|
||||
rows = q.order_by(
|
||||
TextCandidate.strategy.asc(),
|
||||
TextCandidate.is_selected.desc(),
|
||||
TextCandidate.id.asc(),
|
||||
).all()
|
||||
notes: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
strategy = row.strategy or "_"
|
||||
if strategy not in notes:
|
||||
notes[strategy] = _json_loads_safe(row.content)
|
||||
return notes
|
||||
|
||||
|
||||
def _get_db():
|
||||
"""获取同步 DB Session(Celery worker 环境用同步)"""
|
||||
from app.core.database import SessionLocal
|
||||
@@ -79,7 +99,8 @@ def _release_run_lock(task_id: int) -> None:
|
||||
queue="generation",
|
||||
)
|
||||
def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = None,
|
||||
regen_role: str | None = None, custom_prompt: str | None = None) -> dict:
|
||||
regen_role: str | None = None, custom_prompt: str | None = None,
|
||||
reuse_text: bool = False) -> dict:
|
||||
"""
|
||||
生产链主任务。只接收 task_id,绝不接收 key。
|
||||
子步骤委托给 pipeline_steps(查库/解密/飞轮上下文)
|
||||
@@ -88,6 +109,10 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
R2 重生参数(均 None=常规全量生成):
|
||||
regen_strategy='A'/'B'/'C' → 只重生该套;regen_role 配合 → 只重生该套的该张;
|
||||
custom_prompt → 人工追加提示词。重生模式跳过文案生成,复用已有合格文案。
|
||||
|
||||
reuse_text=True:导入轨「去生图」首次出图——状态照常进 GENERATING(非重生),
|
||||
但跳过文案生成、复用库内导入文案作生图依据。与重生区别:重生改图(状态不变),
|
||||
导入是首次出图(进 GENERATING),信号语义不同,故单独标志位不复用 regen 参数。
|
||||
"""
|
||||
logger.info("run_generation_pipeline start: task_id=%s", task_id)
|
||||
# 幂等双保险:抢不到锁说明已有 worker 在跑同一 task,直接跳过(防重复烧钱)。
|
||||
@@ -103,6 +128,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
from app.workers.pipeline_steps import (
|
||||
load_task_and_product,
|
||||
decrypt_user_key,
|
||||
decrypt_codeproxy_key,
|
||||
build_clients_and_clear_key,
|
||||
build_product_dict,
|
||||
load_flywheel_context,
|
||||
@@ -120,11 +146,15 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
|
||||
# Step2: Fernet 解密(局部变量,不传出)
|
||||
plain_key = decrypt_user_key(db, task.operator_id, workspace_id)
|
||||
alt_key = decrypt_codeproxy_key(db, task.operator_id, workspace_id)
|
||||
|
||||
# Step3: 构建 AIClients
|
||||
clients = build_clients_and_clear_key(plain_key)
|
||||
clients = build_clients_and_clear_key(plain_key, alt_key)
|
||||
plain_key = None # 用完即清除,基石B
|
||||
alt_key = None
|
||||
|
||||
_is_regen = bool(regen_strategy or regen_role)
|
||||
if not _is_regen:
|
||||
task.status = TaskStatus.GENERATING
|
||||
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", {
|
||||
"recent_preference": flywheel_ctx.get("recent_preference", ""),
|
||||
"reject_reasons": flywheel_ctx.get("reject_reasons", []),
|
||||
"injected_count": flywheel_ctx.get("injected_count", 0),
|
||||
"signal_count": flywheel_ctx.get("signal_count", 0),
|
||||
}, seq)
|
||||
|
||||
# Step5: 文案生成(S1: 返回 needs_replenish=合格数<目标数,触发后台补充)
|
||||
# R2 重生模式:跳过文案重生,复用库内已有合格文案作生图依据(重生只针对图片)
|
||||
_is_regen = bool(regen_strategy or regen_role)
|
||||
if _is_regen:
|
||||
from app.models.task import TextCandidate as _TC
|
||||
_existing = db.query(_TC).filter(
|
||||
_TC.task_id == task_id
|
||||
).order_by(_TC.id.asc()).first()
|
||||
if not _existing:
|
||||
# reuse_text(导入轨):同样跳过文案生成,复用库内导入文案;与重生共用复用分支。
|
||||
if _is_regen or reuse_text:
|
||||
notes_by_strategy = _load_notes_by_strategy(db, task_id, regen_strategy)
|
||||
if regen_strategy and regen_strategy not in notes_by_strategy:
|
||||
# 重生依赖已有文案作生图语境;一条都没有时硬失败,绝不以空文案降级生图(质量崩)
|
||||
from app.constants.enums import TaskStatus as _TS
|
||||
logger.warning("重生无可复用文案,拒绝空语境生图: task_id=%s", task_id)
|
||||
logger.warning(
|
||||
"重生无可复用文案,拒绝空语境生图: task_id=%s strategy=%s",
|
||||
task_id, regen_strategy,
|
||||
)
|
||||
task.status = _TS.PENDING_SELECTION
|
||||
db.commit()
|
||||
_push_event_sync(task_id, workspace_id, "error", {
|
||||
"code": 40002,
|
||||
"message": f"套{regen_strategy}无可复用文案,请先完成该套文案生成再重生图片",
|
||||
}, seq + 1)
|
||||
return {"task_id": task_id, "status": "regen_no_text"}
|
||||
if not notes_by_strategy:
|
||||
from app.constants.enums import TaskStatus as _TS
|
||||
task.status = _TS.PENDING_SELECTION
|
||||
db.commit()
|
||||
_push_event_sync(task_id, workspace_id, "error", {
|
||||
@@ -166,13 +207,15 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
"message": "无可复用文案,请先完成文案生成再重生图片",
|
||||
}, seq + 1)
|
||||
return {"task_id": task_id, "status": "regen_no_text"}
|
||||
candidates_raw = [_json_loads_safe(_existing.content)]
|
||||
candidates_raw = list(notes_by_strategy.values())
|
||||
needs_replenish = False
|
||||
text_fail_reason = None # 重生不生成新文案,无文案失败原因
|
||||
else:
|
||||
candidates_raw, seq, needs_replenish = run_text_generation(
|
||||
candidates_raw, seq, needs_replenish, text_fail_reason, _saved = run_text_generation(
|
||||
db, clients, task, product_dict, flywheel_fragment,
|
||||
_push_event_sync, workspace_id, seq,
|
||||
)
|
||||
notes_by_strategy = _load_notes_by_strategy(db, task_id)
|
||||
if needs_replenish:
|
||||
# 合格文案不足用户目标条数:先展示已合格的,后台异步补充(先展示后台补铁律)
|
||||
# 注:candidates_raw 是本轮原始生成数(含被过滤的低分条),非合格入库数;
|
||||
@@ -187,24 +230,31 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
logger.error("触发后台补充失败: task_id=%s err=%s", task_id, _re)
|
||||
|
||||
# Step6+7+8: 图片生成 + 后处理 + 存盘
|
||||
first_copy = candidates_raw[0] if candidates_raw else {}
|
||||
# A/B/C 三套必须用各自 strategy 的合格文案,不再全套复用第一条文案。
|
||||
settings = get_settings()
|
||||
seq = run_image_generation(
|
||||
db, clients, task, product_dict,
|
||||
_push_event_sync, workspace_id, seq,
|
||||
first_copy, settings.UPLOAD_BASE_PATH,
|
||||
notes_by_strategy, settings.UPLOAD_ABS_ROOT,
|
||||
regen_strategy=regen_strategy, regen_role=regen_role,
|
||||
custom_prompt=custom_prompt,
|
||||
flywheel_fragment=flywheel_fragment,
|
||||
reuse_text=reuse_text,
|
||||
)
|
||||
|
||||
# 最终状态 + task_done
|
||||
task.status = TaskStatus.PENDING_SELECTION
|
||||
db.commit()
|
||||
|
||||
# B1:透传文案结果原因 + 实际合格数,前端据此区分"评分不可用/质量不合格/补充中/正常"
|
||||
from app.models.task import TextCandidate as _TCcount
|
||||
_text_saved = db.query(_TCcount).filter(_TCcount.task_id == task_id).count()
|
||||
|
||||
seq += 1
|
||||
_push_event_sync(task_id, workspace_id, "task_done", {
|
||||
"task_id": task_id, "status": "pending_selection"
|
||||
"task_id": task_id, "status": "pending_selection",
|
||||
"text_saved": _text_saved, "text_target": task.text_count,
|
||||
"text_reason": text_fail_reason,
|
||||
}, seq)
|
||||
|
||||
logger.info("run_generation_pipeline done: task_id=%s", task_id)
|
||||
@@ -221,6 +271,11 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
t = db.query(GT).filter(GT.id == task_id).first()
|
||||
if t:
|
||||
t.status = TS.FAILED if exhausted else TS.GENERATING
|
||||
# B6+#11:失败终态时落原因,供历史页/确认页查看(Redis error历史TTL仅1h会过期)。
|
||||
# 复用 reject_reason 列(Text),前端按 status 区分展示"失败原因"/"打回原因",
|
||||
# 物理不冲突(failed 不命中 rejected 展示分支),免加 Alembic 迁移。
|
||||
if exhausted:
|
||||
t.reject_reason = str(exc)[:2000]
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -262,7 +317,7 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
|
||||
db = _get_db()
|
||||
try:
|
||||
from app.models.task import GenerationTask
|
||||
from app.workers.pipeline_steps import decrypt_user_key, build_clients_and_clear_key
|
||||
from app.workers.pipeline_steps import decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key
|
||||
from app.services.fission_pipeline import execute_fission_pipeline
|
||||
|
||||
src = db.query(GenerationTask).filter(GenerationTask.id == source_task_id).first()
|
||||
@@ -270,8 +325,10 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
|
||||
return {"fission_id": fission_id, "status": "not_found"}
|
||||
|
||||
plain_key = decrypt_user_key(db, src.operator_id, src.workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key)
|
||||
alt_key = decrypt_codeproxy_key(db, src.operator_id, src.workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key, alt_key)
|
||||
plain_key = None # 用完即清,基石B
|
||||
alt_key = None
|
||||
|
||||
return execute_fission_pipeline(db, clients, fission_id, source_task_id)
|
||||
except Exception as exc:
|
||||
@@ -320,7 +377,7 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict:
|
||||
from app.models.task import GenerationTask
|
||||
from app.models.product import Product
|
||||
from app.workers.pipeline_steps import (
|
||||
decrypt_user_key, build_clients_and_clear_key, build_product_dict,
|
||||
decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key, build_product_dict,
|
||||
)
|
||||
from app.services.fission_image_retry import retry_fission_note_images
|
||||
|
||||
@@ -340,8 +397,10 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict:
|
||||
|
||||
# key 归属源任务 operator,与首轮生图一致(基石B:不接收明文 key)
|
||||
plain_key = decrypt_user_key(db, src.operator_id, ft.workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key)
|
||||
alt_key = decrypt_codeproxy_key(db, src.operator_id, ft.workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key, alt_key)
|
||||
plain_key = None # 用完即清,基石B
|
||||
alt_key = None
|
||||
|
||||
product = build_product_dict(product_row)
|
||||
image_count = max(1, (src.image_count or 3))
|
||||
|
||||
@@ -18,6 +18,22 @@ logger = logging.getLogger(__name__)
|
||||
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:
|
||||
app = FastAPI(
|
||||
title="Clover API",
|
||||
@@ -29,7 +45,7 @@ def create_app() -> FastAPI:
|
||||
# ── CORS ──────────────────────────────────────────
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 生产环境收窄到前端域名
|
||||
allow_origins=_cors_origins(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -60,7 +76,7 @@ def create_app() -> FastAPI:
|
||||
_register_routers(app)
|
||||
|
||||
# ── 静态文件(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)
|
||||
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||
|
||||
@@ -69,6 +85,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
def _register_routers(app: FastAPI) -> None:
|
||||
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.products import router as products_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"
|
||||
app.include_router(auth_router, prefix=prefix)
|
||||
app.include_router(users_router, prefix=prefix)
|
||||
app.include_router(api_keys_router, prefix=prefix)
|
||||
# 导出 router 必须在 products_router 之前:否则 /products/export 被 /products/{product_id} 吞掉
|
||||
app.include_router(exports_router, prefix=prefix)
|
||||
|
||||
@@ -1,36 +1,57 @@
|
||||
# nginx/nginx.conf — Clover 反向代理配置
|
||||
# SSE 端点需关闭 proxy_buffering 才能实时透传
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# ── SSE 端点:关闭缓冲,实时透传 ──────────────────
|
||||
location ~* /api/v1/tasks/[^/]+/stream {
|
||||
proxy_pass http://api:8000;
|
||||
client_max_body_size 20m;
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://api:8000/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ── 普通 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_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
client_max_body_size 20m;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
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 /health {
|
||||
proxy_pass http://api:8000/health;
|
||||
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 $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;
|
||||
}
|
||||
}
|
||||
|
||||
69
backend/nginx/nginx.https.conf
Normal file
69
backend/nginx/nginx.https.conf
Normal 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
4
backend/pytest.ini
Normal file
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
addopts = -q
|
||||
27
backend/scripts/backup.sh
Executable file
27
backend/scripts/backup.sh
Executable 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"
|
||||
@@ -16,7 +16,7 @@ from app.core.database import SessionLocal # noqa: E402
|
||||
from app.models.user import User # noqa: E402
|
||||
from app.models.workspace import Workspace, WorkspaceMember # noqa: E402
|
||||
from app.models.product import Product, BannedWord # noqa: E402
|
||||
from app.services.auth_service import hash_password # noqa: E402
|
||||
from app.services.auth_service import DEFAULT_SEED_PASSWORD, hash_password # noqa: E402
|
||||
|
||||
get_settings() # 触发 .env 校验,缺变量早报错
|
||||
|
||||
@@ -24,10 +24,18 @@ get_settings() # 触发 .env 校验,缺变量早报错
|
||||
WORKSPACE_NAME = "北哥小红书车间"
|
||||
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 = [
|
||||
{"username": "admin", "email": "admin@clover.local", "password": "Clover2026!", "role": "admin"},
|
||||
{"username": "supervisor", "email": "supervisor@clover.local", "password": "Clover2026!", "role": "supervisor"},
|
||||
{"username": "operator", "email": "operator@clover.local", "password": "Clover2026!", "role": "operator"},
|
||||
{"username": "admin", "email": "admin@clover.local", "password": SEED_PASSWORDS["admin"], "role": "admin"},
|
||||
{"username": "supervisor", "email": "supervisor@clover.local", "password": SEED_PASSWORDS["supervisor"], "role": "supervisor"},
|
||||
{"username": "operator", "email": "operator@clover.local", "password": SEED_PASSWORDS["operator"], "role": "operator"},
|
||||
]
|
||||
|
||||
PRODUCT = {
|
||||
@@ -72,6 +80,7 @@ def run():
|
||||
email=u_spec["email"],
|
||||
hashed_password=hash_password(u_spec["password"]),
|
||||
is_active=True,
|
||||
must_change_password=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
@@ -109,38 +118,9 @@ def run():
|
||||
else:
|
||||
print(f"[=] banned_word 已存在: {bw_spec['word']}")
|
||||
|
||||
# ── user_api_keys(R3:明文key只在加密瞬间用,不打印不落明文)──
|
||||
apiports_key = os.environ.get("APIPORTS_KEY", "").strip()
|
||||
if apiports_key:
|
||||
from app.models.workspace import UserApiKey
|
||||
from app.utils.fernet_utils import encrypt_key
|
||||
for u_spec in USERS:
|
||||
u = db.query(User).filter(User.username == u_spec["username"]).first()
|
||||
if not u:
|
||||
continue
|
||||
existing_key = db.query(UserApiKey).filter(
|
||||
UserApiKey.user_id == u.id,
|
||||
UserApiKey.workspace_id == ws.id,
|
||||
UserApiKey.provider == "apiports",
|
||||
).first()
|
||||
encrypted = encrypt_key(apiports_key)
|
||||
last4 = apiports_key[-4:] if len(apiports_key) >= 4 else "****"
|
||||
if existing_key:
|
||||
existing_key.encrypted_key = encrypted
|
||||
existing_key.key_last4 = last4
|
||||
print(f"[=] api_key updated: {u.username} apiports ***{last4}")
|
||||
else:
|
||||
db.add(UserApiKey(
|
||||
user_id=u.id,
|
||||
workspace_id=ws.id,
|
||||
provider="apiports",
|
||||
encrypted_key=encrypted,
|
||||
key_last4=last4,
|
||||
))
|
||||
print(f"[+] api_key: {u.username} apiports ***{last4}")
|
||||
apiports_key = None # 明文用完即清(基石B)
|
||||
else:
|
||||
print("[!] APIPORTS_KEY 未设置,跳过 user_api_keys 录入(部署时需手动录入或重跑种子)")
|
||||
# ── user_api_keys:不再预置(倩倩姐2026-06-12拍板:系统埋的key彻底拔干净)──
|
||||
# 每个用户登录后到「设置 → API Key」自录自用,录一次即记住。
|
||||
# 种子不再从 APIPORTS_KEY 环境变量批量塞 key,避免"免key可用"。
|
||||
|
||||
db.commit()
|
||||
print("\n种子数据初始化完成。")
|
||||
|
||||
70
backend/scripts/watch.sh
Executable file
70
backend/scripts/watch.sh
Executable 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
|
||||
@@ -1,26 +1,44 @@
|
||||
"""Task#6 端到端真跑:3套正交配图。建真实task(product_id=1倍分子素颜霜)。
|
||||
image_count=2 → 每套2张(hook+product_closeup特写) ×3套 = 6张。
|
||||
验:①strategy=A/B/C各2张 ②尺寸1024×1536 ③品牌词进特写图 ④三套图真不同。"""
|
||||
"""Task 端到端真跑临时验证脚本。
|
||||
|
||||
手动运行:
|
||||
cd backend && python3 test_3sets_e2e.py
|
||||
"""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
|
||||
def main():
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.task import GenerationTask
|
||||
from app.constants.enums import TaskStatus
|
||||
from app.workers.tasks import run_generation_pipeline
|
||||
|
||||
db = SessionLocal()
|
||||
t = GenerationTask(
|
||||
workspace_id=3, product_id=1, operator_id=3,
|
||||
try:
|
||||
task = GenerationTask(
|
||||
workspace_id=3,
|
||||
product_id=1,
|
||||
operator_id=3,
|
||||
theme="伪素颜·黄黑皮提亮·3套正交",
|
||||
text_count=1, image_count=2, track="ai",
|
||||
need_product_image=True, status=TaskStatus.PENDING,
|
||||
text_count=1,
|
||||
image_count=2,
|
||||
track="ai",
|
||||
need_product_image=True,
|
||||
status=TaskStatus.PENDING,
|
||||
)
|
||||
db.add(t); db.commit(); db.refresh(t)
|
||||
task_id = t.id
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
task_id = task.id
|
||||
print(f"建task成功 task_id={task_id} image_count=2 (期望3套×2=6张)")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
from app.workers.tasks import run_generation_pipeline
|
||||
print(f"=== 同步执行 run_generation_pipeline({task_id}) ===")
|
||||
result = run_generation_pipeline.apply(args=[task_id]).get()
|
||||
print(f"=== 结果: {result} ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
106
backend/tests/test_delivery_readiness_fixes.py
Normal file
106
backend/tests/test_delivery_readiness_fixes.py
Normal 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
|
||||
64
backend/tests/test_image_provider_routing.py
Normal file
64
backend/tests/test_image_provider_routing.py
Normal 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
9
docker-compose.https.yml
Normal 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
144
docker-compose.prod.yml
Normal 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
34
docs/ops/fernet.md
Normal 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
74
docs/ops/runbook.md
Normal 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
|
||||
```
|
||||
52
docs/ops/troubleshooting.md
Normal file
52
docs/ops/troubleshooting.md
Normal 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
197
docs/user/使用说明.md
Normal 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
9
frontend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.DS_Store
|
||||
4
frontend/.env.example
Normal file
4
frontend/.env.example
Normal 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
28
frontend/Dockerfile
Normal 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"]
|
||||
@@ -1,6 +1,6 @@
|
||||
# Clover Frontend
|
||||
|
||||
Next.js(React)— 5屏骨架
|
||||
Next.js(React)— 运营端
|
||||
|
||||
## 目录规划
|
||||
```
|
||||
@@ -11,14 +11,15 @@ frontend/src/
|
||||
types/ # TypeScript 类型
|
||||
```
|
||||
|
||||
## 5屏
|
||||
1. 管理配置台 — 产品档案/标杆笔记/违禁词/API Key录入
|
||||
2. 开任务 — 选产品+标杆+今天主题+设定数量,发起生成
|
||||
3. 挑文案+挑图 — SSE实时看生成进度,8选3文案/挑图,显示"本次已注入"
|
||||
4. 确认预览 — 提交审核
|
||||
5. 审核台(组长) — 待审队列/通过/打回写原因
|
||||
## 主要页面
|
||||
当前 App Router 生产构建包含 13 个路由:首页、看板、配置、仪表盘、裂变、历史、登录、审核、设置、任务创建、文案选择、图片选择、确认预览。
|
||||
|
||||
## 前端红线(不得违反)
|
||||
- 不展示 Token 余额/用量
|
||||
- 不做积分中心/billing 页
|
||||
- 飞轮偏好信号不暴露独立 POST 给前端(防伪造)
|
||||
|
||||
## 环境变量
|
||||
- `NEXT_PUBLIC_API_BASE_URL`:API 基地址;同域 nginx 部署可留空。
|
||||
- `NEXT_PUBLIC_SSE_BASE_URL`:SSE 后端地址;同域 nginx 部署可留空。
|
||||
- 这两个变量是构建期变量,修改后必须重新 build 前端镜像。
|
||||
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal 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;
|
||||
3
frontend/next-env.d.ts
vendored
3
frontend/next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
},
|
||||
images: {
|
||||
domains: ['localhost'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
2406
frontend/package-lock.json
generated
2406
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,13 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"next": "14.2.0",
|
||||
"next": "^16.2.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
@@ -21,10 +21,13 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.9",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.15"
|
||||
}
|
||||
}
|
||||
|
||||
1
frontend/public/.gitkeep
Normal file
1
frontend/public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 复用 history 的状态色 + Link 跳转风格,保持视觉一致不割裂。
|
||||
*/
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { TaskListItem } from '@/types';
|
||||
|
||||
@@ -18,8 +19,11 @@ export interface ColumnDef {
|
||||
|
||||
export const BOARD_COLUMNS: ColumnDef[] = [
|
||||
{ key: 'generating', label: '生成中',
|
||||
statuses: ['pending', 'generating', 'pending_selection'],
|
||||
statuses: ['pending', 'generating'],
|
||||
accent: 'text-blue-600 bg-blue-50' },
|
||||
{ key: 'pending_selection', label: '待提交',
|
||||
statuses: ['pending_selection'],
|
||||
accent: 'text-indigo-600 bg-indigo-50' },
|
||||
{ key: 'review', label: '待审核',
|
||||
statuses: ['pending_review'], accent: 'text-amber-600 bg-amber-50' },
|
||||
{ key: 'approved', label: '已通过',
|
||||
@@ -28,23 +32,27 @@ export const BOARD_COLUMNS: ColumnDef[] = [
|
||||
statuses: ['rejected'], accent: 'text-red-500 bg-red-50' },
|
||||
];
|
||||
|
||||
// 卡片点击目标:生成中/待审核进 confirm 看文案,其余进 images 排图/查看
|
||||
// 卡片点击目标:除「待提交(pending_selection)」进 images 排图外,其余都进 confirm
|
||||
// (生成中/待审核=看文案进度,已通过/已打回=只读查看文案+图)
|
||||
function cardHref(task: TaskListItem): string {
|
||||
if (['pending', 'generating', 'pending_selection', 'pending_review'].includes(task.status)) {
|
||||
return `/tasks/${task.id}/confirm`;
|
||||
}
|
||||
if (task.status === 'pending_selection') {
|
||||
return `/tasks/${task.id}/images`;
|
||||
}
|
||||
return `/tasks/${task.id}/confirm`;
|
||||
}
|
||||
|
||||
// --- 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 (
|
||||
<div className="relative">
|
||||
<Link
|
||||
href={cardHref(task)}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-text-tertiary">
|
||||
<span>{task.text_count} 文</span>
|
||||
@@ -59,11 +67,43 @@ export function TaskCard({ task }: { task: TaskListItem }) {
|
||||
</p>
|
||||
)}
|
||||
</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 --- 状态列:列头 + 计数 + 卡片堆叠
|
||||
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 (
|
||||
<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">
|
||||
@@ -76,7 +116,7 @@ export function BoardColumn({ col, tasks }: { col: ColumnDef; tasks: TaskListIte
|
||||
{tasks.length === 0 ? (
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { TaskListItem, PaginatedResponse } from '@/types';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
import { BOARD_COLUMNS, BoardColumn, BoardSkeleton } from './components';
|
||||
@@ -20,6 +21,8 @@ export default function BoardPage() {
|
||||
const [tasks, setTasks] = useState<TaskListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
@@ -39,10 +42,27 @@ export default function BoardPage() {
|
||||
|
||||
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) => ({
|
||||
col,
|
||||
items: tasks.filter((t) => col.statuses.includes(t.status)),
|
||||
items: visibleTasks.filter((t) => col.statuses.includes(t.status)),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -58,6 +78,21 @@ export default function BoardPage() {
|
||||
</button>
|
||||
</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 && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
||||
{error}
|
||||
@@ -70,7 +105,13 @@ export default function BoardPage() {
|
||||
) : (
|
||||
<div className="flex gap-4 flex-1 overflow-x-auto">
|
||||
{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>
|
||||
)}
|
||||
|
||||
94
frontend/src/app/change-password/page.tsx
Normal file
94
frontend/src/app/change-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,14 @@
|
||||
import { useState } from 'react';
|
||||
import { BenchmarksTab } from '@/components/config/BenchmarksTab';
|
||||
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 }[] = [
|
||||
{ key: 'benchmarks', label: '标杆笔记' },
|
||||
{ key: 'banned_words', label: '违禁词库' },
|
||||
{ key: 'users', label: '账号管理' },
|
||||
];
|
||||
|
||||
export default function ConfigPage() {
|
||||
@@ -46,6 +48,7 @@ export default function ConfigPage() {
|
||||
<div id={`panel-${activeTab}`} role="tabpanel">
|
||||
{activeTab === 'benchmarks' && <BenchmarksTab />}
|
||||
{activeTab === 'banned_words' && <BannedWordsTab />}
|
||||
{activeTab === 'users' && <UserManageTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,8 +28,9 @@ export default function DashboardPage() {
|
||||
<DashCard href="/review" icon="✅" label="审核台" desc="查看待审笔记,通过或打回" />
|
||||
)}
|
||||
{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="查看历史任务和成品库" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ function DownloadButton({ taskId }: { taskId: number }) {
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
await api.post(`/api/v1/delivery-packages/${id}/mark-downloaded`);
|
||||
setState('done');
|
||||
return;
|
||||
}
|
||||
@@ -55,7 +56,11 @@ function DownloadButton({ taskId }: { taskId: number }) {
|
||||
} 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>;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 数据:GET /api/v1/tasks?status=approved,archived,rejected
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { TaskListItem, PaginatedResponse } from '@/types';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
@@ -19,6 +20,8 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function HistoryPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const isImageView = searchParams.get('view') === 'images';
|
||||
const [tasks, setTasks] = useState<TaskListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -58,10 +61,18 @@ export default function HistoryPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{isImageView && (
|
||||
<p className="text-sm text-text-secondary -mt-2">
|
||||
图片排版需先选一条任务:在下方列表点「排图」进入该任务的图片步骤。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex gap-2" role="group" aria-label="状态筛选">
|
||||
{[{ key: 'all', label: '全部' }, ...HISTORY_STATUSES.map((s) => ({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '龙石小红书内容生产平台',
|
||||
title: 'Clover小红书内容生产平台',
|
||||
description: '小红书内容生产工具',
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function RootLayout({
|
||||
{/* 顶部 Header */}
|
||||
<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">
|
||||
龙石小红书内容生产平台
|
||||
Clover小红书内容生产平台
|
||||
</h1>
|
||||
<UserBadge />
|
||||
</header>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
// /login — 登录页,按角色跳转首页(admin→/config, supervisor→/review, operator→/tasks/new)
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -15,6 +15,14 @@ export default function LoginPage() {
|
||||
const [form, setForm] = useState<LoginRequest>({ username: '', password: '' });
|
||||
const [error, setError] = useState('');
|
||||
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) {
|
||||
e.preventDefault();
|
||||
@@ -27,6 +35,10 @@ export default function LoginPage() {
|
||||
try {
|
||||
const data = await api.post<LoginResponse>('/api/v1/auth/login', form);
|
||||
login(data.token, data.user);
|
||||
if (data.user.must_change_password) {
|
||||
router.replace('/change-password');
|
||||
return;
|
||||
}
|
||||
// 按角色跳对应首页
|
||||
const dest =
|
||||
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="text-center mb-8">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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}
|
||||
@@ -63,9 +80,16 @@ export default function LoginPage() {
|
||||
|
||||
<div>
|
||||
<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 }))}
|
||||
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>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 🔴 红线:key 各人自录自用各算各的;只录不显余额
|
||||
* 拆分自原 /config(B方案):admin 专属的标杆/违禁词留在 /config「系统管理」
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ProductsTab } from '@/components/config/ProductsTab';
|
||||
import { ApiKeysTab } from '@/components/config/ApiKeysTab';
|
||||
|
||||
@@ -18,6 +18,12 @@ const TABS: { key: TabKey; label: string }[] = [
|
||||
|
||||
export default function SettingsPage() {
|
||||
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 (
|
||||
<div className="p-6 max-w-5xl">
|
||||
|
||||
@@ -3,21 +3,82 @@
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar';
|
||||
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
|
||||
import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages';
|
||||
import { ImageLightbox } from '@/components/tasks/ImageLightbox';
|
||||
import { TextLightbox } from '@/components/tasks/TextLightbox';
|
||||
import { useTaskStore } from '@/stores/taskStore';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { useState } from 'react';
|
||||
import { TextCandidate, ImageCandidate } from '@/types/index';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function ConfirmPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
const { selectedTextIds, selectedImageIds } = useTaskStore();
|
||||
const { selectedTextIds, selectedImageIds, setSelections } = useTaskStore();
|
||||
// imageCandidates 平铺(契约 image_candidate 事件无 batch 字段)
|
||||
const { textCandidates, imageCandidates } = useGenerationStore();
|
||||
const { textCandidates, imageCandidates, hydrateFromTask } = useGenerationStore();
|
||||
const [submitting, setSubmitting] = 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报bug):SPA 路由切到不同任务卡片时,
|
||||
// 上个任务数据仍在 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 selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id));
|
||||
@@ -25,9 +86,17 @@ export default function ConfirmPage() {
|
||||
async function handleSubmitReview() {
|
||||
if (!taskId) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/submit-review`);
|
||||
setFlywheelMsg('🌿 已学习你的选择,下次更懂你');
|
||||
setTimeout(() => {
|
||||
setFlywheelMsg(null);
|
||||
router.push('/review');
|
||||
}, 2500);
|
||||
} catch (e) {
|
||||
// 后端校验失败(如未选满)以前被 finally 吞掉 → 用户看着像"点了没反应"
|
||||
setSubmitError(e instanceof Error ? e.message : '提交审核失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -38,7 +107,11 @@ export default function ConfirmPage() {
|
||||
setRegenerating(true);
|
||||
try {
|
||||
await api.post(`/api/v1/tasks/${taskId}/regenerate`);
|
||||
setFlywheelMsg('🌿 已记录你的顾虑,下次会避开');
|
||||
setTimeout(() => {
|
||||
setFlywheelMsg(null);
|
||||
router.push(`/tasks/${taskId}/text`);
|
||||
}, 2500);
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
@@ -46,27 +119,66 @@ export default function ConfirmPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selectedTexts.map((c) => (
|
||||
<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>
|
||||
<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}
|
||||
</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>
|
||||
@@ -74,18 +186,55 @@ export default function ConfirmPage() {
|
||||
</div>
|
||||
|
||||
{/* 已选图 */}
|
||||
<ConfirmPreviewImages images={selectedImages} />
|
||||
<ConfirmPreviewImages images={selectedImages} onZoom={isReadonly ? setZoomImage : undefined} />
|
||||
</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
|
||||
submitting={submitting}
|
||||
regenerating={regenerating}
|
||||
canSubmit={selectedTextIds.length > 0}
|
||||
hydrating={hydrating}
|
||||
canSubmit={selectedTextIds.length > 0 && selectedImageIds.length > 0}
|
||||
onSubmit={handleSubmitReview}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
// 屏4 挑图:0/N异步电影 + 按A/B/C分组选图 + 点图放大 + 单批重试 + 能离开
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ProgressBar } from '@/components/layout/ProgressBar';
|
||||
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||
import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard';
|
||||
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
|
||||
import { DeleteTaskButton } from '@/components/tasks/DeleteTaskButton';
|
||||
import { ImageActionBar } from '@/components/tasks/ImageActionBar';
|
||||
import { ImageLightbox } from '@/components/tasks/ImageLightbox';
|
||||
import { ImageStrategyGroup, ROLE_LABEL } from '@/components/tasks/ImageStrategyGroup';
|
||||
import { RegenControls, RegenTarget } from '@/components/tasks/RegenControls';
|
||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||
import { StageProgress } from '@/components/tasks/StageProgress';
|
||||
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
||||
import { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
|
||||
import { useSse } from '@/hooks/useSse';
|
||||
@@ -24,11 +26,14 @@ export default function ImageSelectionPage() {
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
const router = useRouter();
|
||||
|
||||
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore();
|
||||
const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected, removeImageCandidate } = useGenerationStore();
|
||||
const { selectedImageIds, toggleImageSelection } = useTaskStore();
|
||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||
const [zoom, setZoom] = useState<ImageCandidate | 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 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(
|
||||
(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) {
|
||||
// 一期 regenerate = 整体重生(R2 已扩展按角色单批重生,见 handleRegen)
|
||||
@@ -55,10 +83,23 @@ export default function ImageSelectionPage() {
|
||||
// R2 单张/单套重生 + 可选人工提示词
|
||||
async function handleRegen(target: RegenTarget, customPrompt: string) {
|
||||
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`, {
|
||||
strategy: target.strategy,
|
||||
role: target.role,
|
||||
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 });
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!taskId || selectedImageIds.length === 0) return;
|
||||
for (const candidateId of selectedImageIds) {
|
||||
@@ -82,11 +136,36 @@ export default function ImageSelectionPage() {
|
||||
<FlywheelBannerFromSse
|
||||
recentPreference={flywheelInjected.recentPreference}
|
||||
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} />
|
||||
<div className="p-6 flex-1 overflow-auto">
|
||||
{taskId && (
|
||||
<div className="mb-4 flex justify-end">
|
||||
<DeleteTaskButton taskId={taskId} />
|
||||
</div>
|
||||
)}
|
||||
<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} />
|
||||
{imageCandidates.length === 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3 xl:grid-cols-6">
|
||||
@@ -100,9 +179,11 @@ export default function ImageSelectionPage() {
|
||||
strategy={s}
|
||||
candidates={list}
|
||||
selectedImageIds={selectedImageIds}
|
||||
onToggle={toggleImageSelection}
|
||||
onToggle={handleToggleImage}
|
||||
onZoom={setZoom}
|
||||
onRegen={openRegen}
|
||||
regeneratingKeys={regeneratingKeys}
|
||||
onDelete={handleDeleteImage}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
@@ -110,13 +191,20 @@ export default function ImageSelectionPage() {
|
||||
{/* 失败批次提示 */}
|
||||
{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">
|
||||
有 {failedBatches.length} 批没出来。
|
||||
{failedBatches.filter((b) => b.retryable).map((b) => (
|
||||
<button key={b.batch} onClick={() => handleRetry(b.batch)}
|
||||
className="ml-2 underline" aria-label={`重试${ROLE_LABEL[b.batch] || b.batch}`}>
|
||||
⟳ 重试{ROLE_LABEL[b.batch] || b.batch}
|
||||
<p className="font-medium">有 {failedBatches.length} 批没出来。</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{failedBatches.map((b) => (
|
||||
<li key={b.batch} className="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{imageCandidates.length > 0 && (
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
'use client';
|
||||
// 屏3 挑文案:SSE实时+五维分+多选+飞轮隐形
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
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 { TextStrategyGroup, groupByStrategy } from '@/components/tasks/TextStrategyGroup';
|
||||
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 { RejectReasonBanner } from '@/components/tasks/RejectReasonBanner';
|
||||
import { useSse } from '@/hooks/useSse';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useTaskStore } from '@/stores/taskStore';
|
||||
import { usePreferenceStore } from '@/stores/preferenceStore';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function TextSelectionPage() {
|
||||
@@ -19,14 +23,32 @@ export default function TextSelectionPage() {
|
||||
const taskId = params?.id ? Number(params.id) : null;
|
||||
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 { context, fetchContext } = usePreferenceStore();
|
||||
const { connectionStatus, reconnectAttempt } = useSse(taskId);
|
||||
const [proceedError, setProceedError] = useState('');
|
||||
const [track, setTrack] = useState<'ai' | 'import' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) setCurrentTask(taskId, 'generating');
|
||||
}, [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) {
|
||||
if (!taskId) return;
|
||||
// D1 改稿=飞轮最强信号 text_edit,后端回写 + record_signal
|
||||
@@ -36,43 +58,80 @@ export default function TextSelectionPage() {
|
||||
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() {
|
||||
if (!taskId || selectedTextIds.length === 0) return;
|
||||
setProceedError('');
|
||||
const failed: number[] = [];
|
||||
for (const candidateId of selectedTextIds) {
|
||||
try {
|
||||
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`);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ProgressBar currentStep={2} />
|
||||
<RejectReasonBanner taskId={taskId} />
|
||||
{flywheelInjected && (
|
||||
<FlywheelBannerFromSse recentPreference={flywheelInjected.recentPreference} rejectReasons={flywheelInjected.rejectReasons} />
|
||||
)}
|
||||
{flywheelInjected ? (
|
||||
<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="flex items-center justify-between mb-4">
|
||||
<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>}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||
{taskId && <DeleteTaskButton taskId={taskId} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */}
|
||||
<FatalErrorBanner />
|
||||
|
||||
{/* SSE 进度提示(生成中且无内容时) */}
|
||||
{showSkeleton && (
|
||||
<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">
|
||||
正在生成文案… 预计 15-30 秒,好了会更新,可先去忙别的
|
||||
</div>
|
||||
)}
|
||||
{/* B1 文案产出结果提示:完成但没出文案/补充中/评分不可用,明确告知真实原因 */}
|
||||
<TextOutcomeBanner />
|
||||
|
||||
{/* #3 阶段进度:替代静态"预计15-30秒",展示 分析竞品→生成文案 实时阶段 */}
|
||||
{isGenerating && <StageProgress scope="text" />}
|
||||
|
||||
{/* 文案卡:按 A/B/C 三套分组展示(让运营一眼看出三套不同角度);生成中显示骨架 */}
|
||||
{showSkeleton ? (
|
||||
@@ -88,6 +147,7 @@ export default function TextSelectionPage() {
|
||||
selectedTextIds={selectedTextIds}
|
||||
onToggle={toggleTextSelection}
|
||||
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">
|
||||
<span className="text-sm text-text-secondary">
|
||||
已选 <strong className="text-text-primary">{selectedTextIds.length}</strong> 条
|
||||
{proceedError && <span className="ml-3 text-red-600">{proceedError}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleProceedToImages}
|
||||
disabled={selectedTextIds.length === 0}
|
||||
disabled={selectedTextIds.length === 0 || track === null}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ApiError } from '@/types/index';
|
||||
|
||||
export default function NewTaskPage() {
|
||||
const router = useRouter();
|
||||
const { context } = usePreferenceStore();
|
||||
const { context, fetchContextByProduct } = usePreferenceStore();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
|
||||
@@ -35,6 +35,7 @@ export default function NewTaskPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState(false); // 提升:供右侧「编辑配置」(运营/组长)触发就地编辑
|
||||
|
||||
useEffect(() => { loadProducts(); }, []);
|
||||
|
||||
@@ -47,6 +48,12 @@ export default function NewTaskPage() {
|
||||
.catch(() => setBenchmarks([]));
|
||||
}, [selectedProduct]);
|
||||
|
||||
// 选品变化:拉该产品飞轮偏好上下文,驱动顶部 FlywheelBanner
|
||||
useEffect(() => {
|
||||
if (!selectedProduct) return;
|
||||
fetchContextByProduct(selectedProduct.id);
|
||||
}, [selectedProduct?.id]);
|
||||
|
||||
async function loadProducts() {
|
||||
try {
|
||||
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 {
|
||||
if (!selectedProduct) { setError('请选择产品'); return false; }
|
||||
if (!form.theme.trim()) { setError('请填写今天主题'); return false; }
|
||||
@@ -135,6 +148,9 @@ export default function NewTaskPage() {
|
||||
products={products}
|
||||
selectedProduct={selectedProduct}
|
||||
onSelectProduct={setSelectedProduct}
|
||||
onProductCreated={handleProductCreated}
|
||||
editingProduct={editingProduct}
|
||||
setEditingProduct={setEditingProduct}
|
||||
benchmarks={benchmarks}
|
||||
form={form}
|
||||
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
|
||||
@@ -147,7 +163,7 @@ export default function NewTaskPage() {
|
||||
</div>
|
||||
|
||||
{/* 右侧配置预览面板 */}
|
||||
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} />}
|
||||
{selectedProduct && <ConfigPreviewPanel product={selectedProduct} onEditInline={() => setEditingProduct(true)} />}
|
||||
</div>
|
||||
|
||||
<ImportTextModal
|
||||
|
||||
@@ -37,7 +37,7 @@ interface AuthGuardProps {
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, role, isLoading, fetchMe, endLoading } = useAuthStore();
|
||||
const { isAuthenticated, role, user, isLoading, fetchMe, endLoading } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
// 首屏校验:有 token 且 store 未初始化 → 拉 me;无 token → 结束 loading 让守卫放行判断
|
||||
@@ -60,11 +60,20 @@ export function AuthGuard({ children }: AuthGuardProps) {
|
||||
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);
|
||||
if (required && role && !required.includes(role)) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, role, isLoading, pathname, router]);
|
||||
}, [isAuthenticated, role, user?.must_change_password, isLoading, pathname, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -11,14 +11,23 @@ interface 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 (
|
||||
<div
|
||||
role="status"
|
||||
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>
|
||||
<div>
|
||||
<span className="font-medium">本次已注入:</span>
|
||||
@@ -32,6 +41,8 @@ export function FlywheelBanner({ context }: FlywheelBannerProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,15 +52,24 @@ export function FlywheelBanner({ context }: FlywheelBannerProps) {
|
||||
interface FlywheelBannerFromSseProps {
|
||||
recentPreference: string;
|
||||
rejectReasons: string[];
|
||||
signalCount?: number;
|
||||
}
|
||||
|
||||
export function FlywheelBannerFromSse({ recentPreference, rejectReasons }: FlywheelBannerFromSseProps) {
|
||||
export function FlywheelBannerFromSse({ recentPreference, rejectReasons, signalCount }: FlywheelBannerFromSseProps) {
|
||||
const count = signalCount ?? 0;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
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>
|
||||
<div>
|
||||
<span className="font-medium">本次已注入:</span>
|
||||
@@ -61,5 +81,6 @@ export function FlywheelBannerFromSse({ recentPreference, rejectReasons }: Flywh
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
158
frontend/src/components/common/StageLoadingPanel.tsx
Normal file
158
frontend/src/components/common/StageLoadingPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
// ApiKeysTab — API Key 录入(只录不显余额,CLAUDE.md红线)
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ApiKey, CreateApiKeyRequest, PaginatedResponse, ApiError } from '@/types/index';
|
||||
import { getErrorAction } from '@/types/errors';
|
||||
@@ -10,7 +10,13 @@ export function ApiKeysTab() {
|
||||
const [form, setForm] = useState<CreateApiKeyRequest>({ provider: 'openai', api_key: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
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(); }, []);
|
||||
|
||||
@@ -22,10 +28,18 @@ export function ApiKeysTab() {
|
||||
} 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) {
|
||||
e.preventDefault();
|
||||
if (!form.api_key.trim()) { setError('请填写 API Key'); return; }
|
||||
setSaving(true); setError('');
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
await api.post('/api/v1/api-keys', form);
|
||||
setForm({ provider: 'openai', api_key: '' });
|
||||
@@ -36,6 +50,18 @@ export function ApiKeysTab() {
|
||||
} 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) {
|
||||
if (!confirm('确认删除此 Key?')) return;
|
||||
try {
|
||||
@@ -47,33 +73,41 @@ export function ApiKeysTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-text-secondary bg-brand-cream-dark px-4 py-3 rounded-lg">
|
||||
API Key 由各人自录,余额和用量请到中转站后台查看。
|
||||
API Key 由各人自录,录一次即记住,下次登录自动带出。要换 key 点对应通道的「修改」直接覆盖。余额和用量请到中转站后台查看。
|
||||
</p>
|
||||
<form onSubmit={handleAdd} className="flex gap-3 items-end">
|
||||
<div>
|
||||
<label htmlFor="provider" className="block text-sm font-medium text-text-primary mb-1">Provider</label>
|
||||
<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">
|
||||
<option value="openai">OpenAI (gpt-image-2)</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="openai">主通道 apiports(生图+生文,必填)</option>
|
||||
<option value="codeproxy">备用通道 codeproxy(apiports 繁忙时自动顶上,选填)</option>
|
||||
<option value="gemini">Gemini(生图备选,选填)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="api-key-input" className="block text-sm font-medium text-text-primary mb-1">API Key</label>
|
||||
<input id="api-key-input" type="password" value={form.api_key}
|
||||
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
|
||||
<label htmlFor="api-key-input" className="block text-sm font-medium text-text-primary mb-1">
|
||||
API Key{isOverwrite && <span className="text-brand-orange ml-1">(已录入,重新填写将覆盖)</span>}
|
||||
</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-..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={saving} aria-label="保存 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">
|
||||
{saving ? '保存中…' : '保存'}
|
||||
<button type="button" onClick={handleTest} disabled={testing || saving}
|
||||
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">
|
||||
{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>
|
||||
</form>
|
||||
{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 ? (
|
||||
<div className="text-text-secondary text-sm">加载中…</div>
|
||||
) : (
|
||||
@@ -84,8 +118,12 @@ export function ApiKeysTab() {
|
||||
<span className="font-medium">{k.provider}</span>
|
||||
<span className="text-text-secondary ml-2">…{k.key_last4}</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`}
|
||||
className="text-status-error text-xs hover:underline">删除</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{keys.length === 0 && (
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { CreateBenchmarkRequest } from '@/types/index';
|
||||
|
||||
interface BenchmarkFormProps {
|
||||
productId: number;
|
||||
@@ -13,18 +12,33 @@ interface BenchmarkFormProps {
|
||||
}
|
||||
|
||||
export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormProps) {
|
||||
const [form, setForm] = useState<CreateBenchmarkRequest>({
|
||||
screenshot_url: '', highlights: '', link_url: null,
|
||||
});
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [highlights, setHighlights] = useState('');
|
||||
const [screenshot, setScreenshot] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
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);
|
||||
setError('');
|
||||
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();
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || '保存失败,请重试');
|
||||
} finally {
|
||||
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">
|
||||
<h3 className="font-medium text-text-primary">上传标杆笔记</h3>
|
||||
<div>
|
||||
<label htmlFor="bm-url" className="block text-sm font-medium mb-1">截图 URL</label>
|
||||
<input id="bm-url" value={form.screenshot_url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, screenshot_url: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
<label htmlFor="bm-link" className="block text-sm font-medium mb-1">原始链接</label>
|
||||
<input id="bm-link" value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
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"
|
||||
required aria-required="true"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-tertiary">用于保留来源,不再当截图读取,长链接也可以保存。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="bm-highlights" className="block text-sm font-medium mb-1">亮点(手填)</label>
|
||||
<textarea id="bm-highlights" value={form.highlights} rows={3}
|
||||
onChange={(e) => setForm((f) => ({ ...f, highlights: e.target.value }))}
|
||||
<label htmlFor="bm-screenshot" className="block text-sm font-medium mb-1">截图文件</label>
|
||||
<input id="bm-screenshot" type="file" accept="image/jpeg,image/png,image/webp"
|
||||
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"
|
||||
required aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-sm text-status-error">{error}</p>}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<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">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user