跟新需求文档
This commit is contained in:
+112
-26
@@ -1,7 +1,7 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
巨子测肤 · 1'me — 后端服务(零依赖,Node ≥ 18)
|
||||
1) 静态托管 h5/ 目录
|
||||
2) POST /api/skin-analyze:图片中转 Face++ 高阶版测肤
|
||||
2) POST /api/skin-analyze:三视图(正/左/右)中转 Face++ 专业版测肤
|
||||
密钥只存在服务端(SKIN_API_KEY / SKIN_API_SECRET),
|
||||
前端永远接触不到 api_secret。
|
||||
启动: node server.js (默认 8080,可用 PORT 环境变量改)
|
||||
@@ -14,9 +14,9 @@ const path = require('path');
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8080;
|
||||
const ROOT = __dirname;
|
||||
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_advanced';
|
||||
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro';
|
||||
|
||||
/* ─── 密钥加载:环境变量优先,其次解析项目根的 apikey.bash ─── */
|
||||
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
|
||||
function loadCredentials() {
|
||||
if (process.env.SKIN_API_KEY && process.env.SKIN_API_SECRET) {
|
||||
return { key: process.env.SKIN_API_KEY, secret: process.env.SKIN_API_SECRET };
|
||||
@@ -26,9 +26,16 @@ function loadCredentials() {
|
||||
const key = (txt.match(/SKIN_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
|
||||
const secret = (txt.match(/SKIN_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
|
||||
if (key && secret) return { key, secret };
|
||||
} catch (e) { /* 文件不存在则继续下一级兜底 */ }
|
||||
try {
|
||||
const txt = fs.readFileSync(path.join(ROOT, '..', 'reference', 'facepp-skinanalyze', 'credentials.env'), 'utf8');
|
||||
const key = (txt.match(/FACEPP_API_KEY\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
|
||||
const secret = (txt.match(/FACEPP_API_SECRET\s*=\s*["']?([A-Za-z0-9_-]+)/) || [])[1];
|
||||
if (key && secret) return { key, secret };
|
||||
} catch (e) { /* 文件不存在则走下面的报错 */ }
|
||||
console.error('[启动失败] 找不到密钥:请设置 SKIN_API_KEY / SKIN_API_SECRET 环境变量,');
|
||||
console.error(' 或在项目根目录放置 apikey.bash(内容为 export SKIN_API_KEY=... 等)');
|
||||
console.error(' 或在项目根目录放置 apikey.bash(内容为 export SKIN_API_KEY=... 等),');
|
||||
console.error(' 或在 reference/facepp-skinanalyze/credentials.env 放置 FACEPP_API_KEY=... 等');
|
||||
process.exit(1);
|
||||
}
|
||||
const CRED = loadCredentials();
|
||||
@@ -41,7 +48,13 @@ const MIME = {
|
||||
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2'
|
||||
};
|
||||
function serveStatic(req, res, pathname) {
|
||||
let p = decodeURIComponent(pathname);
|
||||
let p;
|
||||
try {
|
||||
p = decodeURIComponent(pathname);
|
||||
} catch (e) { // 畸形编码(如 /%、/%zz)会抛 URIError,返回 400 而不是打挂进程
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
return res.end('Bad Request');
|
||||
}
|
||||
if (p === '/') p = '/index.html';
|
||||
const file = path.normalize(path.join(ROOT, p));
|
||||
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) { res.writeHead(403); return res.end('Forbidden'); }
|
||||
@@ -63,38 +76,93 @@ const ERR_MSG = {
|
||||
'AUTHENTICATION_ERROR': '检测服务配置有误,请联系管理员',
|
||||
'AUTHORIZATION_ERROR': '检测服务额度或权限异常,请联系管理员',
|
||||
'CONCURRENCY_LIMIT_EXCEEDED': '当前检测人数较多,请稍候重试',
|
||||
'INTERNAL_ERROR': '检测服务开小差了,请稍候重试'
|
||||
'INTERNAL_ERROR': '检测服务开小差了,请稍候重试',
|
||||
'NETWORK_ERROR': '网络异常,请检查网络后重试'
|
||||
};
|
||||
|
||||
function readBody(req, limitBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0; const chunks = [];
|
||||
let size = 0, tooLarge = false; const chunks = [];
|
||||
req.on('data', c => {
|
||||
size += c.length;
|
||||
if (size > limitBytes) { reject(new Error('TOO_LARGE')); req.destroy(); return; }
|
||||
if (size > limitBytes) {
|
||||
// 不 destroy:否则 catch 里的 JSON 响应永远发不出去;只 reject 并丢弃后续 chunk(排空)
|
||||
if (!tooLarge) { tooLarge = true; reject(new Error('TOO_LARGE')); }
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('end', () => { if (!tooLarge) resolve(Buffer.concat(chunks)); });
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
/* 剥离 data:image/xxx;base64, 前缀,空值返回 '' */
|
||||
function stripDataPrefix(s) {
|
||||
return (s || '').replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, '');
|
||||
}
|
||||
|
||||
/* base64 可用性校验:字符集合法且解码后 >500B(过小人脸框必然不合格,提前拦截避免白烧次数包) */
|
||||
function isUsableB64(b64) {
|
||||
if (!b64 || !/^[A-Za-z0-9+/=\s]+$/.test(b64)) return false;
|
||||
return Buffer.from(b64, 'base64').length > 500;
|
||||
}
|
||||
|
||||
/* 调用 Face++ 专业版三视图测肤;
|
||||
* 接口超时/网络错误(fetch 抛错)、HTTP 5xx、CONCURRENCY_LIMIT_EXCEEDED → 等 4s 重试 1 次(V2.0 第七章) */
|
||||
async function callFacepp(front, left, right) {
|
||||
const form = new FormData();
|
||||
form.append('api_key', CRED.key);
|
||||
form.append('api_secret', CRED.secret);
|
||||
// 专业版三视图必须走文件形式(image_file),不支持 image_base64
|
||||
form.append('image_file', new Blob([Buffer.from(front, 'base64')]), 'front.jpg');
|
||||
if (left) form.append('left_side_image_file', new Blob([Buffer.from(left, 'base64')]), 'left.jpg');
|
||||
if (right) form.append('right_side_image_file', new Blob([Buffer.from(right, 'base64')]), 'right.jpg');
|
||||
form.append('return_side_results', 'jawline_info');
|
||||
form.append('return_maps', 'water_area,red_area,brown_area,texture_enhanced_pores,texture_enhanced_blackheads,texture_enhanced_oily_area,texture_enhanced_lines,rough_area,roi_outline_map,texture_enhanced_bw');
|
||||
form.append('return_marks', 'wrinkle_mark,melanin_mark,dark_circle_outline');
|
||||
|
||||
let fetchErr = null, fpRes = null, fpJson = null;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
fetchErr = null; fpRes = null; fpJson = null;
|
||||
try {
|
||||
fpRes = await fetch(FACEPP_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(60000) });
|
||||
fpJson = await fpRes.json().catch(() => ({}));
|
||||
} catch (e) { // 60s 超时(AbortError)/网络异常:无 fpJson,与 5xx/并发超限统一走「等 4s 重试 1 次」
|
||||
fetchErr = e;
|
||||
}
|
||||
const isConcurrency = !fetchErr && typeof fpJson.error_message === 'string' && fpJson.error_message.startsWith('CONCURRENCY_LIMIT_EXCEEDED');
|
||||
const is5xx = !fetchErr && fpRes && !fpRes.ok && fpRes.status >= 500;
|
||||
if (!fetchErr && !is5xx && !isConcurrency) return fpJson; // 正常响应(含 4xx 业务错误)直接返回
|
||||
if (attempt === 0) {
|
||||
const why = fetchErr ? '请求超时/网络异常' : (is5xx ? `HTTP ${fpRes.status}` : '并发超限');
|
||||
console.log(`[analyze] Face++ ${why},4s 后重试 1 次`);
|
||||
await sleep(4000);
|
||||
}
|
||||
}
|
||||
// 重试后仍失败:fetch 抛错无 fpJson,合成 NETWORK_ERROR 走现有错误响应;5xx 则返回最后一次响应
|
||||
return fetchErr ? { error_message: 'NETWORK_ERROR' } : fpJson;
|
||||
}
|
||||
|
||||
async function handleAnalyze(req, res) {
|
||||
try {
|
||||
const body = JSON.parse((await readBody(req, 12 * 1024 * 1024)).toString('utf8'));
|
||||
const b64 = (body.image_base64 || '').replace(/^data:image\/\w+;base64,/, '');
|
||||
if (!b64) { res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, code: 'NO_IMAGE', message: '缺少图片' })); }
|
||||
const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
|
||||
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
|
||||
const front = stripDataPrefix(typeof body.front_image === 'string' ? body.front_image : '');
|
||||
const leftB64 = stripDataPrefix(typeof body.left_image === 'string' ? body.left_image : '');
|
||||
const rightB64 = stripDataPrefix(typeof body.right_image === 'string' ? body.right_image : '');
|
||||
// 左/右图校验失败按未传处理(降级单视图),不报错
|
||||
const left = isUsableB64(leftB64) ? leftB64 : '';
|
||||
const right = isUsableB64(rightB64) ? rightB64 : '';
|
||||
if (!front) { res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, code: 'NO_IMAGE', message: '缺少图片' })); }
|
||||
if (!isUsableB64(front)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify({ ok: false, code: 'INVALID_IMAGE_SIZE', message: '照片数据异常,请重试' }));
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('api_key', CRED.key);
|
||||
form.append('api_secret', CRED.secret);
|
||||
form.append('image_base64', b64);
|
||||
form.append('face_quality_control', '0');
|
||||
form.append('return_rect_confidence', '1');
|
||||
form.append('return_maps', 'red_area');
|
||||
|
||||
const fpRes = await fetch(FACEPP_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(30000) });
|
||||
const fpJson = await fpRes.json().catch(() => ({}));
|
||||
const fpJson = await callFacepp(front, left, right);
|
||||
|
||||
if (fpJson.error_message) {
|
||||
const code = fpJson.error_message.split(':')[0];
|
||||
@@ -106,13 +174,31 @@ async function handleAnalyze(req, res) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
return res.end(JSON.stringify({ ok: false, code: 'INTERNAL_ERROR', message: ERR_MSG.INTERNAL_ERROR }));
|
||||
}
|
||||
// 只回传前端需要的字段,不透传密钥相关内容
|
||||
const result = fpJson.result;
|
||||
// face_maps 是整幅 base64 叠加图,体积过大且报告页暂不使用;
|
||||
// 这里只保留其键名列表,便于前端/调用方知晓可用图层
|
||||
if (result.face_maps && typeof result.face_maps === 'object') {
|
||||
result.face_maps = Object.keys(result.face_maps);
|
||||
}
|
||||
// 下颌线结果在响应顶层 left_side_result/right_side_result(不在 result 内),
|
||||
// 个别批次可能嵌套在 side_result 下,两种层级都兼容
|
||||
const nested = fpJson.side_result || {};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({ ok: true, result: fpJson.result, face_rectangle: fpJson.face_rectangle || null }));
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
result,
|
||||
face_rectangle: fpJson.face_rectangle || null,
|
||||
side: {
|
||||
left: fpJson.left_side_result || nested.left_side_result || null,
|
||||
right: fpJson.right_side_result || nested.right_side_result || null
|
||||
}
|
||||
}));
|
||||
} catch (e) {
|
||||
const code = e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR';
|
||||
const tooLarge = e.message === 'TOO_LARGE';
|
||||
const code = tooLarge ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR';
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({ ok: false, code, message: code === 'NETWORK_ERROR' ? '网络异常,请检查网络后重试' : ERR_MSG[code] }));
|
||||
// 响应发出后再销毁底层 socket:超大请求体客户端可能仍在推送,直接断开避免连接悬挂
|
||||
res.end(JSON.stringify({ ok: false, code, message: ERR_MSG[code] }), () => { if (tooLarge) req.destroy(); });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user