Face++ 集成(高阶版 skinanalyze_advanced): - h5/server.js:零依赖 Node 后端,静态托管 + /api/skin-analyze 中转, api_secret 只存服务端(从 apikey.bash/env 读取,.gitignore 已排除) - 实时相机:getUserMedia 自拍取景,不可用时降级系统相机; 相机令牌防授权回调泄漏摄像头;离开拍照页即释放 - 检测流:三连拍仅送检正面照(压至 1280px JPEG),错误码→友好提示 - 结果映射:肌肤年龄=skin_age 真实值;紧塑力/活力感/稳定力由真实 维度合成;具体描述按检出项动态生成;检测图用用户实拍 - ?mock=1 内置样本供离线/自动化验证;README 补充部署与 HTTPS 说明 三轮迭代修复(QA+美术并行审计→开发统一修复,共 ~28 项): - 功能:P1 鼠标拖拽轮播(Pointer Events+setPointerCapture)、 快门/返回竞态(camSeq 令牌)、报告页状态重置、弹窗 ESC/遮罩关闭 - 视觉:还原度 80%→94%,档案页布局/弹窗比例/报告页产品卡等 结构性偏差修正,字号/间距逐项实测核销 - 工程化:首屏 701KB→386KB(懒加载+preload+清理 212KB 无用资源)、 favicon/SEO meta/隐私协议占位层/README Review 修复:server.js 路径穿越前缀绕过(ROOT+sep 校验)、 getUserMedia 迟到回调释放相机(camSeqCam 令牌)
129 lines
6.4 KiB
JavaScript
129 lines
6.4 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════
|
|
巨子测肤 · 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_advanced';
|
|
|
|
/* ─── 密钥加载:环境变量优先,其次解析项目根的 apikey.bash ─── */
|
|
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) { /* 文件不存在则走下面的报错 */ }
|
|
console.error('[启动失败] 找不到密钥:请设置 SKIN_API_KEY / SKIN_API_SECRET 环境变量,');
|
|
console.error(' 或在项目根目录放置 apikey.bash(内容为 export SKIN_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 = decodeURIComponent(pathname);
|
|
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': '检测服务开小差了,请稍候重试'
|
|
};
|
|
|
|
function readBody(req, limitBytes) {
|
|
return new Promise((resolve, reject) => {
|
|
let size = 0; const chunks = [];
|
|
req.on('data', c => {
|
|
size += c.length;
|
|
if (size > limitBytes) { reject(new Error('TOO_LARGE')); req.destroy(); return; }
|
|
chunks.push(c);
|
|
});
|
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
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 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(() => ({}));
|
|
|
|
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 }));
|
|
}
|
|
// 只回传前端需要的字段,不透传密钥相关内容
|
|
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 }));
|
|
} catch (e) {
|
|
const code = e.message === 'TOO_LARGE' ? '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] }));
|
|
}
|
|
}
|
|
|
|
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)`);
|
|
});
|