diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b0c3ae3 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4518f1c --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..baecf40 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 82d8a40..72f477f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 8723a83..f53a5ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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竞品数据全监控/反推关键词=自动抓小红书,命中合规反爬红线,直接砍,不进任何期。 - 发布:不自动发小红书,产出"达人素材交付包"人工发。 - 数据归属:原始数据(输入+产出+素材包)归客户可导出;飞轮偏好归平台。 diff --git a/PRD/PRD-0-总纲.md b/PRD/PRD-0-总纲.md index 9667c6d..e26c365 100644 --- a/PRD/PRD-0-总纲.md +++ b/PRD/PRD-0-总纲.md @@ -6,7 +6,7 @@ ## 1. 产品定位 - 一句话:北哥团队的小红书达人素材"中央厨房"(多租户SaaS) -- 界面名:龙石小红书内容生产平台 ; 项目代号:Clover🍀 +- 界面名:Clover小红书内容生产平台 ; 项目代号:Clover🍀 - 核心差异化 = **偏好飞轮**(越用越懂你的"老师傅") ## 2. 目标用户与三角色 diff --git a/PRD/PRD-前端.md b/PRD/PRD-前端.md index 5cf3b35..ef37aa0 100644 --- a/PRD/PRD-前端.md +++ b/PRD/PRD-前端.md @@ -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项导航(工作台/开新任务/选择文案/选择图片/建立任务/任务列表/历史归档/配置中心) - 主区 + 右侧配置预览面板 diff --git a/README.md b/README.md new file mode 100644 index 0000000..961a0ca --- /dev/null +++ b/README.md @@ -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 三套内容必须各选择一条文案,并且每套选满配置的图片数量后才能提交审核。 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..2670a3f --- /dev/null +++ b/backend/.dockerignore @@ -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 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..3266575 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/README.md b/backend/README.md index 5dc7cf1..831283b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/alembic/versions/022_user_must_change_password.py b/backend/alembic/versions/022_user_must_change_password.py new file mode 100644 index 0000000..18f6d05 --- /dev/null +++ b/backend/alembic/versions/022_user_must_change_password.py @@ -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") diff --git a/backend/alembic/versions/023_benchmark_link_and_screenshot_text.py b/backend/alembic/versions/023_benchmark_link_and_screenshot_text.py new file mode 100644 index 0000000..a9db0ca --- /dev/null +++ b/backend/alembic/versions/023_benchmark_link_and_screenshot_text.py @@ -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, + ) diff --git a/backend/alembic/versions/024_signal_type_text_import.py b/backend/alembic/versions/024_signal_type_text_import.py new file mode 100644 index 0000000..0459376 --- /dev/null +++ b/backend/alembic/versions/024_signal_type_text_import.py @@ -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}") diff --git a/backend/app/api/v1/api_keys.py b/backend/app/api/v1/api_keys.py index b509610..5479204 100644 --- a/backend/app/api/v1/api_keys.py +++ b/backend/app/api/v1/api_keys.py @@ -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, diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index eaf1014..fa059a6 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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, diff --git a/backend/app/api/v1/benchmarks.py b/backend/app/api/v1/benchmarks.py index f042ba0..194bbee 100644 --- a/backend/app/api/v1/benchmarks.py +++ b/backend/app/api/v1/benchmarks.py @@ -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: - try: - with open(b.screenshot_url, "rb") as f: - screenshot = f.read() - except Exception as e: - logger.warning("标杆截图读取失败 id=%s: %s", benchmark_id, e) + 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() diff --git a/backend/app/api/v1/delivery.py b/backend/app/api/v1/delivery.py index 44a8763..107040d 100644 --- a/backend/app/api/v1/delivery.py +++ b/backend/app/api/v1/delivery.py @@ -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, diff --git a/backend/app/api/v1/products.py b/backend/app/api/v1/products.py index 9b53b54..675088f 100644 --- a/backend/app/api/v1/products.py +++ b/backend/app/api/v1/products.py @@ -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, "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}) + 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]) diff --git a/backend/app/api/v1/review.py b/backend/app/api/v1/review.py index ea4250d..0c6441f 100644 --- a/backend/app/api/v1/review.py +++ b/backend/app/api/v1/review.py @@ -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 分字段返回 - text_parsed: dict = {} - if selected_text and selected_text.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 = {} 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} + text_score = None + 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) - text_score = None - if selected_text and selected_text.score_json: - try: - text_score = _score_array_to_obj(json.loads(selected_text.score_json)) - except (json.JSONDecodeError, ValueError): - text_score = None + 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, } diff --git a/backend/app/api/v1/task_actions.py b/backend/app/api/v1/task_actions.py index 8f469c6..83bff14 100644 --- a/backend/app/api/v1/task_actions.py +++ b/backend/app/api/v1/task_actions.py @@ -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}) diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py index 4e170ff..9a0b685 100644 --- a/backend/app/api/v1/tasks.py +++ b/backend/app/api/v1/tasks.py @@ -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"}) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..e4043b7 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -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, + )) diff --git a/backend/app/api/v1/workspaces.py b/backend/app/api/v1/workspaces.py index 02b2bbe..7fc62f8 100644 --- a/backend/app/api/v1/workspaces.py +++ b/backend/app/api/v1/workspaces.py @@ -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, }) diff --git a/backend/app/constants/enums.py b/backend/app/constants/enums.py index eb10e19..9f042db 100644 --- a/backend/app/constants/enums.py +++ b/backend/app/constants/enums.py @@ -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, } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8211a34..997d2a8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 = "" diff --git a/backend/app/middleware/workspace_guard.py b/backend/app/middleware/workspace_guard.py index 3b35f72..42a44b7 100644 --- a/backend/app/middleware/workspace_guard.py +++ b/backend/app/middleware/workspace_guard.py @@ -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 diff --git a/backend/app/models/product.py b/backend/app/models/product.py index 6795409..5c0b5ca 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index d3401ce..0752908 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 ) diff --git a/backend/app/services/ai_engine/_score_prompt.py b/backend/app/services/ai_engine/_score_prompt.py index 1dfc70d..a2955ce 100644 --- a/backend/app/services/ai_engine/_score_prompt.py +++ b/backend/app/services/ai_engine/_score_prompt.py @@ -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}" ) diff --git a/backend/app/services/ai_engine/_scoring_dims.py b/backend/app/services/ai_engine/_scoring_dims.py index 11a8ebd..636147f 100644 --- a/backend/app/services/ai_engine/_scoring_dims.py +++ b/backend/app/services/ai_engine/_scoring_dims.py @@ -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 diff --git a/backend/app/services/ai_engine/_text_prompt.py b/backend/app/services/ai_engine/_text_prompt.py index fc19e59..180da1a 100644 --- a/backend/app/services/ai_engine/_text_prompt.py +++ b/backend/app/services/ai_engine/_text_prompt.py @@ -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}", "#真实测评", "#好物分享"], diff --git a/backend/app/services/ai_engine/beige_color_grade.py b/backend/app/services/ai_engine/beige_color_grade.py new file mode 100644 index 0000000..1b0de1e --- /dev/null +++ b/backend/app/services/ai_engine/beige_color_grade.py @@ -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 diff --git a/backend/app/services/ai_engine/constants.py b/backend/app/services/ai_engine/constants.py index 76b428c..3fbaa6e 100644 --- a/backend/app/services/ai_engine/constants.py +++ b/backend/app/services/ai_engine/constants.py @@ -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, diff --git a/backend/app/services/ai_engine/fission_prompt.py b/backend/app/services/ai_engine/fission_prompt.py index 0756e89..6fbe02c 100644 --- a/backend/app/services/ai_engine/fission_prompt.py +++ b/backend/app/services/ai_engine/fission_prompt.py @@ -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;不要把图片规划/配图建议/内部审核建议写进正文。""" diff --git a/backend/app/services/ai_engine/gemini_factory.py b/backend/app/services/ai_engine/gemini_factory.py index 8ec862d..bfb1d79 100644 --- a/backend/app/services/ai_engine/gemini_factory.py +++ b/backend/app/services/ai_engine/gemini_factory.py @@ -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 结果以 ![](url) 或 data URL 嵌在 content 文本里, + 不走 images[]/parts 结构化字段,这里兜住这几种常见形态(前面带说明文字也能抠出)。 + """ + import re + m = re.search(r"data:image/[\w.+-]+;base64,[A-Za-z0-9+/=]+", text) + if m: + return _decode_b64_image(m.group(0)) + m = re.search(r"!\[[^\]]*\]\((https?://[^\s)]+)\)", text) # markdown 图链 + if m: + resp = httpx.get(m.group(1), timeout=30.0) + resp.raise_for_status() + return resp.content + m = re.search( # 裸图片URL(限图片后缀,避免误抓非图链接) + r"https?://[^\s)\]\"']+\.(?:png|jpe?g|webp|gif)(?:\?[^\s)\]\"']*)?", + text, re.IGNORECASE, + ) + if m: + resp = httpx.get(m.group(0), timeout=30.0) + resp.raise_for_status() + return resp.content + return None + + +def _extract_chat_image_bytes(resp_json: dict) -> bytes: + """从 /chat/completions 图片响应中提取图片 bytes。""" + choices = resp_json.get("choices") or [] + if not choices: + raise ValueError("chat 图片响应缺少 choices") + message = choices[0].get("message") or {} + + images = message.get("images") + if isinstance(images, list): + for item in images: + found = _bytes_from_image_item(item) + if found: + return found + + content = message.get("content") + if isinstance(content, list): + for part in content: + found = _bytes_from_image_item(part) + if found: + return found + elif isinstance(content, str) and content: + found = _bytes_from_content_string(content) + if found: + return found + + raise ValueError( + "无法解析 chat 图片响应:" + f"top={list(resp_json.keys())}, message={list(message.keys())}" + ) + + def _extract_gemini_image(resp_json: dict) -> bytes: """从 Gemini generateContent 响应提取图片 bytes""" import base64 diff --git a/backend/app/services/ai_engine/image_gen.py b/backend/app/services/ai_engine/image_gen.py index a9a0c71..877eda6 100644 --- a/backend/app/services/ai_engine/image_gen.py +++ b/backend/app/services/ai_engine/image_gen.py @@ -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截图或笔记详情页界面。" diff --git a/backend/app/services/ai_engine/image_postprocessor.py b/backend/app/services/ai_engine/image_postprocessor.py index e3f57e4..7e9ede0 100644 --- a/backend/app/services/ai_engine/image_postprocessor.py +++ b/backend/app/services/ai_engine/image_postprocessor.py @@ -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 削像素水印(可选,默认轻采样)--- - img = _apply_resample(img, 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 diff --git a/backend/app/services/ai_engine/llm_scorer.py b/backend/app/services/ai_engine/llm_scorer.py index 47e559c..8480018 100644 --- a/backend/app/services/ai_engine/llm_scorer.py +++ b/backend/app/services/ai_engine/llm_scorer.py @@ -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)))))) diff --git a/backend/app/services/ai_engine/package_exporter.py b/backend/app/services/ai_engine/package_exporter.py index f5a1b47..c659cb1 100644 --- a/backend/app/services/ai_engine/package_exporter.py +++ b/backend/app/services/ai_engine/package_exporter.py @@ -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 diff --git a/backend/app/services/ai_engine/preference_aggregator.py b/backend/app/services/ai_engine/preference_aggregator.py index a15eca7..627cc7e 100644 --- a/backend/app/services/ai_engine/preference_aggregator.py +++ b/backend/app/services/ai_engine/preference_aggregator.py @@ -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() diff --git a/backend/app/services/ai_engine/storyboard.py b/backend/app/services/ai_engine/storyboard.py index 098a5cb..950ba59 100644 --- a/backend/app/services/ai_engine/storyboard.py +++ b/backend/app/services/ai_engine/storyboard.py @@ -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", ], diff --git a/backend/app/services/ai_engine/storyboard_templates.py b/backend/app/services/ai_engine/storyboard_templates.py index 9e40ac6..11fa8f3 100644 --- a/backend/app/services/ai_engine/storyboard_templates.py +++ b/backend/app/services/ai_engine/storyboard_templates.py @@ -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}和用户对效果的关注", diff --git a/backend/app/services/ai_engine/text_variants.py b/backend/app/services/ai_engine/text_variants.py index 1d824d0..878c3b7 100644 --- a/backend/app/services/ai_engine/text_variants.py +++ b/backend/app/services/ai_engine/text_variants.py @@ -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, ) - scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries]) + 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): - sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries]) + 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", "")}) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index d0f5c0b..c10eeef 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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() diff --git a/backend/app/services/fission_pipeline.py b/backend/app/services/fission_pipeline.py index ecd7c07..2e2353f 100644 --- a/backend/app/services/fission_pipeline.py +++ b/backend/app/services/fission_pipeline.py @@ -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} diff --git a/backend/app/services/fission_service.py b/backend/app/services/fission_service.py index fd2353f..7630745 100644 --- a/backend/app/services/fission_service.py +++ b/backend/app/services/fission_service.py @@ -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 "" diff --git a/backend/app/services/flywheel_service.py b/backend/app/services/flywheel_service.py index 20b0f07..97c17aa 100644 --- a/backend/app/services/flywheel_service.py +++ b/backend/app/services/flywheel_service.py @@ -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), } diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 671b394..5744c33 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -68,17 +68,19 @@ def create_generation_task( # 已生成完等挑选/审核的不占 worker。红线=每用户5个(可配置)。 _check_concurrency_limit(db, current_user.user_id, current_user.workspace_id) + 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: - from app.models.product import Product - product = db.query(Product).filter( - Product.id == body.product_id, - Product.workspace_id == current_user.workspace_id, - ).first() - if not product: - raise_business("产品不存在") if not (product.image_path or "").strip(): raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关") @@ -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) diff --git a/backend/app/services/user_admin_service.py b/backend/app/services/user_admin_service.py new file mode 100644 index 0000000..1723779 --- /dev/null +++ b/backend/app/services/user_admin_service.py @@ -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} diff --git a/backend/app/workers/packaging_task.py b/backend/app/workers/packaging_task.py index 53de92c..1c3ed08 100644 --- a/backend/app/workers/packaging_task.py +++ b/backend/app/workers/packaging_task.py @@ -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 diff --git a/backend/app/workers/pipeline_io.py b/backend/app/workers/pipeline_io.py index a06308b..68b821b 100644 --- a/backend/app/workers/pipeline_io.py +++ b/backend/app/workers/pipeline_io.py @@ -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, diff --git a/backend/app/workers/pipeline_steps.py b/backend/app/workers/pipeline_steps.py index 1335b2f..4cbac0a 100644 --- a/backend/app/workers/pipeline_steps.py +++ b/backend/app/workers/pipeline_steps.py @@ -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 diff --git a/backend/app/workers/replenish_task.py b/backend/app/workers/replenish_task.py index 5f4083e..1df45cf 100644 --- a/backend/app/workers/replenish_task.py +++ b/backend/app/workers/replenish_task.py @@ -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, diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 6e34496..8e891d3 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -27,6 +27,26 @@ def _json_loads_safe(raw: str | None) -> dict: return {"content": raw} +def _load_notes_by_strategy(db, task_id: int, target_strategy: str | None = None) -> dict[str, dict]: + """按 A/B/C 套别取已入库文案,优先已选,其次本套最早一条。""" + from app.models.task import TextCandidate + + q = db.query(TextCandidate).filter(TextCandidate.task_id == task_id) + if target_strategy: + q = q.filter(TextCandidate.strategy == target_strategy) + rows = q.order_by( + TextCandidate.strategy.asc(), + TextCandidate.is_selected.desc(), + TextCandidate.id.asc(), + ).all() + notes: dict[str, dict] = {} + for row in rows: + strategy = row.strategy or "_" + if strategy not in notes: + notes[strategy] = _json_loads_safe(row.content) + return notes + + def _get_db(): """获取同步 DB Session(Celery worker 环境用同步)""" from app.core.database import SessionLocal @@ -79,7 +99,8 @@ def _release_run_lock(task_id: int) -> None: queue="generation", ) def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = None, - regen_role: str | None = None, custom_prompt: str | None = None) -> dict: + regen_role: str | None = None, custom_prompt: str | None = None, + reuse_text: bool = False) -> dict: """ 生产链主任务。只接收 task_id,绝不接收 key。 子步骤委托给 pipeline_steps(查库/解密/飞轮上下文) @@ -88,6 +109,10 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non R2 重生参数(均 None=常规全量生成): regen_strategy='A'/'B'/'C' → 只重生该套;regen_role 配合 → 只重生该套的该张; custom_prompt → 人工追加提示词。重生模式跳过文案生成,复用已有合格文案。 + + reuse_text=True:导入轨「去生图」首次出图——状态照常进 GENERATING(非重生), + 但跳过文案生成、复用库内导入文案作生图依据。与重生区别:重生改图(状态不变), + 导入是首次出图(进 GENERATING),信号语义不同,故单独标志位不复用 regen 参数。 """ logger.info("run_generation_pipeline start: task_id=%s", task_id) # 幂等双保险:抢不到锁说明已有 worker 在跑同一 task,直接跳过(防重复烧钱)。 @@ -103,6 +128,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non from app.workers.pipeline_steps import ( load_task_and_product, decrypt_user_key, + decrypt_codeproxy_key, build_clients_and_clear_key, build_product_dict, load_flywheel_context, @@ -120,13 +146,17 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non # Step2: Fernet 解密(局部变量,不传出) plain_key = decrypt_user_key(db, task.operator_id, workspace_id) + alt_key = decrypt_codeproxy_key(db, task.operator_id, workspace_id) # Step3: 构建 AIClients - clients = build_clients_and_clear_key(plain_key) + clients = build_clients_and_clear_key(plain_key, alt_key) plain_key = None # 用完即清除,基石B + alt_key = None - task.status = TaskStatus.GENERATING - db.commit() + _is_regen = bool(regen_strategy or regen_role) + if not _is_regen: + task.status = TaskStatus.GENERATING + db.commit() # Step4: 推 task_started + 飞轮上下文 seq += 1 @@ -145,20 +175,31 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non _push_event_sync(task_id, workspace_id, "flywheel_injected", { "recent_preference": flywheel_ctx.get("recent_preference", ""), "reject_reasons": flywheel_ctx.get("reject_reasons", []), + "injected_count": flywheel_ctx.get("injected_count", 0), + "signal_count": flywheel_ctx.get("signal_count", 0), }, seq) # Step5: 文案生成(S1: 返回 needs_replenish=合格数<目标数,触发后台补充) # R2 重生模式:跳过文案重生,复用库内已有合格文案作生图依据(重生只针对图片) - _is_regen = bool(regen_strategy or regen_role) - if _is_regen: - from app.models.task import TextCandidate as _TC - _existing = db.query(_TC).filter( - _TC.task_id == task_id - ).order_by(_TC.id.asc()).first() - if not _existing: + # reuse_text(导入轨):同样跳过文案生成,复用库内导入文案;与重生共用复用分支。 + if _is_regen or reuse_text: + notes_by_strategy = _load_notes_by_strategy(db, task_id, regen_strategy) + if regen_strategy and regen_strategy not in notes_by_strategy: # 重生依赖已有文案作生图语境;一条都没有时硬失败,绝不以空文案降级生图(质量崩) from app.constants.enums import TaskStatus as _TS - logger.warning("重生无可复用文案,拒绝空语境生图: task_id=%s", task_id) + logger.warning( + "重生无可复用文案,拒绝空语境生图: task_id=%s strategy=%s", + task_id, regen_strategy, + ) + task.status = _TS.PENDING_SELECTION + db.commit() + _push_event_sync(task_id, workspace_id, "error", { + "code": 40002, + "message": f"套{regen_strategy}无可复用文案,请先完成该套文案生成再重生图片", + }, seq + 1) + return {"task_id": task_id, "status": "regen_no_text"} + if not notes_by_strategy: + from app.constants.enums import TaskStatus as _TS task.status = _TS.PENDING_SELECTION db.commit() _push_event_sync(task_id, workspace_id, "error", { @@ -166,13 +207,15 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non "message": "无可复用文案,请先完成文案生成再重生图片", }, seq + 1) return {"task_id": task_id, "status": "regen_no_text"} - candidates_raw = [_json_loads_safe(_existing.content)] + candidates_raw = list(notes_by_strategy.values()) needs_replenish = False + text_fail_reason = None # 重生不生成新文案,无文案失败原因 else: - candidates_raw, seq, needs_replenish = run_text_generation( + candidates_raw, seq, needs_replenish, text_fail_reason, _saved = run_text_generation( db, clients, task, product_dict, flywheel_fragment, _push_event_sync, workspace_id, seq, ) + notes_by_strategy = _load_notes_by_strategy(db, task_id) if needs_replenish: # 合格文案不足用户目标条数:先展示已合格的,后台异步补充(先展示后台补铁律) # 注:candidates_raw 是本轮原始生成数(含被过滤的低分条),非合格入库数; @@ -187,24 +230,31 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non logger.error("触发后台补充失败: task_id=%s err=%s", task_id, _re) # Step6+7+8: 图片生成 + 后处理 + 存盘 - first_copy = candidates_raw[0] if candidates_raw else {} + # A/B/C 三套必须用各自 strategy 的合格文案,不再全套复用第一条文案。 settings = get_settings() seq = run_image_generation( db, clients, task, product_dict, _push_event_sync, workspace_id, seq, - first_copy, settings.UPLOAD_BASE_PATH, + notes_by_strategy, settings.UPLOAD_ABS_ROOT, regen_strategy=regen_strategy, regen_role=regen_role, custom_prompt=custom_prompt, flywheel_fragment=flywheel_fragment, + reuse_text=reuse_text, ) # 最终状态 + task_done task.status = TaskStatus.PENDING_SELECTION db.commit() + # B1:透传文案结果原因 + 实际合格数,前端据此区分"评分不可用/质量不合格/补充中/正常" + from app.models.task import TextCandidate as _TCcount + _text_saved = db.query(_TCcount).filter(_TCcount.task_id == task_id).count() + seq += 1 _push_event_sync(task_id, workspace_id, "task_done", { - "task_id": task_id, "status": "pending_selection" + "task_id": task_id, "status": "pending_selection", + "text_saved": _text_saved, "text_target": task.text_count, + "text_reason": text_fail_reason, }, seq) logger.info("run_generation_pipeline done: task_id=%s", task_id) @@ -221,6 +271,11 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non t = db.query(GT).filter(GT.id == task_id).first() if t: t.status = TS.FAILED if exhausted else TS.GENERATING + # B6+#11:失败终态时落原因,供历史页/确认页查看(Redis error历史TTL仅1h会过期)。 + # 复用 reject_reason 列(Text),前端按 status 区分展示"失败原因"/"打回原因", + # 物理不冲突(failed 不命中 rejected 展示分支),免加 Alembic 迁移。 + if exhausted: + t.reject_reason = str(exc)[:2000] db.commit() except Exception: pass @@ -262,7 +317,7 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict: db = _get_db() try: from app.models.task import GenerationTask - from app.workers.pipeline_steps import decrypt_user_key, build_clients_and_clear_key + from app.workers.pipeline_steps import decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key from app.services.fission_pipeline import execute_fission_pipeline src = db.query(GenerationTask).filter(GenerationTask.id == source_task_id).first() @@ -270,8 +325,10 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict: return {"fission_id": fission_id, "status": "not_found"} plain_key = decrypt_user_key(db, src.operator_id, src.workspace_id) - clients = build_clients_and_clear_key(plain_key) + alt_key = decrypt_codeproxy_key(db, src.operator_id, src.workspace_id) + clients = build_clients_and_clear_key(plain_key, alt_key) plain_key = None # 用完即清,基石B + alt_key = None return execute_fission_pipeline(db, clients, fission_id, source_task_id) except Exception as exc: @@ -320,7 +377,7 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict: from app.models.task import GenerationTask from app.models.product import Product from app.workers.pipeline_steps import ( - decrypt_user_key, build_clients_and_clear_key, build_product_dict, + decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key, build_product_dict, ) from app.services.fission_image_retry import retry_fission_note_images @@ -340,8 +397,10 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict: # key 归属源任务 operator,与首轮生图一致(基石B:不接收明文 key) plain_key = decrypt_user_key(db, src.operator_id, ft.workspace_id) - clients = build_clients_and_clear_key(plain_key) + alt_key = decrypt_codeproxy_key(db, src.operator_id, ft.workspace_id) + clients = build_clients_and_clear_key(plain_key, alt_key) plain_key = None # 用完即清,基石B + alt_key = None product = build_product_dict(product_row) image_count = max(1, (src.image_count or 3)) diff --git a/backend/main.py b/backend/main.py index e7d5450..45b21a4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/nginx/nginx.conf b/backend/nginx/nginx.conf index 55e46d2..45e0526 100644 --- a/backend/nginx/nginx.conf +++ b/backend/nginx/nginx.conf @@ -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; - 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; - } + client_max_body_size 20m; - # ── 普通 API 端点 ────────────────────────────────── - location /api/ { - proxy_pass http://api:8000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Authorization $http_authorization; - client_max_body_size 20m; - } - - # ── 健康检查 ─────────────────────────────────────── - location /health { + 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 $scheme; + } + + 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 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 /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; } } diff --git a/backend/nginx/nginx.https.conf b/backend/nginx/nginx.https.conf new file mode 100644 index 0000000..294dac3 --- /dev/null +++ b/backend/nginx/nginx.https.conf @@ -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; + } +} diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..3b2c446 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -q diff --git a/backend/scripts/backup.sh b/backend/scripts/backup.sh new file mode 100755 index 0000000..4e90236 --- /dev/null +++ b/backend/scripts/backup.sh @@ -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" diff --git a/backend/scripts/seed_data.py b/backend/scripts/seed_data.py index cec43a8..05e5961 100644 --- a/backend/scripts/seed_data.py +++ b/backend/scripts/seed_data.py @@ -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种子数据初始化完成。") diff --git a/backend/scripts/watch.sh b/backend/scripts/watch.sh new file mode 100755 index 0000000..a1e739a --- /dev/null +++ b/backend/scripts/watch.sh @@ -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 diff --git a/backend/test_3sets_e2e.py b/backend/test_3sets_e2e.py index 0879bcc..2db180d 100644 --- a/backend/test_3sets_e2e.py +++ b/backend/test_3sets_e2e.py @@ -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") -from app.core.database import SessionLocal -from app.models.task import GenerationTask -from app.constants.enums import TaskStatus -db = SessionLocal() -t = GenerationTask( - workspace_id=3, product_id=1, operator_id=3, - theme="伪素颜·黄黑皮提亮·3套正交", - text_count=1, image_count=2, track="ai", - need_product_image=True, status=TaskStatus.PENDING, -) -db.add(t); db.commit(); db.refresh(t) -task_id = t.id -print(f"建task成功 task_id={task_id} image_count=2 (期望3套×2=6张)") -db.close() +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 -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} ===") + db = SessionLocal() + 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, + ) + 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() + + print(f"=== 同步执行 run_generation_pipeline({task_id}) ===") + result = run_generation_pipeline.apply(args=[task_id]).get() + print(f"=== 结果: {result} ===") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_delivery_readiness_fixes.py b/backend/tests/test_delivery_readiness_fixes.py new file mode 100644 index 0000000..4c6b6bd --- /dev/null +++ b/backend/tests/test_delivery_readiness_fixes.py @@ -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 diff --git a/backend/tests/test_image_provider_routing.py b/backend/tests/test_image_provider_routing.py new file mode 100644 index 0000000..c87d0c4 --- /dev/null +++ b/backend/tests/test_image_provider_routing.py @@ -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) diff --git a/docker-compose.https.yml b/docker-compose.https.yml new file mode 100644 index 0000000..e40534d --- /dev/null +++ b/docker-compose.https.yml @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..6e2cd23 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docs/ops/fernet.md b/docs/ops/fernet.md new file mode 100644 index 0000000..01f8601 --- /dev/null +++ b/docs/ops/fernet.md @@ -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。 diff --git a/docs/ops/runbook.md b/docs/ops/runbook.md new file mode 100644 index 0000000..e65fab6 --- /dev/null +++ b/docs/ops/runbook.md @@ -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:///health +``` + +## 更新 + +```bash +git fetch --tags +git checkout +docker compose -f docker-compose.prod.yml --env-file .env up -d --build +``` + +## 回滚 + +```bash +git checkout +docker compose -f docker-compose.prod.yml --env-file .env up -d --build +``` + +## 备份 + +```bash +cd backend +MYSQL_USER=clover MYSQL_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 +``` diff --git a/docs/ops/troubleshooting.md b/docs/ops/troubleshooting.md new file mode 100644 index 0000000..b03a331 --- /dev/null +++ b/docs/ops/troubleshooting.md @@ -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//stream?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/ +``` + +api 和 worker 必须共享同一个 `uploads` volume。 + +## 生成失败 + +常见原因: + +- 客户未录入 API Key。 +- provider key 余额不足或无权限。 +- 上游中转站 429/503。 +- 产品入镜但未上传产品图。 + +查看 worker 日志: + +```bash +docker compose -f docker-compose.prod.yml logs --tail=200 worker +``` diff --git a/docs/user/使用说明.md b/docs/user/使用说明.md new file mode 100644 index 0000000..c9b84ea --- /dev/null +++ b/docs/user/使用说明.md @@ -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。 +- 能完整跑通一条任务:建产品 → 生成 → 选择 → 审核 → 下载交付包。 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f10ef59 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +.env +.env.* +!.env.example +node_modules/ +.next/ +dist/ +build/ +*.log +.DS_Store diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..90e6f18 --- /dev/null +++ b/frontend/.env.example @@ -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= diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4ee6778 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/README.md b/frontend/README.md index 336c05f..49d32c6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 前端镜像。 diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..6f1223f --- /dev/null +++ b/frontend/eslint.config.mjs @@ -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; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 4f11a03..9edff1c 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +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. diff --git a/frontend/next.config.js b/frontend/next.config.js index c288791..f1ccaa0 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -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'; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a63cdac..cf11cb3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "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", @@ -20,9 +20,9 @@ "@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" } @@ -40,6 +40,279 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -56,7 +329,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -103,54 +375,144 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" }, "engines": { - "node": ">=10.10.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -167,59 +529,484 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "Apache-2.0", "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" + "node": ">=18.18" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, + "optional": true, "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@jridgewell/gen-mapping": { @@ -233,6 +1020,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -281,25 +1079,55 @@ } }, "node_modules/@next/env": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.0.tgz", - "integrity": "sha512-4+70ELtSbRtYUuyRpAJmKC8NHBW2x1HMje9KO2Xd7IkoyucmV9SjgO+qeWMC0JWkRQXgydv1O7yKOK8nu/rITQ==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", + "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.0.tgz", - "integrity": "sha512-QkM01VPhwcupezVevy9Uyl1rmpg2PimhMjkb+ySmnPgSKUUM/PGGRQxdFgMpHv/JzQoC8kRySgKeM441GiizcA==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.9.tgz", + "integrity": "sha512-UZi8+YT/MLgTC9nrrn2Xd4lBYv1B7lVmtWHfPcthAI5Tt/C1LuDe6DfmtCtJ+WQod3ksY4VrKSvk3oMVAnL7qw==", "dev": true, "license": "MIT", "dependencies": { - "glob": "10.3.10" + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.0.tgz", - "integrity": "sha512-kHktLlw0AceuDnkVljJ/4lTJagLzDiO3klR1Fzl2APDFZ8r+aTxNaNcPmpp0xLMkgRwwk6sggYeqq0Rz9K4zzA==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", + "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", "cpu": [ "arm64" ], @@ -313,9 +1141,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.0.tgz", - "integrity": "sha512-HFSDu7lb1U3RDxXNeKH3NGRR5KyTPBSUTuIOr9jXoAso7i76gNYvnTjbuzGVWt2X5izpH908gmOYWtI7un+JrA==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", + "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", "cpu": [ "x64" ], @@ -329,9 +1157,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.0.tgz", - "integrity": "sha512-iQsoWziO5ZMxDWZ4ZTCAc7hbJ1C9UDj/gATSqTaMjW2bJFwAsvf9UM79AKnljBl73uPZ+V0kH4rvnHTco4Ps2w==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", + "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", "cpu": [ "arm64" ], @@ -345,9 +1173,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.0.tgz", - "integrity": "sha512-0JOk2uzLUt8fJK5LpsKKZa74zAch7bJjjgJzR9aOMs231AlE4gPYzsSm430ckZitjPGKeH5bgDZjqwqJQKIS2w==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", + "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", "cpu": [ "arm64" ], @@ -361,9 +1189,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.0.tgz", - "integrity": "sha512-uYHkuTzX0NM6biKNp7hdKTf+BF0iMV254SxO0B8PgrQkxUBKGmk5ysHKB+FYBfdf9xei/t8OIKlXJs9ckD943A==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", + "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", "cpu": [ "x64" ], @@ -377,9 +1205,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.0.tgz", - "integrity": "sha512-paN89nLs2dTBDtfXWty1/NVPit+q6ldwdktixYSVwiiAz647QDCd+EIYqoiS+/rPG3oXs/A7rWcJK9HVqfnMVg==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", + "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", "cpu": [ "x64" ], @@ -393,9 +1221,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.0.tgz", - "integrity": "sha512-j1oiidZisnymYjawFqEfeGNcE22ZQ7lGUaa4pGOCVWrWeIDkPSj8zYgS9TzMNlg17Q3wSWCQC/F5uJAhSh7qcA==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", + "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", "cpu": [ "arm64" ], @@ -408,26 +1236,10 @@ "node": ">= 10" } }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.0.tgz", - "integrity": "sha512-6ff6F4xb+QGD1jhx/dOT9Ot7PQ/GAYekV9ykwEh2EFS/cLTyU4Y3cXkX5cNtNIhpctS5NvyjW9gIksRNErYE0A==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.0.tgz", - "integrity": "sha512-09DbG5vXAxz0eTFSf1uebWD36GF3D5toynRkgo2AlSrxwGZkWtJ1RhmrczRYQ17eD5bdo4FZ0ibiffdq5kc4vg==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", + "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", "cpu": [ "x64" ], @@ -488,17 +1300,6 @@ "node": ">=12.4.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -506,27 +1307,13 @@ "dev": true, "license": "MIT" }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", - "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", - "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "license": "Apache-2.0", "dependencies": { - "@swc/counter": "^0.1.3", - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@tybys/wasm-util": { @@ -540,6 +1327,20 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -585,61 +1386,160 @@ "@types/react": "^18.0.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", - "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "7.2.0", - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/typescript-estree": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0", - "debug": "^4.3.4" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.62.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", - "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0" + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/types": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", - "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", "dev": true, "license": "MIT", "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -647,84 +1547,126 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", - "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", - "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", + "node_modules/@typescript-eslint/utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.2.0", - "eslint-visitor-keys": "^3.4.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "ISC" + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.12.2", @@ -1040,9 +1982,9 @@ ] }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -1079,16 +2021,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1190,16 +2122,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -1421,7 +2343,6 @@ "version": "2.10.34", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1501,17 +2422,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -1709,6 +2619,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1866,6 +2783,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1873,19 +2800,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -1893,19 +2807,6 @@ "dev": true, "license": "MIT" }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1921,13 +2822,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.369", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.369.tgz", @@ -2143,81 +3037,84 @@ } }, "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-next": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.0.tgz", - "integrity": "sha512-N0eQkn/wz557mIpW4JQWGEv4wGU8zvJ7emLHMS15uC18jjaU4kx6leR4U9QYT/eNghUZT7N9lBlfd8E4N0cp1w==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.9.tgz", + "integrity": "sha512-olGtBrs07bQchpaJWeqbk9GaMoU0oGmN/pYNEBXSbfgKngb5uHnPe37X6tVeh6DJfaWFQildvinGEOrolo5fmw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "14.2.0", - "@rushstack/eslint-patch": "^1.3.3", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", + "@next/eslint-plugin-next": "16.2.9", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" }, "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0", + "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "peerDependenciesMeta": { @@ -2226,6 +3123,19 @@ } } }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", @@ -2442,16 +3352,23 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.0.0-canary-7118f5dd7-20230705", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz", - "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-react/node_modules/doctrine": { @@ -2478,9 +3395,9 @@ } }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2488,7 +3405,7 @@ "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2507,19 +3424,45 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2633,16 +3576,16 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/fill-range": { @@ -2676,18 +3619,17 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { @@ -2713,23 +3655,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -2744,13 +3669,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2817,6 +3735,16 @@ "node": ">= 0.4" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2887,30 +3815,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2924,43 +3828,14 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2983,27 +3858,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3017,19 +3871,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -3124,6 +3965,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3161,25 +4019,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3379,16 +4218,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -3475,16 +4304,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -3662,25 +4481,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3720,6 +4520,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3870,11 +4683,14 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "ISC" + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -3933,16 +4749,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4004,42 +4810,41 @@ "license": "MIT" }, "node_modules/next": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.0.tgz", - "integrity": "sha512-2T41HqJdKPqheR27ll7MFZ3gtTYvGew7cUc0PwPSyK9Ao5vvwpf9bYfP4V5YBGLckHF2kEGvrLte5BqLSv0s8g==", - "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", + "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", "license": "MIT", "dependencies": { - "@next/env": "14.2.0", - "@swc/helpers": "0.5.5", - "busboy": "1.6.0", + "@next/env": "16.2.9", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "graceful-fs": "^4.2.11", "postcss": "8.4.31", - "styled-jsx": "5.1.1" + "styled-jsx": "5.1.6" }, "bin": { "next": "dist/bin/next" }, "engines": { - "node": ">=18.17.0" + "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.0", - "@next/swc-darwin-x64": "14.2.0", - "@next/swc-linux-arm64-gnu": "14.2.0", - "@next/swc-linux-arm64-musl": "14.2.0", - "@next/swc-linux-x64-gnu": "14.2.0", - "@next/swc-linux-x64-musl": "14.2.0", - "@next/swc-win32-arm64-msvc": "14.2.0", - "@next/swc-win32-ia32-msvc": "14.2.0", - "@next/swc-win32-x64-msvc": "14.2.0" + "@next/swc-darwin-arm64": "16.2.9", + "@next/swc-darwin-x64": "16.2.9", + "@next/swc-linux-arm64-gnu": "16.2.9", + "@next/swc-linux-arm64-musl": "16.2.9", + "@next/swc-linux-x64-gnu": "16.2.9", + "@next/swc-linux-x64-musl": "16.2.9", + "@next/swc-win32-arm64-msvc": "16.2.9", + "@next/swc-win32-x64-msvc": "16.2.9", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "peerDependenciesMeta": { @@ -4049,39 +4854,14 @@ "@playwright/test": { "optional": true }, + "babel-plugin-react-compiler": { + "optional": true + }, "sass": { "optional": true } } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -4264,16 +5044,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4365,16 +5135,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4392,33 +5152,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4472,7 +5205,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -4860,45 +5592,6 @@ "node": ">=0.10.0" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4991,7 +5684,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5049,6 +5742,51 @@ "node": ">= 0.4" } }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5148,29 +5886,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5201,84 +5916,6 @@ "node": ">= 0.4" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -5393,33 +6030,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -5444,9 +6054,9 @@ } }, "node_modules/styled-jsx": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", - "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", "dependencies": { "client-only": "0.0.1" @@ -5455,7 +6065,7 @@ "node": ">= 12.0.0" }, "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "peerDependenciesMeta": { "@babel/core": { @@ -5585,13 +6195,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -5677,16 +6280,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-interface-checker": { @@ -5728,19 +6331,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -5833,6 +6423,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -6069,111 +6683,10 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" }, @@ -6190,6 +6703,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index de44c2d..c21ed79 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/frontend/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/src/app/board/components.tsx b/frontend/src/app/board/components.tsx index c397150..91c01ce 100644 --- a/frontend/src/app/board/components.tsx +++ b/frontend/src/app/board/components.tsx @@ -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,42 +32,78 @@ 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}/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 ( - -

