baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
103
backend/scripts/real_image_check.py
Normal file
103
backend/scripts/real_image_check.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""真实出图验证(正式留存版,不带_tmp,勿当临时文件清理)
|
||||
用法: cd Clover/backend && python3 scripts/real_image_check.py
|
||||
key 只从 ../.env 读,不硬编码不打印明文。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio, base64, io, logging, os, sys, time
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
_env = ROOT / ".env"
|
||||
if _env.exists():
|
||||
for _l in _env.read_text().splitlines():
|
||||
_l = _l.strip()
|
||||
if _l and not _l.startswith("#") and "=" in _l:
|
||||
k, _, v = _l.partition("=")
|
||||
os.environ.setdefault(k.strip(), v.strip())
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("img")
|
||||
|
||||
TEST_PROMPT = "\n".join([
|
||||
"生成一张小红书可上传的独立竖版3:4图文海报,目标1024x1536,不是App截图。",
|
||||
"产品:倍分子素颜霜(美妆护肤·素颜霜)。",
|
||||
"画面角色:封面-钩子。画面主体:自然光生活场景,手持素颜霜或产品在桌面前景,像iPhone实拍封面。",
|
||||
"图上文字:主标题「黄黑皮逆袭|伪素颜天花板」,点位「黄黑皮也能拥有的妈生好皮」。",
|
||||
"核心卖点:黄黑皮提亮、伪素颜自然、不假白不卡纹、洗面奶就能卸、懒人必备。",
|
||||
"成分:烟酰胺(改善暗沉)、水解珍珠(锁水保湿)、角鲨烷(不卡纹)。",
|
||||
"视觉风格:小红书种草风,明亮干净,暖色调,模拟iPhone主摄浅景深。",
|
||||
"重要限制:禁止生成App截图/界面元素;禁止肤色变白/功效前后对比;"
|
||||
"避免美白/祛斑/速效/医用等违规词;必须保持产品包装与参考图一致。",
|
||||
])
|
||||
|
||||
_REF = ROOT / "test/output/01_连通测试.png"
|
||||
|
||||
|
||||
async def _apiports(key, url, ref, model, size):
|
||||
payload = {"model": model, "prompt": TEST_PROMPT, "n": 1, "size": size,
|
||||
"image": base64.b64encode(ref).decode()}
|
||||
async with httpx.AsyncClient(timeout=300.0) as c:
|
||||
r = await c.post(url, json=payload, headers={"Authorization": f"Bearer {key}"})
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:200]}")
|
||||
return _parse(r.json())
|
||||
|
||||
|
||||
async def _codeproxy(key, base, ref, model, size):
|
||||
files = [("model", (None, model)), ("prompt", (None, TEST_PROMPT)),
|
||||
("size", (None, size)), ("n", (None, "1")),
|
||||
("image[]", ("ref.png", io.BytesIO(ref), "image/png"))]
|
||||
async with httpx.AsyncClient(timeout=300.0) as c:
|
||||
r = await c.post(f"{base}/images/edits", files=files,
|
||||
headers={"Authorization": f"Bearer {key}"})
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:200]}")
|
||||
return _parse(r.json())
|
||||
|
||||
|
||||
def _parse(j):
|
||||
item = (j.get("data") or [{}])[0]
|
||||
if "b64_json" in item:
|
||||
return base64.b64decode(item["b64_json"])
|
||||
if "url" in item:
|
||||
rr = httpx.get(item["url"], timeout=30.0); rr.raise_for_status()
|
||||
return rr.content
|
||||
raise ValueError(f"无法解析: {list(item.keys())}")
|
||||
|
||||
|
||||
async def run():
|
||||
model = os.environ.get("MODEL_IMAGE", "gpt-image-2")
|
||||
size = os.environ.get("IMAGE_SIZE", "1024x1536")
|
||||
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "apiports").lower()
|
||||
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "codeproxy").lower()
|
||||
out_dir = Path(__file__).parent.parent / "verify_output"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
ref = _REF.read_bytes()
|
||||
t0 = time.time(); img = None; used = None
|
||||
for prov in [primary, fallback]:
|
||||
key = os.environ.get(f"{prov.upper()}_KEY", "")
|
||||
url = os.environ.get(f"{prov.upper()}_BASE_URL", "")
|
||||
if not key or not url:
|
||||
continue
|
||||
logger.info("[%s] 出图中 model=%s size=%s", prov, model, size)
|
||||
try:
|
||||
img = await (_apiports(key, url, ref, model, size) if prov == "apiports"
|
||||
else _codeproxy(key, url, ref, model, size))
|
||||
used = prov; break
|
||||
except Exception as e:
|
||||
logger.error("[%s] 失败: %s", prov, e)
|
||||
dt = time.time() - t0
|
||||
print("\n===== 出图结果 =====")
|
||||
if img:
|
||||
out = out_dir / "素颜霜_封面hook.png"
|
||||
out.write_bytes(img)
|
||||
print(f" provider: {used}\n 耗时: {dt:.1f}s\n 大小: {len(img)//1024}KB\n 路径: {out}")
|
||||
else:
|
||||
print(f" 耗时: {dt:.1f}s\n 状态: 全失败")
|
||||
print("====================\n")
|
||||
return img is not None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if asyncio.run(run()) else 1)
|
||||
13
backend/scripts/run_log.txt
Normal file
13
backend/scripts/run_log.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
2026-06-09 11:39:33,251 INFO [apiports] 出图中 model=gpt-image-2 size=1024x1536
|
||||
2026-06-09 11:41:04,233 INFO HTTP Request: POST https://www.apiports.com/v1/api/generate "HTTP/1.1 200 OK"
|
||||
2026-06-09 11:41:04,236 ERROR [apiports] 失败: 无法解析: []
|
||||
2026-06-09 11:41:04,236 INFO [codeproxy] 出图中 model=gpt-image-2 size=1024x1536
|
||||
2026-06-09 11:42:33,485 INFO HTTP Request: POST https://codeproxy.dev/v1/images/edits "HTTP/1.1 200 OK"
|
||||
|
||||
===== 出图结果 =====
|
||||
provider: codeproxy
|
||||
耗时: 181.2s
|
||||
大小: 2009KB
|
||||
路径: /Users/qiyu/Documents/企业培训项目/万牛会L1准备/北哥小红书产品/Clover/backend/verify_output/素颜霜_封面hook.png
|
||||
====================
|
||||
|
||||
156
backend/scripts/seed_data.py
Normal file
156
backend/scripts/seed_data.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
scripts/seed_data.py — 初始化北哥 workspace 种子数据
|
||||
建3账号(admin/supervisor/operator) + workspace_members + 预置素颜霜 + 5条违禁词
|
||||
幂等:已存在则跳过,可重复跑。
|
||||
用法:cd backend && python3 scripts/seed_data.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 把 backend/ 加入 path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.core.config import get_settings # noqa: E402 — path must be set first
|
||||
from app.core.database import SessionLocal # noqa: E402
|
||||
from app.models.user import User # noqa: E402
|
||||
from app.models.workspace import Workspace, WorkspaceMember # noqa: E402
|
||||
from app.models.product import Product, BannedWord # noqa: E402
|
||||
from app.services.auth_service import hash_password # noqa: E402
|
||||
|
||||
get_settings() # 触发 .env 校验,缺变量早报错
|
||||
|
||||
|
||||
WORKSPACE_NAME = "北哥小红书车间"
|
||||
WORKSPACE_SLUG = "beige-xhs"
|
||||
|
||||
USERS = [
|
||||
{"username": "admin", "email": "admin@clover.local", "password": "Clover2026!", "role": "admin"},
|
||||
{"username": "supervisor", "email": "supervisor@clover.local", "password": "Clover2026!", "role": "supervisor"},
|
||||
{"username": "operator", "email": "operator@clover.local", "password": "Clover2026!", "role": "operator"},
|
||||
]
|
||||
|
||||
PRODUCT = {
|
||||
"name": "素颜霜(预置样本)",
|
||||
"category": "素颜霜",
|
||||
"source": "preset",
|
||||
"selling_points": json.dumps(["遮瑕提亮", "持妆", "养肤成分"], ensure_ascii=False),
|
||||
"style_tone": "素人分享、真实不浮夸",
|
||||
"text_angles": json.dumps(["痛点切入", "成分党", "使用场景", "质地肤感", "平价替代"], ensure_ascii=False),
|
||||
"custom_prompt": None,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
BANNED_WORDS = [
|
||||
{"word": "美白", "level": "auto_fix", "replacement": "提亮肤色"},
|
||||
{"word": "祛斑", "level": "auto_fix", "replacement": "改善暗沉"},
|
||||
{"word": "速效", "level": "soft_warn", "replacement": "温和渐进"},
|
||||
{"word": "医用", "level": "hard_block", "replacement": None},
|
||||
{"word": "药妆", "level": "hard_block", "replacement": None},
|
||||
]
|
||||
|
||||
|
||||
def run():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# ── workspace ──────────────────────────────────────
|
||||
ws = db.query(Workspace).filter(Workspace.slug == WORKSPACE_SLUG).first()
|
||||
if not ws:
|
||||
ws = Workspace(name=WORKSPACE_NAME, slug=WORKSPACE_SLUG, is_active=True)
|
||||
db.add(ws)
|
||||
db.flush()
|
||||
print(f"[+] workspace 已建: {ws.name} (id={ws.id})")
|
||||
else:
|
||||
print(f"[=] workspace 已存在: id={ws.id}")
|
||||
|
||||
# ── users + members ────────────────────────────────
|
||||
for u_spec in USERS:
|
||||
user = db.query(User).filter(User.username == u_spec["username"]).first()
|
||||
if not user:
|
||||
user = User(
|
||||
username=u_spec["username"],
|
||||
email=u_spec["email"],
|
||||
hashed_password=hash_password(u_spec["password"]),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
print(f"[+] user: {user.username}")
|
||||
else:
|
||||
print(f"[=] user 已存在: {user.username}")
|
||||
|
||||
mem = db.query(WorkspaceMember).filter(
|
||||
WorkspaceMember.workspace_id == ws.id,
|
||||
WorkspaceMember.user_id == user.id,
|
||||
).first()
|
||||
if not mem:
|
||||
db.add(WorkspaceMember(workspace_id=ws.id, user_id=user.id, role=u_spec["role"]))
|
||||
print(f" -> 绑定 role={u_spec['role']}")
|
||||
|
||||
# ── 预置素颜霜 ─────────────────────────────────────
|
||||
prod = db.query(Product).filter(
|
||||
Product.workspace_id == ws.id, Product.name == PRODUCT["name"]
|
||||
).first()
|
||||
if not prod:
|
||||
prod = Product(workspace_id=ws.id, **PRODUCT)
|
||||
db.add(prod)
|
||||
print(f"[+] product: {prod.name}")
|
||||
else:
|
||||
print(f"[=] product 已存在: {prod.name}")
|
||||
|
||||
# ── 违禁词 ─────────────────────────────────────────
|
||||
for bw_spec in BANNED_WORDS:
|
||||
existing = db.query(BannedWord).filter(
|
||||
BannedWord.workspace_id == ws.id, BannedWord.word == bw_spec["word"]
|
||||
).first()
|
||||
if not existing:
|
||||
db.add(BannedWord(workspace_id=ws.id, **bw_spec))
|
||||
print(f"[+] banned_word: {bw_spec['word']} ({bw_spec['level']})")
|
||||
else:
|
||||
print(f"[=] banned_word 已存在: {bw_spec['word']}")
|
||||
|
||||
# ── user_api_keys(R3:明文key只在加密瞬间用,不打印不落明文)──
|
||||
apiports_key = os.environ.get("APIPORTS_KEY", "").strip()
|
||||
if apiports_key:
|
||||
from app.models.workspace import UserApiKey
|
||||
from app.utils.fernet_utils import encrypt_key
|
||||
for u_spec in USERS:
|
||||
u = db.query(User).filter(User.username == u_spec["username"]).first()
|
||||
if not u:
|
||||
continue
|
||||
existing_key = db.query(UserApiKey).filter(
|
||||
UserApiKey.user_id == u.id,
|
||||
UserApiKey.workspace_id == ws.id,
|
||||
UserApiKey.provider == "apiports",
|
||||
).first()
|
||||
encrypted = encrypt_key(apiports_key)
|
||||
last4 = apiports_key[-4:] if len(apiports_key) >= 4 else "****"
|
||||
if existing_key:
|
||||
existing_key.encrypted_key = encrypted
|
||||
existing_key.key_last4 = last4
|
||||
print(f"[=] api_key updated: {u.username} apiports ***{last4}")
|
||||
else:
|
||||
db.add(UserApiKey(
|
||||
user_id=u.id,
|
||||
workspace_id=ws.id,
|
||||
provider="apiports",
|
||||
encrypted_key=encrypted,
|
||||
key_last4=last4,
|
||||
))
|
||||
print(f"[+] api_key: {u.username} apiports ***{last4}")
|
||||
apiports_key = None # 明文用完即清(基石B)
|
||||
else:
|
||||
print("[!] APIPORTS_KEY 未设置,跳过 user_api_keys 录入(部署时需手动录入或重跑种子)")
|
||||
|
||||
db.commit()
|
||||
print("\n种子数据初始化完成。")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"[ERROR] {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
93
backend/scripts/verify_real_text_gen.py
Normal file
93
backend/scripts/verify_real_text_gen.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
scripts/verify_real_text_gen.py — 真实文案生成验收脚本
|
||||
调 LLM 用素颜霜产品数据生成3条,原文打印给北哥人工核查。
|
||||
用法:cd backend && python3 scripts/verify_real_text_gen.py
|
||||
读取 .env 里的 CODEPROXY_BASE_URL + CODEPROXY_KEY(中转站)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# 加载 .env
|
||||
env_path = Path(__file__).parent.parent / ".env"
|
||||
load_dotenv(env_path)
|
||||
|
||||
from app.services.ai_engine._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array
|
||||
|
||||
# 素颜霜产品数据(方法验证用,不依赖 DB)
|
||||
PRODUCT = {
|
||||
"name": "素颜霜",
|
||||
"category": "美妆护肤",
|
||||
"selling_points": ["遮瑕提亮", "持妆全天", "养肤成分温和"],
|
||||
"style_tone": "素人分享、真实不浮夸",
|
||||
"text_angles": ["痛点切入", "成分党", "使用场景", "质地肤感", "平价替代"],
|
||||
"brand_keyword": "XX素颜霜",
|
||||
"custom_prompt": "",
|
||||
}
|
||||
|
||||
COUNT = 3
|
||||
|
||||
|
||||
def main():
|
||||
# 主通道 apiports(实测真实可用,模型见 /v1/models)
|
||||
base_url = (os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
|
||||
api_key = os.environ.get("APIPORTS_KEY", "")
|
||||
# apiports 无 gpt-4o-mini,文案用 claude-sonnet-4-5(中文质量好)
|
||||
model = os.environ.get("MODEL_TEXT", "claude-sonnet-4-5")
|
||||
|
||||
if not base_url or not api_key:
|
||||
print("[ERROR] .env 缺少 APIPORTS_BASE_URL 或 APIPORTS_KEY")
|
||||
sys.exit(1)
|
||||
|
||||
user_prompt = build_prompt(PRODUCT, COUNT)
|
||||
|
||||
print("=" * 60)
|
||||
print("SYSTEM PROMPT(前300字):")
|
||||
print(COPY_SYSTEM[:300], "...")
|
||||
print("=" * 60)
|
||||
print("USER PROMPT(完整):")
|
||||
print(user_prompt)
|
||||
print("=" * 60)
|
||||
print(f"调用 {base_url} 模型={model} 生成 {COUNT} 条文案...")
|
||||
|
||||
resp = httpx.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": COPY_SYSTEM},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"temperature": 0.9,
|
||||
"max_tokens": 3000,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
raw = resp.json()["choices"][0]["message"]["content"]
|
||||
print("\n===== 原始输出(供北哥核查)=====")
|
||||
print(raw)
|
||||
|
||||
copies = parse_json_array(raw)
|
||||
if copies:
|
||||
print(f"\n===== 解析成功,共 {len(copies)} 条 =====")
|
||||
for i, c in enumerate(copies):
|
||||
print(f"\n--- 第{i+1}条 [{c.get('angle','')}] ---")
|
||||
print(f"标题:{c.get('title')}")
|
||||
print(f"正文({len(c.get('content',''))}字):\n{c.get('content')}")
|
||||
print(f"标签:{c.get('tags')}")
|
||||
print(f"封面大字:{c.get('coverTitle')}")
|
||||
else:
|
||||
print("\n[WARN] JSON 解析失败,请人工看原始输出")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user