From 4af825a5e422c633c43dd0df3923ada1e6a8f760 Mon Sep 17 00:00:00 2001 From: xiangsilian Date: Mon, 19 Oct 2026 22:59:10 +0800 Subject: [PATCH] feat(rednote): add isolated persistent browser H5 entry --- README.md | 1 + h5/css/rednote.css | 21 ++++ h5/js/account-client.js | 11 +- h5/js/admin.js | 14 ++- h5/js/app.js | 36 ++++-- h5/js/profile-store.js | 4 +- h5/lib/accounts.js | 36 ++++-- h5/lib/entry-config.js | 33 +++++ h5/lib/provider-gate.js | 17 +++ h5/lib/rednote-view.js | 13 ++ h5/lib/wrinkle-jobs.js | 4 +- h5/server.js | 36 ++++-- rednote/README.md | 56 +++++++++ rednote/deploy/juzi-rednote.service | 27 ++++ rednote/deploy/nginx-rednote.locations.conf | 42 +++++++ rednote/package-lock.json | 62 +++++++++ rednote/package.json | 11 ++ rednote/scripts/dev.cjs | 20 +++ rednote/scripts/fixture-server.cjs | 16 +++ rednote/scripts/proxy.cjs | 17 +++ rednote/test/backend.test.cjs | 69 ++++++++++ rednote/test/browser.cjs | 132 ++++++++++++++++++++ rednote/test/config.test.cjs | 51 ++++++++ rednote/test/support.cjs | 23 ++++ 24 files changed, 707 insertions(+), 45 deletions(-) create mode 100644 h5/css/rednote.css create mode 100644 h5/lib/entry-config.js create mode 100644 h5/lib/provider-gate.js create mode 100644 h5/lib/rednote-view.js create mode 100644 rednote/README.md create mode 100644 rednote/deploy/juzi-rednote.service create mode 100644 rednote/deploy/nginx-rednote.locations.conf create mode 100644 rednote/package-lock.json create mode 100644 rednote/package.json create mode 100644 rednote/scripts/dev.cjs create mode 100644 rednote/scripts/fixture-server.cjs create mode 100644 rednote/scripts/proxy.cjs create mode 100644 rednote/test/backend.test.cjs create mode 100644 rednote/test/browser.cjs create mode 100644 rednote/test/config.test.cjs create mode 100644 rednote/test/support.cjs diff --git a/README.md b/README.md index 9301ea5..84fba1a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ - `h5/`:现有浏览器/微信公众号 H5、管理后台,以及共享后端 `server.js`。 - `taobao/`:淘宝原生小程序。开发、联调、测试说明见 [taobao/README.md](taobao/README.md)。 +- `rednote/`:复用 H5 的持久浏览器身份入口,独立运行和存储。说明见 [rednote/README.md](rednote/README.md)。 - `h5/js/skin-score.js`、`report-ui.js`、`report-copy.js`:业务规则的源文件。小程序内的副本由同步脚本生成并检查哈希。 - `h5/lib/taobao-*.js`:淘宝网关验签、短期上传/异步任务和独立 API 路由。 - `docs/`、`reference/`、`design/`、`feedback/`、`new_feedback/`:需求、接口资料、设计与历轮反馈;早期文档的规则可能已被后续实现替代。 diff --git a/h5/css/rednote.css b/h5/css/rednote.css new file mode 100644 index 0000000..27a0504 --- /dev/null +++ b/h5/css/rednote.css @@ -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; } diff --git a/h5/js/account-client.js b/h5/js/account-client.js index 6097b73..c742a8f 100644 --- a/h5/js/account-client.js +++ b/h5/js/account-client.js @@ -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; }); } diff --git a/h5/js/admin.js b/h5/js/admin.js index eb69131..db99681 100644 --- a/h5/js/admin.js +++ b/h5/js/admin.js @@ -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 - ? ' 下载完整原始 JSON' + var rawFile = data.raw_file || (window.JuziEntry && window.JuziEntry.mode === 'rednote' ? r.raw_file : null); + var rawDl = rawFile + ? ' 下载完整原始 JSON' : ''; - h += dSec('接口原始 JSON', '
展开/收起(' + (data.raw_file || '') + ')' + rawDl + '
' +
+    h += dSec('接口原始 JSON', '
展开/收起(' + (rawFile || '') + ')' + rawDl + '
' +
       esc(JSON.stringify(data.raw_json, null, 1)) + '
'); } else { h += dSec('接口原始 JSON', '
无(失败记录或未落盘)
'); @@ -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 = ''; diff --git a/h5/js/app.js b/h5/js/app.js index 960bcc1..0d24de7 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -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 = '

暂无历史报告
请使用微信登录,后续检测报告会自动保存。

'; @@ -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 = '

暂无报告
完成一次检测后,报告会自动保存在这里。

'; 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' diff --git a/h5/js/profile-store.js b/h5/js/profile-store.js index 41548c0..777d69d 100644 --- a/h5/js/profile-store.js +++ b/h5/js/profile-store.js @@ -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; diff --git a/h5/lib/accounts.js b/h5/lib/accounts.js index c3bcd1d..fc3bdbe 100644 --- a/h5/lib/accounts.js +++ b/h5/lib/accounts.js @@ -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) { diff --git a/h5/lib/entry-config.js b/h5/lib/entry-config.js new file mode 100644 index 0000000..d770fa2 --- /dev/null +++ b/h5/lib/entry-config.js @@ -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 } }; +}; diff --git a/h5/lib/provider-gate.js b/h5/lib/provider-gate.js new file mode 100644 index 0000000..5084470 --- /dev/null +++ b/h5/lib/provider-gate.js @@ -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; + }; +}; diff --git a/h5/lib/rednote-view.js b/h5/lib/rednote-view.js new file mode 100644 index 0000000..b8138b1 --- /dev/null +++ b/h5/lib/rednote-view.js @@ -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 = ''; + if (admin) return html.replace('', bootstrap + '') + .replace('', '<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">'); +}; diff --git a/h5/lib/wrinkle-jobs.js b/h5/lib/wrinkle-jobs.js index f9da66a..e3fb018 100644 --- a/h5/lib/wrinkle-jobs.js +++ b/h5/lib/wrinkle-jobs.js @@ -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; diff --git a/h5/server.js b/h5/server.js index 3ffc26a..e4de71c 100644 --- a/h5/server.js +++ b/h5/server.js @@ -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')); diff --git a/rednote/README.md b/rednote/README.md new file mode 100644 index 0000000..f595559 --- /dev/null +++ b/rednote/README.md @@ -0,0 +1,56 @@ +# Rednote H5 + +面向 `/rednote/` 的浏览器入口,复用 `h5/` 的页面、相机、检测、评分、报告及管理后台。不是小红书账号授权:同意隐私说明后由服务器生成随机浏览器身份,使用独立 HttpOnly Cookie 保存30天,可查看最近5份完整报告。清除浏览器数据、清除本机身份、身份到期或更换设备后无法恢复原身份;不同 App 的内置浏览器可能不共享身份。 + +## 本地样本环境 + +```powershell +cd D:\juzi\rednote +npm ci +npm run dev +``` + +- 新入口:`http://127.0.0.1:18380/rednote/`,后台 `/rednote/admin.html`。 +- 对照入口:`http://127.0.0.1:18380/state/`,本地未配置微信真实授权,只提供原游客体验。 +- 数据:`../temp/rednote-local-data/rednote/` 和 `state/`,与真实 H5 数据隔离。 +- 所有检测返回固定样本,不连接 Face++ 或淘宝/微信。后台在回环开发环境不要求 Basic Auth,公网必须使用部署模板的鉴权保护。 + +## 配置与隔离 + +真实服务直接运行共享 `h5/server.js`,不要运行 fixture-server 或 dev 脚本。新增 `JUZI_ENTRY_MODE=rednote`,并显式配置外部独立 `JUZI_DATA_ROOT` 及以 `/rednote/` 结尾的 `PUBLIC_BASE_URL`。配置错误会在初始化账号/数据目录之前终止。部署模板另通过 `JUZI_PROTECTED_DATA_ROOT` 拦截旧数据路径(含符号链接),并将旧部署目录以只读方式挂载到新服务的运行环境;生产模式要求HTTPS。未设置入口模式时仍是原 state 模式。 + +- 原入口:`juzi_session`,原有 `/state/` Cookie 路径,微信和临时游客规则不变。 +- 新入口:`juzi_rednote_session`,`/rednote/` 路径,服务器持久化浏览器身份;不会恢复微信 Cookie,也不会接受微信/淘宝登录接口。 +- 账号身份和会话仅由服务器生成,客户端提交的 token、用户 ID、channel 不用来恢复身份或决定来源;服务器只保存会话 token 哈希。 +- 表单缓存使用 `juzi.rednote.profile.v1.*` 和 `juzi.rednote.auth.kind.v1`,避免同域 localStorage 与 state 相互清理。 +- 首次打开仅生成页面内体验状态,同意隐私后才创建服务器身份;有效 Cookie 自动恢复。清除本机身份会撤销服务端会话并清理本机档案缓存,界面明确说明历史不可恢复。 +- 检测记录统一由服务端标记 `channel=rednote`。新实例后台及 CSV 可查看新来源,原实例数据和后台不改。 +- 每个新实例同时只接纳1个主检测;上游主检测和侧脸请求使用有界串行队列。限流只约束新实例,不能代替旧入口和新入口共享 Face++ 账户的全局额度管理。 +- 前台展示最近5份报告,后台检测记录及照片的保留方式沿用现有微信入口;没有删除或迁移原渠道数据。 + +## 验证 + +```powershell +npm test +npm run test:browser +cd ..\taobao +npm test +npm run check:shared +npm run test:h5 +``` + +新测试覆盖持久身份、首次同意、Secure Cookie、来源防伪、重启恢复、最近5份报告、越权拦截、侧脸缓存、并发限制,以及同一浏览器下 state/rednote 的登录、缓存、历史和退出隔离。浏览器截图位于 `../temp/rednote-browser/`。自动测试使用样本,不代表手机相机已验收。 + +## 部署方式 + +2026-10-19发布前基线:线上 `juzi-h5.service` 在127.0.0.1:8080运行,工作目录 `/home/ubuntu/juzi/h5`,版本 `fc9f251`;`/state/` 正常,微信授权已启用。新入口使用独立目录、进程和数据存储。 + +部署到 `/home/ubuntu/juzi-rednote/releases/<版本>/`,`current` 仅指向新入口发布目录;数据固定 `/home/ubuntu/juzi-rednote-data`。不能在旧线上目录执行 git pull、覆盖其静态文件、移动旧数据或重启旧服务。 + +`deploy/juzi-rednote.service` 和 `deploy/nginx-rednote.locations.conf` 是部署模板,不会自动安装。管理员通过私有 `/etc/juzi/rednote.env` 配置 Face++ 凭据;不复制旧微信配置,也不将密钥放进仓库。 + +发布顺序:备份原Nginx配置并记录旧进程/版本/健康基线 → 部署新目录和独立数据 → 启动新服务并用8081检查 → 在现有HTTPS server中仅添加新location → `nginx -t`通过后平滑reload → 两入口健康、鉴权和数据隔离检查 → 用户手机验收相机及微信登录。 + +回退:还原新增前的Nginx配置、检查并reload,停止新服务;保留新入口数据供复核。无需重启旧服务,不删除或覆盖任何原数据。 + +共享主机、Nginx与Face++额度仍是公共依赖;生产发布记录应包含版本、配置备份位置及两入口验证结果。 diff --git a/rednote/deploy/juzi-rednote.service b/rednote/deploy/juzi-rednote.service new file mode 100644 index 0000000..e5c0250 --- /dev/null +++ b/rednote/deploy/juzi-rednote.service @@ -0,0 +1,27 @@ +[Unit] +Description=1me rednote H5 - isolated browser identities +After=network.target + +[Service] +Type=simple +User=ubuntu +WorkingDirectory=/home/ubuntu/juzi-rednote/current/h5 +Environment=JUZI_ENTRY_MODE=rednote +Environment=NODE_ENV=production +Environment=HOST=127.0.0.1 +Environment=PORT=8081 +Environment=PUBLIC_BASE_URL=https://1me.xajuzi.com/rednote/ +Environment=JUZI_DATA_ROOT=/home/ubuntu/juzi-rednote-data +Environment=JUZI_PROTECTED_DATA_ROOT=/home/ubuntu/juzi/h5/data +Environment=TRUST_LOOPBACK_PROXY=1 +EnvironmentFile=/etc/juzi/rednote.env +ExecStart=/usr/bin/node /home/ubuntu/juzi-rednote/current/h5/server.js +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ReadOnlyPaths=/home/ubuntu/juzi +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/rednote/deploy/nginx-rednote.locations.conf b/rednote/deploy/nginx-rednote.locations.conf new file mode 100644 index 0000000..979dd67 --- /dev/null +++ b/rednote/deploy/nginx-rednote.locations.conf @@ -0,0 +1,42 @@ +# Insert into the EXISTING HTTPS server during an authorized deployment. +# Retain all /state/, /api/, TLS and WeChat verification locations unchanged. +location = /rednote { + return 301 /rednote/; +} +location = /rednote/admin.html { + auth_basic "juzi admin"; + auth_basic_user_file /etc/nginx/.htpasswd-juzi; + proxy_pass http://127.0.0.1:8081/admin.html; + proxy_set_header Host $host; +} +location = /rednote/css/admin.css { + auth_basic "juzi admin"; + auth_basic_user_file /etc/nginx/.htpasswd-juzi; + proxy_pass http://127.0.0.1:8081/css/admin.css; +} +location = /rednote/js/admin.js { + auth_basic "juzi admin"; + auth_basic_user_file /etc/nginx/.htpasswd-juzi; + proxy_pass http://127.0.0.1:8081/js/admin.js; +} +location ^~ /rednote/api/admin/ { + auth_basic "juzi admin"; + auth_basic_user_file /etc/nginx/.htpasswd-juzi; + proxy_pass http://127.0.0.1:8081/api/admin/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 150s; +} +location ^~ /rednote/data/ { + return 404; +} +location /rednote/ { + client_max_body_size 20m; + proxy_pass http://127.0.0.1:8081/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 150s; +} diff --git a/rednote/package-lock.json b/rednote/package-lock.json new file mode 100644 index 0000000..edba36e --- /dev/null +++ b/rednote/package-lock.json @@ -0,0 +1,62 @@ +{ + "name": "juzi-skin-rednote", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "juzi-skin-rednote", + "version": "0.1.0", + "devDependencies": { + "playwright": "1.58.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/rednote/package.json b/rednote/package.json new file mode 100644 index 0000000..8916736 --- /dev/null +++ b/rednote/package.json @@ -0,0 +1,11 @@ +{ + "name": "juzi-skin-rednote", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "node scripts/dev.cjs", + "test": "node --test test/*.test.cjs", + "test:browser": "node test/browser.cjs" + }, + "devDependencies": { "playwright": "1.58.2" } +} diff --git a/rednote/scripts/dev.cjs b/rednote/scripts/dev.cjs new file mode 100644 index 0000000..615941a --- /dev/null +++ b/rednote/scripts/dev.cjs @@ -0,0 +1,20 @@ +'use strict'; +const path = require('node:path'), { spawn } = require('node:child_process'); +const root = path.resolve(__dirname, '../..'), children = []; +for (const mode of ['state', 'rednote']) { + children.push(spawn(process.execPath, [path.join(__dirname, 'fixture-server.cjs')], { + env: { ...process.env, JUZI_ENTRY_MODE: mode, PORT: mode === 'state' ? '18381' : '18382', + PUBLIC_BASE_URL: 'http://127.0.0.1:18380/' + mode + '/', JUZI_DATA_ROOT: path.join(root, 'temp/rednote-local-data', mode) }, + stdio: 'inherit' + })); +} +const proxy = require('./proxy.cjs')({ statePort: 18381, rednotePort: 18382 }); +proxy.listen(18380, '127.0.0.1', () => console.log('Samples only: http://127.0.0.1:18380/rednote/ and /state/; no external API calls')); +let closing = false; +function stop(code = 0) { + if (closing) return; closing = true; + children.forEach(child => { if (child.exitCode == null) child.kill(); }); + proxy.close(() => { process.exitCode = code; }); +} +children.forEach(child => child.on('exit', code => { if (!closing) stop(code || 1); })); +process.on('SIGINT', () => stop()); process.on('SIGTERM', () => stop()); diff --git a/rednote/scripts/fixture-server.cjs b/rednote/scripts/fixture-server.cjs new file mode 100644 index 0000000..7a97692 --- /dev/null +++ b/rednote/scripts/fixture-server.cjs @@ -0,0 +1,16 @@ +'use strict'; +// Local test/dev runner only. The production service launches h5/server.js directly. +const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); +if (!process.env.JUZI_DATA_ROOT || !process.env.PUBLIC_BASE_URL) throw Error('Explicit isolated data root and public URL required'); +Object.assign(process.env, { NODE_ENV: 'test', HOST: '127.0.0.1', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture', + WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', TAOBAO_APP_KEY: '', TAOBAO_APP_SECRET: '' }); +const root = path.resolve(__dirname, '../..'), context = { window: {} }; +vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), context); +const fixture = context.window.MOCK_FIXTURE; +global.fetch = async url => { + if (!String(url).startsWith('https://api-cn.faceplusplus.com/')) throw Error('External requests disabled in fixture mode'); + if (process.env.FIXTURE_DELAY_MS) await new Promise(resolve => setTimeout(resolve, Number(process.env.FIXTURE_DELAY_MS))); + return { ok: true, status: 200, json: async () => ({ request_id: 'fixture', result: structuredClone(fixture.result), + face_rectangle: fixture.face_rectangle, left_side_result: fixture.side.left, right_side_result: fixture.side.right }) }; +}; +require('../../h5/server'); diff --git a/rednote/scripts/proxy.cjs b/rednote/scripts/proxy.cjs new file mode 100644 index 0000000..5726d1d --- /dev/null +++ b/rednote/scripts/proxy.cjs @@ -0,0 +1,17 @@ +'use strict'; +const http = require('node:http'); +module.exports = function proxy({ statePort, rednotePort }) { + return http.createServer((req, res) => { + const mode = /^\/(state|rednote)(?:\/|\?|$)/.exec(req.url)?.[1]; + if (!mode) { res.writeHead(404); return res.end('Not Found'); } + if (req.url === '/' + mode) { res.writeHead(301, { Location: '/' + mode + '/' }); return res.end(); } + const upstream = http.request({ host: '127.0.0.1', port: mode === 'state' ? statePort : rednotePort, + method: req.method, path: req.url.slice(mode.length + 1) || '/', + headers: { ...req.headers, 'x-real-ip': '127.0.0.1', 'x-forwarded-proto': 'http' } }, reply => { + res.writeHead(reply.statusCode, reply.headers); reply.pipe(res); + }); + upstream.setTimeout(150000, () => upstream.destroy()); + upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end(); }); + req.pipe(upstream); + }); +}; diff --git a/rednote/test/backend.test.cjs b/rednote/test/backend.test.cjs new file mode 100644 index 0000000..0716fa1 --- /dev/null +++ b/rednote/test/backend.test.cjs @@ -0,0 +1,69 @@ +'use strict'; +const { test } = require('node:test'), assert = require('node:assert/strict'); +const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), vm = require('node:vm'); +const { freePort, start, stop } = require('./support.cjs'); +const fx = { window: {} }; +vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../h5/assets/mock/report-fixture.js'), 'utf8'), fx); +const fixture = fx.window.MOCK_FIXTURE; + +test('persistent browser consent, cookies, owner isolation, reports, source, admission control and restart', async () => { + const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-rednote-backend-')), port = await freePort(); + const base = 'http://127.0.0.1:' + port, publicBase = base + '/rednote/'; + let child; + const get = async (route, cookie = '') => fetch(base + route, { headers: { cookie } }); + const post = async (route, data = {}, cookie = '', headers = {}) => { + const response = await fetch(base + route, { method: 'POST', headers: { 'Content-Type': 'application/json', cookie, ...headers }, body: JSON.stringify(data) }); + return { status: response.status, cookie: response.headers.getSetCookie(), data: await response.json() }; + }; + try { + child = await start({ dataRoot, port, publicBase, delay: 80 }); + const html = await (await get('/')).text(); + assert(html.includes('window.JuziEntry={mode:"rednote"')); assert(!html.includes('data-action="login-wechat"')); + assert.equal((await (await get('/api/auth/config')).json()).wechat, false); + assert.equal((await post('/api/auth/wechat', { consent: true })).status, 404); + assert.equal((await get('/api/auth/wechat/callback?code=fake&state=fake')).status, 404); + assert.equal((await post('/api/taobao/session')).status, 404); + assert.equal((await post('/api/auth/browser', { consent: false })).status, 400); + assert.equal((await post('/api/auth/browser', { consent: true }, '', { Origin: 'https://evil.invalid' })).status, 403); + assert.equal(fs.existsSync(path.join(dataRoot, 'accounts/identities.json')), false, 'no identity before consent'); + const alice = await post('/api/auth/browser', { consent: true, channel: 'taobao', user_id: 'forged' }); + assert.equal(alice.data.user.kind, 'browser_persistent'); assert.notEqual(alice.data.user.id, 'forged'); + assert.match(alice.cookie[0], /juzi_rednote_session=[a-f0-9]{64}; Path=\/rednote\/; HttpOnly; SameSite=Lax; Max-Age=2592000/); + const cookie = alice.cookie[0].split(';')[0]; + assert.equal((await post('/api/auth/browser', { consent: true }, cookie)).data.user.id, alice.data.user.id); + assert.equal((await post('/api/auth/browser', { consent: true, token: cookie })).status, 401); + const bob = await post('/api/auth/browser', { consent: true }), bobCookie = bob.cookie[0].split(';')[0]; + const body = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], + profile: { real_age: 44, daily_skin_type: '干性肌肤', gender: '女' }, channel: 'h5' }; + const [first, busy] = await Promise.all([post('/api/skin-analyze', body, cookie), post('/api/skin-analyze', body, cookie)]); + assert.equal(first.status, 200); assert.equal(first.data.report_saved, true); + assert.equal(busy.status, 429); assert.equal(busy.data.code, 'BUSY'); + assert.equal((await get('/api/reports/' + first.data.record_id, bobCookie)).status, 404); + assert.equal((await get('/api/reports', cookie.replace('juzi_rednote_session=', 'juzi_session='))).status, 401); + assert.equal((await get('/data/accounts/identities.json')).status, 403); + const detail = await (await get('/api/admin/records/' + first.data.record_id)).json(); + assert.equal(detail.record.channel, 'rednote'); + assert(detail.photos.front.startsWith('/rednote/api/admin/photos/')); + const ids = [first.data.record_id]; let last = first; + for (let i = 0; i < 5; i++) { last = await post('/api/skin-analyze', body, cookie); assert.equal(last.data.report_saved, true); ids.push(last.data.record_id); } + const history = await (await get('/api/reports', cookie)).json(); + assert.equal(history.total, 5); assert.equal(history.items[0].id, ids.at(-1)); + assert.equal((await get('/api/reports/' + ids[0], cookie)).status, 404); + assert.equal((await (await get('/api/reports', bobCookie)).json()).total, 0); + const stats = await (await get('/api/admin/stats')).json(); assert.equal(stats.by_channel.rednote.total, 6); + assert.equal((await (await get('/api/admin/records?channel=h5')).json()).total, 0); + const csv = await (await get('/api/admin/export?channel=rednote')).text(); assert.equal(csv.trim().split('\n').length, 7); assert(csv.includes(',rednote')); + const snapshot = await (await get('/api/reports/' + last.data.record_id, cookie)).json(); + await stop(child); child = await start({ dataRoot, port, publicBase }); + assert.equal((await (await get('/api/auth/session', cookie)).json()).user.id, alice.data.user.id); + assert.deepEqual((await (await get('/api/reports/' + last.data.record_id, cookie)).json()).report, snapshot.report); + const side = await post('/api/wrinkle-views', { token: last.data.wrinkle_job.token }, cookie); + assert.equal(side.data.ok, true); assert.equal(side.data.wrinkle_views.left.status, 'ready'); + assert.equal((await post('/api/wrinkle-views', { token: last.data.wrinkle_job.token }, bobCookie)).status, 404); + assert.equal((await post('/api/auth/logout', {}, cookie)).status, 200); + assert.equal((await get('/api/reports', cookie)).status, 401); + assert.notEqual((await post('/api/auth/browser', { consent: true }, cookie)).data.user.id, alice.data.user.id); + const db = fs.readFileSync(path.join(dataRoot, 'accounts/identities.json'), 'utf8'); + assert(!db.includes(cookie.split('=')[1]), 'server stores session hashes, not cookie secrets'); + } finally { await stop(child); fs.rmSync(dataRoot, { recursive: true, force: true }); } +}); diff --git a/rednote/test/browser.cjs b/rednote/test/browser.cjs new file mode 100644 index 0000000..a3600f5 --- /dev/null +++ b/rednote/test/browser.cjs @@ -0,0 +1,132 @@ +'use strict'; +const assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); +const { chromium } = require('playwright'), { freePort, start, stop } = require('./support.cjs'); +const root = path.resolve(__dirname, '../..'); +const artifacts = path.join(root, 'temp/rednote-browser'); fs.mkdirSync(artifacts, { recursive: true }); +const runRoot = fs.mkdtempSync(path.join(root, 'temp/rednote-dual-')); +const ports = {}, children = []; let browser, proxy; +function disk(dir) { + const out = {}; + function visit(current) { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); if (entry.isDirectory()) visit(full); else out[path.relative(dir, full)] = fs.readFileSync(full).toString('base64'); + } } visit(dir); return out; +} +async function screenshot(page, name, fullPage = false) { + await page.evaluate(() => document.fonts.ready); + await page.screenshot({ path: path.join(artifacts, name + '.png'), fullPage, animations: 'disabled' }); +} +async function consent(page, capture = false) { + await page.click('#s-splash [data-action="open-privacy"]'); + if (capture) { + for (const size of [{ width: 320, height: 568 }, { width: 375, height: 726 }]) { + await page.setViewportSize(size); + const layout = await page.evaluate(() => { + const body = document.querySelector('#p-privacy .popup-body').getBoundingClientRect(); + const row = document.querySelector('#privacy-row').getBoundingClientRect(); + const button = document.querySelector('#p-privacy .popup-btn').getBoundingClientRect(); + return { separated: body.bottom <= row.top && row.bottom <= button.top, visible: button.bottom <= innerHeight }; + }); + assert(layout.separated && layout.visible, 'persistence disclosure must not overlap consent or hide Continue'); + } + await screenshot(page, 'rednote-consent'); + } + await page.locator('#privacy-check').click(); + await page.click('#p-privacy [data-action="agree-continue"]'); + await page.waitForSelector('#s-profile.active'); +} +(async () => { + try { + for (const key of ['proxy', 'state', 'rednote']) ports[key] = await freePort(); + const base = 'http://127.0.0.1:' + ports.proxy; + const stateData = path.join(runRoot, 'state'), rednoteData = path.join(runRoot, 'rednote'); + const wechat = require('../../h5/test/wechat-fixture.cjs')(path.join(stateData, 'accounts'))[0]; + for (const mode of ['state', 'rednote']) children.push(await start({ mode, dataRoot: path.join(runRoot, mode), port: ports[mode], publicBase: base + '/' + mode + '/' })); + proxy = require('../scripts/proxy.cjs')({ statePort: ports.state, rednotePort: ports.rednote }); + await new Promise(resolve => proxy.listen(ports.proxy, '127.0.0.1', resolve)); + const baseline = disk(stateData); + browser = await chromium.launch({ headless: true }); + let context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true }); + await context.addCookies([{ name: 'juzi_session', value: wechat.token, domain: '127.0.0.1', path: '/state/' }]); + const state = await context.newPage(), rednote = await context.newPage(), errors = [], rednoteRequests = []; + for (const page of [state, rednote]) page.on('pageerror', error => errors.push(error.message)); + rednote.on('request', request => { if (request.url().includes('/api/')) rednoteRequests.push(request.url()); }); + await state.goto(base + '/state/'); await state.waitForSelector('#s-splash.active'); + assert.equal(await state.locator('#s-splash [data-auth-status]').textContent(), '微信已登录'); + const stateKind = await state.evaluate(() => localStorage.getItem('juzi.auth.kind.v1')); + await rednote.goto(base + '/rednote/'); await rednote.waitForSelector('#s-splash.active'); + assert.equal(await rednote.locator('[data-action="login-wechat"]').count(), 0); + assert.equal((await context.cookies()).filter(c => c.name === 'juzi_rednote_session').length, 0, 'opening the page does not persist an identity before consent'); + await screenshot(rednote, 'rednote-home'); + await rednote.click('#s-splash [data-action="history"]'); + await rednote.waitForSelector('#s-history.active'); + assert((await rednote.locator('#identity-note').textContent()).includes('同意隐私')); + await rednote.goBack(); await rednote.waitForSelector('#s-splash.active'); + await consent(rednote, true); + const alice = await rednote.evaluate(() => AccountClient.user()); assert.equal(alice.kind, 'browser_persistent'); + const cookie = (await context.cookies()).find(c => c.name === 'juzi_rednote_session'); + assert.equal(cookie.path, '/rednote/'); assert(cookie.httpOnly && cookie.expires > Date.now() / 1000); + await rednote.evaluate(() => ProfileStore.create(() => localStorage).write(AccountClient.user(), { birth: '1996-01', gender: '女', region: { province: '北京市', city: '北京市' }, skinIndex: 2 })); + const stored = await context.storageState(); + await context.close(); + context = await browser.newContext({ storageState: stored, viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true }); + const restored = await context.newPage(); restored.on('pageerror', e => errors.push(e.message)); + restored.on('request', r => { if (r.url().includes('/api/')) rednoteRequests.push(r.url()); }); + await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active'); + assert.equal(await restored.evaluate(() => AccountClient.user().id), alice.id); + await consent(restored); assert.equal(await restored.locator('#f-birth').inputValue(), '1996-01'); + assert.equal(await restored.locator('#f-gender').inputValue(), '女'); + await screenshot(restored, 'rednote-profile'); + const fx = { window: {} }; vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), fx); + const photo = fx.window.MOCK_FIXTURE.photos[0]; + const payload = await (await context.request.post(base + '/rednote/api/skin-analyze', { data: { + front_image: photo, left_image: photo, right_image: photo, profile: { real_age: 30, daily_skin_type: '中性肌肤' }, channel: 'taobao' } })).json(); + assert.equal(payload.report_saved, true); + await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active'); + await restored.click('#s-splash [data-action="history"]'); await restored.waitForSelector('.history-detail'); + assert(!(await restored.locator('#identity-note').textContent()).includes('微信')); + await screenshot(restored, 'rednote-history'); + await restored.click('.history-detail'); await restored.waitForSelector('#s-report.active'); + assert.equal(await restored.locator('#stat-score b').textContent(), String(payload.vm.total)); + assert.equal(await restored.locator('#stat-age b').textContent(), String(payload.vm.skinAgeDisplay)); + await screenshot(restored, 'rednote-report', true); + const stateAgain = await context.newPage(); await stateAgain.goto(base + '/state/'); await stateAgain.waitForSelector('#s-splash.active'); + assert.equal(await stateAgain.evaluate(() => AccountClient.user().id), wechat.user.id); + assert.equal(await stateAgain.evaluate(() => localStorage.getItem('juzi.auth.kind.v1')), stateKind); + assert((await stateAgain.evaluate(() => Object.keys(localStorage))).some(key => key.startsWith('juzi.rednote.profile.v1.'))); + const admin = await browser.newPage({ viewport: { width: 1280, height: 900 } }), adminRequests = []; admin.on('pageerror', e => errors.push(e.message)); + admin.on('request', r => { if (r.url().includes('/api/')) adminRequests.push(r.url()); }); + await admin.goto(base + '/rednote/admin.html'); await admin.waitForSelector('#tbl-records .thumb'); + await admin.locator('#tbl-records tbody tr').first().click(); await admin.waitForSelector('#drawer:not(.hidden)'); + await admin.waitForSelector('#d-body a[download]'); + assert((await admin.locator('#d-body a[href$="/raw"]').getAttribute('href')).startsWith('/rednote/api/admin/')); + assert(adminRequests.every(url => url.startsWith(base + '/rednote/api/'))); + assert(adminRequests.some(url => url.includes('/api/admin/photos/'))); + await screenshot(admin, 'rednote-admin', true); + await admin.click('#btn-close'); + await admin.click('[data-tab="stats"]'); + const [download] = await Promise.all([admin.waitForEvent('download'), admin.click('#btn-export')]); + assert(download.url().startsWith(base + '/rednote/api/admin/export')); + assert(fs.readFileSync(await download.path(), 'utf8').includes(',rednote')); + await admin.click('[data-tab="records"]'); + assert(adminRequests.every(url => url.startsWith(base + '/rednote/api/'))); + await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active'); + restored.once('dialog', dialog => dialog.dismiss()); await restored.click('#s-splash [data-action="logout-wechat"]'); + assert.equal(await restored.evaluate(() => AccountClient.user().id), alice.id); + restored.once('dialog', dialog => dialog.accept()); await restored.click('#s-splash [data-action="logout-wechat"]'); + await restored.waitForFunction(() => AccountClient.user().kind === 'browser'); + assert.equal((await context.request.get(base + '/rednote/api/reports')).status(), 401); + await restored.goBack(); + assert.equal(await restored.locator('#s-report.active').count(), 0, 'back navigation cannot restore the old private report after clearing identity'); + assert.equal((await context.request.get(base + '/state/api/auth/session')).status(), 200); + assert.equal((await (await context.request.get(base + '/state/api/auth/session')).json()).user.id, wechat.user.id); + assert(rednoteRequests.every(url => url.startsWith(base + '/rednote/api/'))); + assert.deepEqual(disk(stateData), baseline, 'rednote operations never modify original state identities/reports/data'); + assert.deepEqual(errors, []); + console.log('PASS: consent-only creation, persistent browser restore, profile cache, saved/history reports, admin API/photo subpaths, source isolation, independent logout and unchanged state data; screenshots in temp/rednote-browser'); + } finally { + if (browser) await browser.close(); + if (proxy) await new Promise(resolve => proxy.close(resolve)); + for (const child of children) await stop(child); + fs.rmSync(runRoot, { recursive: true, force: true }); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/rednote/test/config.test.cjs b/rednote/test/config.test.cjs new file mode 100644 index 0000000..0dae47d --- /dev/null +++ b/rednote/test/config.test.cjs @@ -0,0 +1,51 @@ +'use strict'; +const { test } = require('node:test'), assert = require('node:assert/strict'), path = require('node:path'); +const config = require('../../h5/lib/entry-config'); +test('rednote rejects missing, shared data paths and wrong public prefixes before initializing storage', () => { + const root = path.resolve(__dirname, '../../h5'); + assert.equal(config({}, root).mode, 'state'); + const env = { JUZI_ENTRY_MODE: 'rednote', PUBLIC_BASE_URL: 'https://skin.example.test/rednote/' }; + for (const dataRoot of [undefined, root, path.join(root, 'custom-data'), path.join(root, 'data'), path.join(root, 'data/subdir')]) { + assert.throws(() => config({ ...env, JUZI_DATA_ROOT: dataRoot }, root), /own JUZI_DATA_ROOT/); + } + assert.throws(() => config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/rednote'), PUBLIC_BASE_URL: 'https://skin.example.test/state/' }, root), /rednote/); + assert.equal(config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/rednote') }, root).channel, 'rednote'); + assert.throws(() => config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/original'), JUZI_PROTECTED_DATA_ROOT: path.join(root, '../temp/original') }, root), /own JUZI_DATA_ROOT/); + assert.throws(() => config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/rednote'), NODE_ENV: 'production', PUBLIC_BASE_URL: 'http://skin.example.test/rednote/' }, root), /rednote/); +}); +test('HTTPS browser cookies are secure and persistent, even when WeChat credentials exist', async () => { + const fs = require('node:fs'), os = require('node:os'), { Readable } = require('node:stream'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-rednote-secure-')); + try { + const accounts = require('../../h5/lib/accounts')(dir, { JUZI_ENTRY_MODE: 'rednote', PUBLIC_BASE_URL: 'https://skin.example.test/rednote/', WECHAT_APP_ID: 'configured', WECHAT_APP_SECRET: 'fixture' }); + assert.equal(accounts.wechatAvailable, false); + const headers = {}; + const req = Readable.from([Buffer.from('{"consent":true}')]); + req.method = 'POST'; req.url = '/api/auth/browser'; req.headers = { host: 'skin.example.test', origin: 'https://skin.example.test' }; req.socket = { remoteAddress: '127.0.0.1' }; + let response; + await accounts.route(req, { setHeader: (k, v) => { headers[k] = v; }, getHeader: k => headers[k], writeHead() {}, end: text => { response = JSON.parse(text); } }, '/api/auth/browser'); + assert.match(headers['Set-Cookie'][0], /HttpOnly; SameSite=Lax; Max-Age=2592000; Secure$/); + assert.equal(response.user.kind, 'browser_persistent'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); +test('data-directory aliases cannot bypass protection of the original deployment', () => { + const fs = require('node:fs'), os = require('node:os'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-rednote-path-')); + try { + const original = path.join(dir, 'original'), alias = path.join(dir, 'alias'); fs.mkdirSync(original); + fs.symlinkSync(original, alias, process.platform === 'win32' ? 'junction' : 'dir'); + assert.throws(() => config({ JUZI_ENTRY_MODE: 'rednote', PUBLIC_BASE_URL: 'https://skin.example.test/rednote/', + JUZI_DATA_ROOT: path.join(alias, 'nested'), JUZI_PROTECTED_DATA_ROOT: original }, path.join(dir, 'h5')), /own JUZI_DATA_ROOT/); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); +test('new-channel provider requests serialize through body consumption and cap the queue', async () => { + let active = 0, peak = 0; + const gated = require('../../h5/lib/provider-gate')(async () => { + peak = Math.max(peak, ++active); + return { ok: true, status: 200, json: async () => { await new Promise(resolve => setTimeout(resolve, 10)); active--; return { ok: true }; } }; + }, 2); + const a = gated('fixture'), b = gated('fixture'); + await assert.rejects(gated('fixture'), /queue is full/); + assert.deepEqual(await (await a).json(), { ok: true }); await b; + assert.equal(peak, 1); assert.equal(active, 0); +}); diff --git a/rednote/test/support.cjs b/rednote/test/support.cjs new file mode 100644 index 0000000..781ce05 --- /dev/null +++ b/rednote/test/support.cjs @@ -0,0 +1,23 @@ +'use strict'; +const http = require('node:http'), path = require('node:path'), { spawn } = require('node:child_process'), { once } = require('node:events'); +async function freePort() { + const server = http.createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; await new Promise(resolve => server.close(resolve)); return port; +} +async function start({ mode = 'rednote', dataRoot, port, publicBase, delay = 0 }) { + const child = spawn(process.execPath, [path.join(__dirname, '../scripts/fixture-server.cjs')], { + env: { ...process.env, JUZI_ENTRY_MODE: mode, JUZI_DATA_ROOT: dataRoot, PORT: String(port), + PUBLIC_BASE_URL: publicBase, FIXTURE_DELAY_MS: String(delay) }, stdio: ['ignore', 'pipe', 'pipe'] }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { child.kill(); reject(Error('startup timeout')); }, 15000); + let error = ''; + child.stderr.on('data', bytes => { error += bytes; }); + child.stdout.on('data', bytes => { if (String(bytes).includes('已启动')) { clearTimeout(timer); resolve(); } }); + child.once('exit', code => { clearTimeout(timer); reject(Error('backend exited ' + code + ': ' + error)); }); + }); + return child; +} +async function stop(child) { + if (child && child.exitCode == null) { const exited = once(child, 'exit'); child.kill(); await exited; } +} +module.exports = { freePort, start, stop };