feat(rednote): add isolated persistent browser H5 entry

This commit is contained in:
xsl
2026-10-19 22:59:10 +08:00
parent 9eec4369da
commit 4af825a5e4
24 changed files with 707 additions and 45 deletions
+21
View File
@@ -0,0 +1,21 @@
/* Loaded only by the rednote instance: persistence disclosure needs natural height. */
#p-privacy .popup {
width: min(308px, calc(100vw - 24px));
height: auto;
min-height: 0;
max-height: calc(100vh - 24px);
overflow-y: auto;
padding: 36px 32px 32px;
top: 0;
}
#p-privacy .popup-title,
#p-privacy .popup-body,
#p-privacy .privacy-row,
#p-privacy .popup-btn {
position: static;
flex-shrink: 0;
}
#p-privacy .popup-title { margin: 0 0 16px; }
#p-privacy .popup-body { width: auto; margin: 0 0 18px; }
#p-privacy .privacy-row { margin: 0; }
#p-privacy .popup-btn { margin: 26px auto 0; }
+8 -3
View File
@@ -1,8 +1,10 @@
/* Guest access uses an ephemeral server session, never a saved browser identity. */
window.AccountClient = (function () {
'use strict';
var kindKey = 'juzi.auth.kind.v1', user = null, guestReady = false;
var rednote = !!(window.JuziEntry && window.JuziEntry.mode === 'rednote');
var kindKey = rednote ? 'juzi.rednote.auth.kind.v1' : 'juzi.auth.kind.v1', user = null, guestReady = false;
function clearGuestCache() {
if (rednote) return;
try {
localStorage.removeItem('juzi.identity.v1');
localStorage.removeItem('juzi.reports.v1'); // Retired local guest-report cache.
@@ -12,7 +14,7 @@ window.AccountClient = (function () {
} catch (_) {}
}
function rememberKind() {
try { if (user && user.kind === 'wechat') localStorage.setItem(kindKey, 'wechat'); else localStorage.removeItem(kindKey); } catch (_) {}
try { if (user && (user.kind === 'wechat' || (rednote && user.kind === 'browser_persistent'))) localStorage.setItem(kindKey, user.kind); else localStorage.removeItem(kindKey); } catch (_) {}
}
function request(url, data) {
var controller = new AbortController();
@@ -43,7 +45,7 @@ window.AccountClient = (function () {
function ensureGuest() {
// The visitor entry is entirely local. Create temporary server access only
// after explicit consent in the welcome dialog, not on entry/button tap.
if (!user || user.kind === 'wechat' || guestReady) return Promise.resolve({ user: user });
if (!user || user.kind === 'wechat' || (rednote && user.kind === 'browser_persistent') || guestReady) return Promise.resolve({ user: user });
return browser(true);
}
function init(options) {
@@ -58,6 +60,9 @@ window.AccountClient = (function () {
}
function logout() {
return request('api/auth/logout', {}).then(function (v) {
if (rednote && user) {
try { localStorage.removeItem('juzi.rednote.profile.v1.browser_persistent.' + encodeURIComponent(user.id)); } catch (_) {}
}
user = null; guestReady = false; rememberKind(); return v;
});
}
+9 -5
View File
@@ -38,8 +38,11 @@ function toast(msg) {
toast._timer = setTimeout(function () { t.classList.add('hidden'); }, 2600);
}
function adminURL(url) {
return window.JuziEntry && window.JuziEntry.mode === 'rednote' && url.indexOf('/api/') === 0 ? '/rednote' + url : url;
}
function api(url, opts) {
return fetch(url, opts || {}).then(function (res) {
return fetch(adminURL(url), opts || {}).then(function (res) {
return res.json().then(function (data) {
if (!res.ok || data.ok === false) throw new Error(data.message || ('HTTP ' + res.status));
return data;
@@ -548,10 +551,11 @@ function renderDetail(data) {
/* 接口原始 JSON:details 折叠,pre 展示(face_maps 已在服务端裁剪,完整版走下载链接) */
if (data.raw_json) {
var rawDl = data.raw_file
? ' <a href="/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw" download>下载完整原始 JSON</a>'
var rawFile = data.raw_file || (window.JuziEntry && window.JuziEntry.mode === 'rednote' ? r.raw_file : null);
var rawDl = rawFile
? ' <a href="' + adminURL('/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw') + '" download>下载完整原始 JSON</a>'
: '';
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (data.raw_file || '') + ')' + rawDl + '</summary><pre>' +
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (rawFile || '') + ')' + rawDl + '</summary><pre>' +
esc(JSON.stringify(data.raw_json, null, 1)) + '</pre></details>');
} else {
h += dSec('接口原始 JSON', '<div class="sub">无(失败记录或未落盘)</div>');
@@ -601,7 +605,7 @@ function bind() {
var p = [];
if ($('#exp-from').value) p.push('from=' + $('#exp-from').value);
if ($('#exp-to').value) p.push('to=' + $('#exp-to').value);
var url = '/api/admin/export' + (p.length ? '?' + p.join('&') : '');
var url = adminURL('/api/admin/export' + (p.length ? '?' + p.join('&') : ''));
var a = el('a');
a.href = url;
a.download = '';
+26 -10
View File
@@ -3,6 +3,8 @@
'use strict';
var $ = function (sel, root) { return (root || document).querySelector(sel); };
var REDNOTE = !!(window.JuziEntry && window.JuziEntry.mode === 'rednote');
function savedAccount(user) { return !!(user && (user.kind === 'wechat' || (REDNOTE && user.kind === 'browser_persistent'))); }
var captureJourney = null;
function beginCaptureJourney() {
@@ -96,13 +98,13 @@
function renderAccountStatus() {
var user = AccountClient.user();
document.querySelectorAll('[data-auth-status]').forEach(function (label) {
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '游客模式') : '尚未登录';
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : REDNOTE ? (savedAccount(user) ? '浏览器已登录' : '浏览器体验') : '游客模式') : '尚未登录';
label.parentElement.dataset.kind = user ? user.kind : '';
});
document.querySelectorAll('[data-action="logout-wechat"]').forEach(function (button) {
button.hidden = !user || user.kind !== 'wechat';
button.hidden = !savedAccount(user);
button.disabled = logoutBusy;
button.textContent = logoutBusy ? '正在退出…' : '退出登录';
button.textContent = logoutBusy ? '正在退出…' : REDNOTE ? '清除本机身份' : '退出登录';
});
}
function loginSucceeded() {
@@ -1208,7 +1210,7 @@
}
renderReport(vm, photos[0], photos[1], payload, photos); // photos 已统一为正/左/右
if (IS_MOCK) toast('演示报告,不保存至我的报告');
else if (AccountClient.user() && AccountClient.user().kind !== 'wechat') toast('游客报告仅供本次查看,不保存历史记录');
else if (AccountClient.user() && !savedAccount(AccountClient.user())) toast('游客报告仅供本次查看,不保存历史记录');
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
@@ -1934,6 +1936,7 @@
'history-retry': function () { renderHistoryList(); },
'logout-wechat': logoutWechat,
'open-wechat-login': function () {
if (REDNOTE) return;
historySeq++;
$('#login-consent').checked = false;
$('#login-message').textContent = '微信登录后,后续检测报告可保存和跨手机查看。';
@@ -1952,7 +1955,12 @@
var historySeq = 0;
function renderHistoryList() {
var seq = ++historySeq, box = $('#history-list');
if (!AccountClient.user() || AccountClient.user().kind !== 'wechat') {
if (!savedAccount(AccountClient.user())) {
if (REDNOTE) {
$('#history-count').textContent = '暂无历史报告';
$('#identity-note').textContent = '同意隐私说明并完成检测后,将在此浏览器保留最近 5 份报告。';
box.textContent = '请返回首页开始检测。'; return;
}
$('#history-count').textContent = '游客模式不保存检测记录';
$('#identity-note').textContent = '游客不保存个人信息和检测记录,报告仅供本次查看。微信登录后可保留历史报告。';
box.innerHTML = '<p class="history-empty">暂无历史报告<br>请使用微信登录,后续检测报告会自动保存。<br><button class="history-retry" data-action="open-wechat-login">微信登录,保存报告</button></p>';
@@ -1962,7 +1970,7 @@
AccountClient.request('api/reports').then(function (data) {
if (seq !== historySeq) return;
$('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测';
$('#identity-note').textContent = '保留最近 5 份报告,同一微信账号可跨手机查看。';
$('#identity-note').textContent = REDNOTE ? '保留最近 5 份报告。请使用同一浏览器查看;清除浏览器数据或更换设备后无法恢复。' : '保留最近 5 份报告,同一微信账号可跨手机查看。';
if (!data.items.length) { box.innerHTML = '<p class="history-empty">暂无报告<br>完成一次检测后,报告会自动保存在这里。</p>'; return; }
box.innerHTML = data.items.map(function (item) {
var date = new Date(item.created_at).toLocaleDateString('sv-SE');
@@ -1995,13 +2003,17 @@
var loginBusy = false;
function logoutWechat() {
var user = AccountClient.user();
if (logoutBusy || !user || user.kind !== 'wechat') return;
if (!window.confirm('确认退出微信登录?\n历史报告将保留,重新微信登录后可查看。')) return;
if (logoutBusy || !savedAccount(user)) return;
if (!window.confirm(REDNOTE ? '确认清除本机浏览器身份?\n清除后将无法恢复该身份的历史报告,下次体验会生成新身份。' : '确认退出微信登录?\n历史报告将保留,重新微信登录后可查看。')) return;
logoutBusy = true; renderAccountStatus();
AccountClient.logout().then(function () {
historySeq++; reportOwner = null;
$('#history-list').textContent = ''; $('#history-count').textContent = ''; $('#identity-note').textContent = '';
$('#login-consent').checked = false;
if (REDNOTE) {
agreed = false; $('#privacy-check').classList.remove('checked');
AccountClient.guest(); showScreen('s-splash', 'replace'); return;
}
$('#login-message').textContent = '已退出微信登录,历史报告已保留。';
showScreen('s-login', 'replace');
$('[data-action="login-wechat"]').focus({ preventScroll: true });
@@ -2020,6 +2032,7 @@
showScreen('s-splash', 'replace');
}
function loginWechat() {
if (REDNOTE) return;
if (!loginAllowed()) return;
if (!/MicroMessenger/i.test(navigator.userAgent)) {
$('#login-message').textContent = '请在微信内打开本页面,或选择免登录体验。';
@@ -2074,11 +2087,14 @@
history.replaceState({ screen: 's-login' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐
renderCamStep();
if (!(/[?&]mock=1/.test(location.search) && /[?&]screen=report/.test(location.search))) {
var loginError = /[?&]login_error=([^&#]*)/.exec(location.search);
var loginSuccess = /[?&]login=success(?:&|$)/.test(location.search);
var loginError = REDNOTE ? null : /[?&]login_error=([^&#]*)/.exec(location.search);
var loginSuccess = !REDNOTE && /[?&]login=success(?:&|$)/.test(location.search);
var oauthReturn = !!loginError || loginSuccess;
loginBusy = true;
AccountClient.init({ restoreBrowser: !oauthReturn }).then(function () {
if (REDNOTE && !AccountClient.user()) {
AccountClient.guest(); showScreen('s-splash', 'replace'); return;
}
if (loginError) {
var code = loginError[1];
$('#login-message').textContent = code === 'state'
+2 -2
View File
@@ -5,8 +5,8 @@
})(typeof window !== 'undefined' ? window : this, function() {
'use strict';
function key(user) {
return user && user.kind === 'wechat' && typeof user.id === 'string' && user.id.length <= 128 && user.id
? 'juzi.profile.v1.' + user.kind + '.' + encodeURIComponent(user.id) : null;
return user && ['wechat', 'browser_persistent'].indexOf(user.kind) >= 0 && typeof user.id === 'string' && user.id.length <= 128 && user.id
? (user.kind === 'browser_persistent' ? 'juzi.rednote.profile.v1.' : 'juzi.profile.v1.') + user.kind + '.' + encodeURIComponent(user.id) : null;
}
function age(birth, now) {
if (typeof birth !== 'string' || !/^\d{4}-(0[1-9]|1[0-2])$/.test(birth)) return null;
+25 -11
View File
@@ -11,6 +11,9 @@ const hash = value => crypto.createHash('sha256').update(value).digest('hex');
// One Node process owns this embedded store. No credentials or identity are accepted
// from a report request; the owner always comes from a server-created session.
module.exports = function createAccounts(root, env = process.env) {
const rednote = env.JUZI_ENTRY_MODE === 'rednote';
const sessionCookie = rednote ? 'juzi_rednote_session' : 'juzi_session';
const persistentKind = kind => ['wechat', 'taobao'].includes(kind) || (rednote && kind === 'browser_persistent');
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
const dbFile = path.join(root, 'identities.json');
const snapshots = path.join(root, 'reports');
@@ -32,7 +35,7 @@ module.exports = function createAccounts(root, env = process.env) {
const guestSessions = new Map(); // No guest identities/sessions are written to disk.
const appId = env.WECHAT_APP_ID || '';
const secret = env.WECHAT_APP_SECRET || '';
const available = !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
const available = !rednote && !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
function cookies(req) {
return Object.fromEntries((req.headers.cookie || '').split(';').map(s => s.trim().split('=')));
}
@@ -42,17 +45,17 @@ module.exports = function createAccounts(root, env = process.env) {
res.setHeader('Set-Cookie', [].concat(old, line));
}
function session(req) {
const token = cookies(req).juzi_session;
const token = cookies(req)[sessionCookie];
const guest = token && guestSessions.get(hash(token));
if (guest && guest.expires > Date.now()) return guest.user;
const row = token && db.sessions[hash(token)];
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'wechat') : null;
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === (rednote ? 'browser_persistent' : 'wechat')) : null;
}
function login(res, user) {
for (const key of Object.keys(db.sessions)) if (db.sessions[key].expires < Date.now()) delete db.sessions[key];
const token = random();
db.sessions[hash(token)] = { user: user.id, expires: Date.now() + 30 * 86400000 };
cookie(res, 'juzi_session', token, 30 * 86400);
cookie(res, sessionCookie, token, 30 * 86400);
save();
}
function publicUser(u) { return u ? { id: u.id, kind: u.kind } : null; }
@@ -91,6 +94,9 @@ module.exports = function createAccounts(root, env = process.env) {
if (!pathname.startsWith('/api/auth/') && !pathname.startsWith('/api/reports')) return false;
try {
if (req.method !== 'GET' && !sameOrigin(req)) { json(res, 403, { ok: false, message: '请求来源不匹配' }); return true; }
if (rednote && pathname.startsWith('/api/auth/wechat')) {
json(res, 404, { ok: false, message: '此入口不提供微信登录' }); return true;
}
if (pathname === '/api/auth/config' && req.method === 'GET') {
json(res, 200, { ok: true, wechat: available, fallback: 'browser', reason: available ? null : '当前环境尚未开通微信登录,可使用游客登录' });
} else if (pathname === '/api/auth/session' && req.method === 'GET') {
@@ -101,20 +107,28 @@ module.exports = function createAccounts(root, env = process.env) {
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
// Old persistent guest tokens cannot restore identity or history.
if (data.token) { json(res, 401, { ok: false, message: '游客身份已失效,请重新登录' }); return true; }
if (rednote) {
let user = session(req);
if (!user) {
user = { id: crypto.randomUUID(), kind: 'browser_persistent', channel: 'rednote', consent: new Date().toISOString(), reports: [] };
db.users.push(user); login(res, user);
}
json(res, 200, { ok: true, user: publicUser(user) }); return true;
}
for (const [key, value] of guestSessions) if (value.expires < Date.now()) guestSessions.delete(key);
const previous = cookies(req).juzi_session;
const previous = cookies(req)[sessionCookie];
if (previous) guestSessions.delete(hash(previous));
const token = random(), user = { id: crypto.randomUUID(), kind: 'browser' };
guestSessions.set(hash(token), { user, expires: Date.now() + 8 * 3600000 });
cookie(res, 'juzi_session', token, null);
cookie(res, sessionCookie, token, null);
json(res, 200, { ok: true, user: publicUser(user) });
} else if (pathname === '/api/auth/logout' && req.method === 'POST') {
const token = cookies(req).juzi_session;
const token = cookies(req)[sessionCookie];
if (token) {
guestSessions.delete(hash(token));
if (db.sessions[hash(token)]) { delete db.sessions[hash(token)]; save(); }
}
cookie(res, 'juzi_session', '', 0); json(res, 200, { ok: true });
cookie(res, sessionCookie, '', 0); json(res, 200, { ok: true });
} else if (pathname === '/api/auth/wechat' && req.method === 'POST') {
const data = await body(req);
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
@@ -155,7 +169,7 @@ module.exports = function createAccounts(root, env = process.env) {
const user = session(req);
if (!user) { json(res, 401, { ok: false, message: '请先登录' }); return true; }
const id = pathname.slice('/api/reports/'.length);
if (user.kind !== 'wechat') {
if (user.kind !== 'wechat' && !(rednote && user.kind === 'browser_persistent')) {
json(res, pathname === '/api/reports' ? 200 : 404,
pathname === '/api/reports' ? { ok: true, total: 0, items: [] } : { ok: false, message: '游客模式不保存历史报告' });
return true;
@@ -177,7 +191,7 @@ module.exports = function createAccounts(root, env = process.env) {
}
function storeReport(ownerId, snapshot) {
const user = db.users.find(u => u.id === ownerId);
if (!user || !['wechat', 'taobao'].includes(user.kind) || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
if (!user || !persistentKind(user.kind) || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
const dir = path.join(snapshots, user.id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
atomic(path.join(dir, snapshot.id + '.json'), snapshot);
user.reports = [{ id: snapshot.id, created_at: snapshot.created_at, total: snapshot.vm.total, skinAge: snapshot.vm.skinAgeDisplay }]
@@ -193,7 +207,7 @@ module.exports = function createAccounts(root, env = process.env) {
catch (_) { return null; }
}
function reportSnapshot(ownerId,id) {
if (!db.users.some(u=>u.id===ownerId&&['wechat','taobao'].includes(u.kind)) || !/^R[\w-]+$/.test(id)) return null;
if (!db.users.some(u=>u.id===ownerId&&persistentKind(u.kind)) || !/^R[\w-]+$/.test(id)) return null;
try {return JSON.parse(fs.readFileSync(path.join(snapshots,ownerId,id+'.json'),'utf8'));} catch (_) {return null;}
}
function updateWrinkleViews(ownerId,id,views) {
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const path = require('node:path');
const fs = require('node:fs');
function physical(file) {
if (fs.existsSync(file)) return fs.realpathSync(file);
const parent = path.dirname(file);
return parent === file ? file : path.join(physical(parent), path.basename(file));
}
function inside(file, parent) {
if (process.platform === 'win32') { file = file.toLowerCase(); parent = parent.toLowerCase(); }
return file === parent || file.startsWith(parent + path.sep);
}
// One immutable entry mode per process. Never infer a channel from client input.
module.exports = function entryConfig(env, root) {
const mode = env.JUZI_ENTRY_MODE || 'state';
if (!['state', 'rednote'].includes(mode)) throw Error('Unsupported JUZI_ENTRY_MODE');
const rednote = mode === 'rednote';
const dataRoot = env.JUZI_DATA_ROOT ? path.resolve(env.JUZI_DATA_ROOT) : path.join(root, 'data');
if (rednote) {
const protectedRoots = [root, env.JUZI_PROTECTED_DATA_ROOT].filter(Boolean).map(p => physical(path.resolve(p)));
if (!env.JUZI_DATA_ROOT || protectedRoots.some(p => inside(physical(dataRoot), p))) {
throw Error('Rednote requires its own JUZI_DATA_ROOT outside the shared h5 directory and protected data');
}
const base = new URL(env.PUBLIC_BASE_URL || '');
if (!['http:', 'https:'].includes(base.protocol) || base.pathname !== '/rednote/' || base.search || base.hash || base.username || base.password ||
(env.NODE_ENV === 'production' && base.protocol !== 'https:')) {
throw Error('Rednote PUBLIC_BASE_URL must end with /rednote/');
}
}
return { mode, rednote, dataRoot, channel: rednote ? 'rednote' : 'h5',
client: { mode, persistentBrowser: rednote } };
};
+17
View File
@@ -0,0 +1,17 @@
'use strict';
// Rednote-only admission control. Includes JSON consumption in the critical section.
module.exports = function providerGate(fetchImpl, maxPending = 4) {
let tail = Promise.resolve(), pending = 0;
return function (...args) {
if (pending >= maxPending) return Promise.reject(Error('Detection queue is full'));
pending++;
const result = tail.then(async () => {
args[1]?.signal?.throwIfAborted();
const response = await fetchImpl(...args);
const data = await response.json();
return { ok: response.ok, status: response.status, json: async () => data };
});
tail = result.catch(() => {}).finally(() => { pending--; });
return result;
};
};
+13
View File
@@ -0,0 +1,13 @@
'use strict';
// Only the rednote instance transforms the shared HTML; state is served verbatim.
module.exports = function rednoteView(html, admin = false) {
const bootstrap = '<script>window.JuziEntry={mode:"rednote",persistentBrowser:true};</script>';
if (admin) return html.replace('</head>', bootstrap + '</head>')
.replace('<title>', '<title>Rednote · ');
return html.replace('</head>', bootstrap + '<link rel="stylesheet" href="css/rednote.css"></head>')
.replace(/<button class="login-button login-wechat"[\s\S]*?<\/button>/, '')
.replace('游客登陆</button>', '浏览器继续体验</button>')
.replace('我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。',
'同意后将自动建立本机浏览器身份。我们将采集面部影像和基础档案,交由 Face⁺⁺ 分析,并在服务器保存照片和报告,供此浏览器查看最近5份报告。浏览器身份有效期30天,清除浏览器数据或更换设备后无法恢复。')
.replace('<h1 class="policy-title">', '<p>本入口使用浏览器身份保存检测记录及照片,最近5份报告可由同一浏览器查看。Face⁺⁺提供检测分析,以下为其政策说明;清除本机身份后无法恢复原身份的历史报告。</p><h1 class="policy-title">');
};
+2 -2
View File
@@ -16,7 +16,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
fs.renameSync(tmp,file);
}
function register(owner,record,context) {
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:['wechat','taobao'].includes(owner.kind),status:'pending',context};
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:['wechat','taobao','browser_persistent'].includes(owner.kind),status:'pending',context};
save(job);remember(job);
return {token:job.token,status:'pending'};
}
@@ -26,7 +26,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
let job=jobs.get(token);
if (!job) {
try {job=JSON.parse(fs.readFileSync(path.join(dir,token+'.json'),'utf8'));} catch (_) {return null;}
if (job.owner!==owner.id || !['wechat','taobao'].includes(owner.kind)) return null;
if (job.owner!==owner.id || !['wechat','taobao','browser_persistent'].includes(owner.kind)) return null;
remember(job);
}
if (job.owner!==owner.id) return null;
+24 -12
View File
@@ -20,8 +20,10 @@ 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 entry = require('./lib/entry-config')(process.env, ROOT);
const providerFetch = entry.rednote ? require('./lib/provider-gate')((...args) => fetch(...args)) : (...args) => fetch(...args);
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_ROOT = entry.dataRoot;
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)
@@ -34,7 +36,7 @@ const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
const wrinkleJobs = require('./lib/wrinkle-jobs.js')({
dir:path.join(DATA_ROOT,'wrinkle-jobs'),
collect:async context=>WrinkleViews.forReport(await WrinkleViews.collect(context.primary,context.photos,{
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,gapMs:process.env.NODE_ENV==='test'?0:4000,deadline:Date.now()+60000
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,fetch:providerFetch,gapMs:process.env.NODE_ENV==='test'?0:4000,deadline:Date.now()+60000
})),
load(owner,id) {
const snapshot=accounts.reportSnapshot(owner,id);
@@ -116,6 +118,9 @@ function serveStatic(req, res, pathname) {
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'); }
if (entry.rednote && ['index.html', 'admin.html'].some(name => file === path.join(ROOT, name))) {
data = Buffer.from(require('./lib/rednote-view')(data.toString('utf8'), path.basename(file) === 'admin.html'));
}
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.
@@ -360,7 +365,7 @@ async function runDetect(rec, front, left, right, persist = true, deferWrinkles
const fpJson = await callFacepp(front, left, right);
if (fpJson.result && !fpJson.error_message && !deferWrinkles) {
fpJson.wrinkle_views = await WrinkleViews.collect(fpJson,{left,right},{
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,fetch:providerFetch,
gapMs:process.env.NODE_ENV==='test'?0:4000,
// Legacy admin retries may still collect inline; customer reports use
// deferred tasks instead. Missing side maps never fail a report.
@@ -404,7 +409,7 @@ async function callFacepp(front, left, right) {
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) });
fpRes = await providerFetch(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;
@@ -432,8 +437,8 @@ async function handleAnalyze(req, res, miniProgram = false, preparedBody = null,
if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, message: '请求来源不匹配' });
const body = preparedBody || JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
// A client-supplied channel must never change H5 guest retention behavior.
const channel = miniProgram ? 'taobao' : 'h5';
persist = ['wechat', 'taobao'].includes(owner.kind);
const channel = miniProgram ? 'taobao' : entry.channel;
persist = ['wechat', 'taobao', 'browser_persistent'].includes(owner.kind);
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
const front = stripDataPrefix(typeof body.front_image === 'string' ? body.front_image : '');
const leftB64 = stripDataPrefix(typeof body.left_image === 'string' ? body.left_image : '');
@@ -572,9 +577,10 @@ function sendJson(res, status, 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`;
const prefix = entry.rednote ? '/rednote' : '';
if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front`;
if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left`;
if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right`;
return urls;
}
@@ -941,9 +947,10 @@ async function handleAdmin(req, res, pathname, url) {
}
}
let rednoteAnalyses = 0;
http.createServer(async (req, res) => {
const pathname = req.url.split('?')[0];
if (pathname.startsWith('/api/taobao/')) return handleTaobao(req, res, pathname);
if (pathname.startsWith('/api/taobao/')) return entry.rednote ? sendJson(res, 404, { ok: false }) : handleTaobao(req, res, pathname);
if (await accounts.route(req, res, pathname)) return;
if (req.method==='POST' && pathname==='/api/wrinkle-views') {
const owner=accounts.session(req);
@@ -962,10 +969,15 @@ http.createServer(async (req, res) => {
return sendJson(res, accepted ? 200 : 400, { ok: accepted });
} catch (e) { return sendJson(res, e.message === 'TOO_LARGE' ? 413 : e instanceof SyntaxError ? 400 : 500, { ok: false }); }
}
if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res);
if (req.method === 'POST' && pathname === '/api/skin-analyze') {
if (!entry.rednote) return handleAnalyze(req, res);
if (rednoteAnalyses) return sendJson(res, 429, { ok: false, code: 'BUSY', message: '当前检测人数较多,请稍后重试' });
rednoteAnalyses++;
try { return await handleAnalyze(req, res); } finally { rednoteAnalyses--; }
}
if (req.method === 'GET' && pathname === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true }));
return res.end(JSON.stringify(entry.rednote ? { ok: true, channel: entry.channel } : { ok: true }));
}
if (pathname.startsWith('/api/admin/')) {
return handleAdmin(req, res, pathname, new URL(req.url, 'http://localhost'));