fix(report): recommend by measured skin age and align admin results

This commit is contained in:
xsl
2026-10-12 00:23:57 +08:00
parent 7518dc2307
commit 0ec5e5dc32
21 changed files with 3010 additions and 1875 deletions
+4
View File
@@ -37,6 +37,10 @@ const ReportUI = require('../js/report-ui');
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.deepEqual(accounts.reportPresentation(a.data.user.id, 'Rtest0'), JSON.parse(JSON.stringify(presentation)));
assert.equal(accounts.reportPresentation(b.data.user.id, 'Rtest0'), null);
assert.equal(accounts.reportPresentation(a.data.user.id, '../identities'), null);
assert.equal(accounts.reportPresentation('../reports', 'Rtest0'), null);
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 });
+67
View File
@@ -0,0 +1,67 @@
'use strict';
const assert = require('node:assert/strict');
const SS = require('../js/skin-score.js');
const UI = require('../js/report-ui.js');
let count = 0;
function check(age, firm, vita, stab, tier, adjustment = 'none') {
const r = SS.recommendProduct(age, { firm, vita, stab });
assert.equal(r.tier, tier, `age=${age}; scores=${firm}/${vita}/${stab}`);
assert.equal(r.adjustment, adjustment);
assert.equal(r.version, '2026-10-12-skin-age-v1');
if (tier) assert(Math.abs(r.tier - r.baseTier) <= 1);
count++;
}
// Explicit decision-table expectations, including fractional ages and inclusive endpoints.
for (const [age, normal, low, high] of [
[24, 1, 1, 1], [31.99, 1, 1, 1], [32, 1, 2, 1], [34.99, 1, 2, 1],
[35, 2, 2, 1], [36, 2, 2, 1], [37, 2, 2, 1], [38, 2, 2, 1], [38.01, 2, 2, 2],
[46.99, 2, 2, 2], [47, 2, 3, 2], [49.99, 2, 3, 2],
[50, 3, 3, 2], [51, 3, 3, 2], [52, 3, 3, 2], [53, 3, 3, 2], [53.01, 3, 3, 3], [80, 3, 3, 3]
]) {
check(age, 8, 8, 8, normal);
check(age, 7.99, 10, 10, low, low > normal ? 'up' : 'none');
check(age, 9, 9, 7, high, high < normal ? 'down' : 'none');
check(age, 9, 7, 9, high, high < normal ? 'down' : 'none');
}
// Thresholds are compared before display rounding, not to 1- or 2-decimal scores.
for (const age of [32, 34.99, 47, 49.99]) {
const base = age < 35 ? 1 : 2;
check(age, 7.99999, 10, 10, base + 1, 'up');
check(age, 8, 10, 10, base);
check(age, null, 10, 10, base);
}
for (const age of [35, 38, 50, 53]) {
const base = age < 50 ? 2 : 3;
check(age, 8.99999, 10, 10, base);
check(age, 9, 8.99999, 8.99999, base);
check(age, 9, null, null, base);
check(age, null, 10, 10, base);
check(age, 9, null, 9, base - 1, 'down');
check(age, 9, 9, null, base - 1, 'down');
}
check(37, 9, 7, 9, 1, 'down'); // Only dullness: the third dimension may be arbitrarily low.
check(35, 8.99, 10, 10, 2); // Firmness is necessary, other two alone do not downgrade.
check(24, null, null, null, 1);
check(40, null, null, null, 2);
for (const invalid of [null, undefined, NaN, Infinity, -Infinity, 0, -5, '37', {}]) {
check(invalid, 10, 10, 10, null, 'unavailable');
}
const raw = JSON.parse(JSON.stringify(SS.MOCK_RESULT));
raw.skin_age = { value: 24 };
for (const realAge of [24, 40, 60, undefined]) {
const vm = SS.buildViewModel(raw, SS.MOCK_SIDE, { realAge });
assert.equal(vm.essence.tier, 1); // Weak wrinkles never upgrade under32.
assert.equal(vm.recommendation.skinAge, 24);
assert(Math.abs(vm.recommendation.scores.firm - 7.69) < 1e-12);
assert.equal(UI.build(vm, raw).product.tier, 1);
if (realAge === 60) assert.equal(vm.skinAgeDisplay, 57); // Display isn't the recommendation input.
count++;
}
delete raw.skin_age;
const missing = SS.buildViewModel(raw, SS.MOCK_SIDE, { realAge: 60 });
assert.equal(missing.group, null);
assert.equal(missing.essence.tier, null);
assert.equal(UI.build(missing, raw).product, null);
assert(missing.recommendation.reason.includes('未获取有效原始肌龄'));
count++;
console.log(`PASS: ${count} recommendation cases; boundary/threshold/missing/raw-age/actual-age isolation`);
+12 -4
View File
@@ -8,7 +8,8 @@
* - 6 分类文案(L/P/T/X/AB/AF)、综合文案(AW)、生活方式(AX) 逐字一致
* - 浮点容差 0.05;文案逐字无容差
*
* 引擎与真值不一致时:不改 h5/js/skin-score.js(审计代理负责),差异登记在 KNOWN_DIFFS
* 产品推荐按2026-10-12用户确认规则覆盖旧Excel期望;评分真值和原始fixture不改写。
* 非授权的新差异登记在 KNOWN_DIFFS
* 并输出明细;未登记的新差异 → 退出码 1。登记项若转为通过(引擎已修复)会提示清理。
*/
'use strict';
@@ -20,6 +21,12 @@ var SkinScore = require(path.join(__dirname, '..', 'js', 'skin-score.js'));
/* ─── 已知差异登记:键 = 案例.列,值 = 差异原因(逐条说明)。当前为空:V2.2 审计后
引擎与两组真值全部一致;今后出现引擎/真值不一致时在此登记,未登记的新差异 → 退出码 1 ─── */
var KNOWN_DIFFS = {};
var RECOMMENDATION_EXPECT = {
case1: { essence: '25+束紧精华', oldSummary: '按三重判据(触发:最差项约束)推荐35+支撑精华',
newSummary: '按肤龄及边界规则(触发:肤龄基础分组)推荐25+束紧精华' },
case2: { essence: '50+重塑精华', oldSummary: '按三重判据(触发:年龄组档)推荐50+重塑精华',
newSummary: '按肤龄及边界规则(触发:肤龄基础分组)推荐50+重塑精华' }
};
/* ─── V2.3 §8.8 护理建议成品真值(五段,逐字) ─── */
var V23_SKIN = { case1: '混合偏干肌肤', case2: '干性肌肤' };
@@ -28,7 +35,7 @@ var V23_EXPECT = {
{ tag: '肤质', text: '你的日常肤质为混合偏干肌肤:T区状态尚可,但两颊及面部外侧偏干,易出现紧绷与细纹。建议分区护理:两颊侧重保湿滋养,选用滋润型乳液/面霜,T区轻量保湿即可,避免全脸统一用厚重面霜。干燥会放大纹理粗糙感,建议先将两颊水合度提上来再做角质调理。' },
{ tag: '皱纹', text: '加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。' },
{ tag: '光滑', text: '加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。' },
{ tag: '精华', text: '建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。' },
{ tag: '精华', text: '建议搭配使用25+束紧精华:针对年轻肌肤活力减弱问题,激活肌肤动力、改善暗沉,提升弹润紧致表现,守护年轻光泽并延缓初始衰老迹象。' },
{ tag: '生活方式', text: '保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳' }
],
case2: [
@@ -120,9 +127,10 @@ function main() {
}
// 组别/精华/均分/综合文案/生活方式
okStr(tag + '.AT 产品组别', String(vm.group), tr.group);
okStr(tag + '.AU 精华产品线', vm.essence.name, tr.essence);
okStr(tag + '.AU 精华产品线(肤龄新版)', vm.essence.name, RECOMMENDATION_EXPECT[tag].essence);
okNum(tag + '.AV 六分类S均分', vm.avgS, tr.avgS);
okStr(tag + '.AW 综合文案', vm.summary, tr.summary, tag + '.AW');
okStr(tag + '.AW 综合文案', vm.summary, tr.summary.replace(RECOMMENDATION_EXPECT[tag].oldSummary,
RECOMMENDATION_EXPECT[tag].newSummary), tag + '.AW');
okStr(tag + '.AX 生活方式', vm.lifestyle, tr.lifestyle);
// V2.3 §8 护理建议五段(真值:§8.8 样例;daily_skin_type 按样例取,与 summary 口径的肤质参数不同)
+101 -2
View File
@@ -8,6 +8,9 @@ import http from 'node:http';
import { spawn, execSync } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
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-'));
@@ -40,15 +43,68 @@ try {
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();
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);
assert.equal(result.report_saved, true);
assert(result.vm && result.presentation);
assert(result.vm.skinAgeRaw < 32);
assert.equal(result.vm.skinAgeDisplay, 57);
assert.equal(result.vm.essence.tier, 1); // Raw age, not actual60 or display57.
assert.equal(result.presentation.product.tier, 1);
const adminRecord = (await (await api.get(base + 'api/admin/records/' + result.record_id)).json()).record;
assert.deepEqual(adminRecord.presentation, result.presentation);
assert.deepEqual(adminRecord.recommendation, result.vm.recommendation);
assert.equal(adminRecord.skin_age, result.vm.skinAgeRaw);
assert.equal(adminRecord.skin_age_display, result.vm.skinAgeDisplay);
for (const key of ['firm', 'vita', 'stab']) assert.equal(adminRecord.dims[key].S, result.vm.dims[key].S);
const list = await (await api.get(base + 'api/admin/records')).json();
assert.equal(list.items[0].skin_age, result.vm.skinAgeRaw);
assert.equal(list.items[0].skin_age_display, 57);
assert.deepEqual(list.items[0].recommendation, result.vm.recommendation);
const csv = await (await api.get(base + 'api/admin/export')).text();
assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄'));
assert(csv.includes(result.vm.recommendation.version));
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]);
const desktop = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
desktop.on('pageerror', e => errors.push(e.message));
await desktop.goto(base + 'admin.html');
const row = desktop.locator('#tbl-records tbody tr').first();
await row.waitFor();
assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁'));
assert((await row.textContent()).includes('展示 57岁'));
assert((await row.textContent()).includes("1'me 主场精华 I(25+)"));
for (const key of ['firm', 'vita', 'stab']) {
assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + result.vm.dims[key].S.toFixed(1) + ' ' + ReportUI.levelMap[result.vm.dims[key].level]));
}
assert.deepEqual(await desktop.locator('#f-dimlevel option').allTextContents(), ['不限档位', '改善', '良好', '优秀']);
if (process.env.H5_ARTIFACT_DIR) {
fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true });
await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-list.png'), fullPage: true });
}
await desktop.selectOption('#f-dim', 'firm');
await desktop.selectOption('#f-dimlevel', adminRecord.dims.firm.level);
const filtered = desktop.waitForResponse(r => r.url().includes('/api/admin/records?'));
await desktop.click('#btn-search');
assert.equal((await (await filtered).json()).total, 1);
await row.click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('推荐依据'));
const detailText = await desktop.locator('#d-body').textContent();
assert(detailText.includes(result.vm.recommendation.reason));
assert(detailText.includes('前端展示肌龄 57岁'));
for (const view of result.presentation.views) assert(detailText.includes(view.text));
for (const care of result.presentation.care) assert(detailText.includes(care.text));
if (process.env.H5_ARTIFACT_DIR) {
await desktop.locator('.d-sec').filter({ hasText: '使用原始肌龄' }).scrollIntoViewIfNeeded();
await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-recommendation.png') });
}
await desktop.click('#btn-close');
await desktop.click('[data-tab="stats"]');
await desktop.waitForFunction(() => document.querySelector('#dim-dist').textContent.includes('透亮感'));
assert(!(await desktop.locator('#dim-dist').textContent()).includes('预警'));
// 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);
@@ -78,12 +134,55 @@ try {
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);
assert.equal(await page.locator('#product-title').textContent(), result.presentation.product.title);
await page.goBack();
await page.waitForSelector('#s-history.active');
await page.goBack();
await page.waitForSelector('#s-splash.active');
// Simulate an old, immutable report whose original product was II, even though
// today's rule would choose I. Admin must reuse its saved frontend copy, too.
const recordFile = path.join(dataRoot, 'records.jsonl');
const record = JSON.parse(fs.readFileSync(recordFile, 'utf8').trim());
const snapshotFile = path.join(dataRoot, 'accounts/reports', record.owner_id, result.record_id + '.json');
const legacy = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
legacy.presentation.product = ReportUI.products[1];
legacy.presentation.views[0].text = '历史报告原文,不应按新文案重新计算。';
legacy.vm.essence = { name: '35+支撑精华', text: '旧版推荐', triggers: ['最差项约束'] };
delete legacy.vm.recommendation;
fs.writeFileSync(snapshotFile, JSON.stringify(legacy));
record.essence = legacy.vm.essence;
delete record.presentation;
delete record.recommendation;
delete record.skin_age_display;
fs.writeFileSync(recordFile, JSON.stringify(record) + '\n');
const storedBytes = fs.readFileSync(snapshotFile, 'utf8');
const recordBytes = fs.readFileSync(recordFile, 'utf8');
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[1].title);
assert((await page.locator('.ditem-para').allTextContents()).includes(legacy.presentation.views[0].text));
await desktop.goto(base + 'admin.html');
await desktop.locator('#tbl-records tbody tr').first().click();
await desktop.waitForFunction(() => document.querySelector('#d-body').textContent.includes('历史报告原文'));
assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)'));
assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes);
assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes);
// Missing skin age must not leave the previous product visible in the H5.
delete legacy.payload.result.skin_age;
const SkinScore = require('../js/skin-score.js');
legacy.vm = SkinScore.buildViewModel(legacy.payload.result, legacy.payload.side, { realAge: 60 });
legacy.presentation = ReportUI.build(legacy.vm, legacy.payload.result);
fs.writeFileSync(snapshotFile, JSON.stringify(legacy));
await page.reload();
await page.waitForSelector('#s-splash.active');
await page.click('[data-action="history"]');
await page.click('[data-report-id]');
await page.waitForSelector('#s-report.active');
assert.equal(await page.locator('.card-product').isVisible(), false);
assert.equal(await page.locator('#product-unavailable').isVisible(), true);
assert.deepEqual(errors, []);
console.log('PASS: authenticated analyze → full snapshot → backend history → reload → seven identical images/copy; owner isolation, private files, browser back');
console.log('PASS: raw-age recommendation; admin/H5 scores, ages, product, seven-view copy, care, CSV, filters; saved/reloaded and legacy immutable snapshots; missing-age UI; owner isolation/private files/back');
} finally {
if (browser) await browser.close();
child.kill('SIGTERM');