/* ═══════════════════════════════════════════════════════════ 巨子测肤 · 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// 原始返回仍落盘 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 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//{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); // html/css/js 不缓存(迭代期样式/脚本改动必须即时到端;图片/字体/音频走默认) const headers = { 'Content-Type': MIME[ext] || 'application/octet-stream' }; if (['.html', '.css', '.js'].includes(ext)) headers['Cache-Control'] = 'no-cache'; 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//{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 前缀供前端 直接用;未选中的图层直接删除键; // 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] = ``; } } } // 版本链:它 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`); });