feat(h5): align October UI and add WeChat auth with saved reports

This commit is contained in:
xsl
2026-10-11 17:28:11 +08:00
parent ee744b6b5f
commit ce8d0ff1f7
126 changed files with 18711 additions and 1898 deletions
+31 -2
View File
@@ -19,10 +19,12 @@ const PORT = Number(process.env.PORT) || 8080;
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 = path.join(ROOT, 'data');
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');
@@ -66,12 +68,19 @@ function serveStatic(req, res, pathname) {
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);
@@ -342,6 +351,9 @@ async function callFacepp(front, left, right) {
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 : '');
@@ -362,6 +374,7 @@ async function handleAnalyze(req, res) {
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,
@@ -412,9 +425,24 @@ async function handleAnalyze(req, res) {
// 下颌线结果在响应顶层 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 = 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: {
@@ -752,8 +780,9 @@ async function handleAdmin(req, res, pathname, url) {
}
}
http.createServer((req, res) => {
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' });