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

This commit is contained in:
xsl
2026-10-11 17:28:11 +08:00
parent ee744b6b5f
commit ce8d0ff1f7
126 changed files with 18711 additions and 1898 deletions
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const create = require('../lib/accounts');
const SkinScore = require('../js/skin-score');
const ReportUI = require('../js/report-ui');
(async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-accounts-test-'));
let accounts = create(root, {});
const server = http.createServer(async (req, res) => {
if (!await accounts.route(req, res, req.url.split('?')[0])) { res.writeHead(404); res.end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const base = 'http://127.0.0.1:' + server.address().port;
const request = async (url, data, cookie, extra = {}) => {
const r = await fetch(base + url, { redirect: 'manual', method: data ? 'POST' : 'GET',
headers: { ...(cookie ? { cookie } : {}), ...extra }, body: data ? JSON.stringify(data) : undefined });
const text = await r.text();
return { status: r.status, data: text ? JSON.parse(text) : null, cookie: r.headers.getSetCookie().map(c => c.split(';')[0]).join('; '), location: r.headers.get('location'), headers: r.headers };
};
try {
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 });
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: '干性肌肤' });
const presentation = ReportUI.build(vm, SkinScore.MOCK_RESULT);
const snapshot = { id: 'Rtest0', created_at: new Date().toISOString(), vm, presentation,
photos: ['data:image/jpeg;base64,front', 'data:image/jpeg;base64,left', 'data:image/jpeg;base64,right'],
payload: { result: { ...SkinScore.MOCK_RESULT, face_maps: { brown_area: 'data:image/png;base64,saved-layer' } }, side: SkinScore.MOCK_SIDE } };
accounts.storeReport(a.data.user.id, snapshot);
assert.deepEqual((await request('/api/reports/Rtest0', null, a.cookie)).data.report, JSON.parse(JSON.stringify(snapshot)));
assert.equal((await request('/api/reports/Rtest0', null, b.cookie)).status, 404);
assert.equal((await request('/api/reports', null, b.cookie)).data.total, 0);
for (let i = 1; i <= 5; i++) accounts.storeReport(a.data.user.id, { ...snapshot, id: 'Rtest' + i });
assert.equal((await request('/api/reports', null, a.cookie)).data.total, 5);
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/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);
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 });
const url = new URL(start.data.url);
assert.equal(url.searchParams.get('redirect_uri'), 'https://skin.example.com/state/api/auth/wechat/callback');
assert(start.headers.get('set-cookie').includes('Secure'));
assert(start.headers.get('set-cookie').includes('Path=/state/'));
const state = url.searchParams.get('state');
assert((await request('/api/auth/wechat/callback?code=test&state=' + state)).location.includes('login_error=state'));
// Mock only the upstream token exchange, not our HTTP routes, state or persistence.
const realFetch = global.fetch;
global.fetch = (url, options) => String(url).startsWith('https://api.weixin.qq.com/')
? Promise.resolve({ json: async () => ({ openid: 'same-wechat-user' }) }) : realFetch(url, options);
try {
async function wechat() {
const start = await request('/api/auth/wechat', { consent: true });
const state = new URL(start.data.url).searchParams.get('state');
const callback = await request('/api/auth/wechat/callback?code=test&state=' + state, null, start.cookie);
assert.equal(callback.location, '/state/?login=success');
assert((await request('/api/auth/wechat/callback?code=test&state=' + state, null, start.cookie)).location.includes('login_error=state'));
return (await request('/api/auth/session', null, callback.cookie)).data.user;
}
assert.deepEqual(await wechat(), await wechat());
} finally { global.fetch = realFetch; }
accounts = create(root, { TRUST_LOOPBACK_PROXY: '1' });
// Malformed credentials exercise the limiter without creating 60 users.
for (let i = 0; i < 60; i++) assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.1' })).status, 401);
assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.1' })).status, 429);
assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.2' })).status, 401);
accounts = create(root, {});
for (let i = 0; i < 60; i++) await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.1' });
assert.equal((await request('/api/auth/browser', { consent: true, token: 'bad' }, '', { 'X-Real-IP': '203.0.113.2' })).status, 429, 'untrusted forwarding headers cannot bypass rate limit');
assert.equal(presentation.views.length, 7);
assert(presentation.views.every(v => !/\{[a-z_]+\}/.test(v.text)));
assert(!presentation.views.find(v => v.key === 'wrinkles').text.includes('未获取 条'));
console.log('PASS: consent, CSRF, isolated owners, exact snapshots, latest 5, restart, restore, logout, OAuth state/replay/subpath/same identity, seven-view copy');
} finally {
await new Promise(resolve => server.close(resolve));
fs.rmSync(root, { recursive: true, force: true }); // Only this test-created temp directory.
}
})().catch(e => { console.error(e); process.exitCode = 1; });
+6 -3
View File
@@ -109,13 +109,16 @@ try {
return b && b.textContent && b.textContent !== '--';
}, null, { timeout: 8000 });
const score = await page.locator('#stat-score b').innerText();
const essence = await page.locator('#essence-name').innerText();
const essence = await page.locator('#product-title').innerText();
const reportOn = await page.locator('#s-report.active').count();
check('直达报告页', reportOn === 1 && /^\d/.test(score) && essence && essence !== '--',
`score=${score} essence=${essence}`);
// 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.waitForSelector('#s-splash.active');
await page.getByRole('button', { name: '开始检测' }).click();
await page.locator('#privacy-check').click();
await page.getByRole('button', { name: '继续' }).click();
@@ -138,11 +141,11 @@ try {
return v && v.videoWidth > 0 && document.querySelector('#s-cam').classList.contains('cam-live');
}, null, { timeout: 12000 });
const step1 = (await page.locator('#cam-step').innerText()).replace(/\s+/g, '');
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正对屏幕'), step1);
check('进入拍照 1/3', step1.includes('1/3') && step1.includes('正脸出镜'), step1);
// mock 无人脸检测,快门作为调试入口保留;听完每步引导再按(最长 step2 4.3s):引导、「拍摄完成」、「分析中」都应完整播出
// 步骤顺序与轮廓对齐 Figma:正脸 → 右脸(耳在画面右,mask 2)→ 左脸(耳在画面左,mask 1)
const FIGMA_STEPS = [['正对屏幕', '0'], ['右脸', '2'], ['左脸', '1']];
const FIGMA_STEPS = [['正脸出镜', '0'], ['右脸', '2'], ['左脸', '1']];
for (let i = 0; i < 3; i++) {
const n = i + 1;
await page.waitForFunction((num) => {
+92
View File
@@ -0,0 +1,92 @@
// Isolated HTTP + browser integration. Upstream Face++ is fixture-backed; no paid call.
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 http from 'node:http';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath, pathToFileURL } from 'node: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-report-test-'));
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;
global.fetch=async()=>({ok:true,status:200,json:async()=>({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: '18089', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot,
SKIN_API_KEY: 'fixture-key', SKIN_API_SECRET: 'fixture-secret', PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] });
let browser;
try {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('test server startup timeout')), 10000);
child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timeout); resolve(); } });
child.once('exit', code => { clearTimeout(timeout); reject(new Error('server exited: ' + code)); });
});
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 } });
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');
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: 28, daily_skin_type: '干性肌肤' } } })).json();
assert.equal(result.ok, true);
assert.equal(result.report_saved, true);
assert(result.vm && result.presentation);
const snapshot = await (await api.get(base + 'api/reports/' + result.record_id)).json();
assert.equal(snapshot.report.photos.length, 3);
assert.deepEqual(snapshot.report.presentation, result.presentation);
assert.deepEqual(snapshot.report.payload.result.face_maps, result.result.face_maps);
assert.equal(snapshot.report.photos[0], fixture.photos[0]);
// A separate browser cannot access this user's report, even knowing its ID.
const other = await browser.newContext();
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 401);
await other.request.post(base + 'api/auth/browser', { data: { consent: true } });
assert.equal((await other.request.get(base + 'api/reports/' + result.record_id)).status(), 404);
for (const route of ['data/accounts/identities.json', 'lib/accounts.js', 'server.js', 'test/accounts.js', '%2edata/identities.json']) {
assert([403,404].includes((await api.get(base + route)).status()), route);
}
// Raw HTTP avoids URL clients normalizing away the malicious path under test.
for (const route of ['//data/accounts/identities.json', '/%2fdata/accounts/identities.json',
'/%5cdata/accounts/identities.json', '//lib/accounts.js', '/%2fserver.js', '/assets/../data/accounts/identities.json']) {
const status = await new Promise((resolve, reject) => {
http.get({ hostname: '127.0.0.1', port: 18089, path: route }, r => { r.resume(); resolve(r.statusCode); }).on('error', reject);
});
assert([403,404].includes(status), route);
}
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
const imagesBefore = await page.locator('.ditem-img').evaluateAll(images => images.map(i => i.src));
const copyBefore = await page.locator('.ditem-para').allTextContents();
await page.reload();
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForFunction(() => [...document.querySelectorAll('.ditem-img')].every(i => !i.hidden && i.naturalWidth));
assert.deepEqual(await page.locator('.ditem-img').evaluateAll(images => images.map(i => i.src)), imagesBefore);
assert.deepEqual(await page.locator('.ditem-para').allTextContents(), copyBefore);
await page.goBack();
await page.waitForSelector('#s-history.active');
await page.goBack();
await page.waitForSelector('#s-splash.active');
assert.deepEqual(errors, []);
console.log('PASS: authenticated analyze → full snapshot → backend history → reload → seven identical images/copy; owner isolation, private files, browser back');
} finally {
if (browser) await browser.close();
child.kill('SIGTERM');
if (child.exitCode === null) await once(child, 'exit');
fs.rmSync(dataRoot, { recursive: true, force: true });
}
+72
View File
@@ -0,0 +1,72 @@
import { execSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
import fs from 'node:fs';
import assert from 'node:assert/strict';
const root = new URL('../../design/ui-20261009/verification/', import.meta.url);
fs.mkdirSync(root, { recursive: true });
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 375, height: 726 } });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
const base = process.env.H5_BASE_URL || 'http://127.0.0.1:8080/';
await page.goto(base);
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
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"]');
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.waitForSelector('#s-splash.active');
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.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-splash.active');
await page.click('[data-action="open-privacy"]');
await page.waitForTimeout(400);
await page.screenshot({ path: new URL('04-privacy.png', root).pathname });
await page.click('#privacy-check');
await page.click('[data-action="agree-continue"]');
await page.waitForTimeout(400);
assert.equal(await page.locator('#s-profile').evaluate(e => e.getBoundingClientRect().top), 0);
await page.screenshot({ path: new URL('05-profile.png', root).pathname });
// Static mask layout audit only; real capture flow is covered by e2e-browser.mjs.
await page.evaluate(() => document.querySelectorAll('.screen').forEach(s => s.classList.toggle('active', s.id === 's-cam')));
await page.waitForTimeout(400);
for (const [step, name, label] of [['0', 'front', '1/3<br>请正脸出镜'], ['2', 'right', '2/3<br>请右脸出镜'], ['1', 'left', '3/3<br>请左脸出镜']]) {
await page.evaluate(({ step, label }) => { document.querySelector('#cam-mask').dataset.step = step; document.querySelector('#cam-step').innerHTML = label; }, { step, label });
await page.screenshot({ path: new URL('08-camera-' + name + '-layout.png', root).pathname });
}
await page.goto(base + '?mock=1&screen=report');
await page.waitForFunction(() => document.querySelector('#stat-score b').textContent !== '--');
await page.waitForTimeout(1200);
assert.equal(await page.locator('.card-detail').count(), 3);
assert.equal(await page.locator('.dtab').count(), 7);
assert.equal(await page.locator('.card-product img#product-image').count(), 1);
await page.screenshot({ path: new URL('06-report-top.png', root).pathname });
await page.locator('#s-report').evaluate(e => { e.style.position = 'relative'; e.style.height = 'auto'; e.style.overflow = 'visible'; document.body.style.overflow = 'visible'; });
await page.screenshot({ path: new URL('07-report-full.png', root).pathname, fullPage: true });
for (const card of await page.locator('.card-detail').all()) {
for (const tab of await card.locator('.dtab').all()) {
await tab.click();
assert.equal(await card.locator('.dpanel.active').count(), 1);
}
}
for (const width of [320, 390, 480]) {
await page.setViewportSize({ width, height: 844 });
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'horizontal overflow ' + width);
}
assert.deepEqual(errors, []);
console.log('PASS: login/consent/fallback/refresh/history/profile/report/7 tabs/3 viewport widths, no JS errors');
await browser.close();
+80
View File
@@ -0,0 +1,80 @@
// Real HTTPS cookies + /state/ reverse-proxy layout; only WeChat is simulated.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import https from 'node:https';
import { execFileSync, execSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { pathToFileURL, fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const createAccounts = require('../lib/accounts');
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-wechat-test-'));
const h5 = fileURLToPath(new URL('../', import.meta.url));
let browser, server;
const realFetch = global.fetch;
try {
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', path.join(root, 'key.pem'),
'-out', path.join(root, 'cert.pem'), '-days', '1', '-subj', '/CN=skin.example.test'], { stdio: 'ignore' });
let accounts;
server = https.createServer({ key: fs.readFileSync(path.join(root, 'key.pem')), cert: fs.readFileSync(path.join(root, 'cert.pem')) }, async (req, res) => {
const url = new URL(req.url, 'https://skin.example.test');
if (!url.pathname.startsWith('/state/')) { res.writeHead(404); return res.end(); }
req.url = req.url.slice('/state'.length); // nginx proxy_pass trailing slash
const route = url.pathname.slice('/state'.length);
if (await accounts.route(req, res, route)) return;
const file = route === '/' ? '/index.html' : route;
if (!/^\/(index\.html|(?:js|css|assets|vendor)\/[^.][^]*)$/.test(file) || file.includes('..')) { res.writeHead(404); return res.end(); }
try {
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2' };
res.setHeader('Content-Type', types[path.extname(file)] || 'application/octet-stream');
res.end(fs.readFileSync(path.join(h5, file)));
} catch (_) { res.writeHead(404); res.end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const base = 'https://skin.example.test:' + server.address().port + '/state/';
accounts = createAccounts(path.join(root, 'accounts'), { WECHAT_APP_ID: 'test-app', WECHAT_APP_SECRET: 'test-secret', PUBLIC_BASE_URL: base });
global.fetch = async url => {
assert.equal(new URL(url).origin, 'https://api.weixin.qq.com');
return { ok: true, json: async () => ({ openid: 'test-same-wechat-user' }) };
};
browser = await chromium.launch({ headless: true, args: ['--no-proxy-server', '--host-resolver-rules=MAP skin.example.test 127.0.0.1'] });
let firstId;
for (let device = 0; device < 2; device++) {
const context = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 MicroMessenger/8.0', viewport: { width: 375, height: 726 } });
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('https://open.weixin.qq.com/**', async route => {
const auth = new URL(route.request().url());
assert.equal(auth.searchParams.get('scope'), 'snsapi_base');
const callback = new URL(auth.searchParams.get('redirect_uri'));
assert.equal(callback.href, base + 'api/auth/wechat/callback');
callback.search = new URLSearchParams({ code: 'test-code', state: auth.searchParams.get('state') });
await route.fulfill({ status: 302, headers: { location: callback.href }, body: '' });
});
await page.goto(base);
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
// Redirects may preserve WeChat's fragment; it is not sent to the backend.
await page.waitForURL(url => url.origin + url.pathname === base && url.searchParams.get('login') === 'success');
await page.waitForSelector('#s-splash.active');
const user = await page.evaluate(() => AccountClient.user());
assert.equal(user.kind, 'wechat');
if (firstId) assert.equal(user.id, firstId); else firstId = user.id;
const cookie = (await context.cookies()).find(c => c.name === 'juzi_session');
assert(cookie.secure && cookie.httpOnly && cookie.path === '/state/' && cookie.sameSite === 'Lax');
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#identity-note').textContent.includes('同一微信账号'));
assert.deepEqual(errors, []);
await context.close();
}
console.log('PASS: HTTPS /state/ login → simulated WeChat → callback with bound Secure cookie → homepage/history; two browser contexts share WeChat identity');
} finally {
global.fetch = realFetch;
if (browser) await browser.close();
if (server) await new Promise(resolve => server.close(resolve));
fs.rmSync(root, { recursive: true, force: true });
}