feat(h5): allow guest access without login and retain history only for WeChat

This commit is contained in:
xsl
2026-10-13 15:23:23 +08:00
parent 98935dd168
commit 1fdbb575b0
29 changed files with 2561 additions and 1866 deletions
+2
View File
@@ -79,6 +79,8 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
## 档案页与报告页
2026-10-13 当前留存规则:仅微信登录记住本机基础信息、在后端保留最近 5 份完整报告并支持跨设备查看。游客使用仅内存的临时会话,不持久化身份/基础信息/历史报告,也不新增后台检测记录、照片或原始结果;本次报告仍可正常生成并查看,刷新后基础信息不回填。旧服务器数据不自动删除。详见 [游客不留存与样式调整](../docs/游客不留存与弹窗护理间距调整-2026-10-13.md)。下方早期的 localStorage 报告历史说明已被此规则替代。
- **档案页**:出生年月 / 性别 / 所在地区三个滚轮选择器同一风格(`vendor/mobile-select`,MIT),出生年月自动换算年龄;地区为省–市两级联动,数据为 `js/region-data.js`(由 china-division 2.7.0 `pc.json` 生成,另含港澳台与「海外」)。当前账号在本机浏览器记住已确认的基础信息和日常肤质,再次进入自动回填,滚轮也定位到原值;年龄按出生年月重新计算。肤质支持跟手拖动、松手吸附、轻扫切换及边界回弹,纵向手势仍滚动页面。详见 [基础信息记忆与肤质拖动](../docs/基础信息记忆与肤质拖动-2026-10-12.md)。
- **报告页**:无返回按钮与页头标题;总分环形图 + 肌肤年龄与「比实际年龄年轻/大 N 岁」;三维度分数 + 进度条(最低分标红并挂「优先关注」);维度详情为原布局——一张卡内紧塑力/活力感/稳定力三个 Tab,每个 Tab 两条「竖排标题 + 左图右文」;点检测图全屏放大,再点或按返回键回到报告。
+1 -1
View File
@@ -120,7 +120,7 @@
#stat-score { display: flex; align-items: baseline; justify-content: center; }
#stat-score b { display: block; letter-spacing: 0; }
#stat-score i { position: static; margin-left: 2px; }
.card-care .card-heading { text-align: left; }
.card-care .card-heading { text-align: left; margin-bottom: calc(9 * var(--u)); }
/* The profile is a fixed page, not a scroll container. Keep normal-height PSD
spacing; shrink only vertical whitespace/photo height on shorter viewports.
+4 -4
View File
@@ -24,7 +24,7 @@
<img class="login-brand" src="assets/img/ui-20261009/brand-login.png" alt="1’me 以我">
<div class="login-bottom">
<button class="login-button login-wechat" data-action="login-wechat"><img src="assets/img/ui-20261009/icon-wechat.png" alt="">微信登录</button>
<button class="login-button login-browser" data-action="login-browser"><img src="assets/img/ui-20261009/icon-guest.png" alt="">游客登录</button>
<button class="login-button login-browser" data-action="guest-experience"><img src="assets/img/ui-20261009/icon-guest.png" alt="">免登录体验</button>
<label class="login-consent"><input type="checkbox" id="login-consent"><span>同意<button type="button" data-action="open-policy">用户协议、隐私政策和儿童保护政策</button></span></label>
<p class="login-message" id="login-message" role="status">正在检查登录状态…</p>
</div>
@@ -32,7 +32,7 @@
<section class="screen" id="s-history">
<div class="history-content">
<h1 class="report-title">您的报告</h1>
<button class="account-status account-status-history" data-action="switch-login" aria-label="切换登录方式"><span data-auth-status></span><small>切换登录方式 ›</small></button>
<div class="account-status account-status-history"><span data-auth-status></span></div>
<div class="card history-count"><span>测试数量</span><p id="history-count">正在读取报告…</p></div>
<p class="identity-note" id="identity-note"></p>
<div id="history-list" aria-live="polite"></div>
@@ -42,7 +42,7 @@
</section>
<section class="screen" id="s-splash">
<div class="splash-bg"></div>
<button class="account-status account-status-home" data-action="switch-login" aria-label="切换登录方式"><span data-auth-status></span><small>切换 ›</small></button>
<div class="account-status account-status-home"><span data-auth-status></span></div>
<img class="splash-slogan" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="ONE ME,FROM THE INSIDE OUT;以我为始 自有表现">
<div class="splash-bottom">
<div class="brand-row">
@@ -291,7 +291,7 @@
<p class="popup-body">我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。</p>
<label class="privacy-row" id="privacy-row">
<span class="checkbox" id="privacy-check"></span>
<span class="privacy-text">我已阅读并同意<a class="privacy-link" id="privacy-policy-link">相关隐私协议</a></span>
<span class="privacy-text">我已阅读并同意相关<a class="privacy-link" id="privacy-policy-link">隐私协议</a></span>
</label>
<button class="btn btn-primary popup-btn" data-action="agree-continue">继续</button>
</div>
+32 -15
View File
@@ -1,9 +1,19 @@
/* Browser identity is a random credential, never a fingerprint or a report. */
/* Guest access uses an ephemeral server session, never a saved browser identity. */
window.AccountClient = (function () {
'use strict';
var key = 'juzi.identity.v1', kindKey = 'juzi.auth.kind.v1', user = null;
function read(name) { try { return localStorage.getItem(name || key); } catch (_) { return null; } }
function rememberKind() { try { if (user) localStorage.setItem(kindKey, user.kind); } catch (_) {} }
var kindKey = 'juzi.auth.kind.v1', user = null, guestReady = false;
function clearGuestCache() {
try {
localStorage.removeItem('juzi.identity.v1');
localStorage.removeItem('juzi.reports.v1'); // Retired local guest-report cache.
Object.keys(localStorage).filter(function (name) { return name.indexOf('juzi.profile.v1.browser.') === 0; })
.forEach(function (name) { localStorage.removeItem(name); });
if (localStorage.getItem(kindKey) === 'browser') localStorage.removeItem(kindKey);
} catch (_) {}
}
function rememberKind() {
try { if (user && user.kind === 'wechat') localStorage.setItem(kindKey, 'wechat'); else localStorage.removeItem(kindKey); } catch (_) {}
}
function request(url, data) {
var controller = new AbortController();
// Bound only authentication waits. Full report snapshots contain photos and
@@ -20,24 +30,31 @@ window.AccountClient = (function () {
}).finally(function () { clearTimeout(timer); });
}
function browser(consent) {
return request('api/auth/browser', { consent: consent, token: read() }).then(function (v) {
try { localStorage.setItem(key, v.token); } catch (_) { /* session cookie still works */ }
user = v.user; rememberKind(); return v;
clearGuestCache();
return request('api/auth/browser', { consent: consent }).then(function (v) {
user = v.user; guestReady = true; rememberKind(); return v;
});
}
function guest() {
clearGuestCache();
user = { id: 'guest-page-' + Date.now() + '-' + Math.random().toString(36).slice(2), kind: 'browser' };
guestReady = false; rememberKind(); return user;
}
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 });
return browser(true);
}
function init(options) {
clearGuestCache();
return request('api/auth/session').then(function (v) {
user = v.user;
guestReady = !!(user && user.kind === 'browser');
rememberKind();
// A missing WeChat session is not permission to switch to a browser account.
// OAuth returns must also stay explicit when its cookie was not retained.
if (!user && !(options && options.restoreBrowser === false) && read(kindKey) !== 'wechat' && read()) return browser(true).catch(function (e) {
// Do not replace an existing credential on a transient server/network error.
if (/身份已失效/.test(e.message)) { try { localStorage.removeItem(key); } catch (_) {} }
throw e;
});
// Never restore a guest credential or silently fall back from WeChat.
return v;
});
}
return { request: request, init: init, browser: browser, user: function () { return user; } };
return { request: request, init: init, browser: browser, guest: guest, ensureGuest: ensureGuest, user: function () { return user; } };
})();
+33 -24
View File
@@ -76,15 +76,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' ? '微信已登录' : '游客模式') : '尚未登录';
label.parentElement.dataset.kind = user ? user.kind : '';
});
}
function loginSucceeded() {
showScreen('s-splash', 'replace');
$('#login-success-message').textContent = AccountClient.user().kind === 'wechat'
? '当前使用:微信登录\n同一微信账号可跨手机查看报告。'
: '当前使用:游客登录\n游客报告仅可在当前浏览器查看,不能跨手机同步。';
$('#login-success-message').textContent = '当前使用:微信登录\n同一微信账号可跨手机查看报告。';
openOverlay('p-login-success');
document.querySelectorAll('.screen').forEach(function (screen) { screen.inert = true; });
$('[data-action="login-confirm"]').focus();
@@ -331,7 +329,11 @@
}
function restoreProfile() {
var user = AccountClient.user();
profileOwner = ProfileStore.key(user);
var owner = user && user.kind === 'browser' ? 'guest:' + user.id : ProfileStore.key(user);
// Keep the current form while a guest retries a capture in this page only.
// Reload/new guest session discards it; saveProfile never writes this owner.
if (user && user.kind === 'browser' && profileOwner === owner) return;
profileOwner = owner;
var saved = ProfileStore.clean(profileStore.read(user), window.REGION_DATA, SKINS.length);
var y = Number(saved.birth.slice(0, 4)), m = Number(saved.birth.slice(5));
if (saved.birth && YEARS.indexOf(y + '年') < 0) saved.birth = '';
@@ -1073,6 +1075,7 @@
}
renderReport(vm, photos[0], photos[1], payload); // img1=正脸实拍,payload 供检测可视化取 face_maps/marks
if (IS_MOCK) toast('演示报告,不保存至我的报告');
else if (AccountClient.user() && AccountClient.user().kind !== 'wechat') toast('游客报告仅供本次查看,不保存历史记录');
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
@@ -1561,9 +1564,10 @@
});
/* ─── 动作分发 ─── */
var privacyBusy = false;
var actions = {
'open-policy': function () { openOverlay('p-policy'); },
'login-browser': function () { loginBrowser(); },
'guest-experience': function () { enterGuest(); },
'login-wechat': function () { loginWechat(); },
'login-confirm': closeLoginSuccess,
'open-privacy': function () { if (!AccountClient.user()) { showScreen('s-login', 'replace'); return; } openOverlay('p-privacy'); },
@@ -1571,8 +1575,13 @@
'close-policy': function () { closeOverlay('p-policy'); },
'agree-continue': function () {
if (!agreed) { toast('请先阅读并同意隐私协议'); return; }
closeOverlay('p-privacy');
showScreen('s-profile', 'push');
if (privacyBusy) return;
privacyBusy = true;
var button = $('#p-privacy [data-action="agree-continue"]'); button.disabled = true;
AccountClient.ensureGuest().then(function () {
if (!$('#p-privacy').classList.contains('show')) return;
closeOverlay('p-privacy'); showScreen('s-profile', 'push');
}).catch(function (e) { toast(e.message); }).finally(function () { privacyBusy = false; button.disabled = false; });
},
'submit-profile': function () {
$('#f-age').value = ProfileStore.age($('#f-birth').value) || '';
@@ -1609,10 +1618,10 @@
},
'history': function () { openHistory(); },
'history-retry': function () { renderHistoryList(); },
'switch-login': function () {
'open-wechat-login': function () {
historySeq++;
$('#login-consent').checked = false;
$('#login-message').textContent = '微信与游客为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
$('#login-message').textContent = '微信登录后,后续检测报告可保存和跨手机查看。';
showScreen('s-login', 'push');
}
};
@@ -1628,13 +1637,17 @@
var historySeq = 0;
function renderHistoryList() {
var seq = ++historySeq, box = $('#history-list');
if (!AccountClient.user() || AccountClient.user().kind !== 'wechat') {
$('#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>';
return;
}
box.textContent = '正在读取报告…';
AccountClient.request('api/reports').then(function (data) {
if (seq !== historySeq) return;
$('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测';
$('#identity-note').textContent = AccountClient.user().kind === 'wechat'
? '保留最近 5 份报告,同一微信账号可跨手机查看。'
: '保留最近 5 份报告。游客报告仅可在当前浏览器查看,不能跨手机同步;清除浏览器数据后无法找回。';
$('#identity-note').textContent = '保留最近 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');
@@ -1669,19 +1682,15 @@
if (!$('#login-consent').checked) { toast('请先阅读并同意用户协议和隐私政策'); return false; }
return true;
}
function loginBrowser() {
if (!loginAllowed()) return;
loginBusy = true;
$('#login-message').textContent = '正在登录…';
AccountClient.browser(true).then(function () {
loginSucceeded();
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () { loginBusy = false; });
function enterGuest() {
if (loginBusy) return;
AccountClient.guest();
showScreen('s-splash', 'replace');
}
function loginWechat() {
if (!loginAllowed()) return;
if (!/MicroMessenger/i.test(navigator.userAgent)) {
$('#login-message').textContent = '请在微信内打开本页面,或使用游客登录。';
$('#login-message').textContent = '请在微信内打开本页面,或选择免登录体验。';
return;
}
loginBusy = true;
@@ -1742,14 +1751,14 @@
$('#login-message').textContent = code === 'state'
? '登录链接已过期或会话丢失,请在同一微信窗口重新点击微信登录。'
: code === 'network' ? '暂时无法连接微信服务,请稍后重新登录。'
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择游客登录。';
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择免登录体验。';
} else if (loginSuccess && (!AccountClient.user() || AccountClient.user().kind !== 'wechat')) {
$('#login-message').textContent = '微信返回后登录会话未能保存,请在同一微信窗口重新登录。';
} else if (AccountClient.user()) {
if (loginSuccess) loginSucceeded();
else showScreen('s-splash', 'replace');
}
else $('#login-message').textContent = '微信登录可跨手机查看报告;游客登录仅限当前浏览器。';
else $('#login-message').textContent = '微信登录可保留历史报告;游客不保存个人信息和检测记录。';
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () {
loginBusy = false;
+1 -1
View File
@@ -5,7 +5,7 @@
})(typeof window !== 'undefined' ? window : this, function() {
'use strict';
function key(user) {
return user && ['browser', 'wechat'].indexOf(user.kind) >= 0 && typeof user.id === 'string' && user.id.length <= 128 && user.id
return user && user.kind === 'wechat' && typeof user.id === 'string' && user.id.length <= 128 && user.id
? 'juzi.profile.v1.' + user.kind + '.' + encodeURIComponent(user.id) : null;
}
function age(birth, now) {
+26 -15
View File
@@ -27,6 +27,7 @@ module.exports = function createAccounts(root, env = process.env) {
const prefix = base ? base.pathname.replace(/\/?$/, '/') : '/';
if (base) { base.pathname = prefix; base.search = ''; base.hash = ''; }
const secure = !!(base && base.protocol === 'https:');
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));
@@ -34,14 +35,16 @@ module.exports = function createAccounts(root, env = process.env) {
return Object.fromEntries((req.headers.cookie || '').split(';').map(s => s.trim().split('=')));
}
function cookie(res, name, value, age) {
const line = `${name}=${value}; Path=${prefix}; HttpOnly; SameSite=Lax; Max-Age=${age}${secure ? '; Secure' : ''}`;
const line = `${name}=${value}; Path=${prefix}; HttpOnly; SameSite=Lax${age == null ? '' : '; Max-Age=' + age}${secure ? '; Secure' : ''}`;
const old = res.getHeader('Set-Cookie') || [];
res.setHeader('Set-Cookie', [].concat(old, line));
}
function session(req) {
const token = cookies(req).juzi_session;
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) : null;
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === '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];
@@ -94,19 +97,22 @@ module.exports = function createAccounts(root, env = process.env) {
if (!rate(req)) { json(res, 429, { ok: false, message: '操作频繁,请稍后再试' }); return true; }
const data = await body(req);
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
let token = data.token, user;
if (token) {
if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token)) token = null;
user = token && db.users.find(u => u.kind === 'browser' && u.credential === hash(token));
if (!user) { json(res, 401, { ok: false, message: '游客身份已失效,请重新登录' }); return true; }
} else {
token = random(); user = { id: crypto.randomUUID(), kind: 'browser', credential: hash(token), consent: new Date().toISOString(), reports: [] };
db.users.push(user);
}
login(res, user); json(res, 200, { ok: true, user: publicUser(user), token });
// Old persistent guest tokens cannot restore identity or history.
if (data.token) { json(res, 401, { ok: false, message: '游客身份已失效,请重新登录' }); return true; }
for (const [key, value] of guestSessions) if (value.expires < Date.now()) guestSessions.delete(key);
const previous = cookies(req).juzi_session;
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);
json(res, 200, { ok: true, user: publicUser(user) });
} else if (pathname === '/api/auth/logout' && req.method === 'POST') {
const token = cookies(req).juzi_session; if (token) delete db.sessions[hash(token)];
cookie(res, 'juzi_session', '', 0); save(); json(res, 200, { ok: true });
const token = cookies(req).juzi_session;
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 });
} 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; }
@@ -147,6 +153,11 @@ 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') {
json(res, pathname === '/api/reports' ? 200 : 404,
pathname === '/api/reports' ? { ok: true, total: 0, items: [] } : { ok: false, message: '游客模式不保存历史报告' });
return true;
}
if (pathname === '/api/reports') json(res, 200, { ok: true, total: user.reports.length, items: user.reports });
else if (/^R[\w-]+$/.test(id) && user.reports.some(r => r.id === id)) {
const file = path.join(snapshots, user.id, id + '.json');
@@ -159,7 +170,7 @@ module.exports = function createAccounts(root, env = process.env) {
}
function storeReport(ownerId, snapshot) {
const user = db.users.find(u => u.id === ownerId);
if (!user || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
if (!user || user.kind !== 'wechat' || !/^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 }]
+21 -14
View File
@@ -267,7 +267,7 @@ function scoreProfile(rec) {
}
/* 检测成功:用 SkinScore.buildViewModel 计算并补全记录字段(复用同一份公式) */
function fillSuccess(rec, fpJson) {
function fillSuccess(rec, fpJson, persist = true) {
// Observation only: do not block, re-shoot, retry or alter scoring based on it.
rec.capture_quality = CaptureDiagnostics.assess(fpJson);
const vm = SkinScore.buildViewModel(fpJson.result, {
@@ -299,7 +299,7 @@ function fillSuccess(rec, fpJson) {
rec.essence_line = vm.essence ? vm.essence.text : null;
rec.trigger = (vm.essence && vm.essence.triggers) ? vm.essence.triggers.join(',') : null;
rec.notes = vm.notes || [];
rec.raw_file = dumpRaw(fpJson);
rec.raw_file = persist ? dumpRaw(fpJson) : null;
}
/* 检测失败:补失败字段 */
@@ -311,7 +311,7 @@ function fillFailure(rec, code) {
}
/* 执行一次检测:计时调用 Face++,按结果补全记录(成功/失败都落库)。返回 Face++ 原始返回 */
async function runDetect(rec, front, left, right) {
async function runDetect(rec, front, left, right, persist = true) {
rec.detect_status = '检测中';
rec.updated_at = nowIso();
const t0 = Date.now();
@@ -325,7 +325,7 @@ async function runDetect(rec, front, left, right) {
fillFailure(rec, 'INTERNAL_ERROR');
} else {
try {
fillSuccess(rec, fpJson);
fillSuccess(rec, fpJson, persist);
} catch (e) { // 评分异常按检测失败落库,但不改写 C 端成功响应(原始结果仍透传)
console.log(`[analyze] 评分异常: ${e.message}`);
fillFailure(rec, 'INTERNAL_ERROR');
@@ -372,10 +372,13 @@ async function callFacepp(front, left, right) {
}
async function handleAnalyze(req, res) {
res.setHeader('Cache-Control', 'no-store');
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
let persist = false;
try {
const owner = accounts.session(req);
if (!owner) return sendJson(res, 401, { ok: false, message: '请先登录后检测' });
persist = owner.kind === 'wechat';
if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, message: '请求来源不匹配' });
const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
@@ -394,7 +397,7 @@ async function handleAnalyze(req, res) {
/* ── 记录落库:生成 record_id → 写照片 → 落一条 detect_status=检测中 ── */
const profile = normalizeProfile(body.profile);
const recordId = newRecordId();
const photos = writePhotos(recordId, { front, left, right });
const photos = persist ? writePhotos(recordId, { front, left, right }) : {};
rec = {
record_id: recordId,
owner_id: owner.id,
@@ -420,11 +423,13 @@ async function handleAnalyze(req, res) {
retry_of: null, retry_count: 0
};
rec.capture_diagnostics = CaptureDiagnostics.normalize(body.capture_diagnostics);
appendRecord(rec);
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
if (persist) {
appendRecord(rec);
console.log(`[record] 新检测记录 ${recordId}(${rec.shoot_status},照片${Object.keys(photos).length}/3)`);
}
const fpJson = await runDetect(rec, front, left, right);
updateRecord(rec);
const fpJson = await runDetect(rec, front, left, right, persist);
if (persist) updateRecord(rec);
if (fpJson.error_message) {
const code = fpJson.error_message.split(':')[0];
@@ -458,10 +463,12 @@ async function handleAnalyze(req, res) {
try {
snapshotVm = SkinScore.buildViewModel(result, payload.side, scoreProfile(rec));
presentation = rec.presentation || ReportUI.build(snapshotVm, result);
accounts.storeReport(owner.id, { id: recordId, created_at: rec.created_at,
version: '2026-10-09', profile, vm: snapshotVm, presentation, payload,
photos: [front, left, right].map(b64 => b64 ? toDataUrl(b64) : null) });
reportSaved = true;
if (persist) {
accounts.storeReport(owner.id, { id: recordId, created_at: rec.created_at,
version: '2026-10-09', profile, vm: snapshotVm, presentation, payload,
photos: [front, left, right].map(b64 => b64 ? toDataUrl(b64) : null) });
reportSaved = true;
}
} catch (e) { console.error('[report] 完整报告保存失败:', e.message); }
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({
@@ -475,7 +482,7 @@ async function handleAnalyze(req, res) {
}
}));
} catch (e) {
if (rec) { // 记录已创建但流程异常:按检测失败补全,便于后台看到这条调用
if (rec && persist) { // 仅微信检测失败记录落库;游客不保留异常记录。
fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR');
rec.updated_at = nowIso();
updateRecord(rec);
+13 -6
View File
@@ -26,8 +26,16 @@ const ReportUI = require('../js/report-ui');
assert.equal((await request('/api/reports')).status, 401);
assert.equal((await request('/api/auth/browser', {})).status, 400);
assert.equal((await request('/api/auth/browser', { consent: true }, '', { Origin: 'https://unrelated.example' })).status, 403);
const a = await request('/api/auth/browser', { consent: true });
const b = await request('/api/auth/browser', { consent: true });
const guest = await request('/api/auth/browser', { consent: true });
assert.equal(guest.data.token, undefined, 'guest credentials are never persisted by clients');
assert(!guest.headers.get('set-cookie').includes('Max-Age='), 'guest cookie is session-only');
assert.equal(fs.existsSync(path.join(root, 'identities.json')), false, 'guest login does not write an identity');
assert.throws(() => accounts.storeReport(guest.data.user.id, { id: 'Rguest' }));
assert.equal((await request('/api/reports', null, guest.cookie)).data.total, 0);
accounts = create(root, {});
assert.equal((await request('/api/auth/session', null, guest.cookie)).data.user, null, 'process restart forgets guests');
const [a, b] = require('./wechat-fixture.cjs')(root, 2).map(entry => ({ ...entry, data: { user: entry.user }, headers: guest.headers }));
accounts = create(root, {});
assert.notEqual(a.data.user.id, b.data.user.id);
assert(a.headers.get('set-cookie').includes('HttpOnly'));
const vm = SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, { realAge: 30, skinType: '干性肌肤' });
@@ -48,11 +56,10 @@ const ReportUI = require('../js/report-ui');
assert.equal((await request('/api/reports/Rtest0', null, a.cookie)).status, 404);
accounts = create(root, {}); // Process restart reloads sessions and reports.
assert.equal((await request('/api/reports', null, a.cookie)).data.total, 5);
const restored = await request('/api/auth/browser', { consent: true, token: a.data.token });
assert.equal(restored.data.user.id, a.data.user.id);
assert.equal((await request('/api/auth/session', null, a.cookie)).data.user.id, a.data.user.id);
assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' })).status, 401);
await request('/api/auth/logout', {}, restored.cookie);
assert.equal((await request('/api/reports', null, restored.cookie)).status, 401);
await request('/api/auth/logout', {}, a.cookie);
assert.equal((await request('/api/reports', null, a.cookie)).status, 401);
assert.equal((await request('/api/auth/config')).data.wechat, false);
accounts = create(root, { WECHAT_APP_ID: 'test-app', WECHAT_APP_SECRET: 'test-secret', PUBLIC_BASE_URL: 'https://skin.example.com/state' });
const start = await request('/api/auth/wechat', { consent: true });
+1 -2
View File
@@ -40,9 +40,8 @@ try {
await page.evaluate(() => { window.faceapi = undefined; });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
await page.click('[data-action="open-privacy"]');
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
+8 -6
View File
@@ -13,6 +13,7 @@ const ReportUI = require('../js/report-ui');
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-fourth-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const artifacts = process.env.H5_ARTIFACT_DIR;
const child = spawn(process.execPath, ['-e', `
const fs=require('fs'), vm=require('vm'), w={};
@@ -45,13 +46,14 @@ try {
await page.goto(base); await page.waitForLoadState('networkidle');
assert.equal(await page.title(), "1'me肌肤状态检测");
assert.equal(await page.locator('meta[property="og:title"]').getAttribute('content'), "1'me肌肤状态检测");
assert.equal((await page.locator('[data-action="login-browser"]').textContent()).trim(), '游客登录');
assert.equal((await page.locator('[data-action="guest-experience"]').textContent()).trim(), '免登录体验');
assert(!requests.some(p => /scan-(base|face)|\.woff2$|faceapi/.test(p)), 'no heavy/scanning entry requests');
await page.check('#login-consent'); await page.click('[data-action="login-browser"]');
await page.waitForSelector('#p-login-success.show');
assert((await page.locator('#login-success-message').textContent()).includes('当前使用:游客登录'));
await page.click('[data-action="login-confirm"]');
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客已登录');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客模式');
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
await page.evaluate(() => AccountClient.init()); // Persistence scenarios require WeChat.
const api = page.context().request;
await page.addScriptTag({ url: base + 'assets/mock/report-fixture.js' });
const fixture = await page.evaluate(() => window.MOCK_FIXTURE);
+1 -2
View File
@@ -117,9 +117,8 @@ try {
// 3) 完整 mock 流程:启动 → 档案 → 三连拍 → 报告,并记录语音起播
await page.goto(BASE + '/?mock=1', { waitUntil: 'networkidle' });
await page.locator('#login-consent').check();
await page.locator('[data-action="login-browser"]').click();
await page.locator('[data-action="guest-experience"]').click();
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).click();
+138
View File
@@ -0,0 +1,138 @@
// Isolated backend and fixture-backed analysis. No production data or paid API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-guest-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const fx = {}; vm.runInNewContext(fs.readFileSync(path.join(h5, 'assets/mock/report-fixture.js'), 'utf8'), { window: fx });
const fixture = fx.MOCK_FIXTURE;
const child = spawn(process.execPath, ['-e', `
const fs=require('fs'),vm=require('vm'),w={};
vm.runInNewContext(fs.readFileSync('assets/mock/report-fixture.js','utf8'),{window:w});
const f=w.MOCK_FIXTURE; let calls=0;
global.fetch=async()=>({ok:true,status:200,json:async()=>++calls===2?{error_message:'INVALID_IMAGE_FACE'}:
{request_id:'fixture',result:f.result,face_rectangle:f.face_rectangle,left_side_result:f.side.left,right_side_result:f.side.right}});
require('./server.js');
`], { cwd: h5, env: { ...process.env, PORT: '18115', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] });
let browser;
function diskSnapshot(dir = dataRoot, prefix = '') {
const files = {};
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const name = prefix + entry.name;
if (entry.isDirectory()) Object.assign(files, diskSnapshot(path.join(dir, entry.name), name + '/'));
else files[name] = fs.readFileSync(path.join(dir, entry.name)).toString('base64');
}
return files;
}
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('test startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } });
child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code)); });
});
const base = 'http://127.0.0.1:18115/';
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
const errors = []; page.on('pageerror', e => errors.push(e.message));
const guestRequests = [];
page.on('request', r => { if (r.url().endsWith('/api/auth/browser')) guestRequests.push(r.url()); });
await page.addInitScript(() => {
localStorage.setItem('juzi.identity.v1', 'old-guest-token');
localStorage.setItem('juzi.reports.v1', '[{"id":"old-report"}]');
localStorage.setItem('juzi.profile.v1.browser.old', JSON.stringify({ gender: '男' }));
});
const baseline = diskSnapshot();
await page.goto(base);
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
assert.equal(await page.locator('#s-login.active').count(), 1, 'old guest token cannot silently restore login');
const artifacts = process.env.H5_ARTIFACT_DIR;
async function screenshot(name) {
await page.evaluate(async () => { await document.fonts.ready; window.scrollTo(0, 0); });
await page.waitForTimeout(400);
await page.screenshot({ path: path.join(artifacts, name + '.png'), animations: 'disabled' });
}
if (artifacts) { fs.mkdirSync(artifacts, { recursive: true }); await screenshot('guest-entry'); }
assert.equal(await page.locator('#login-consent').isChecked(), false);
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'guest entry never shows login success');
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0, 'switch-login controls are removed');
assert.equal(await page.locator('#s-splash [data-auth-status]').textContent(), '游客模式');
assert.equal(guestRequests.length, 0, 'guest entry performs no authentication request');
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), null);
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.reports.v1')), null);
assert.equal(await page.evaluate(() => Object.keys(localStorage).some(k => k.startsWith('juzi.profile.v1.browser.'))), false);
assert.equal((await page.context().cookies()).some(c => c.name === 'juzi_session'), false);
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await screenshot('guest-home');
}
await page.click('[data-action="open-privacy"]'); await page.waitForSelector('#p-privacy.show');
assert.equal(await page.locator('#privacy-policy-link').textContent(), '隐私协议');
const linkStyle = await page.locator('#privacy-policy-link').evaluate(el => ({ weight: getComputedStyle(el).fontWeight, decoration: getComputedStyle(el).textDecorationLine }));
assert(Number(linkStyle.weight) >= 600 && linkStyle.decoration.includes('underline'));
assert.equal(await page.locator('.privacy-text').evaluate(el => getComputedStyle(el).textDecorationLine), 'none');
if (artifacts) await screenshot('welcome-privacy');
await page.click('#privacy-policy-link'); await page.waitForSelector('#p-policy.show');
assert.equal(await page.locator('#privacy-check').evaluate(el => el.classList.contains('checked')), false, 'opening agreement does not tick consent');
await page.click('[data-action="close-policy"]');
await page.click('[data-action="agree-continue"]');
assert.equal(guestRequests.length, 0, 'no temporary server session before consent');
await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
assert.equal(guestRequests.length, 1, 'temporary access is created only after welcome consent');
const cookie = (await page.context().cookies()).find(c => c.name === 'juzi_session');
assert.equal(cookie.expires, -1, 'temporary cookie is non-persistent');
await page.reload(); await page.waitForSelector('#s-splash.active');
const api = page.context().request;
const data = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0],
profile: { real_age: 30, gender: '女', region: '北京市', daily_skin_type: '干性肌肤' } };
const guestResponse = await api.post(base + 'api/skin-analyze', { data });
assert.equal(guestResponse.headers()['cache-control'], 'no-store');
const guest = await guestResponse.json();
assert.equal(guest.ok, true); assert.equal(guest.report_saved, false); assert(guest.vm && guest.presentation);
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 0);
assert.equal((await api.get(base + 'api/reports/' + guest.record_id)).status(), 404);
const failed = await (await api.post(base + 'api/skin-analyze', { data })).json();
assert.equal(failed.ok, false);
await page.waitForTimeout(150); assert.deepEqual(diskSnapshot(), baseline, 'guest success/failure writes no identity, report, photo, profile, raw result or admin record');
await page.click('[data-action="history"]');
assert((await page.locator('#identity-note').textContent()).includes('不保存个人信息和检测记录'));
assert.equal(await page.locator('[data-report-id]').count(), 0);
if (artifacts) await screenshot('guest-history');
// Same backend and payload: WeChat must continue to retain complete reports.
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
const saved = await (await api.post(base + 'api/skin-analyze', { data })).json();
assert.equal(saved.ok, true); assert.equal(saved.report_saved, true);
assert.equal((await (await api.get(base + 'api/reports')).json()).total, 1);
assert.equal((await (await api.get(base + 'api/reports/' + saved.record_id)).json()).report.id, saved.record_id);
assert(fs.existsSync(path.join(dataRoot, 'records.jsonl')));
assert(fs.existsSync(path.join(dataRoot, 'photos', saved.record_id, 'front.jpg')));
// Visual sample only; not another analysis call.
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#care-list').children.length > 1);
await page.evaluate(() => document.fonts.ready);
await page.locator('.card-care').scrollIntoViewIfNeeded();
const spacing = await page.locator('.card-care').evaluate(el => {
const heading = el.querySelector('.card-heading'), body = el.querySelector('#care-list');
return { margin: parseFloat(getComputedStyle(heading).marginBottom), gap: body.getBoundingClientRect().top - heading.getBoundingClientRect().bottom };
});
assert(Math.abs(spacing.margin - 9) < .1 && Math.abs(spacing.gap - 9) < 1, 'care title gap is halved from 18px to 9px');
if (artifacts) await page.locator('.card-care').screenshot({ path: path.join(artifacts, 'care-spacing.png') });
assert.deepEqual(errors, []);
console.log('PASS: four-character bold/underlined privacy link, half care gap; guest success/failure no disk writes or saved history/identity; WeChat retains snapshots/photos/admin records; screenshots; no paid calls');
} finally {
if (browser) await browser.close();
if (child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; }
fs.rmSync(dataRoot, { recursive: true, force: true });
}
+8 -7
View File
@@ -72,13 +72,13 @@ try {
assert(!requests.some(p => /faceapi|models|\.woff2$|bg-splash\.jpg$/.test(p)), JSON.stringify(requests));
const resourceBytes = await page.evaluate(() => performance.getEntriesByType('resource').reduce((sum, r) => sum + r.encodedBodySize, 0));
assert(resourceBytes < 350000, 'login resource budget exceeded: ' + resourceBytes);
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#p-login-success.show');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.waitForTimeout(400); // Allow the home entry animation to settle.
const artifacts = process.env.H5_ARTIFACT_DIR;
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, 'browser-login-success.png') });
await page.screenshot({ path: path.join(artifacts, 'guest-no-login.png') });
}
await page.keyboard.press('Escape');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
@@ -94,10 +94,11 @@ try {
assert(!requests.some(p => /faceapi|models|\.woff2$/.test(p)), JSON.stringify(requests));
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-home-status.png') });
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '游客已登录');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存'));
assert.equal(await page.locator('#s-history [data-auth-status]').textContent(), '游客模式');
if (artifacts) await page.screenshot({ path: path.join(artifacts, 'browser-history-status.png') });
await page.click('#s-history [data-action="switch-login"]');
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0);
await page.click('#s-history [data-action="open-wechat-login"]');
assert.equal(await page.locator('#s-login.active').count(), 1);
assert.deepEqual(errors, []);
console.log('PASS: gzip/304/revalidation, no-store private API, login without fonts/face engine, success dialog/Escape/status/switching/layout; throttled cold login=' + readyMs + 'ms, resources=' + resourceBytes + ' bytes');
+20 -32
View File
@@ -35,22 +35,23 @@ try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
const api = context.request;
assert.equal((await api.get(base + 'api/reports')).status(), 200);
const cookie = (await context.cookies()).find(c => c.httpOnly);
assert(cookie && cookie.secure && cookie.sameSite === 'Lax' && cookie.path === new URL(base).pathname);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {} })).status(), 400);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {}, headers: { Origin: 'https://invalid.example' } })).status(), 403);
console.log('PASS: public HTTPS, secure scoped cookie, missing-image validation, cross-origin denial');
assert.equal((await api.get(base + 'api/reports')).status(), 401);
await page.click('[data-action="open-privacy"]');
await page.click('[data-action="agree-continue"]');
assert.equal(await page.locator('#s-profile.active').count(), 0);
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
await page.waitForSelector('#s-profile.active');
assert.equal((await api.get(base + 'api/reports')).status(), 200);
const cookie = (await context.cookies()).find(c => c.httpOnly);
assert(cookie && cookie.secure && cookie.sameSite === 'Lax' && cookie.path === new URL(base).pathname);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {} })).status(), 400);
assert.equal((await api.post(base + 'api/skin-analyze', { data: {}, headers: { Origin: 'https://invalid.example' } })).status(), 403);
console.log('PASS: consent-gated temporary HTTPS session, secure scoped cookie, missing-image validation, cross-origin denial');
await page.click('[data-action="submit-profile"]');
assert.equal(await page.locator('#s-profile.active').count(), 1);
for (const selector of ['#pick-birth', '#pick-gender', '#pick-region']) {
@@ -79,17 +80,12 @@ try {
assert.equal(response.status(), 200);
const result = await response.json();
assert.equal(result.ok, true, JSON.stringify({ code: result.code, message: result.message }));
assert.equal(result.report_saved, true);
assert.equal(result.report_saved, false, 'guest reports are deliberately not saved');
assert.equal(analyzeCalls, 1);
assert((await page.evaluate(() => window.__analyzeTimeouts)).includes(150000));
await page.waitForSelector('#s-report.active');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth > 0));
const snap = (await (await api.get(base + 'api/reports/' + result.record_id)).json()).report;
assert.equal(snap.photos.filter(Boolean).length, 3);
assert.deepEqual(snap.presentation, result.presentation);
assert.deepEqual(snap.payload.result.face_maps, result.result.face_maps);
const images = await page.locator('.ditem-img').evaluateAll(nodes => nodes.map(i => i.src));
const copy = await page.locator('.ditem-para').allTextContents();
assert.equal((await api.get(base + 'api/reports/' + result.record_id)).status(), 404);
for (const card of await page.locator('.card-detail').all()) {
for (const tab of await card.locator('.dtab').all()) {
await tab.click();
@@ -102,31 +98,23 @@ try {
await page.waitForSelector('#p-zoom.show', { state: 'hidden' });
assert.equal(await page.locator('#s-report.active').count(), 1);
await page.screenshot({ path: path.join(artifacts, 'real-report.png'), fullPage: true });
console.log('PASS: three sample views → REAL Face++ → scoring → seven images/tabs → zoom/back → backend snapshot');
console.log('PASS: three sample views → REAL Face++ → scoring → seven images/tabs → zoom/back; guest report is not saved');
// Cookie loss is restored using this browser's stored random identity, not a new account.
// Cookie loss cannot restore a guest identity or any guest history.
await context.clearCookies();
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('#s-login.active');
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]');
await page.click('[data-report-id="' + result.record_id + '"]');
await page.waitForSelector('#s-report.active');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth > 0));
assert.deepEqual(await page.locator('.ditem-img').evaluateAll(nodes => nodes.map(i => i.src)), images);
assert.deepEqual(await page.locator('.ditem-para').allTextContents(), copy);
await page.goBack();
await page.waitForSelector('#s-history.active');
// Read failure + explicit retry must recover without losing the report.
await page.route('**/api/reports', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ ok: false, message: '测试临时不可用' }) }));
await page.click('[data-action="history-retry"]');
await page.waitForFunction(() => document.querySelector('#history-list').textContent.includes('测试临时不可用'));
await page.unroute('**/api/reports');
await page.click('[data-action="history-retry"]');
await page.waitForSelector('[data-report-id="' + result.record_id + '"]');
assert.equal(await page.locator('[data-report-id]').count(), 0);
assert((await page.locator('#identity-note').textContent()).includes('不保存'));
await page.screenshot({ path: path.join(artifacts, 'real-history.png') });
await page.goBack();
await page.waitForSelector('#s-splash.active');
console.log('PASS: cookie restoration, report reload with identical seven images/copy, history error/retry, browser back');
console.log('PASS: guest has no credential restoration or saved history; refresh/back navigation works');
const other = await browser.newContext();
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 401);
@@ -139,7 +127,7 @@ try {
assert.deepEqual(errors, []);
fs.writeFileSync(path.join(artifacts, 'summary.json'), JSON.stringify({ record_id: result.record_id, analyzeCalls, errors, tested_at: new Date().toISOString() }, null, 2));
console.log('PASS: cross-account isolation, admin/source/data protection, zero JS errors');
console.log('Test report (dedicated identity): ' + result.record_id + '; artifacts: ' + artifacts);
console.log('Temporary guest verification report (not saved): ' + result.record_id + '; artifacts: ' + artifacts);
} finally {
await browser.close();
}
+6 -2
View File
@@ -27,7 +27,7 @@ try {
const context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true,
userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' });
const page = await context.newPage(), errors = [], blockedWrites = [];
let user = { id: 'profile-fixture-a', kind: 'browser' };
let user = { id: 'profile-fixture-a', kind: 'wechat' };
page.setDefaultTimeout(20000);
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => {
@@ -88,7 +88,7 @@ try {
user = { id: 'profile-fixture-b', kind: 'wechat' };
await enter(); assert.deepEqual(await values(), blank, 'another account does not inherit profile/picker/skin values');
await pick('#pick-gender'); assert.equal((await values()).gender, '女', 'new account wheel reset too');
user = { id: 'profile-fixture-a', kind: 'browser' };
user = { id: 'profile-fixture-a', kind: 'wechat' };
await page.evaluate(() => AccountClient.init());
await page.goBack(); await page.waitForSelector('#s-splash.active');
await enter(false); assert.deepEqual(await values(), original, 'switching back without a reload restores the right account');
@@ -182,6 +182,10 @@ try {
await enter(); assert.equal((await values()).skin, 3, 'skin selection also survives reload');
await page.click('[data-action="submit-profile"]');
await page.waitForSelector('#p-photoguide.show');
user = { id: 'profile-fixture-guest', kind: 'browser' };
await enter(); assert.deepEqual(await values(), blank, 'guests never inherit WeChat preferences');
await pick('#pick-gender', [1]); assert.equal((await values()).gender, '男');
await enter(); assert.deepEqual(await values(), blank, 'guest preferences do not survive reload');
assert.deepEqual(errors, []); assert.deepEqual(blockedWrites, [], 'no backend writes or paid requests');
console.log('PASS: actual profile defaults/wheels/reload/account isolation; real mobile finger-follow/snap/flick/edges/cancel; fixed profile/no vertical movement/controls fit in seven viewport sizes; real birth-wheel vertical touch still works; arrows/dots/keyboard/submission; no JS errors/backend writes');
} finally {
+4 -2
View File
@@ -2,7 +2,7 @@ const assert = require('node:assert/strict');
const Profile = require('../js/profile-store');
const regions = [['北京市', ['北京市']], ['浙江省', ['杭州市', '宁波市']]];
const now = new Date(2026, 9, 12);
const user = { id: 'fixture-a', kind: 'browser' }, other = { id: 'fixture-b', kind: 'browser' };
const user = { id: 'fixture-a', kind: 'wechat' }, other = { id: 'fixture-b', kind: 'wechat' };
const valid = { birth: '1991-11', gender: '男', region: { province: '浙江省', city: '宁波市' }, skinIndex: 3 };
const empty = { birth: '', gender: '', region: null, skinIndex: 0 };
assert.equal(Profile.age(valid.birth, now), 34);
@@ -30,7 +30,9 @@ assert.equal(store.read(user), null);
store.write(user, valid);
assert.deepEqual(Profile.create(() => storage).read(user), valid, 'survives a page reload');
assert.equal(store.read(other), null, 'different accounts are isolated');
assert.equal(store.read({ ...user, kind: 'wechat' }), null, 'login kinds are isolated');
assert.equal(store.read({ ...user, kind: 'browser' }), null, 'guests cannot read saved preferences');
store.write({ ...user, kind: 'browser' }, valid);
assert.equal(Profile.key({ ...user, kind: 'browser' }), null);
store.write(other, empty); assert.deepEqual(store.read(user), valid);
store.write(null, valid); assert.equal(data.size, 2, 'no unauthenticated writes');
for (const raw of ['broken', 'x'.repeat(4097)]) {
+2 -4
View File
@@ -14,6 +14,7 @@ const ReportUI = require('../js/report-ui.js');
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const h5 = fileURLToPath(new URL('../', import.meta.url));
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-report-test-'));
const wechat = require('./wechat-fixture.cjs')(path.join(dataRoot, 'accounts'))[0];
const fx = {};
vm.runInNewContext(fs.readFileSync(path.join(h5, 'assets/mock/report-fixture.js'), 'utf8'), { window: fx });
const fixture = fx.MOCK_FIXTURE;
@@ -35,14 +36,11 @@ try {
const base = 'http://127.0.0.1:18089/';
browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
await page.context().addCookies([{ name: 'juzi_session', value: wechat.token, url: base }]);
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.goto(base);
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="login-confirm"]');
const api = page.context().request;
const result = await (await api.post(base + 'api/skin-analyze', { data: { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0], profile: { real_age: 60, daily_skin_type: '干性肌肤' } } })).json();
assert.equal(result.ok, true);
+8 -7
View File
@@ -17,27 +17,28 @@ await page.waitForFunction(() => !document.querySelector('#login-message').textC
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(400);
await page.screenshot({ path: new URL('01-login.png', root).pathname });
await page.click('[data-action="login-browser"]');
await page.click('[data-action="login-wechat"]');
assert(await page.locator('#s-login').evaluate(e => e.classList.contains('active')));
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('游客'));
await page.click('[data-action="login-browser"]');
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('免登录'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.waitForSelector('#p-login-success.show');
await page.screenshot({ path: new URL('02-login-success.png', root).pathname });
await page.click('[data-action="login-confirm"]');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
await page.waitForTimeout(400);
await page.waitForFunction(() => !document.querySelector('#toast').classList.contains('show'));
await page.waitForTimeout(400);
await page.screenshot({ path: new URL('02-home.png', root).pathname });
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('次'));
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存'));
await page.waitForTimeout(400);
await page.screenshot({ path: new URL('03-history-empty.png', root).pathname });
await page.goBack();
await page.waitForSelector('#s-splash.active');
await page.reload();
await page.waitForSelector('#s-login.active');
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="open-privacy"]');
await page.waitForTimeout(400);
+22 -19
View File
@@ -90,18 +90,19 @@ try {
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
let browserId, browserToken;
if (device === 0) {
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'browser');
assert.equal(await page.locator('#p-login-success.show').count(), 0);
browserId = await page.evaluate(() => AccountClient.user().id);
browserToken = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' });
assert.equal(browserToken, null);
assert.throws(() => accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' }));
await page.click('[data-action="history"]');
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
await page.click('[data-action="switch-login"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存'));
assert.equal(await page.locator('[data-report-id]').count(), 0);
await page.click('[data-action="open-wechat-login"]');
assert.equal(await page.locator('#login-consent').isChecked(), false);
assert((await page.locator('#login-message').textContent()).includes('报告不会自动合并'));
assert((await page.locator('#login-message').textContent()).includes('后续检测报告'));
upstreamError = true;
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
@@ -140,26 +141,28 @@ try {
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'wechat');
assert.equal(await page.evaluate(() => AccountClient.user().id), firstId);
await page.click('[data-action="history"]');
await page.click('[data-action="switch-login"]');
await page.check('#login-consent');
await page.click('[data-action="login-browser"]');
assert.equal(await page.locator('[data-action="switch-login"]').count(), 0);
await context.clearCookies(); await page.goto(base);
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.click('[data-action="guest-experience"]');
await page.waitForSelector('#s-splash.active');
await confirmLogin(page, 'browser');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
assert.equal(await page.locator('#p-login-success.show').count(), 0);
assert.notEqual(await page.evaluate(() => AccountClient.user().id), browserId, 'new guest login creates only a fresh temporary session');
await page.click('[data-action="history"]');
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
await page.waitForFunction(() => document.querySelector('#history-count').textContent.includes('不保存'));
assert.equal(await page.locator('[data-report-id]').count(), 0);
assert.equal(await page.locator('[data-report-id="RwechatCrossDevice"]').count(), 0);
assert.equal((await api(page, 'reports/' + saved.id)).status, 404);
assert.equal((await api(page, 'reports/' + saved.id)).status, 401, 'no backend session before welcome consent');
// An OAuth success URL with a lost cookie must not restore an unrelated browser credential.
await context.clearCookies();
await page.goto(base + '?login=success');
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('会话未能保存'));
assert.equal((await api(page, 'auth/session')).data.user, null);
await page.waitForFunction(() => !location.search.includes('login=success'));
await page.reload(); // Ordinary browser restore is still supported.
await page.waitForSelector('#s-splash.active');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
await page.reload(); // A guest is never restored from browser storage.
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
assert.equal(await page.locator('#s-login.active').count(), 1);
assert.equal(await page.evaluate(() => AccountClient.user()), null);
assert.equal(await page.locator('#p-login-success.show').count(), 0, 'session restore must not repeatedly open a success dialog');
}
assert.deepEqual(errors, []);
@@ -179,7 +182,7 @@ try {
assert.equal((await api(strangerPage, 'reports/' + saved.id)).status, 404);
assert.equal((await api(strangerPage, 'reports')).data.total, 0);
await stranger.close();
console.log('PASS: HTTPS /state/ simulated WeChat login, Secure cookies, same identity/report across devices, different-user isolation, explicit account switching, preserved browser reports, OAuth failure/missing-cookie handling, no silent browser fallback');
console.log('PASS: HTTPS /state/ simulated WeChat login, Secure cookies, same identity/report across devices, different-user isolation, explicit switching, ephemeral guests without history, OAuth failure/missing-cookie handling, no silent guest fallback');
} finally {
global.fetch = realFetch;
if (browser) await browser.close();
+20
View File
@@ -0,0 +1,20 @@
// Test-only persisted WeChat sessions. Never import from production code.
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
module.exports = function seedWechat(root, count = 1) {
const file = path.join(root, 'identities.json');
if (fs.existsSync(file)) throw Error('Wechat fixture requires an empty test store');
const db = { users: [], sessions: {}, states: {} }, entries = [];
for (let i = 0; i < count; i++) {
const user = { id: 'fixture-wechat-' + i, kind: 'wechat', identity: 'fixture-' + i, reports: [] };
const token = crypto.randomBytes(32).toString('hex');
db.users.push(user);
db.sessions[crypto.createHash('sha256').update(token).digest('hex')] = { user: user.id, expires: Date.now() + 86400000 };
entries.push({ user, token, cookie: 'juzi_session=' + token });
}
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(file, JSON.stringify(db));
return entries;
};