/* ═══════════════════════════════════════════════════════════ 巨子测肤 · 1'me — 后端服务(零依赖,Node ≥ 18) 1) 静态托管 h5/ 目录 2) POST /api/skin-analyze:三视图(正/左/右)中转 Face++ 专业版测肤 密钥只存在服务端(SKIN_API_KEY / SKIN_API_SECRET), 前端永远接触不到 api_secret。 启动: node server.js (默认 8080,可用 PORT 环境变量改) ═══════════════════════════════════════════════════════════ */ 'use strict'; const http = require('http'); const fs = require('fs'); 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_pro'; const DATA_DIR = path.join(ROOT, 'data', 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore) /* ─── 密钥加载:环境变量优先,其次 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 }; } try { const txt = fs.readFileSync(path.join(ROOT, '..', 'apikey.bash'), 'utf8'); 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(' 或在 reference/facepp-skinanalyze/credentials.env 放置 FACEPP_API_KEY=... 等'); process.exit(1); } const CRED = loadCredentials(); /* ─── 静态文件 ─── */ const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2' }; function serveStatic(req, res, 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'); } fs.readFile(file, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); } res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' }); res.end(data); }); } /* ─── Face++ 错误 → 用户可读提示 ─── */ const ERR_MSG = { 'NO_FACE_FOUND': '未检测到人脸,请正对镜头、露出完整五官后重试', 'INVALID_IMAGE_FACE': '照片中人脸不完整或检测到多张人脸,请单人正脸出镜', 'INVALID_FACE_SIZE': '距离太远啦,请靠近镜头一些', 'INVALID_IMAGE_SIZE': '照片尺寸不符合要求,请重试', 'IMAGE_FILE_TOO_LARGE': '照片过大,请重试', 'IMAGE_ERROR_UNSUPPORTED_FORMAT': '照片格式不支持,请重试', 'AUTHENTICATION_ERROR': '检测服务配置有误,请联系管理员', 'AUTHORIZATION_ERROR': '检测服务额度或权限异常,请联系管理员', 'CONCURRENCY_LIMIT_EXCEEDED': '当前检测人数较多,请稍候重试', 'INTERNAL_ERROR': '检测服务开小差了,请稍候重试', 'NETWORK_ERROR': '网络异常,请检查网络后重试' }; function readBody(req, limitBytes) { return new Promise((resolve, reject) => { let size = 0, tooLarge = false; const chunks = []; req.on('data', c => { size += c.length; if (size > limitBytes) { // 不 destroy:否则 catch 里的 JSON 响应永远发不出去;只 reject 并丢弃后续 chunk(排空) if (!tooLarge) { tooLarge = true; reject(new Error('TOO_LARGE')); } return; } chunks.push(c); }); req.on('end', () => { if (!tooLarge) resolve(Buffer.concat(chunks)); }); req.on('error', reject); }); } const sleep = ms => new Promise(r => setTimeout(r, ms)); /* ─── 报告页图层白名单:face_maps 只透传这几层,一行增删 ─── */ const REPORT_MAPS = ['red_area', 'brown_area', 'texture_enhanced_lines', 'texture_enhanced_pores', 'texture_enhanced_blackheads']; /* 按 magic bytes 判断图片格式并补 data: URL 前缀(实测 red_area/brown_area 为 jpeg 头、其余 png,不硬编码) */ function toDataUrl(b64) { const head = Buffer.from(b64.slice(0, 16), 'base64'); const mime = (head[0] === 0xFF && head[1] === 0xD8) ? 'image/jpeg' : 'image/png'; return 'data:' + mime + ';base64,' + b64; } /* 剥离 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, 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 fpJson = await callFacepp(front, left, right); if (fpJson.error_message) { const code = fpJson.error_message.split(':')[0]; console.log(`[analyze] Face++ 错误: ${code}`); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, code, message: ERR_MSG[code] || '检测失败,请重试' })); } if (!fpJson.result) { 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),供复核/真值比对;写盘失败不影响检测响应 try { fs.mkdirSync(DATA_DIR, { recursive: true }); const reqId = String(fpJson.request_id || 'noreq').replace(/[^\w,-]/g, '').slice(0, 40); const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15); const fname = `${stamp}-${reqId}.json`; fs.writeFile(path.join(DATA_DIR, fname), JSON.stringify(fpJson), () => {}); console.log(`[analyze] 原始返回已落盘: data/results/${fname}`); } catch (e) { console.log(`[analyze] 落盘失败(忽略): ${e.message}`); } // face_maps 是整幅 base64 叠加图,体积很大:只透传报告页所需图层(REPORT_MAPS), // 值补 data: URL 前缀供前端 直接用;未选中的图层直接删除键; // marks 坐标字段(acne/brown_spot/mole/closed_comedones/wrinkle_mark 等)本就在 result 里,原样透传 if (result.face_maps && typeof result.face_maps === 'object') { const maps = {}; for (const k of REPORT_MAPS) { if (typeof result.face_maps[k] === 'string' && result.face_maps[k]) maps[k] = toDataUrl(result.face_maps[k]); } result.face_maps = 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, 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 tooLarge = e.message === 'TOO_LARGE'; const code = tooLarge ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR'; res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); // 响应发出后再销毁底层 socket:超大请求体客户端可能仍在推送,直接断开避免连接悬挂 res.end(JSON.stringify({ ok: false, code, message: ERR_MSG[code] }), () => { if (tooLarge) req.destroy(); }); } } http.createServer((req, res) => { if (req.method === 'POST' && req.url.split('?')[0] === '/api/skin-analyze') return handleAnalyze(req, res); if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true })); } return serveStatic(req, res, req.url.split('?')[0]); }).listen(PORT, '0.0.0.0', () => { console.log(`[server] 巨子测肤 H5 已启动: http://127.0.0.1:${PORT} (静态 + /api/skin-analyze)`); });