Files
juzi/h5/server.js
T

890 lines
44 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═══════════════════════════════════════════════════════════
巨子测肤 · 1'me — 后端服务(零依赖,Node ≥ 18)
1) 静态托管 h5/ 目录
2) POST /api/skin-analyze:三视图(正/左/右)中转 Face++ 专业版测肤
密钥只存在服务端(SKIN_API_KEY / SKIN_API_SECRET),
前端永远接触不到 api_secret。
3) 检测记录落库:data/records.jsonl(一行一条 JSON)+ data/photos/<record_id>/
原始返回仍落盘 data/results/(复核用)。后台查看页 admin.html + /api/admin/*。
启动: node server.js (默认 8080,可用 PORT 环境变量改)
═══════════════════════════════════════════════════════════ */
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
const PORT = Number(process.env.PORT) || 8080;
// 只绑回环:公网入口是 nginx(1me.xajuzi.com/state/),8080 不再对公网裸奔;需局域网调试时 HOST=0.0.0.0
const HOST = process.env.HOST || '127.0.0.1';
const ROOT = __dirname;
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro';
const DATA_ROOT = process.env.JUZI_DATA_ROOT ? path.resolve(process.env.JUZI_DATA_ROOT) : path.join(ROOT, 'data');
const DATA_DIR = path.join(DATA_ROOT, 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore)
const PHOTOS_DIR = path.join(DATA_ROOT, 'photos'); // 检测照片:data/photos/<record_id>/{front,left,right}.jpg
const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一行一条 JSON)
const ReportUI = require('./js/report-ui.js');
const BeijingTime = require('./js/beijing-time.js');
const CaptureDiagnostics = require('./lib/capture-diagnostics.js');
const WrinkleViews = require('./lib/wrinkle-views.js');
const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
const SkinScore = require('./js/skin-score.js');
/* ─── 密钥加载:环境变量优先,其次 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', '.csv': 'text/csv; charset=utf-8'
};
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');
}
// Authorize the same canonical path that the filesystem will read. Repeated,
// encoded and Windows-style slashes must not bypass private-directory checks.
p = path.posix.normalize('/' + p.replace(/\\/g, '/'));
if (p === '/') p = '/index.html';
// /data/ 下是检测照片/原始 JSON/记录库(敏感个人信息),禁止静态直出:
// 照片只能走 /api/admin/photos(由 nginx Basic Auth 保护),此处一律 403
if (p === '/data' || p.startsWith('/data/')) { res.writeHead(403); return res.end('Forbidden'); }
if (p === '/server.js' || /^\/(lib|test)(\/|$)/.test(p) || p.split('/').some(s => s.startsWith('.'))) {
res.writeHead(404); return res.end('Not Found');
}
const file = path.normalize(path.join(ROOT, p));
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) { res.writeHead(403); return res.end('Forbidden'); }
if (file === DATA_ROOT || file.startsWith(DATA_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'); }
const ext = path.extname(file);
// Revalidate every time: updates remain immediate; unchanged resources use
// 304 instead of downloading again. APIs/private reports never enter here.
const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream',
'Cache-Control': 'no-cache', ETag: 'W/"' + crypto.createHash('sha256').update(data).digest('hex') + '"' };
const compressible = /\.(html|css|js|json|svg)$/.test(file);
if (compressible) headers.Vary = 'Accept-Encoding';
if ((req.headers['if-none-match'] || '').split(/\s*,\s*/).includes(headers.ETag)) {
res.writeHead(304, headers); return res.end();
}
// Compress static public text only, not authenticated JSON or photographs.
const gzipAccepted = (req.headers['accept-encoding'] || '').split(',').some(value => {
const parts = value.trim().split(';');
return parts[0] === 'gzip' && !parts.slice(1).some(p => /^\s*q=0(?:\.0*)?\s*$/.test(p));
});
if (compressible && data.length > 1024 && gzipAccepted) {
zlib.gzip(data, (error, compressed) => {
if (!error) headers['Content-Encoding'] = 'gzip';
res.writeHead(200, headers); res.end(error ? data : compressed);
});
} else { res.writeHead(200, headers); 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': '检测服务开小差了,请稍候重试',
'TIMEOUT': '检测服务响应超时,请稍候重试',
'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', 'water_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;
}
/* ═══════════════════════════════════════════════════════════
检测记录存储(records.jsonl,一行一条 JSON,零依赖嵌入式存储)
字段对照后台产品文档第五章 detect_record。
═══════════════════════════════════════════════════════════ */
function nowIso() { return new Date().toISOString(); }
function newRecordId() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
return `R${stamp}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
}
function readAllRecords() {
try {
return fs.readFileSync(RECORDS_FILE, 'utf8').split('\n')
.map(line => { try { return JSON.parse(line); } catch (e) { return null; } })
.filter(r => r && r.record_id);
} catch (e) { return []; } // 文件不存在视为空库
}
function appendRecord(rec) {
try {
fs.mkdirSync(DATA_ROOT, { recursive: true });
fs.appendFileSync(RECORDS_FILE, JSON.stringify(rec) + '\n');
} catch (e) { console.log(`[record] 记录追加失败(忽略): ${e.message}`); }
}
function updateRecord(rec) {
try {
const all = readAllRecords();
const i = all.findIndex(r => r.record_id === rec.record_id);
if (i >= 0) all[i] = rec;
fs.writeFileSync(RECORDS_FILE, all.map(r => JSON.stringify(r)).join('\n') + '\n');
} catch (e) { console.log(`[record] 记录更新失败(忽略): ${e.message}`); }
}
function findRecord(id) {
const safe = String(id || '').replace(/[^\w-]/g, '');
return readAllRecords().find(r => r.record_id === safe) || null;
}
/* 请求体里的档案信息归一化:年龄非法(非 1–120 整数)按 null,其余空串按 null */
function normalizeProfile(raw) {
const p = (raw && typeof raw === 'object') ? raw : {};
const age = Number(p.real_age);
const str = v => (typeof v === 'string' && v.trim()) ? v.trim() : null;
return {
real_age: Number.isInteger(age) && age >= 1 && age <= 120 ? age : null,
gender: str(p.gender),
region: str(p.region),
daily_skin_type: str(p.daily_skin_type)
};
}
/* 照片写盘:data/photos/<record_id>/{front,left,right}.jpg(本地磁盘代替对象存储,库只存相对地址)。
写盘失败不影响检测流程,只打日志。返回 {front|left|right: 相对路径} */
function writePhotos(recordId, parts) {
const out = {};
try {
const dir = path.join(PHOTOS_DIR, recordId);
fs.mkdirSync(dir, { recursive: true });
for (const which of ['front', 'left', 'right']) {
const b64 = parts[which];
if (!b64) continue;
fs.writeFileSync(path.join(dir, which + '.jpg'), Buffer.from(b64, 'base64'));
out[which] = `data/photos/${recordId}/${which}.jpg`;
}
} catch (e) { console.log(`[record] 照片写盘失败(忽略): ${e.message}`); }
return out;
}
/* 从侧脸结果取下颌线角度:quality≠1 时角度无效,记 null */
function jawlineAngle(side, which) {
if (!side || typeof side !== 'object') return null;
const info = side[which + '_jawline_info'];
if (!info || info[which + '_jawline_quality'] !== 1) return null;
const a = info[which + '_jawline_angle'];
return typeof a === 'number' ? a : null;
}
/* 原始返回完整落盘(含 face_maps base64),供复核/真值比对;写盘失败不影响检测。
返回落盘文件名(相对 data/results/),失败返回 null */
function dumpRaw(fpJson) {
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}`);
return fname;
} catch (e) { console.log(`[analyze] 落盘失败(忽略): ${e.message}`); return null; }
}
/* 评分引擎入参档案:SkinScore 用 realAge/skinType 键 */
function scoreProfile(rec) {
return { realAge: rec.real_age, skinType: rec.daily_skin_type };
}
/* 检测成功:用 SkinScore.buildViewModel 计算并补全记录字段(复用同一份公式) */
function fillSuccess(rec, fpJson, persist = true) {
// Observation only: do not block, re-shoot, retry or alter scoring based on it.
rec.capture_quality = CaptureDiagnostics.assess(fpJson);
const vm = SkinScore.buildViewModel(fpJson.result, {
left: fpJson.left_side_result || (fpJson.side_result && fpJson.side_result.left_side_result) || null,
right: fpJson.right_side_result || (fpJson.side_result && fpJson.side_result.right_side_result) || null
}, scoreProfile(rec));
rec.detect_status = '成功';
rec.fail_stage = null; rec.fail_code = null; rec.fail_msg = null;
rec.skin_age = vm.skinAgeRaw;
rec.skin_age_display = vm.skinAgeDisplay;
rec.recommendation = vm.recommendation;
rec.presentation = ReportUI.build(vm, fpJson.result);
rec.grade_rule = ReportUI.gradeRule;
rec.vita_score_rule = vm.vitaScoreRule;
rec.sub_scores = Object.fromEntries(rec.presentation.views.map(view => [view.key, {
P: view.P, S: view.S, level: ReportUI.internalLevel(view.S)
}]));
rec.total_score = vm.total;
rec.total_score_rule = vm.totalRule;
rec.wrinkle_score_rule = vm.wrinkleScoreRule;
rec.acne_scoring = vm.cats.acne.vars.acne_scoring;
rec.tone_scoring = vm.cats.tone.vars.tone_scoring;
rec.wrinkle_view_status = fpJson.wrinkle_views ? Object.fromEntries(['left','right'].map(view => [view, {
status:fpJson.wrinkle_views[view]?.status||'unavailable', error_code:fpJson.wrinkle_views[view]?.error_code||null
}])) : null;
rec.jawline_angle_l = jawlineAngle(fpJson.left_side_result, 'left');
rec.jawline_angle_r = jawlineAngle(fpJson.right_side_result, 'right');
rec.cats = {}; rec.copy_6 = {};
for (const k of Object.keys(vm.cats)) {
rec.cats[k] = { P: vm.cats[k].P, S: vm.cats[k].S, level: vm.cats[k].level };
rec.copy_6[k] = vm.cats[k].text;
}
rec.dims = {};
for (const k of Object.keys(vm.dims)) rec.dims[k] = { P: vm.dims[k].P, S: vm.dims[k].S, level: ReportUI.internalLevel(vm.dims[k].S) };
rec.avg_s = vm.avgS;
rec.group = vm.group;
rec.copy_summary = vm.summary;
rec.lifestyle = vm.lifestyle;
rec.careAdvice = vm.careAdvice;
rec.essence = vm.essence ? { name: vm.essence.name, text: vm.essence.text } : null;
rec.essence_line = vm.essence ? vm.essence.text : null;
rec.trigger = (vm.essence && vm.essence.triggers) ? vm.essence.triggers.join(',') : null;
rec.notes = vm.notes || [];
rec.raw_file = persist ? dumpRaw(fpJson) : null;
}
/* 检测失败:补失败字段 */
function fillFailure(rec, code) {
rec.detect_status = '失败';
rec.fail_stage = 'detect';
rec.fail_code = code;
rec.fail_msg = ERR_MSG[code] || '检测失败,请重试';
}
/* 执行一次检测:计时调用 Face++,按结果补全记录(成功/失败都落库)。返回 Face++ 原始返回 */
async function runDetect(rec, front, left, right, persist = true) {
rec.detect_status = '检测中';
rec.updated_at = nowIso();
const t0 = Date.now();
const fpJson = await callFacepp(front, left, right);
if (fpJson.result && !fpJson.error_message) {
fpJson.wrinkle_views = await WrinkleViews.collect(fpJson,{left,right},{
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,
gapMs:process.env.NODE_ENV==='test'?0:4000,
// Keep within the existing 150-second browser timeout, including
// upload/serialization headroom. Missing side maps never fail a report.
deadline:t0+120000
});
}
rec.cost_ms = Date.now() - t0;
if (fpJson.error_message) {
const code = fpJson.error_message.split(':')[0];
console.log(`[analyze] Face++ 错误: ${code}`);
fillFailure(rec, code);
} else if (!fpJson.result) {
fillFailure(rec, 'INTERNAL_ERROR');
} else {
try {
fillSuccess(rec, fpJson, persist);
} catch (e) { // 评分异常按检测失败落库,但不改写 C 端成功响应(原始结果仍透传)
console.log(`[analyze] 评分异常: ${e.message}`);
fillFailure(rec, 'INTERNAL_ERROR');
}
}
rec.updated_at = nowIso();
return fpJson;
}
/* 调用 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) {
res.setHeader('Cache-Control', 'no-store');
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
let persist = false;
try {
const owner = accounts.session(req);
if (!owner) return sendJson(res, 401, { ok: false, message: '请先登录后检测' });
persist = owner.kind === 'wechat';
if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, 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: '照片数据异常,请重试' }));
}
/* ── 记录落库:生成 record_id → 写照片 → 落一条 detect_status=检测中 ── */
const profile = normalizeProfile(body.profile);
const recordId = newRecordId();
const photos = persist ? writePhotos(recordId, { front, left, right }) : {};
rec = {
record_id: recordId,
owner_id: owner.id,
created_at: nowIso(),
updated_at: nowIso(),
real_age: profile.real_age,
gender: profile.gender,
region: profile.region,
daily_skin_type: profile.daily_skin_type,
img_front: photos.front || null,
img_left: photos.left || null,
img_right: photos.right || null,
shoot_status: (photos.left && photos.right) ? '已完成' : '降级',
upload_status: photos.front ? '成功' : '失败',
detect_status: '检测中',
fail_stage: null, fail_code: null, fail_msg: null,
skin_age: null, total_score: null,
jawline_angle_l: null, jawline_angle_r: null,
cats: null, dims: null, avg_s: null, group: null,
copy_6: null, copy_summary: null, lifestyle: null, careAdvice: null,
essence: null, essence_line: null, trigger: null, notes: null,
raw_file: null, cost_ms: null,
retry_of: null, retry_count: 0
};
rec.capture_diagnostics = CaptureDiagnostics.normalize(body.capture_diagnostics);
if (persist) {
appendRecord(rec);
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
}
const fpJson = await runDetect(rec, front, left, right, persist);
if (persist) updateRecord(rec);
if (fpJson.error_message) {
const code = fpJson.error_message.split(':')[0];
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) { // 与改造前一致:仅 result 缺失才走 INTERNAL_ERROR(评分异常不改写 C 端成功响应)
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 叠加图,体积很大:只透传报告页所需图层(REPORT_MAPS),
// 值补 data: URL 前缀供前端 <img> 直接用;未选中的图层直接删除键;
// 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 || {};
const payload = {
result, face_rectangle: fpJson.face_rectangle || null,
wrinkle_views: WrinkleViews.forReport(fpJson.wrinkle_views),
side: { left: fpJson.left_side_result || nested.left_side_result || null,
right: fpJson.right_side_result || nested.right_side_result || null }
};
let reportSaved = false, presentation = null, snapshotVm = null;
try {
snapshotVm = SkinScore.buildViewModel(result, payload.side, scoreProfile(rec));
presentation = rec.presentation || ReportUI.build(snapshotVm, result);
if (persist) {
accounts.storeReport(owner.id, { id: recordId, created_at: rec.created_at,
version: '2026-10-09', profile, vm: snapshotVm, presentation, payload,
photos: [front, left, right].map(b64 => b64 ? toDataUrl(b64) : null) });
reportSaved = true;
}
} catch (e) { console.error('[report] 完整报告保存失败:', e.message); }
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({
ok: true,
record_id: recordId, report_saved: reportSaved, presentation, vm: snapshotVm,
result,
wrinkle_views: payload.wrinkle_views,
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) {
if (rec && persist) { // 仅微信检测失败记录落库;游客不保留异常记录。
fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR');
rec.updated_at = nowIso();
updateRecord(rec);
}
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(); });
}
}
/* ═══════════════════════════════════════════════════════════
后台查看 API(本地查看版,无登录鉴权 —— 上线前需加网关/鉴权)
═══════════════════════════════════════════════════════════ */
function sendJson(res, status, obj) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(obj));
}
/* 记录里照片的在线 URL(有存才给) */
function photoUrls(rec) {
const urls = {};
if (rec.img_front) urls.front = `/api/admin/photos/${rec.record_id}/front`;
if (rec.img_left) urls.left = `/api/admin/photos/${rec.record_id}/left`;
if (rec.img_right) urls.right = `/api/admin/photos/${rec.record_id}/right`;
return urls;
}
/* 列表用的字段子集(不含 raw_json 与文案全文) */
function displaySkinAge(rec) {
// Match H5 even for old records; do not rewrite historical files.
return SkinScore.reportSkinAge({ skinAgeRaw: rec.skin_age, realAge: rec.real_age });
}
function displayTotalScore(rec) {
return SkinScore.weightedTotal(rec.dims);
}
function displayScoreLevels(scores) {
return scores ? Object.fromEntries(Object.entries(scores).map(([key, value]) => [key,
Object.assign({}, value, { level: ReportUI.internalLevel(value && value.S) })])) : scores;
}
function listItem(rec) {
return {
record_id: rec.record_id,
created_at: rec.created_at,
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
capture_quality: rec.capture_quality || null,
capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null,
fail_code: rec.fail_code, fail_msg: rec.fail_msg,
total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age: rec.skin_age,
skin_age_display: displaySkinAge(rec), recommendation: rec.recommendation || null,
dims: displayScoreLevels(rec.dims), grade_rule: ReportUI.gradeRule, group: rec.group,
essence: rec.essence ? rec.essence.name : null,
cost_ms: rec.cost_ms,
retry_of: rec.retry_of, retry_count: rec.retry_count || 0,
photos: photoUrls(rec)
};
}
/* Dates mean Beijing calendar days; fully zoned ISO timestamps retain their instant. */
function matchQuery(rec, q) {
if (q.timeRange && (q.from || q.to)) {
const timestamp = BeijingTime.milliseconds(rec.created_at);
if (!Number.isFinite(timestamp) || timestamp < q.timeRange.start || timestamp >= q.timeRange.end) return false;
}
if (q.detect_status && rec.detect_status !== q.detect_status) return false;
if (q.fail_code && rec.fail_code !== q.fail_code) return false;
if (q.group && rec.group !== q.group) return false;
if (q.q) {
const needle = String(q.q).trim();
if (needle && !rec.record_id.startsWith(needle)) return false;
}
if (q.age_min != null) { if (rec.real_age == null || rec.real_age < q.age_min) return false; }
if (q.age_max != null) { if (rec.real_age == null || rec.real_age > q.age_max) return false; }
if (q.dim && q.dim_level) {
const d = rec.dims && rec.dims[q.dim];
if (!d || ReportUI.internalLevel(d.S) !== q.dim_level) return false;
}
if (q.only_fail === '1' && rec.detect_status !== '失败') return false;
return true;
}
function handleRecordsQuery(req, res, url) {
const sp = url.searchParams;
const q = {
from: sp.get('from') || null,
to: sp.get('to') || null,
detect_status: sp.get('detect_status') || null,
fail_code: sp.get('fail_code') || null,
group: sp.get('group') || null,
dim: sp.get('dim') || null,
dim_level: sp.get('dim_level') || null,
q: sp.get('q') || null,
only_fail: sp.get('only_fail') || null,
age_min: sp.has('age_min') && sp.get('age_min') !== '' ? Number(sp.get('age_min')) : null,
age_max: sp.has('age_max') && sp.get('age_max') !== '' ? Number(sp.get('age_max')) : null
};
if ((q.age_min != null && !Number.isFinite(q.age_min)) || (q.age_max != null && !Number.isFinite(q.age_max))) {
return sendJson(res, 400, { ok: false, message: 'age_min/age_max 必须是数字' });
}
try { q.timeRange = BeijingTime.range(q.from, q.to); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20));
const filtered = readAllRecords().filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序
const total = filtered.length;
const items = filtered.slice((page - 1) * pageSize, page * pageSize).map(listItem);
sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items });
}
function handleRecordDetail(req, res, id) {
const rec = findRecord(id);
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
// 原始 JSON 全文:读文件失败给 null。
// face_maps 是整幅 base64 叠加图(单文件可达数 MB),详情页直接渲染会卡死浏览器:
// 展示层把各图层值替换为占位说明,完整原文件走 /api/admin/records/:id/raw 下载
let rawJson = null;
if (rec.raw_file) {
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
try { rawJson = JSON.parse(fs.readFileSync(path.join(DATA_DIR, fname), 'utf8')); }
catch (e) { rawJson = null; }
}
if (rawJson && rawJson.result && rawJson.result.face_maps) {
for (const k of Object.keys(rawJson.result.face_maps)) {
const v = rawJson.result.face_maps[k];
if (typeof v === 'string' && v.length) {
rawJson.result.face_maps[k] = `<base64 图层已省略,共 ${v.length} 字符,点上方链接下载完整原文件>`;
}
}
}
// 版本链:它 retry_of 指向的(父)+ retry_of 指向它的(子)
const all = readAllRecords();
const parent = rec.retry_of ? (all.find(r => r.record_id === rec.retry_of) || null) : null;
const children = all.filter(r => r.retry_of === rec.record_id)
.map(r => ({ record_id: r.record_id, detect_status: r.detect_status, created_at: r.created_at }));
let presentation = rec.presentation || accounts.reportPresentation(rec.owner_id, rec.record_id);
if (presentation && (presentation.gradeRule !== ReportUI.gradeRule || presentation.copyRule !== ReportUI.copyRule) && rawJson && rawJson.result) {
// Recover text variables from raw data, but retain the saved scores,
// recommendation and care order. No database/snapshot mutation.
const displayVm = SkinScore.buildViewModel(rawJson.result, {
left: rawJson.left_side_result || (rawJson.side_result && rawJson.side_result.left_side_result),
right: rawJson.right_side_result || (rawJson.side_result && rawJson.side_result.right_side_result)
}, scoreProfile(rec));
for (const key of Object.keys(rec.cats || {})) Object.assign(displayVm.cats[key], rec.cats[key]);
displayVm.dims = rec.dims || displayVm.dims;
if (rec.sub_scores && rec.sub_scores.color) displayVm.cats.tone.vars.color_score = rec.sub_scores.color.P;
presentation = ReportUI.forDisplay(displayVm, rawJson.result, presentation);
}
sendJson(res, 200, {
ok: true,
record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec),
dims: displayScoreLevels(rec.dims), sub_scores: displayScoreLevels(rec.sub_scores), grade_rule: ReportUI.gradeRule, presentation }),
raw_json: rawJson,
photos: photoUrls(rec),
versions: {
parent: parent ? { record_id: parent.record_id, detect_status: parent.detect_status, created_at: parent.created_at } : null,
children
}
});
}
/* 完整原始 JSON 下载(face_maps base64 未裁剪,动辄数 MB,仅供复核下载,不做页面内展示) */
function handleRawDownload(req, res, id) {
const rec = findRecord(id);
if (!rec || !rec.raw_file) return sendJson(res, 404, { ok: false, message: '记录或原始 JSON 不存在' });
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
const file = path.join(DATA_DIR, fname);
fs.readFile(file, (err, data) => {
if (err) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' });
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${rec.record_id}-raw.json"`
});
res.end(data);
});
}
function handlePhoto(req, res, id, which) {
const safe = String(id || '').replace(/[^\w-]/g, '');
const file = path.join(PHOTOS_DIR, safe, which + '.jpg');
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': 'image/jpeg',
'Content-Disposition': `inline; filename="${safe}-${which}.jpg"`
});
res.end(data);
});
}
function handleStats(req, res) {
const all = readAllRecords();
const now = Date.now(), today = BeijingTime.day(now);
const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败');
const succeeded = finished.filter(r => r.detect_status === '成功');
const failed = finished.filter(r => r.detect_status === '失败');
const avgCost = finished.length
? Math.round(finished.reduce((s, r) => s + (r.cost_ms || 0), 0) / finished.length)
: 0;
// 失败原因分布
const failMap = {};
for (const r of failed) failMap[r.fail_code || 'UNKNOWN'] = (failMap[r.fail_code || 'UNKNOWN'] || 0) + 1;
const fail_dist = Object.keys(failMap).map(code => ({
code, count: failMap[code],
pct: failed.length ? Math.round(failMap[code] * 1000 / failed.length) / 10 : 0
})).sort((a, b) => b.count - a.count);
// 三维度档位分布(成功记录)
const dim_dist = { firm: { '预警': 0, '改善': 0, '良好': 0 }, vita: { '预警': 0, '改善': 0, '良好': 0 }, stab: { '预警': 0, '改善': 0, '良好': 0 } };
const group_dist = {};
for (const r of succeeded) {
for (const d of ['firm', 'vita', 'stab']) {
const lv = r.dims && r.dims[d] && ReportUI.internalLevel(r.dims[d].S);
if (lv && dim_dist[d][lv] != null) dim_dist[d][lv] += 1;
}
const g = r.group || '未分组';
group_dist[g] = (group_dist[g] || 0) + 1;
}
// 近 14 天每日检测量/成功数(含今天,今天在后)
const daily = [];
for (let i = 13; i >= 0; i--) {
const day = BeijingTime.day(now - i * 86400000);
const recs = all.filter(r => BeijingTime.day(r.created_at) === day);
daily.push({
date: day,
total: recs.length,
success: recs.filter(r => r.detect_status === '成功').length
});
}
sendJson(res, 200, {
ok: true,
today_total: all.filter(r => BeijingTime.day(r.created_at) === today).length,
total: all.length,
finished_total: finished.length,
success_total: succeeded.length,
fail_total: failed.length,
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
avg_cost_ms: avgCost,
fail_dist,
dim_dist,
group_dist,
daily
});
}
/* 手动重试(文档 4.4):读原记录照片重新检测,生成新记录,原记录 retry_count+1 */
async function handleRetry(req, res, id) {
const rec = findRecord(id);
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
const readB64 = which => {
const file = path.join(PHOTOS_DIR, rec.record_id, which + '.jpg');
try { return fs.readFileSync(file).toString('base64'); } catch (e) { return null; }
};
const front = readB64('front');
if (!front) return sendJson(res, 400, { ok: false, message: '该记录没有可用的正脸照片,无法重试' });
const nid = newRecordId();
const photos = writePhotos(nid, { front, left: readB64('left'), right: readB64('right') });
const nrec = {
record_id: nid,
created_at: nowIso(),
updated_at: nowIso(),
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
img_front: photos.front || null,
img_left: photos.left || null,
img_right: photos.right || null,
shoot_status: (photos.left && photos.right) ? '已完成' : '降级',
upload_status: photos.front ? '成功' : '失败',
detect_status: '检测中',
fail_stage: null, fail_code: null, fail_msg: null,
skin_age: null, total_score: null,
jawline_angle_l: null, jawline_angle_r: null,
cats: null, dims: null, avg_s: null, group: null,
copy_6: null, copy_summary: null, lifestyle: null, careAdvice: null,
essence: null, essence_line: null, trigger: null, notes: null,
raw_file: null, cost_ms: null,
retry_of: rec.record_id, retry_count: 0
};
appendRecord(nrec);
console.log(`[record] 手动重试 ${rec.record_id} → 新记录 ${nid}`);
await runDetect(nrec, front, nrec.img_left ? readB64('left') : null, nrec.img_right ? readB64('right') : null);
updateRecord(nrec);
rec.retry_count = (rec.retry_count || 0) + 1;
rec.updated_at = nowIso();
updateRecord(rec);
sendJson(res, 200, { ok: true, record: listItem(nrec) });
}
/* CSV 单元格转义;raw_json 等大字段严禁进单元格(Excel 单格 32767 字符截断) */
function csvCell(v) {
if (v == null) return '';
const s = String(v);
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
function handleExport(req, res, url) {
const q = {
from: url.searchParams.get('from') || null,
to: url.searchParams.get('to') || null
};
try { q.timeRange = BeijingTime.range(q.from, q.to); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const filtered = readAllRecords().filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].S : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelForScore(r.dims[d].S) : null;
const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄(推荐依据)', '前端展示肌龄',
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本'];
const lines = [header.join(',')];
for (const r of filtered) {
lines.push([
r.record_id, BeijingTime.format(r.created_at), r.real_age, r.gender, r.region, r.daily_skin_type,
r.shoot_status, r.upload_status, r.detect_status,
r.fail_stage, r.fail_code, r.fail_msg, displayTotalScore(r), r.skin_age, displaySkinAge(r),
dimP(r, 'firm'), dimS(r, 'firm'), dimLv(r, 'firm'),
dimP(r, 'vita'), dimS(r, 'vita'), dimLv(r, 'vita'),
dimP(r, 'stab'), dimS(r, 'stab'), dimLv(r, 'stab'),
r.avg_s, r.group, r.essence ? ((ReportUI.products.find(p => p.match === r.essence.name) || {}).title || r.essence.name) : null,
r.trigger, r.cost_ms,
r.recommendation ? r.recommendation.version : '旧版规则(历史记录)',
r.recommendation ? r.recommendation.reason : null,
r.capture_quality ? r.capture_quality.status : null,
r.capture_diagnostics ? r.capture_diagnostics.version : null
].map(csvCell).join(','));
}
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
const toTag = (q.to || 'all').replace(/[^\d-]/g, '');
const fname = `skin-records-${fromTag}_${toTag}.csv`;
res.writeHead(200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${fname}"`
});
res.end('' + lines.join('\r\n')); // UTF-8 BOM:Excel 直接打开不乱码
}
async function handleAdmin(req, res, pathname, url) {
try {
const retryM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/retry$/);
if (req.method === 'POST' && retryM) return await handleRetry(req, res, retryM[1]);
const rawM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/raw$/);
if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1]);
const detailM = pathname.match(/^\/api\/admin\/records\/([\w-]+)$/);
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1]);
const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/);
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]);
if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url);
if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res);
if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url);
return sendJson(res, 404, { ok: false, message: '接口不存在' });
} catch (e) {
console.log(`[admin] ${e.message}`);
return sendJson(res, 500, { ok: false, message: '服务内部错误' });
}
}
http.createServer(async (req, res) => {
const pathname = req.url.split('?')[0];
if (await accounts.route(req, res, pathname)) return;
if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res);
if (req.method === 'GET' && pathname === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true }));
}
if (pathname.startsWith('/api/admin/')) {
return handleAdmin(req, res, pathname, new URL(req.url, 'http://localhost'));
}
return serveStatic(req, res, pathname);
}).listen(PORT, HOST, () => {
console.log(`[server] 巨子测肤 H5 已启动: http://${HOST}:${PORT} (静态 + /api/skin-analyze + /api/admin/*)`);
console.log(`[server] 后台查看页: http://${HOST}:${PORT}/admin.html`);
});