将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
110 lines
4.1 KiB
JavaScript
110 lines
4.1 KiB
JavaScript
'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 };
|