830 lines
40 KiB
JavaScript
830 lines
40 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════
|
||
巨子测肤 · 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 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) {
|
||
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.total_score = vm.total;
|
||
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: vm.dims[k].level };
|
||
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 = dumpRaw(fpJson);
|
||
}
|
||
|
||
/* 检测失败:补失败字段 */
|
||
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) {
|
||
rec.detect_status = '检测中';
|
||
rec.updated_at = nowIso();
|
||
const t0 = Date.now();
|
||
const fpJson = await callFacepp(front, left, right);
|
||
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);
|
||
} 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) {
|
||
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
|
||
try {
|
||
const owner = accounts.session(req);
|
||
if (!owner) return sendJson(res, 401, { ok: false, message: '请先登录后检测' });
|
||
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 = 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
|
||
};
|
||
appendRecord(rec);
|
||
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
|
||
|
||
const fpJson = await runDetect(rec, front, left, right);
|
||
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,
|
||
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);
|
||
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,
|
||
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) { // 记录已创建但流程异常:按检测失败补全,便于后台看到这条调用
|
||
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) {
|
||
if (Object.prototype.hasOwnProperty.call(rec, 'skin_age_display')) return rec.skin_age_display;
|
||
// 旧记录没有展示值,沿用生成时的规则,不重算推荐或改写文件。
|
||
if (rec.skin_age == null || rec.real_age == null) return rec.skin_age;
|
||
return Math.min(Math.max(rec.skin_age, rec.real_age - 3), rec.real_age + 3);
|
||
}
|
||
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,
|
||
fail_code: rec.fail_code, fail_msg: rec.fail_msg,
|
||
total_score: rec.total_score, skin_age: rec.skin_age,
|
||
skin_age_display: displaySkinAge(rec), recommendation: rec.recommendation || null,
|
||
dims: rec.dims, 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)
|
||
};
|
||
}
|
||
|
||
/* 查询参数过滤。from/to 支持 YYYY-MM-DD 或完整 ISO;纯日期 to 含当天全天 */
|
||
function matchQuery(rec, q) {
|
||
if (q.from && rec.created_at < q.from) return false;
|
||
if (q.to) {
|
||
const to = q.to.length === 10 ? q.to + 'T23:59:59.999Z' : q.to;
|
||
if (rec.created_at > to) 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 || d.level !== 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 必须是数字' });
|
||
}
|
||
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 }));
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
record: Object.assign({}, rec, { skin_age_display: displaySkinAge(rec),
|
||
presentation: rec.presentation || accounts.reportPresentation(rec.owner_id, rec.record_id) }),
|
||
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 today = nowIso().slice(0, 10);
|
||
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] && r.dims[d].level;
|
||
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 d = new Date(Date.now() - i * 86400000);
|
||
const day = d.toISOString().slice(0, 10);
|
||
const recs = all.filter(r => (r.created_at || '').slice(0, 10) === 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 => (r.created_at || '').slice(0, 10) === 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
|
||
};
|
||
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.levelMap[r.dims[d].level] : null;
|
||
const header = ['记录ID', '创建时间', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
|
||
'失败环节', '失败码', '失败信息', '总分', '原始肌龄(推荐依据)', '前端展示肌龄',
|
||
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
|
||
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因'];
|
||
const lines = [header.join(',')];
|
||
for (const r of filtered) {
|
||
lines.push([
|
||
r.record_id, 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, r.total_score, 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
|
||
].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`);
|
||
});
|