{task.theme}

-

{task.product_name ?? '—'}

-
- {task.text_count} 文 - · - {task.image_count} 图 - {new Date(task.created_at).toLocaleDateString('zh-CN')} -
- {/* 被打回任务:列内直接显示原因摘要,运营一眼看到为何被打回 */} - {task.status === 'rejected' && task.reject_reason && ( -

- {task.reject_reason} -

+
+ +

{task.theme}

+

{task.product_name ?? '—'}

+
+ {task.text_count} 文 + · + {task.image_count} 图 + {new Date(task.created_at).toLocaleDateString('zh-CN')} +
+ {/* 被打回任务:列内直接显示原因摘要,运营一眼看到为何被打回 */} + {task.status === 'rejected' && task.reject_reason && ( +

+ {task.reject_reason} +

+ )} + + {/* 删除按钮(管理员,非待审核列):浮在 Link 外层,点击不触发跳转 */} + {onDelete && !confirming && ( + )} - + {onDelete && confirming && ( +
+

确定删除这个任务?

+
+ + +
+
+ )} +
); } // --- 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 (
@@ -76,7 +116,7 @@ export function BoardColumn({ col, tasks }: { col: ColumnDef; tasks: TaskListIte {tasks.length === 0 ? (

暂无任务

) : ( - tasks.map((t) => ) + tasks.map((t) => ) )}
diff --git a/frontend/src/app/board/page.tsx b/frontend/src/app/board/page.tsx index 0ad59c8..627a924 100644 --- a/frontend/src/app/board/page.tsx +++ b/frontend/src/app/board/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [productId, setProductId] = useState('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() { +
+ + +
+ {error && (
{error} @@ -70,7 +105,13 @@ export default function BoardPage() { ) : (
{byColumn.map(({ col, items }) => ( - + ))}
)} diff --git a/frontend/src/app/change-password/page.tsx b/frontend/src/app/change-password/page.tsx new file mode 100644 index 0000000..69ea7a3 --- /dev/null +++ b/frontend/src/app/change-password/page.tsx @@ -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 ( +
+
+

+ {mustChange ? '首次登录请修改密码' : '修改密码'} +

+

+ {mustChange + ? '为了保护客户数据,初始密码只能用于首次登录。' + : '建议定期更换密码,新密码至少 8 位。'} +

+
+
+ + setCurrentPassword(e.target.value)} className={INPUT_CLS} /> +
+
+ + setNewPassword(e.target.value)} className={INPUT_CLS} /> +
+
+ + setConfirmPassword(e.target.value)} className={INPUT_CLS} /> +
+ {error &&
{error}
} + + {!mustChange && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/app/config/page.tsx b/frontend/src/app/config/page.tsx index e2d3c96..0449380 100644 --- a/frontend/src/app/config/page.tsx +++ b/frontend/src/app/config/page.tsx @@ -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() {
{activeTab === 'benchmarks' && } {activeTab === 'banned_words' && } + {activeTab === 'users' && }
); diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index c28626f..f61fc51 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -28,8 +28,9 @@ export default function DashboardPage() { )} {role === 'admin' && ( - + )} + diff --git a/frontend/src/app/history/components.tsx b/frontend/src/app/history/components.tsx index e39b43a..db49403 100644 --- a/frontend/src/app/history/components.tsx +++ b/frontend/src/app/history/components.tsx @@ -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 已下载; + if (state === 'done') return ( + + ✓ 已下载到「下载」文件夹 + + ); if (state === 'error') return 失败,刷新重试; return ( + {error && ( diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 8c20abc..ca73f97 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -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('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 (
diff --git a/frontend/src/app/tasks/[id]/confirm/page.tsx b/frontend/src/app/tasks/[id]/confirm/page.tsx index b241e24..69fe38b 100644 --- a/frontend/src/app/tasks/[id]/confirm/page.tsx +++ b/frontend/src/app/tasks/[id]/confirm/page.tsx @@ -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(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(null); + const [zoomText, setZoomText] = useState(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`); - router.push('/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`); - router.push(`/tasks/${taskId}/text`); + setFlywheelMsg('🌿 已记录你的顾虑,下次会避开'); + setTimeout(() => { + setFlywheelMsg(null); + router.push(`/tasks/${taskId}/text`); + }, 2500); } finally { setRegenerating(false); } @@ -46,27 +119,66 @@ export default function ConfirmPage() { return (
- + {/* 已通过/已打回=只读查看,不显示步骤进度条(倩倩姐2026-06-26) */} + {!isReadonly && }
-

确认预览

+
+

+ {isReadonly ? '查看笔记' : '确认预览'} +

+ {taskId && } +
+ + {/* C1: 拉取失败给重新加载入口,避免永久灰死 */} + {hydrateError && ( +
+

加载选中内容失败。

+ +
+ )} + {taskMeta?.status === 'failed' && ( +
+

⚠ 本任务生成失败

+

+ {taskMeta.failReason || '生成过程中出错,未产出可用内容。可点下方「重新生成」重试。'} +

+ +
+ )}
{/* 已选文案 */}

已选文案

- {selectedTexts.length === 0 ? ( + {hydrating ? ( +

加载中…

+ ) : selectedTexts.length === 0 ? (

暂无选中文案

) : (
{selectedTexts.map((c) => (
+ onClick={isReadonly ? () => setZoomText(c) : undefined} + className={`border border-border-default rounded-xl p-4 bg-surface-primary ${isReadonly ? 'cursor-pointer hover:border-brand-orange' : ''}`}> {c.angle_label} -

+

{c.content}

- 总分 {c.score.total} + + 总分 {c.score.total}{isReadonly ? ' · 点击看全文' : ''} +
))}
@@ -74,18 +186,55 @@ export default function ConfirmPage() {
{/* 已选图 */} - +
- {/* 操作栏 */} - 0} - onSubmit={handleSubmitReview} - onRegenerate={handleRegenerate} - /> + {/* 提审被拒:显式红框,不再静默吞错 */} + {submitError && ( +
+

{submitError}

+
+ )} + + {/* 飞轮节点反馈:提交成功后短暂绿芽提示 */} + {flywheelMsg && ( +
+ {flywheelMsg} +
+ )} + + {/* 操作栏:只读态(已通过/已打回)不提交审核,只给只读说明;否则正常确认操作 */} + {isReadonly ? ( +
+ {taskMeta?.status === 'approved' + ? '本笔记已通过审核,当前为查看模式。点击文案看全文、点击图片放大查看。' + : '本笔记已被打回,当前为查看模式。点击文案看全文、点击图片放大查看。'} +
+ ) : ( + 0 && selectedImageIds.length > 0} + onSubmit={handleSubmitReview} + onRegenerate={handleRegenerate} + /> + )}
+ + {/* 只读查看:文案全文 / 图片放大 */} + setZoomText(null)} + /> + setZoomImage(null)} + />
); } diff --git a/frontend/src/app/tasks/[id]/images/page.tsx b/frontend/src/app/tasks/[id]/images/page.tsx index 892ac74..47aedd7 100644 --- a/frontend/src/app/tasks/[id]/images/page.tsx +++ b/frontend/src/app/tasks/[id]/images/page.tsx @@ -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(null); const [regenTarget, setRegenTarget] = useState(null); + const [regenPending, setRegenPending] = useState>({}); + const [regenNotice, setRegenNotice] = useState(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() { )} + {justSavedFlywheel && ( +

+ 🌿 已记录你的选择偏好 +

+ )}
+ {taskId && ( +
+ +
+ )} + {regenNotice && ( +
+ {regenNotice} + +
+ )} + {/* #4 阶段进度:配图全程阶段(分析→文案→配图→完成) */} + {!allDone && } {imageCandidates.length === 0 ? (
@@ -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 && (
- 有 {failedBatches.length} 批没出来。 - {failedBatches.filter((b) => b.retryable).map((b) => ( - - ))} +

有 {failedBatches.length} 批没出来。

+
    + {failedBatches.map((b) => ( +
  • + {ROLE_LABEL[b.batch] || b.batch}:{b.reason || '中转站暂时无响应'} + {b.retryable && ( + + )} +
  • + ))} +
)} {imageCandidates.length > 0 && ( diff --git a/frontend/src/app/tasks/[id]/text/page.tsx b/frontend/src/app/tasks/[id]/text/page.tsx index d45d31e..ca61e20 100644 --- a/frontend/src/app/tasks/[id]/text/page.tsx +++ b/frontend/src/app/tasks/[id]/text/page.tsx @@ -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) { - await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId }); + 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 (
- {flywheelInjected && ( - - )} + {flywheelInjected ? ( + + ) : context ? ( + + ) : null}

挑文案{textTotal > 0 && 已生成 {textDone}/{textTotal} 条}

- +
+ + {taskId && } +
{/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */} - {/* SSE 进度提示(生成中且无内容时) */} - {showSkeleton && ( -
- 正在生成文案… 预计 15-30 秒,好了会更新,可先去忙别的 -
- )} + {/* B1 文案产出结果提示:完成但没出文案/补充中/评分不可用,明确告知真实原因 */} + + + {/* #3 阶段进度:替代静态"预计15-30秒",展示 分析竞品→生成文案 实时阶段 */} + {isGenerating && } {/* 文案卡:按 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() {
已选 {selectedTextIds.length} 条 + {proceedError && {proceedError}}
{/* 右侧配置预览面板 */} - {selectedProduct && } + {selectedProduct && setEditingProduct(true)} />}
{ // 首屏校验:有 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 ( diff --git a/frontend/src/components/FlywheelBanner.tsx b/frontend/src/components/FlywheelBanner.tsx index 8a15808..4d709df 100644 --- a/frontend/src/components/FlywheelBanner.tsx +++ b/frontend/src/components/FlywheelBanner.tsx @@ -11,26 +11,37 @@ 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 (
- -
- 本次已注入: - {context.recent_preference && ( - {context.recent_preference} - )} - {context.reject_reasons?.length > 0 && ( - - · 打回原因:{context.reject_reasons.slice(0, 2).join(';')} - - )} -
+ {signalCount > 0 && ( +
+ + 飞轮已积累 {signalCount} 条偏好信号,越用越懂你 +
+ )} + {(context.recent_preference || (context.reject_reasons?.length ?? 0) > 0) && ( +
+ +
+ 本次已注入: + {context.recent_preference && ( + {context.recent_preference} + )} + {context.reject_reasons?.length > 0 && ( + + · 打回原因:{context.reject_reasons.slice(0, 2).join(';')} + + )} +
+
+ )}
); } @@ -41,24 +52,34 @@ 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 (
- -
- 本次已注入: - {recentPreference && {recentPreference}} - {rejectReasons?.length > 0 && ( - - · 打回原因:{rejectReasons.slice(0, 2).join(';')} - - )} + {count > 0 && ( +
+ + 飞轮已积累 {count} 条偏好信号,越用越懂你 +
+ )} +
+ +
+ 本次已注入: + {recentPreference && {recentPreference}} + {rejectReasons?.length > 0 && ( + + · 打回原因:{rejectReasons.slice(0, 2).join(';')} + + )} +
); diff --git a/frontend/src/components/common/StageLoadingPanel.tsx b/frontend/src/components/common/StageLoadingPanel.tsx new file mode 100644 index 0000000..038f386 --- /dev/null +++ b/frontend/src/components/common/StageLoadingPanel.tsx @@ -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 | 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 ( +
+ {/* 标题行 + ETA 胶囊 */} +
+ {title} + {!allDone && startedAt && ( + + {hint || formatEta(remainSec)} + + )} + {allDone && ( + + 完成 + + )} +
+ + {/* 进度条 */} +
+
+
+ + {/* 阶段列表 */} +
    + {stages.map((s, i) => { + const isDone = i < activeIdx || allDone; + const isActive = i === activeIdx && !allDone; + return ( +
  1. + {/* 状态指示器 */} + {isDone ? ( + + ) : isActive ? ( +
  2. + ); + })} +
+ + {/* 骨架屏占位(图片未到时展示 shimmer) */} + {skeletonCount > 0 && !allDone && ( +
+ {Array.from({ length: skeletonCount }).map((_, i) => ( + + )} +
+ ); +} diff --git a/frontend/src/components/config/ApiKeysTab.tsx b/frontend/src/components/config/ApiKeysTab.tsx index 33bb89b..53350db 100644 --- a/frontend/src/components/config/ApiKeysTab.tsx +++ b/frontend/src/components/config/ApiKeysTab.tsx @@ -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({ 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(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 (

- API Key 由各人自录,余额和用量请到中转站后台查看。 + API Key 由各人自录,录一次即记住,下次登录自动带出。要换 key 点对应通道的「修改」直接覆盖。余额和用量请到中转站后台查看。

- - setForm((f) => ({ ...f, api_key: e.target.value }))} + + { 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" />
- + {error &&

{error}

} + {success &&

{success}

} {loading ? (
加载中…
) : ( @@ -84,8 +118,12 @@ export function ApiKeysTab() { {k.provider} …{k.key_last4} - + + + + ))} {keys.length === 0 && ( diff --git a/frontend/src/components/config/BenchmarkForm.tsx b/frontend/src/components/config/BenchmarkForm.tsx index b631b04..665587a 100644 --- a/frontend/src/components/config/BenchmarkForm.tsx +++ b/frontend/src/components/config/BenchmarkForm.tsx @@ -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({ - screenshot_url: '', highlights: '', link_url: null, - }); + const [linkUrl, setLinkUrl] = useState(''); + const [highlights, setHighlights] = useState(''); + const [screenshot, setScreenshot] = useState(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">

上传标杆笔记

- - setForm((f) => ({ ...f, screenshot_url: e.target.value }))} - placeholder="https://..." + + 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" /> +

用于保留来源,不再当截图读取,长链接也可以保存。

- -