From bb6da17f5499ed438145da998c2db3c8d30e267a Mon Sep 17 00:00:00 2001 From: xsl Date: Fri, 16 Oct 2026 19:03:25 +0800 Subject: [PATCH] fix(recommendation): use displayed skin age across reports and admin --- docs/产品推荐改用展示肌龄-2026-10-16.md | 13 +++++++++++ docs/产品推荐规则-肤龄版.md | 2 ++ h5/js/admin.js | 8 +++---- h5/js/app.js | 1 + h5/js/report-ui.js | 4 ++++ h5/js/skin-score.js | 29 +++++++++++++++++++------ h5/lib/accounts.js | 9 +++++++- h5/server.js | 19 +++++++++++----- h5/test/display-age-recommendation.js | 19 ++++++++++++++++ h5/test/display-grade.js | 2 +- h5/test/recommendation.js | 15 +++++++------ h5/test/report-persistence.mjs | 20 ++++++++--------- 12 files changed, 106 insertions(+), 35 deletions(-) create mode 100644 docs/产品推荐改用展示肌龄-2026-10-16.md create mode 100644 h5/test/display-age-recommendation.js diff --git a/docs/产品推荐改用展示肌龄-2026-10-16.md b/docs/产品推荐改用展示肌龄-2026-10-16.md new file mode 100644 index 0000000..278f6b7 --- /dev/null +++ b/docs/产品推荐改用展示肌龄-2026-10-16.md @@ -0,0 +1,13 @@ +# 产品推荐按报告展示肌龄 + +规则版本 `2026-10-16-display-skin-age-v2`,替代原来的原始肌龄推荐依据。 + +推荐年龄必须与报告顶部显示的肌肤年龄一致:使用当前实际年龄 ±5 岁展示修正规则后的肤龄;没有实际年龄则显示原始肤龄,并使用同一个值推荐。原始肌龄缺失时展示值也缺失,不以实际年龄代替推测产品。 + +仅改变年龄输入,原有分档和边界调整条件保留:<35 岁基础 I、35~不足50 岁基础 II、≥50 岁基础 III;32~不足35 / 47~不足50 岁紧塑力低于8升一档;35~38 / 50~53 岁紧塑力至少9且透亮感或稳韧力任一至少9降一档;不足32固定 I;其余按展示肤龄。不改变评分、年龄显示规则或报告结论。 + +新报告保存新推荐。历史报告重新打开、后台列表/详情/筛选/产品分布及 CSV 按当前展示肌龄只读更新产品推荐;历史检测记录与原始报告快照不批量重写,分数、结论与护理顺序保留。后台明确标示「展示肌龄(推荐依据)」,原始肌龄仅供复核。 + +案例 R20261016-152835-5C0Q:实际44、原始肌龄34、展示肌龄39;按39岁推荐主场精华 II,不再按34岁推荐 I。 + +验证包括推荐边界/未舍入分数、真实案例等价数据、缺失年龄、新旧报告产品一致、前后台筛选和 CSV、原始数据不变以及异步皱纹浮层回归。不调用付费检测接口。 diff --git a/docs/产品推荐规则-肤龄版.md b/docs/产品推荐规则-肤龄版.md index d6c5aaf..21abdb7 100644 --- a/docs/产品推荐规则-肤龄版.md +++ b/docs/产品推荐规则-肤龄版.md @@ -1,5 +1,7 @@ # 产品推荐规则:肤龄基准与边界调整 +> 2026-10-16 更新:以下为原始肌龄依据的历史规则说明。当前已改为报告展示肌龄,分档和边界条件保留;以 [产品推荐改用展示肌龄-2026-10-16.md](产品推荐改用展示肌龄-2026-10-16.md) 为准。 + 状态:2026-10-12 用户已确认三个边界条件;已实现。规则版本:`2026-10-12-skin-age-v1`。 ## 已明确的要求 diff --git a/h5/js/admin.js b/h5/js/admin.js index cc6b97e..eb69131 100644 --- a/h5/js/admin.js +++ b/h5/js/admin.js @@ -427,8 +427,8 @@ function renderDetail(data) { if (r.detect_status === '成功') { h += dSec('综合', '
' + '综合得分 ' + fmtTotal(r.total_score) + '' + - '原始肌龄(新推荐依据) ' + ageText(r.skin_age) + '' + - '前端展示肌龄 ' + ageText(r.skin_age_display) + '' + + '原始肌龄(接口返回) ' + ageText(r.skin_age) + '' + + '报告展示肌龄(推荐依据) ' + ageText(r.skin_age_display) + '' + '六分类均分S ' + fmtScore(r.avg_s) + '' + '左下颌线角 ' + (r.jawline_angle_l != null ? r.jawline_angle_l + '°' : '-') + '' + '右下颌线角 ' + (r.jawline_angle_r != null ? r.jawline_angle_r + '°' : '-') + '' + @@ -439,7 +439,7 @@ function renderDetail(data) { var tone = r.tone_scoring; h += '

