baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

109
test/aiClient.js Normal file
View File

@@ -0,0 +1,109 @@
'use strict';
// Clover 图片质量测试 · codeproxy 调用核心
// 复用自 xhs-tool/backend/infrastructure/aiClient.js已实测跑通的对的路简化去降级、去sharp
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '.env') });
const FormData = require('form-data');
const https = require('https');
const fs = require('fs');
const KEY = process.env.AI_API_KEY;
const BASE_URL = process.env.AI_BASE_URL;
const MODEL_IMAGE = process.env.MODEL_IMAGE || 'gpt-image-2';
const IMAGE_TIMEOUT = 300000;
if (!KEY) throw new Error('AI_API_KEY 未配置(见 test/.env');
const sleep = ms => new Promise(r => setTimeout(r, ms));
function toBuffer(img) {
if (Buffer.isBuffer(img)) return img;
if (typeof img === 'string' && img.startsWith('data:'))
return Buffer.from(img.replace(/^data:[^;]+;base64,/, ''), 'base64');
return fs.readFileSync(img); // 本地文件路径
}
// 单次请求POST /images/edits (multipart)
function postEditOnce({ imgList, prompt, m, sz }) {
const form = new FormData();
form.append('model', m);
form.append('prompt', prompt);
form.append('size', sz);
form.append('n', '1');
for (let i = 0; i < imgList.length; i++) {
const buf = toBuffer(imgList[i]);
form.append('image[]', buf, { filename: `img_${i}.jpg`, contentType: 'image/jpeg' });
}
return new Promise((resolve, reject) => {
const ac = new AbortController();
const timer = setTimeout(() => { ac.abort(); req.destroy(new Error('300s timeout')); }, IMAGE_TIMEOUT);
const req = https.request(`${BASE_URL}/images/edits`, {
method: 'POST',
headers: { ...form.getHeaders(), Authorization: `Bearer ${KEY}` },
signal: ac.signal,
}, (r) => {
let body = '';
r.on('data', d => body += d);
r.on('end', () => { clearTimeout(timer); resolve({ status: r.statusCode, body }); });
r.on('error', reject);
});
req.on('error', (e) => { clearTimeout(timer); reject(e); });
form.pipe(req);
});
}
// 可重试错误:上游/网关临时故障 + 限流 + 超时
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
// 带参考图出图:失败自动退避重试(复用 xhs-tool 的重试思路)
async function imageEdit({ images, prompt, traceId = 't', model, size, maxRetry = 3 }) {
const m = model || MODEL_IMAGE;
const sz = size || process.env.IMAGE_SIZE || '1024x1536';
const imgList = Array.isArray(images) ? images : [images];
let lastErr;
for (let attempt = 1; attempt <= maxRetry; attempt++) {
const t0 = Date.now();
try {
const res = await postEditOnce({ imgList, prompt, m, sz });
console.log(`[imageEdit][${traceId}] try=${attempt}/${maxRetry} model=${m} size=${sz} imgs=${imgList.length} status=${res.status} ms=${Date.now()-t0}`);
if (res.status === 200) {
const d = JSON.parse(res.body).data[0];
if (d.url) return { type: 'url', value: d.url };
if (d.b64_json) return { type: 'b64', value: d.b64_json };
throw new Error('返回既无 url 也无 b64_json');
}
lastErr = new Error(`HTTP ${res.status}: ${res.body.slice(0, 300)}`);
if (!RETRYABLE.has(res.status)) throw lastErr; // 401/404等不重试直接抛
} catch (e) {
lastErr = e;
const code = (e.message.match(/HTTP (\d+)/) || [])[1];
if (code && !RETRYABLE.has(Number(code))) throw e;
}
if (attempt < maxRetry) {
const wait = 3000 * attempt; // 3s,6s 退避
console.log(` ↻ 上游临时故障,${wait/1000}s 后重试…`);
await sleep(wait);
}
}
throw lastErr;
}
// 保存结果到文件url则下载b64则解码
async function saveResult(result, outPath) {
if (result.type === 'b64') {
fs.writeFileSync(outPath, Buffer.from(result.value, 'base64'));
return outPath;
}
// url下载
await new Promise((resolve, reject) => {
https.get(result.value, (r) => {
const chunks = [];
r.on('data', c => chunks.push(c));
r.on('end', () => { fs.writeFileSync(outPath, Buffer.concat(chunks)); resolve(); });
r.on('error', reject);
}).on('error', reject);
});
return outPath;
}
module.exports = { imageEdit, saveResult, toBuffer, sleep };