肤色项=色素扣斑分×80%+黑眼圈评分×20%;' + (r.vita_score_rule === 'independent-color-water-smooth-min-v1' ? '该历史版本透亮感取独立肤色、水分、平滑度三项最低分。' : '肤色项与水分项各占50%合成肤色水润分,透亮感取肤色水润与平滑度的最低分。') + '色素扣斑分:' + esc(fmtToneScore(tone.pigment_adjusted_score)) + ',黑眼圈评分:' + esc(fmtToneScore(tone.dark_circle_score)) + ',独立肤色项:' + esc(fmtToneScore(tone.color_score)) + ',水分项:' + esc(fmtToneScore(tone.water_adjusted_score)) + '。肤色缺项按有效权重归一。

'; } - h += '

前端展示肌龄限制在实际年龄±5岁;推荐仍使用原始肌龄。历史报告保留当时的推荐。

'; + h += '

展示肌龄限制在实际年龄±5岁;当前推荐使用报告展示肌龄及边界规则。原始检测数据和历史快照保留。

'; /* 6 分类表格 */ var catRows = ''; @@ -532,7 +532,7 @@ function renderDetail(data) { var score = rec.scores[sk]; scoreParts.push(DIM_NAMES[sk] + ':' + (score == null ? '未获取' : String(score))); } - h += dSec('推荐依据', '
使用原始肌龄 ' + ageText(rec.skinAge) + '' + + h += dSec('推荐依据', '
' + (rec.ageBasis === 'display' ? '使用展示肌龄' : '使用原始肌龄') + ' ' + ageText(rec.skinAge) + '' + '基础档 ' + esc(tierText(rec.baseTier)) + '' + '最终档 ' + esc(tierText(rec.tier)) + '
' + '
' + esc(rec.reason) + '
判定分(未舍入S,不使用前端一位小数):' + esc(scoreParts.join(';')) + '
'); diff --git a/h5/js/app.js b/h5/js/app.js index ed4861b..960bcc1 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -1669,6 +1669,7 @@ refresh(); } function renderReport(vm, frontPhoto, sidePhoto, payload, reportPhotos) { + vm=SkinScore.withDisplayRecommendation(vm,payload && payload.result); activeWrinkleReport={payload:payload,photos:reportPhotos || [frontPhoto,sidePhoto,null],owner:AccountClient.user()&&AccountClient.user().id,started:false}; renderContourSides(reportPhotos || [frontPhoto, sidePhoto, null]); reportOwner = AccountClient.user() && AccountClient.user().id; diff --git a/h5/js/report-ui.js b/h5/js/report-ui.js index eabb5e1..fb4743a 100644 --- a/h5/js/report-ui.js +++ b/h5/js/report-ui.js @@ -181,6 +181,10 @@ // Refresh only presentation for old snapshots: scores, selected care order, // product and original stored objects are never rewritten/recomputed. function forDisplay(vm, result, saved) { + if (saved && vm.recommendation && vm.recommendation.ageBasis === 'display') { + var product=products.filter(function(p){return p.tier === vm.recommendation.tier;})[0] || null; + if(saved.product!==product)saved = Object.assign({}, saved, { product: product }); + } if (!saved || (saved.gradeRule === gradeRule && saved.copyRule === copyRule)) return saved || build(vm, result); var current = build(vm, result); var care = (saved.care || []).map(function(item) { diff --git a/h5/js/skin-score.js b/h5/js/skin-score.js index afda9c2..ccb0df1 100644 --- a/h5/js/skin-score.js +++ b/h5/js/skin-score.js @@ -628,12 +628,12 @@ var firm = isNum(scores.firm) ? scores.firm : null; var vita = isNum(scores.vita) ? scores.vita : null; var stab = isNum(scores.stab) ? scores.stab : null; - var rec = { version: '2026-10-12-skin-age-v1', skinAge: skinAge, + var rec = { version: '2026-10-16-display-skin-age-v2', ageBasis: 'display', skinAge: skinAge, baseTier: null, tier: null, boundary: null, adjustment: 'none', scores: { firm: firm, vita: vita, stab: stab }, reason: '', triggers: [] }; if (!isNum(skinAge) || skinAge <= 0) { rec.adjustment = 'unavailable'; - rec.reason = '未获取有效原始肌龄,暂不推荐具体产品;不以实际年龄代替。'; + rec.reason = '未获取有效展示肌龄,暂不推荐具体产品;不以实际年龄代替。'; return rec; } rec.baseTier = skinAge < 35 ? 1 : (skinAge < 50 ? 2 : 3); @@ -653,11 +653,11 @@ rec.reason = '处于' + rec.boundary + '岁上侧边界,紧塑力≥9且透亮感或稳韧力≥9,降一档。'; rec.triggers.push(rec.boundary + '岁边界高分降档'); } else if (skinAge < 32) { - rec.reason = '原始肌龄小于32岁,固定推荐25+,轮廓或皱纹低分不升档。'; + rec.reason = '展示肌龄小于32岁,固定推荐25+,轮廓或皱纹低分不升档。'; } else if (rec.boundary != null) { rec.reason = '处于' + rec.boundary + '岁边界,未满足升降档条件,保持肤龄基础档。'; } else { - rec.reason = '不在35±3或50±3岁边界内,仅按原始肌龄推荐。'; + rec.reason = '不在35±3或50±3岁边界内,仅按展示肌龄推荐。'; } return rec; } @@ -777,7 +777,7 @@ if (!worst || cP < worst.P) worst = { catKey: def.key, name: def.name, P: cP, S: sShow(cP) }; } - var recommendation = recommendProduct(skinAge, { + var recommendation = recommendProduct(skinAgeDisplay, { firm: dims.firm.P == null ? null : sRaw(dims.firm.P), vita: dims.vita.P == null ? null : sRaw(dims.vita.P), stab: dims.stab.P == null ? null : sRaw(dims.stab.P) @@ -934,7 +934,7 @@ }; // Apply the current display rule to new and historical reports without - // modifying their raw age, stored snapshot, copy or recommendation. + // modifying their raw age or stored snapshot. Recommendation uses this age. function reportSkinAge(vm, result) { var raw = vm && Object.prototype.hasOwnProperty.call(vm, 'skinAgeRaw') ? val(vm.skinAgeRaw) : val(result && result.skin_age); if (!isNum(raw) || raw < 0) return null; @@ -942,7 +942,22 @@ if (!isNum(actual) || actual < 0) return raw; return Math.max(actual - 5, Math.min(actual + 5, raw)); } + function withDisplayRecommendation(vm, result) { + if (!vm) return vm; + var age = reportSkinAge(vm, result), scores = {}; + ['firm','vita','stab'].forEach(function(key) { + var d = vm.dims && vm.dims[key]; + var saved = vm.recommendation && vm.recommendation.scores && vm.recommendation.scores[key]; + scores[key] = isNum(saved) ? saved : d && isNum(d.P) ? sRaw(d.P) : d && isNum(d.S) ? d.S : null; + }); + var recommendation = recommendProduct(age, scores), tier = recommendation.tier; + return Object.assign({}, vm, { skinAgeDisplay: age, recommendation: recommendation, + group: tier == null ? null : ['25+组','35+组','50+组'][tier-1], + essence: { name: tier == null ? '暂无法推荐' : ESSENCE[tier].name, + text: tier == null ? recommendation.reason : ESSENCE[tier].text, tier: tier, triggers: recommendation.triggers } }); + } return { + withDisplayRecommendation: withDisplayRecommendation, capDimensionScore: capDimensionScore, weightedTotal: weightedTotal, reportSkinAge: reportSkinAge, @@ -1020,7 +1035,7 @@ if (typeof require === 'function' && typeof module !== 'undefined' && require.ma ok(vm2.dims.firm.P === Math.min(vm2.cats.contour.P, vm2.cats.wrinkles.P) && vm2.dims.vita.P === Math.min(vm2.cats.smooth.P, vm2.cats.tone.P) && vm2.dims.stab.P === Math.min(vm2.cats.sens.P, vm2.cats.acne.P), '三维度 P = min(旗下两分类)'); - ok(vm2.group === '25+组', '实际年龄40不影响推荐,原始肌龄20仍为25+组'); + ok(vm2.group === '35+组', '实际年龄40、原始肌龄20、展示肌龄35,按展示肌龄推荐35+组'); console.log('== T3 原始肌龄展示 =='); var r3 = clone(SS.MOCK_RESULT); diff --git a/h5/lib/accounts.js b/h5/lib/accounts.js index 9cc8f6c..5a965bf 100644 --- a/h5/lib/accounts.js +++ b/h5/lib/accounts.js @@ -3,6 +3,8 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const net = require('net'); +const SkinScore = require('../js/skin-score'); +const ReportUI = require('../js/report-ui'); const random = () => crypto.randomBytes(32).toString('hex'); const hash = value => crypto.createHash('sha256').update(value).digest('hex'); @@ -162,7 +164,12 @@ module.exports = function createAccounts(root, env = process.env) { else if (/^R[\w-]+$/.test(id) && user.reports.some(r => r.id === id)) { const file = path.join(snapshots, user.id, id + '.json'); if (!fs.existsSync(file)) json(res, 404, { ok: false, message: '报告文件暂不可用' }); - else json(res, 200, { ok: true, report: JSON.parse(fs.readFileSync(file, 'utf8')) }); + else { + const snapshot=JSON.parse(fs.readFileSync(file,'utf8')); + snapshot.vm=SkinScore.withDisplayRecommendation(snapshot.vm,snapshot.payload && snapshot.payload.result); + if(snapshot.presentation)snapshot.presentation=Object.assign({},snapshot.presentation,{product:ReportUI.products.find(p=>p.tier===snapshot.vm.recommendation.tier)||null}); + json(res,200,{ok:true,report:snapshot}); + } } else json(res, 404, { ok: false, message: '报告不存在' }); } else json(res, 404, { ok: false, message: '接口不存在' }); } catch (_) { if (!res.headersSent) json(res, 400, { ok: false, message: '请求处理失败,请稍后重试' }); } diff --git a/h5/server.js b/h5/server.js index fd1cbf3..2f277d3 100644 --- a/h5/server.js +++ b/h5/server.js @@ -587,6 +587,7 @@ function displayScoreLevels(scores, capDimensions = false) { Object.assign({}, value, { S:capDimensions ? SkinScore.capDimensionScore(value && value.S) : value && value.S, level: ReportUI.internalLevel(value && value.S) })])) : scores; } function listItem(rec) { + rec=displayRecommendationRecord(rec); return { record_id: rec.record_id, created_at: rec.created_at, @@ -606,6 +607,13 @@ function listItem(rec) { } /* Dates mean Beijing calendar days; fully zoned ISO timestamps retain their instant. */ +function displayRecommendationRecord(rec) { + if(!rec || rec.detect_status!=='成功')return rec; + const vm=SkinScore.withDisplayRecommendation({skinAgeRaw:rec.skin_age,realAge:rec.real_age,dims:rec.dims,recommendation:rec.recommendation}); + const presentation=rec.presentation?Object.assign({},rec.presentation,{product:ReportUI.products.find(p=>p.tier===vm.recommendation.tier)||null}):rec.presentation; + return Object.assign({},rec,{skin_age_display:vm.skinAgeDisplay,recommendation:vm.recommendation,group:vm.group,essence:vm.essence,essence_line:vm.essence.text, + trigger:vm.recommendation.triggers.join('、'),presentation}); +} function matchQuery(rec, q) { if (q.timeRange && (q.from || q.to)) { const timestamp = BeijingTime.milliseconds(rec.created_at); @@ -650,7 +658,7 @@ function handleRecordsQuery(req, res, url) { catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); } const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20)); - const filtered = readAllRecords().filter(r => matchQuery(r, q)) + const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q)) .sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序 const total = filtered.length; const items = filtered.slice((page - 1) * pageSize, page * pageSize).map(listItem); @@ -658,7 +666,7 @@ function handleRecordsQuery(req, res, url) { } function handleRecordDetail(req, res, id) { - const rec = findRecord(id); + const rec = displayRecommendationRecord(findRecord(id)); if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' }); // 原始 JSON 全文:读文件失败给 null。 // face_maps 是整幅 base64 叠加图(单文件可达数 MB),详情页直接渲染会卡死浏览器: @@ -695,6 +703,7 @@ function handleRecordDetail(req, res, id) { if (rec.sub_scores && rec.sub_scores.color) displayVm.cats.tone.vars.color_score = rec.sub_scores.color.P; presentation = ReportUI.forDisplay(displayVm, rawJson.result, presentation); } + if(presentation && rec.recommendation && rec.recommendation.ageBasis==='display')presentation=Object.assign({},presentation,{product:ReportUI.products.find(p=>p.tier===rec.recommendation.tier)||null}); sendJson(res, 200, { ok: true, record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec), @@ -738,7 +747,7 @@ function handlePhoto(req, res, id, which) { } function handleStats(req, res) { - const all = readAllRecords(); + const all = readAllRecords().map(displayRecommendationRecord); const now = Date.now(), today = BeijingTime.day(now); const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败'); const succeeded = finished.filter(r => r.detect_status === '成功'); @@ -853,13 +862,13 @@ function handleExport(req, res, url) { }; try { q.timeRange = BeijingTime.range(q.from, q.to); } catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); } - const filtered = readAllRecords().filter(r => matchQuery(r, q)) + const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q)) .sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null; const dimS = (r, d) => (r.dims && r.dims[d]) ? SkinScore.capDimensionScore(r.dims[d].S) : null; const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelForScore(r.dims[d].S) : null; const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态', - '失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄(推荐依据)', '前端展示肌龄', + '失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)', '紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档', '均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本']; const lines = [header.join(',')]; diff --git a/h5/test/display-age-recommendation.js b/h5/test/display-age-recommendation.js new file mode 100644 index 0000000..5ffda25 --- /dev/null +++ b/h5/test/display-age-recommendation.js @@ -0,0 +1,19 @@ +'use strict'; +const assert=require('node:assert/strict'),SS=require('../js/skin-score'),UI=require('../js/report-ui'); +// The reported case: raw34, actual44 => displayed39, product II. +const old={skinAgeRaw:34,realAge:44,skinAgeDisplay:39,dims:{firm:{P:76,S:9.28},vita:{P:76.2,S:9.29},stab:{P:95.5,S:9.87}}, + recommendation:{version:'old',scores:{firm:9.28,vita:9.286,stab:9.865}},essence:{name:'25+束紧精华'},summary:'原结论',cats:{},careAdvice:[]}; +const bytes=JSON.stringify(old),updated=SS.withDisplayRecommendation(old); +assert.equal(updated.recommendation.skinAge,39);assert.equal(updated.recommendation.ageBasis,'display');assert.equal(updated.essence.tier,2); +assert.equal(updated.skinAgeRaw,34);assert.equal(updated.summary,old.summary);assert.strictEqual(updated.dims,old.dims);assert.equal(JSON.stringify(old),bytes); +const saved={gradeRule:UI.gradeRule,copyRule:UI.copyRule,product:UI.products[0],views:[],care:[]},before=JSON.stringify(saved); +const presentation=UI.forDisplay(updated,{},saved);assert.equal(presentation.product.tier,2);assert.equal(JSON.stringify(saved),before);assert.strictEqual(presentation.views,saved.views); +for(const [raw,actual,display,tier] of [[24,60,55,3],[55,30,35,1],[34,44,39,2],[null,44,null,null],[34,null,34,1]]){ + const vm=SS.withDisplayRecommendation({...old,skinAgeRaw:raw,realAge:actual});assert.equal(vm.skinAgeDisplay,display);assert.equal(vm.essence.tier,tier); +} +// New reports also use the exact same age displayed by the report renderer. +for(const actual of [24,40,44,60]){ + const raw=JSON.parse(JSON.stringify(SS.MOCK_RESULT));raw.skin_age={value:34};const vm=SS.buildViewModel(raw,SS.MOCK_SIDE,{realAge:actual}); + assert.equal(vm.recommendation.skinAge,SS.reportSkinAge(vm,raw));assert.equal(vm.skinAgeRaw,34); +} +console.log('PASS: real case raw34/display39 => II; new/history identical display-age basis; boundary/missing age; scores/copy/saved snapshots unchanged'); diff --git a/h5/test/display-grade.js b/h5/test/display-grade.js index e72fb23..8979890 100644 --- a/h5/test/display-grade.js +++ b/h5/test/display-grade.js @@ -35,7 +35,7 @@ old.product={tier:3,title:'历史推荐不改'}; const oldBytes=JSON.stringify(old), vmBytes=JSON.stringify(vm); const display=UI.forDisplay(vm,r,old); assert.equal(display.views[0].level,'优秀'); -assert.deepEqual(display.product,old.product); +assert.deepEqual(display.product,ui.product); assert.deepEqual(display.care.map(v=>v.key),old.care.map(v=>v.key)); assert.equal(JSON.stringify(old),oldBytes);assert.equal(JSON.stringify(vm),vmBytes); assert.equal(UI.forDisplay(vm,r,ui),ui); diff --git a/h5/test/recommendation.js b/h5/test/recommendation.js index de805ba..18ffef9 100644 --- a/h5/test/recommendation.js +++ b/h5/test/recommendation.js @@ -7,7 +7,7 @@ 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'); + assert.equal(r.version, '2026-10-16-display-skin-age-v2'); if (tier) assert(Math.abs(r.tier - r.baseTier) <= 1); count++; } @@ -50,11 +50,12 @@ 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); + const age=realAge==null?24:Math.max(realAge-5,Math.min(realAge+5,24)); + assert.equal(vm.essence.tier, SS.recommendProduct(age,vm.recommendation.scores).tier); + assert.equal(vm.recommendation.skinAge, age); assert(Math.abs(vm.recommendation.scores.firm - 7.69) < 1e-12); - assert.equal(UI.build(vm, raw).product.tier, 1); - assert.equal(vm.skinAgeDisplay, realAge == null ? 24 : Math.max(realAge - 5, Math.min(realAge + 5, 24))); // Display clamp does not change raw-age recommendation. + assert.equal(UI.build(vm, raw).product.tier,vm.essence.tier); + assert.equal(vm.skinAgeDisplay, realAge == null ? 24 : Math.max(realAge - 5, Math.min(realAge + 5, 24))); count++; } delete raw.skin_age; @@ -62,6 +63,6 @@ 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('未获取有效原始肌龄')); +assert(missing.recommendation.reason.includes('未获取有效展示肌龄')); count++; -console.log(`PASS: ${count} recommendation cases; boundary/threshold/missing/raw-age/actual-age isolation`); +console.log(`PASS: ${count} recommendation cases; unchanged boundary/threshold rules, display-age input and missing-age handling`); diff --git a/h5/test/report-persistence.mjs b/h5/test/report-persistence.mjs index 8bb93aa..3118633 100644 --- a/h5/test/report-persistence.mjs +++ b/h5/test/report-persistence.mjs @@ -73,8 +73,8 @@ try { assert(result.vm.skinAgeRaw < 32); const expectedAge = Math.max(55, Math.min(65, result.vm.skinAgeRaw)); assert.equal(result.vm.skinAgeDisplay, expectedAge); - assert.equal(result.vm.essence.tier, 1); // Raw age, never actual60. - assert.equal(result.presentation.product.tier, 1); + assert.equal(result.vm.essence.tier, 3); // Display age 55, not raw age below32. + assert.equal(result.presentation.product.tier, 3); 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); @@ -101,7 +101,7 @@ try { assert.deepEqual(list.items[0].recommendation, result.vm.recommendation); assert.equal(list.items[0].total_score, expectedTotal); const csv = await (await api.get(base + 'api/admin/export')).text(); - assert(csv.includes('原始肌龄(推荐依据),前端展示肌龄')); + assert(csv.includes('原始肌龄,前端展示肌龄(推荐依据)')); assert(csv.includes(result.vm.recommendation.version)); assert(csv.includes('综合得分(4:3:3)')); assert.equal(csv.trim().split('\n')[1].split(',')[14],String(expectedAge)); @@ -125,7 +125,7 @@ try { assert.equal(await row.locator('td').nth(6).textContent(), String(expectedTotal)); assert((await row.textContent()).includes('原始 ' + result.vm.skinAgeRaw + '岁')); assert((await row.textContent()).includes('展示 ' + expectedAge + '岁')); - assert((await row.textContent()).includes("1'me 主场精华 I(25+)")); + assert((await row.textContent()).includes("1'me 主场精华 III(50+)")); for (const key of ['firm', 'vita', 'stab']) { assert((await row.textContent()).includes(ReportUI.dimNames[key] + ' ' + ReportUI.dimensionScore(result.vm.dims[key].S) + ' ' + ReportUI.levelForScore(result.vm.dims[key].S))); } @@ -154,11 +154,11 @@ try { } if (process.env.H5_ARTIFACT_DIR) await desktop.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'admin-weighted-detail.png') }); assert(detailText.includes(result.vm.recommendation.reason)); - assert(detailText.includes('前端展示肌龄 ' + expectedAge + '岁')); + assert(detailText.includes('报告展示肌龄(推荐依据) ' + expectedAge + '岁')); 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.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'); @@ -249,15 +249,15 @@ try { const legacyCsv=(await (await api.get(base + 'api/admin/export')).text()).trim().split('\n')[1].split(','); assert.equal(legacyCsv[12], String(expectedTotal)); assert.equal(legacyCsv[14], String(expectedAge)); - assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[1].title); - assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[1].image, 'refresh artwork crop, never the historical recommendation/copy'); + assert.equal(await page.locator('#product-title').textContent(), ReportUI.products[2].title); + assert.equal(await page.locator('#product-image').getAttribute('src'), ReportUI.products[2].image, 'historical product refreshes to current displayed age without rewriting stored snapshot'); assert((await page.locator('.ditem-para').allTextContents()).includes(ReportUI.forDisplay(legacy.vm,legacy.payload.result,legacy.presentation).views[0].text)); assert(!(await page.locator('#care-list').textContent()).includes('旧护理待刷新')); await desktop.goto(base + 'admin.html'); await desktop.locator('#tbl-records tbody tr').first().click(); await desktop.waitForFunction(text => document.querySelector('#d-body').textContent.includes(text), legacyDetail.record.presentation.views[0].text); - assert((await desktop.locator('#d-body').textContent()).includes('旧版规则(历史记录)')); - assert((await desktop.locator('#d-body').textContent()).includes('前端展示肌龄 ' + expectedAge + '岁')); + assert((await desktop.locator('#d-body').textContent()).includes('2026-10-16-display-skin-age-v2')); + assert((await desktop.locator('#d-body').textContent()).includes('报告展示肌龄(推荐依据) ' + expectedAge + '岁')); assert.equal(fs.readFileSync(snapshotFile, 'utf8'), storedBytes); assert.equal(fs.readFileSync(recordFile, 'utf8'), recordBytes); // An incomplete set must show no total everywhere, not fall back to the API score.