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
@@ -1,5 +1,7 @@
# 基础信息记忆与肤质拖动
> 2026-10-13 调整:以下偏好缓存仅对微信登录生效。游客仅保留当前页面操作中的临时表单值,不写缓存、不回填历史;刷新后重新填写。详见《游客不留存与弹窗护理间距调整-2026-10-13.md》。
## 范围
- 出生年月、性别、地区每次点滚轮“确定”即保存,不必拍照或提交报告。
@@ -0,0 +1,34 @@
# 游客不留存与弹窗护理间距调整
## 发版范围
本次已完成本地开发、截图与用户确认,按要求提交和部署。发布沿用 Git bundle 快进同步至 `/home/ubuntu/juzi` 并重启 `juzi-h5.service`;不代用户推送 origin,不删除既有服务器历史数据或更改微信配置。发布后校验静态资源哈希、服务健康、免登录入口、协议同意前后临时会话边界和游客空历史;不调用线上付费检测或写入真实报告。
- 欢迎弹窗保留完整句子「我已阅读并同意相关隐私协议」,仅「隐私协议」四字加粗并下划线;「相关」恢复为普通文字,点击协议不勾选同意。
- 报告护理建议标题的下方间距由 18u 减为 9u(375px 屏宽对应 18px→9px),仍左对齐,不影响其他标题。
- 仅微信登录保留个人偏好和历史报告。微信现有账号、最近 5 份完整报告、跨手机查询、后台记录与原始结果留存不变。
- 游客入口改为「免登录体验」:不要求登录页勾选协议,不发起登录请求、不弹登录成功,直接进入首页。开始检测时仍须在欢迎弹窗明确同意隐私协议,之后才建立后台检测所需的临时会话。首页/报告列表的「切换登录」按钮删除,状态改为不可点击的「游客模式」/「微信已登录」。微信登录保留在原入口页,以及游客历史页的「微信登录,保存报告」入口。
- 游客使用内存中临时身份和非持久 Cookie,不落盘账号/会话,不再发送或保存可恢复的游客凭据。浏览器旧游客身份、基础信息和早期本地报告缓存清除;不清空其他应用或微信的缓存。
- 游客基础信息只在当前页面操作中使用,例如拍摄失败返回可继续重拍;不写 localStorage,刷新/新游客登录后重新填写。游客报告仅供本次查看;「我的报告」显示不保存提示,不请求历史列表、不展示旧游客报告。
- 服务端同样限制:游客成功/失败均不新增 records.jsonl、照片、原始结果和完整报告快照;检测和评分在内存中正常进行。检测响应设为 no-store,游客不出现「保存失败」误提示。该规则指本项目不留存,不代表上游检测供应商的数据政策发生变化。
- 已有服务器游客历史数据不自动删除(避免未经确认批量清理);旧持久游客凭据不能恢复身份或读取历史。后续若需清理存量数据,应另行确认范围与备份。
## 验证
使用隔离临时目录、固定图片/上游响应与模拟微信;不操作线上账号/数据,不调用付费检测。
```bash
H5_ARTIFACT_DIR=/tmp/juzi-guest-privacy-20261013 node h5/test/guest-privacy.mjs
node h5/test/accounts.js
node h5/test/profile-store.js
node h5/test/profile-interaction.mjs
node h5/test/report-persistence.mjs
node h5/test/wechat-browser.mjs
node h5/test/customer-feedback-fourth.mjs
node h5/test/login-performance.mjs
node h5/test/report-touch-layout.mjs
```
新增端到端测试比对游客成功/失败前后的整个临时数据目录字节,确保无身份、照片、档案、报告、检测原文或后台记录写入;同一服务随后切换微信,验证报告/照片/后台记录正常保存。既有测试更新为仅微信持久化,保留跨设备、历史快照不可变、账号隔离和基础信息/拍照/报告交互回归。
截图:guest-entry.png、guest-home.png、welcome-privacy.png、care-spacing.png、guest-history.png(均为本地界面,报告内容为固定样例)。最新版可运行 `H5_ARTIFACT_DIR=/tmp/juzi-guest-no-login-20261013 node h5/test/guest-privacy.mjs` 生成。
+28 -19
View File
@@ -4,7 +4,7 @@
"2": "app.js",
"3": "skin-score.js",
"4": "customer-feedback-second.js",
"5": "t",
"5": "disposeUnusedWeightTensors",
"6": "n",
"7": "test/profile-store.js",
"8": "admin.js",
@@ -13,19 +13,19 @@
"11": "lib/accounts.js",
"12": "server.js",
"13": "files",
"14": ".draw",
"15": "extractFaces",
"14": "NeuralNetwork",
"15": "customer-feedback-fourth.mjs",
"16": "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范",
"17": "report-persistence.mjs",
"18": "1'me肌肤状态检测 H5",
"19": "customer-feedback-fourth.js",
"20": "build_fixtures.py",
"21": "disposeUnusedWeightTensors",
"21": "getEnv",
"22": "k",
"23": "facepp-skinanalyze(旷视皮肤分析专业版调用技能)",
"24": "巨子测肤 · 1'me 语音播报内容设计文档 V1.0",
"25": "mobile-select.iife.min.js",
"26": "NeuralNetwork",
"26": ".extractWeights",
"27": "gen_voice.py",
"28": "C",
"29": "face-api.js 本地化依赖(h5/vendor/faceapi/)",
@@ -42,10 +42,11 @@
"40": "第四轮反馈修改与验收",
"41": "showScreen",
"42": "layer_at",
"43": "TinyYolov2Base",
"43": "extractAllFacesAndComputeResults",
"44": "start-local.sh",
"45": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"46": "PredictAllFaceExpressionsWithFaceAlignmentTask",
"47": "Box",
"48": "已修改",
"49": "mobile-select/README.md",
"50": "call_skinanalyze_3view.sh",
@@ -53,7 +54,7 @@
"52": "region-data.js",
"53": "护理建议(全量文本,按需求摘抄)",
"54": "UI 对齐 10.09 设计稿:调研与实施计划",
"55": "FaceLandmarks68",
"55": "guest-privacy.mjs",
"56": "report-ui.js",
"57": "regression.js",
"58": ".forwardInput",
@@ -62,9 +63,9 @@
"61": "报告布局与跟手交互调整",
"62": "提交前 Review(2026-10-11)",
"63": "e2e-browser.mjs",
"64": "start",
"64": "recommendation.js",
"65": "微信登录接入与公网部署说明",
"66": ".runAndExtendWithFaceDetections",
"66": "h5/README.md",
"67": "ui-20261009.mjs",
"68": "analyze-timeout.js",
"69": "misans/README.md",
@@ -76,14 +77,14 @@
"75": "prepare_h5_assets.py",
"76": "报告页滑动防冲突-2026-10-13.md",
"77": "drawFaceExpressions",
"78": ".toString",
"78": "FaceLandmarks68",
"79": "登录反馈与加载优化(2026-10-12)",
"80": "线上微信登录部署记录",
"81": "wechat-production.mjs",
"82": "拍摄约束放宽与标题更新",
"83": "10.09 新版 H5 实施与验收",
"84": "微信登录联调:2026-10-12",
"85": "test/accounts.js",
"85": "head-pose.js",
"86": "report-touch-layout.mjs",
"87": "产品推荐规则:肤龄基准与边界调整",
"88": "ui-20261012/manifest.json",
@@ -93,11 +94,14 @@
"92": "客户反馈修改(2026-10-12)",
"93": ".runAndExtendWithFaceDetection",
"94": "第三轮反馈:PSD 对齐、验收及上线记录",
"95": "login-performance.mjs",
"96": "production-loop.mjs",
"99": "DetectSingleFaceLandmarksTask",
"100": "extractAllFacesAndComputeResults",
"102": "extractFaceTensors",
"95": "pn",
"96": "start",
"97": "account-client.js",
"98": "js/profile-store.js",
"99": "capture-diagnostics.js",
"100": ".computeFaceDescriptor",
"101": "wechat-fixture.cjs",
"102": "extractFaces",
"103": "第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md",
"104": "export_fourth_feedback_assets.py",
"105": "capture-quality-browser.mjs",
@@ -107,12 +111,17 @@
"109": "submitPhotos",
"110": "doCapture",
"111": "i",
"112": ".toString",
"113": "swipe-track.js",
"114": "optimize_delivery_images.py",
"116": "convLayer",
"115": "ao",
"116": "TinyYolov2Base",
"117": "startCamera",
"118": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
"119": "PredictSingleFaceExpressionsWithFaceAlignmentTask",
"120": "LabeledFaceDescriptors",
"121": ".runAndExtendWithFaceDetections",
"123": "评分圈内居中、基础信息页固定与正脸语音修复",
"124": "TinyYolov2",
"125": "报告等待页实拍背景-2026-10-12.md",
"127": "height"
"125": "报告等待页实拍背景-2026-10-12.md"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "e93608cf288712b8", "1": "cb6ea50bd4e4a5ca", "2": "3427a6e8a9c7cb3e", "3": "1f737ca8edb978f2", "4": "56bf12952b0de42b", "5": "7eecef0f27ae2903", "6": "23e6fea2f88a24d1", "7": "ca1cdb7d0ae4e71e", "8": "a689fcb62e7754af", "9": "5b3557ee5c2bf900", "10": "2b037735e1f5b0f6", "11": "4a490abd5e503318", "12": "aeca274694e158d2", "13": "7e3b558a587a4ebe", "14": "bf75bfc99d4f9e34", "15": "6dfa695990b6977c", "16": "95add6a975c0cb72", "17": "e042d7fe7351bcb9", "18": "f93d5d85e671fcee", "19": "39abb1eef19df707", "20": "aecf2fce44ba49c5", "21": "6079f456ac9d4b2e", "22": "02495fa16ac642c3", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "a8588d8e77a6ae93", "27": "eab427ce9a5219e9", "28": "037cf5fc8fd22dd0", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "241be5abeab1da6f", "36": "a7d375041779bad4", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "40": "5e546676ab96de03", "41": "71cbe5098f4c1b6f", "42": "916170b9f5d7346b", "43": "110bce64414980d4", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "48": "cf2f2424bc00436f", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "68c52c80f38f8b2c", "56": "9ee2ad45dc27851d", "57": "f2a40b5f1b29f669", "58": "d8c090d6837ee23a", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "5209ce2a267bf33b", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "a01bccd7f7c24c78", "65": "bdee3c4a37df52f8", "66": "46204fb2e4d50c53", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "be0c8310abda2467", "71": "589a2d5a76f3880a", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "3c4c278d6026bc3a", "77": "b4e08c87a078a832", "78": "71f300fdfae6e5a6", "79": "86f177bf0a7dd07c", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "200f25fbad7af8fb", "83": "1284897282eb47a3", "84": "2fb17901aa87e0e7", "85": "4bc370be8afd7db1", "86": "386c6fcc7be918e0", "87": "e5fa7717989eda80", "88": "8b5ab5232cef3457", "89": "9e3363b80eeceeea", "90": "dd1c246e19a2d032", "91": "83b0fd3b14363e4d", "92": "fced244d93677d90", "93": "5a9014c4a7f87699", "94": "713d04996c4627df", "95": "59c31f858c7b6970", "96": "4195478d5c528371", "99": "23f1d406f6c779ae", "100": "e42f384dfedcff89", "102": "4dba2da8e6a57bef", "103": "6ffbe07b521a061b", "104": "e114a7aebaaca2f1", "105": "af50c66d16c81bba", "106": "8bc72c179793e1c2", "107": "d9c195d9ccdb137e", "108": "e0fc0a42e33bda91", "109": "4afb1715f62865b1", "110": "52f28eb30e80851f", "111": "b34758de9b00285f", "113": "0d99f86b9e41c4bb", "114": "b9d7c266afa84437", "116": "d1ec12eee057e765", "117": "cc191db3bbde70d7", "123": "b091e3eb82b456eb", "124": "c96276581a8c98d4", "125": "e4f29216745ec758", "127": "746b316833e9c203"}
{"0": "6518846594ff224e", "1": "cb6ea50bd4e4a5ca", "2": "b161d025e66218cd", "3": "1f737ca8edb978f2", "4": "56bf12952b0de42b", "5": "5d94cf57d5d1a6fb", "6": "09c43aeeb7124cf2", "7": "03b56b13a08b5f83", "8": "a689fcb62e7754af", "9": "4d10d0cc23541396", "10": "2b037735e1f5b0f6", "11": "ed0d13cfa8571d24", "12": "aeca274694e158d2", "13": "7e3b558a587a4ebe", "14": "5fb489abcf0ab827", "15": "fbc8a72e6fda6359", "16": "95add6a975c0cb72", "17": "e042d7fe7351bcb9", "18": "f93d5d85e671fcee", "19": "2becf835f050dc29", "20": "aecf2fce44ba49c5", "21": "edc5fd195ace167e", "22": "4103800758a55924", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "92ef1d4e1a67b77f", "27": "eab427ce9a5219e9", "28": "de5647b9c3703ba7", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "241be5abeab1da6f", "36": "a7d375041779bad4", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "40": "5e546676ab96de03", "41": "95da3c47e9f484e6", "42": "916170b9f5d7346b", "43": "3c55135955ae8607", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "88346d1cde75f0df", "48": "cf2f2424bc00436f", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "812950f51be20e40", "56": "9ee2ad45dc27851d", "57": "f2a40b5f1b29f669", "58": "74f01de01fa1f70c", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "5209ce2a267bf33b", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "e594ecaa8e7d1538", "65": "bdee3c4a37df52f8", "66": "a36e5eb320ca30dc", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "be0c8310abda2467", "71": "589a2d5a76f3880a", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "3c4c278d6026bc3a", "77": "6cd5c5b10bc3e59b", "78": "68c52c80f38f8b2c", "79": "c6cfdfe181232ecc", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "200f25fbad7af8fb", "83": "1284897282eb47a3", "84": "d8bb296741d3d05c", "85": "0a373ad2197fd1a6", "86": "386c6fcc7be918e0", "87": "e5fa7717989eda80", "88": "8b5ab5232cef3457", "89": "9e3363b80eeceeea", "90": "dd1c246e19a2d032", "91": "83b0fd3b14363e4d", "92": "fced244d93677d90", "93": "987a3393280b3d91", "94": "713d04996c4627df", "95": "57b2d61a5df6f056", "96": "a01bccd7f7c24c78", "97": "55f3c43a702bef8a", "98": "9b1cf1985223f90a", "99": "9380a89a868c76d2", "100": "444ef0744645f5ac", "101": "beb276de6301634c", "102": "4a34431a7e4bc59f", "103": "6ffbe07b521a061b", "104": "e114a7aebaaca2f1", "105": "af50c66d16c81bba", "106": "8bc72c179793e1c2", "107": "3877490226a78145", "108": "e0fc0a42e33bda91", "109": "4afb1715f62865b1", "110": "52f28eb30e80851f", "111": "dc9609eb7ae61b97", "112": "064a2b8ba0231c0a", "113": "0d99f86b9e41c4bb", "114": "b9d7c266afa84437", "115": "a3859fc25c864a81", "116": "a99a3157460afca4", "117": "cc191db3bbde70d7", "118": "01f68dfc34a29d32", "119": "8c471c7baeaaca54", "120": "f8b38aeaaab11ec4", "121": "46204fb2e4d50c53", "123": "b091e3eb82b456eb", "124": "c96276581a8c98d4", "125": "e4f29216745ec758"}
+143 -94
View File
@@ -1,16 +1,16 @@
# Graph Report - juzi (2026-10-13)
## Corpus Check
- 107 files · ~4,905,775 words
- 110 files · ~4,907,117 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1457 nodes · 2904 edges · 116 communities (91 shown, 25 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 210 edges (avg confidence: 0.57)
- 1475 nodes · 2920 edges · 125 communities (101 shown, 24 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 209 edges (avg confidence: 0.57)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `4c478b16`
- Built from commit: `98935dd1`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -20,7 +20,7 @@
- app.js
- skin-score.js
- customer-feedback-second.js
- t
- disposeUnusedWeightTensors
- n
- test/profile-store.js
- admin.js
@@ -29,19 +29,19 @@
- lib/accounts.js
- server.js
- files
- .draw
- extractFaces
- NeuralNetwork
- customer-feedback-fourth.mjs
- 巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范
- report-persistence.mjs
- 1'me肌肤状态检测 H5
- customer-feedback-fourth.js
- build_fixtures.py
- disposeUnusedWeightTensors
- getEnv
- k
- facepp-skinanalyze(旷视皮肤分析专业版调用技能)
- 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
- mobile-select.iife.min.js
- NeuralNetwork
- .extractWeights
- gen_voice.py
- C
- face-api.js 本地化依赖(h5/vendor/faceapi/)
@@ -56,16 +56,17 @@
- 第四轮反馈修改与验收
- showScreen
- layer_at
- TinyYolov2Base
- extractAllFacesAndComputeResults
- start-local.sh
- PredictAllAgeAndGenderWithFaceAlignmentTask
- PredictAllFaceExpressionsWithFaceAlignmentTask
- Box
- 已修改
- mobile-select/README.md
- call_skinanalyze_3view.sh
- 护理建议(全量文本,按需求摘抄)
- UI 对齐 10.09 设计稿:调研与实施计划
- FaceLandmarks68
- guest-privacy.mjs
- report-ui.js
- regression.js
- .forwardInput
@@ -73,9 +74,9 @@
- 报告布局与跟手交互调整
- 提交前 Review(2026-10-11)
- e2e-browser.mjs
- start
- recommendation.js
- 微信登录接入与公网部署说明
- .runAndExtendWithFaceDetections
- h5/README.md
- ui-20261009.mjs
- analyze-timeout.js
- misans/README.md
@@ -84,14 +85,14 @@
- build_report_copy.py
- 报告页滑动防冲突-2026-10-13.md
- drawFaceExpressions
- .toString
- FaceLandmarks68
- 登录反馈与加载优化(2026-10-12)
- 线上微信登录部署记录
- wechat-production.mjs
- 拍摄约束放宽与标题更新
- 10.09 新版 H5 实施与验收
- 微信登录联调:2026-10-12
- test/accounts.js
- head-pose.js
- report-touch-layout.mjs
- 产品推荐规则:肤龄基准与边界调整
- ui-20261012/manifest.json
@@ -100,11 +101,14 @@
- 客户反馈修改(2026-10-12)
- .runAndExtendWithFaceDetection
- 第三轮反馈:PSD 对齐、验收及上线记录
- login-performance.mjs
- production-loop.mjs
- DetectSingleFaceLandmarksTask
- extractAllFacesAndComputeResults
- extractFaceTensors
- pn
- start
- account-client.js
- js/profile-store.js
- capture-diagnostics.js
- .computeFaceDescriptor
- wechat-fixture.cjs
- extractFaces
- 第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md
- 第三轮反馈20261012/傅艳NEW与项思炼的会话 2026年10月12日.md
- 基础信息记忆与肤质拖动
@@ -112,12 +116,17 @@
- submitPhotos
- doCapture
- i
- convLayer
- .toString
- ao
- TinyYolov2Base
- startCamera
- PredictSingleAgeAndGenderWithFaceAlignmentTask
- PredictSingleFaceExpressionsWithFaceAlignmentTask
- LabeledFaceDescriptors
- .runAndExtendWithFaceDetections
- 评分圈内居中、基础信息页固定与正脸语音修复
- TinyYolov2
- 报告等待页实拍背景-2026-10-12.md
- height
## God Nodes (most connected - your core abstractions)
1. `__awaiter()` - 59 edges
@@ -146,11 +155,11 @@
## Import Cycles
- None detected.
## Communities (116 total, 25 thin omitted)
## Communities (125 total, 24 thin omitted)
### Community 0 - "face-api.js"
Cohesion: 0.02
Nodes (41): bgrToRgbTensor(), cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), decodeBoxesLayer(), Dimensions() (+33 more)
Nodes (36): cn(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), decodeBoxesLayer(), dr(), en(), FaceDetection() (+28 more)
### Community 1 - "线上闭环测试(不含真实微信授权)"
Cohesion: 0.25
@@ -158,7 +167,7 @@ Nodes (7): 发现并修复, 可复现命令, 已通过的检查, 线上闭环测
### Community 2 - "app.js"
Cohesion: 0.11
Nodes (22): cancelSkinDrag(), esc(), goSkin(), openHistory(), renderHistoryList(), renderReport(), renderViz(), restartScanCycle() (+14 more)
Nodes (21): cancelSkinDrag(), esc(), goSkin(), loginAllowed(), loginWechat(), openHistory(), renderHistoryList(), renderReport() (+13 more)
### Community 3 - "skin-score.js"
Cohesion: 0.12
@@ -168,33 +177,33 @@ Nodes (45): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens()
Cohesion: 0.17
Nodes (10): assert, context, face, fs, geometry, original, path, source (+2 more)
### Community 5 - "t"
### Community 5 - "disposeUnusedWeightTensors"
Cohesion: 0.09
Nodes (31): be(), bi(), Bo(), ce(), cl(), de(), e(), Ef() (+23 more)
Nodes (23): disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$2() (+15 more)
### Community 6 - "n"
Cohesion: 0.14
Nodes (37): a(), ae(), bf(), cf(), d(), drawContour(), dt(), ea() (+29 more)
Cohesion: 0.17
Nodes (38): a(), ae(), bf(), ce(), cf(), d(), de(), drawContour() (+30 more)
### Community 7 - "test/profile-store.js"
Cohesion: 0.07
Nodes (29): browser(), init(), read(), rememberKind(), request(), age(), clean(), create() (+21 more)
Cohesion: 0.14
Nodes (13): assert, data, empty, now, other, Profile, quota, regions (+5 more)
### Community 8 - "admin.js"
Cohesion: 0.20
Nodes (29): ageText(), api(), bind(), captureQualityHtml(), closeDetail(), collectFilters(), dimsHtml(), doRetry() (+21 more)
### Community 9 - "__awaiter"
Cohesion: 0.13
Nodes (12): __awaiter(), ComposableTask(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow(), __generator(), getModelUris() (+4 more)
Cohesion: 0.18
Nodes (9): __awaiter(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow(), __generator(), loadWeightMap(), Mtcnn() (+1 more)
### Community 10 - "wechat-browser.mjs"
Cohesion: 0.22
Nodes (6): createAccounts, h5, ReportUI, require, root, SkinScore
### Community 11 - "lib/accounts.js"
Cohesion: 0.17
Nodes (20): atomic(), body(), cookie(), cookies(), crypto, fs, json(), login() (+12 more)
Cohesion: 0.07
Nodes (34): atomic(), body(), cookie(), cookies(), crypto, fs, json(), login() (+26 more)
### Community 12 - "server.js"
Cohesion: 0.08
@@ -204,9 +213,13 @@ Nodes (52): accounts, appendRecord(), callFacepp(), CaptureDiagnostics, CRED, cr
Cohesion: 0.12
Nodes (15): files, analyze-fail, analyzing, countdown-321, hint-far, hint-front, hint-hold, hint-near (+7 more)
### Community 15 - "extractFaces"
Cohesion: 0.17
Nodes (18): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+10 more)
### Community 14 - "NeuralNetwork"
Cohesion: 0.20
Nodes (3): getModelUris(), NeuralNetwork(), TinyFaceDetector()
### Community 15 - "customer-feedback-fourth.mjs"
Cohesion: 0.20
Nodes (8): evaluate(), finite(), CaptureQuality, child, dataRoot, h5, ReportUI, require
### Community 16 - "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范"
Cohesion: 0.15
@@ -221,20 +234,20 @@ Cohesion: 0.13
Nodes (15): 10.09 更新, 1'me肌肤状态检测 H5, Admin API(统一返回 `{ok:false,message}` 错误), Windows 本机部署(提测用), 后台查看(admin.html), 密钥配置, 已知占位清单, 技术说明 (+7 more)
### Community 19 - "customer-feedback-fourth.js"
Cohesion: 0.06
Nodes (37): evaluate(), finite(), assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal() (+29 more)
Cohesion: 0.14
Nodes (15): assert, check(), Diagnostics, face(), HeadPose, held, ReportUI, normalized (+7 more)
### Community 20 - "build_fixtures.py"
Cohesion: 0.40
Nodes (9): build_case1(), build_case2(), build_truth(), col(), dump(), main(), parse_value(), set_path() (+1 more)
### Community 21 - "disposeUnusedWeightTensors"
Cohesion: 0.05
Nodes (39): conv(), conv$1(), convDown(), convLayer$1(), convNoRelu(), denseBlock3(), denseBlock4(), depthwiseSeparableConv() (+31 more)
### Community 21 - "getEnv"
Cohesion: 0.07
Nodes (34): awaitMediaLoaded(), bufferToImage(), conv(), conv$1(), convDown(), convLayer$1(), convNoRelu(), createCanvas() (+26 more)
### Community 22 - "k"
Cohesion: 0.10
Nodes (21): b(), ba(), ca(), da(), fa(), gn(), k(), kn() (+13 more)
Cohesion: 0.16
Nodes (16): ba(), ca(), da(), fa(), k(), pa(), pd(), Pi() (+8 more)
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
Cohesion: 0.20
@@ -248,7 +261,7 @@ Nodes (8): 1. 音频格式约定(已定), 2. 播报清单(14 条), 3. 播放策
Cohesion: 0.20
Nodes (16): _arrayLikeToArray(), _arrayWithHoles(), _arrayWithoutHoles(), _classCallCheck(), _createClass(), _defineProperties(), h(), _iterableToArray() (+8 more)
### Community 26 - "NeuralNetwork"
### Community 26 - ".extractWeights"
Cohesion: 0.05
Nodes (30): AgeGenderNet(), createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory() (+22 more)
@@ -258,7 +271,7 @@ Nodes (11): ndarray, Path, gen_countdown(), main(), normalize_clip(), 倒计时
### Community 28 - "C"
Cohesion: 0.11
Nodes (25): ao(), C(), cd(), co(), Do(), eo(), fo(), Ft() (+17 more)
Nodes (28): Bo(), C(), cd(), cl(), co(), eo(), fo(), Ft() (+20 more)
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
Cohesion: 0.29
@@ -289,17 +302,25 @@ Cohesion: 0.22
Nodes (8): PSD 来源, 前端参数及限制, 后台校准留痕, 已确认并实现, 生产部署及只读验收, 皱纹(紧塑力)原文, 第四轮反馈修改与验收, 验证
### Community 41 - "showScreen"
Cohesion: 0.22
Nodes (11): loadAppFont(), loginAllowed(), loginBrowser(), loginSucceeded(), loginWechat(), openOverlay(), prepareScanArtwork(), renderAccountStatus() (+3 more)
Cohesion: 0.20
Nodes (12): enterGuest(), loadAppFont(), loginSucceeded(), openOverlay(), prepareScanArtwork(), renderAccountStatus(), resetReport(), restartScanCycle() (+4 more)
### Community 42 - "layer_at"
Cohesion: 0.18
Nodes (11): list, bounds(), layer_at(), main(), save_image(), composite(), main(), sha256() (+3 more)
### Community 43 - "extractAllFacesAndComputeResults"
Cohesion: 0.14
Nodes (10): age(), extendWithAge(), extendWithFaceExpressions(), extendWithGender(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), PredictAllAgeAndGenderTask(), PredictAllFaceExpressionsTask() (+2 more)
### Community 44 - "start-local.sh"
Cohesion: 0.50
Nodes (3): HOST, PORT, start-local.sh script
### Community 47 - "Box"
Cohesion: 0.11
Nodes (11): bgrToRgbTensor(), Box(), Dimensions(), extendWithFaceLandmarks(), FaceLandmarks(), isDimensions(), isValidNumber(), isValidProbablitiy() (+3 more)
### Community 48 - "已修改"
Cohesion: 0.22
Nodes (8): 12 日第二批:明确项修改, 公网部署与复核(2026-10-12,北京时间), 准备捕捉提醒, 字重, 已修改, 拍照展示, 首页高清批注, 验证与状态
@@ -312,6 +333,10 @@ Nodes (19): 5\.1 轮廓(紧塑力), 5\.2 皱纹(紧塑力), 5\.3 肤色
Cohesion: 0.11
Nodes (17): 1. 结论和依据, 2. 统一比较口径, 3. 逐页差异和调整内容, 4. 报告数据如何接到新 UI, 5. 登录与“我的报告”的真实能力缺口, 6. 原稿中需要整理的内容, 7. 实施顺序与交付物, 8. 验收定义 (+9 more)
### Community 55 - "guest-privacy.mjs"
Cohesion: 0.25
Nodes (5): child, dataRoot, fx, h5, require
### Community 56 - "report-ui.js"
Cohesion: 0.50
Nodes (7): build(), count(), fill(), focusOf(), numeric(), scalar(), wrinkleDetails()
@@ -321,8 +346,8 @@ Cohesion: 0.60
Nodes (5): diffCtx(), loadJSON(), main(), okNum(), okStr()
### Community 58 - ".forwardInput"
Cohesion: 0.19
Nodes (8): FaceRecognitionNet(), iou(), nonMaxSuppression(), nonMaxSuppression$1(), scale(), stage1(), stage2(), stage3()
Cohesion: 0.14
Nodes (16): computeReshapedDimensions(), extractBoundingBoxes(), getSizesForScale(), iou(), isEven(), nonMaxSuppression(), nonMaxSuppression$1(), normalize$1() (+8 more)
### Community 61 - "报告布局与跟手交互调整"
Cohesion: 0.33
@@ -336,14 +361,18 @@ Nodes (4): 仍需公网/业务确认, 已发现并修复, 提交前 Review(202
Cohesion: 0.29
Nodes (4): EXPECTED, fails, pageErrors, pwRoot
### Community 64 - "start"
Cohesion: 0.29
Nodes (7): decode(), drain(), load(), preload(), start(), stopCur(), unlock()
### Community 64 - "recommendation.js"
Cohesion: 0.38
Nodes (6): assert, check(), missing, raw, SS, UI
### Community 65 - "微信登录接入与公网部署说明"
Cohesion: 0.29
Nodes (7): 公网服务器配置, 公网真机验收清单, 反向代理与后台保护, 已完成的链路, 微信登录接入与公网部署说明, 数据、迁移与备份, 本机开发环境限制(不代表线上状态)
### Community 66 - "h5/README.md"
Cohesion: 0.33
Nodes (3): 本地待确认, 游客不留存与弹窗护理间距调整, 验证
### Community 68 - "analyze-timeout.js"
Cohesion: 0.20
Nodes (7): assert, end, fs, path, source, start, vm
@@ -353,15 +382,11 @@ Cohesion: 0.29
Nodes (3): errors, samples, unexpectedWrites
### Community 77 - "drawFaceExpressions"
Cohesion: 0.12
Nodes (10): DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), FaceExpressions(), isWithFaceDetection(), isWithFaceExpressions(), isWithFaceLandmarks() (+2 more)
### Community 78 - ".toString"
Cohesion: 0.12
Nodes (11): FaceLandmarks5(), FaceMatch(), getCenterPoint(), la(), Point(), pr(), pt(), vt() (+3 more)
Cohesion: 0.19
Nodes (10): DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), extendWithFaceDetection(), FaceExpressions(), isWithFaceDetection(), isWithFaceExpressions() (+2 more)
### Community 79 - "登录反馈与加载优化(2026-10-12)"
Cohesion: 0.33
Cohesion: 0.25
Nodes (5): 体验变化, 公网部署后复测, 慢的原因与处理, 登录反馈与加载优化(2026-10-12), 验证
### Community 80 - "线上微信登录部署记录"
@@ -381,12 +406,12 @@ Cohesion: 0.29
Nodes (5): 10.09 新版 H5 实施与验收, 按确认项实现, 文档未定义部分的实施口径, 素材再生成, 验证命令
### Community 84 - "微信登录联调:2026-10-12"
Cohesion: 0.22
Cohesion: 0.33
Nodes (6): 后续真机确认与体验优化, 微信登录联调:2026-10-12, 本轮修正, 白名单与凭据, 真实微信验收与排查, 自动验证
### Community 85 - "test/accounts.js"
Cohesion: 0.22
Nodes (9): assert, create, fs, http, os, path, ReportUI, SkinScore (+1 more)
### Community 85 - "head-pose.js"
Cohesion: 0.33
Nodes (8): assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal(), makeSide(), midpoint()
### Community 87 - "产品推荐规则:肤龄基准与边界调整"
Cohesion: 0.33
@@ -405,27 +430,47 @@ Cohesion: 0.33
Nodes (5): 公网部署与复测, 客户反馈修改(2026-10-12), 皱纹数据说明, 逐项处理, 验证
### Community 93 - ".runAndExtendWithFaceDetection"
Cohesion: 0.33
Nodes (5): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), detectAllFaces(), DetectSingleFaceTask()
Cohesion: 0.23
Nodes (7): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), ComposableTask(), detectAllFaces(), DetectSingleFaceTask(), Gf()
### Community 94 - "第三轮反馈:PSD 对齐、验收及上线记录"
Cohesion: 0.33
Nodes (5): 原稿与导出可追溯性, 已完成, 本地验证, 生产部署及只读验收, 第三轮反馈:PSD 对齐、验收及上线记录
### Community 96 - "production-loop.mjs"
### Community 95 - "pn"
Cohesion: 0.22
Nodes (8): bn(), fn(), ln(), pn(), un(), wl(), wn(), xn()
### Community 96 - "start"
Cohesion: 0.29
Nodes (7): decode(), drain(), load(), preload(), start(), stopCur(), unlock()
### Community 97 - "account-client.js"
Cohesion: 0.57
Nodes (7): browser(), clearGuestCache(), ensureGuest(), guest(), init(), rememberKind(), request()
### Community 98 - "js/profile-store.js"
Cohesion: 0.67
Nodes (3): clean(), create(), key()
### Community 99 - "capture-diagnostics.js"
Cohesion: 0.38
Nodes (6): assess(), CaptureQuality, HeadPose, normalize(), scalar(), views
### Community 100 - ".computeFaceDescriptor"
Cohesion: 0.18
Nodes (4): ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithFaceDescriptor(), FaceRecognitionNet()
### Community 101 - "wechat-fixture.cjs"
Cohesion: 0.50
Nodes (3): artifacts, errors, samples
Nodes (3): crypto, fs, path
### Community 100 - "extractAllFacesAndComputeResults"
Cohesion: 0.09
Nodes (12): ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithAge(), extendWithFaceDescriptor(), extendWithFaceExpressions(), extendWithGender(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult() (+4 more)
### Community 102 - "extractFaceTensors"
Cohesion: 0.10
Nodes (11): Box(), DetectAllFaceLandmarksTask(), extendWithFaceDetection(), extendWithFaceLandmarks(), extractFaceTensors(), FaceLandmarks(), isDimensions(), isTensor3D() (+3 more)
### Community 102 - "extractFaces"
Cohesion: 0.14
Nodes (9): height(), DetectAllFaceLandmarksTask(), DetectSingleFaceLandmarksTask(), extractFaces(), extractFaceTensors(), FaceLandmark68NetBase(), imageTensorToCanvas(), isTensor3D() (+1 more)
### Community 107 - "基础信息记忆与肤质拖动"
Cohesion: 0.33
Cohesion: 0.40
Nodes (4): 回归命令, 基础信息记忆与肤质拖动, 肤质交互, 范围
### Community 108 - "profile-interaction.mjs"
@@ -441,12 +486,20 @@ Cohesion: 0.17
Nodes (22): armNudge(), busyWithFlow(), captureFromVideo(), captureHaptic(), captureStageHint(), checkStill(), doCapture(), dropHint() (+14 more)
### Community 111 - "i"
Cohesion: 0.12
Nodes (18): bn(), dn(), ee(), et(), fn(), i(), je(), ln() (+10 more)
Cohesion: 0.09
Nodes (29): b(), be(), bi(), dn(), ee(), Ef(), er(), et() (+21 more)
### Community 116 - "convLayer"
Cohesion: 0.14
Nodes (17): boxPredictionLayer(), convLayer(), convWithBatchNorm(), depthwiseConvLayer(), depthwiseSeparableConv$1(), fullyConnectedLayer(), getStridesForLayerIdx(), leaky() (+9 more)
### Community 112 - ".toString"
Cohesion: 0.18
Nodes (7): FaceLandmarks5(), FaceMatch(), getCenterPoint(), la(), pr(), pt(), vt()
### Community 115 - "ao"
Cohesion: 0.40
Nodes (5): ao(), Do(), io(), so(), To()
### Community 116 - "TinyYolov2Base"
Cohesion: 0.11
Nodes (20): boxPredictionLayer(), convLayer(), convWithBatchNorm(), depthwiseConvLayer(), depthwiseSeparableConv$1(), fullyConnectedLayer(), getStridesForLayerIdx(), leaky() (+12 more)
### Community 117 - "startCamera"
Cohesion: 0.15
@@ -456,24 +509,20 @@ Nodes (17): applyMirror(), beginDetect(), detectFrame(), detTick(), effMirror(),
Cohesion: 0.40
Nodes (4): 发布, 范围, 评分圈内居中、基础信息页固定与正脸语音修复, 验证
### Community 127 - "height"
Cohesion: 0.38
Nodes (5): height(), FaceLandmark68NetBase(), getSizesForScale(), normalize$1(), rescaleAndNormalize()
## Knowledge Gaps
- **320 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+315 more)
- **330 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+325 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **25 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **24 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `height()` connect `height` to `app.js`, `customer-feedback.js`, `scan-placeholder.mjs`, `extractFaceTensors`, `showScreen`, `__awaiter`, `profile-interaction.mjs`, `extractFaces`, `.forwardInput`?**
_High betweenness centrality (0.062) - this node is a cross-community bridge._
- **Why does `height()` connect `extractFaces` to `app.js`, `customer-feedback.js`, `scan-placeholder.mjs`, `showScreen`, `profile-interaction.mjs`, `.forwardInput`?**
_High betweenness centrality (0.050) - this node is a cross-community bridge._
- **Why does `v()` connect `n` to `report-ui.js`, `face-api.js`, `app.js`, `C`?**
_High betweenness centrality (0.045) - this node is a cross-community bridge._
- **Why does `k()` connect `k` to `face-api.js`, `app.js`, `t`, `n`, `lib/accounts.js`?**
_High betweenness centrality (0.039) - this node is a cross-community bridge._
_High betweenness centrality (0.038) - this node is a cross-community bridge._
- **Why does `r()` connect `n` to `face-api.js`, `server.js`, `i`, `.toString`, `getEnv`, `k`, `C`?**
_High betweenness centrality (0.037) - this node is a cross-community bridge._
- **Are the 24 inferred relationships involving `n()` (e.g. with `newRecordId()` and `ae()`) actually correct?**
_`n()` has 24 INFERRED edges - model-reasoned connections that need verification._
- **Are the 27 inferred relationships involving `r()` (e.g. with `handleExport()` and `handleRecordsQuery()`) actually correct?**
@@ -481,4 +530,4 @@ _Questions this graph is uniquely positioned to answer:_
- **Are the 9 inferred relationships involving `i()` (e.g. with `da()` and `f()`) actually correct?**
_`i()` has 9 INFERRED edges - model-reasoned connections that need verification._
- **What connects `source`, `document_size`, `pages` to the rest of the system?**
_320 weakly-connected nodes found - possible documentation gaps or missing edges._
_330 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+1925 -1547
View File
File diff suppressed because it is too large Load Diff
+53 -38
View File
@@ -40,8 +40,8 @@
"semantic_hash": ""
},
"h5/js/app.js": {
"mtime": 1791823225.0391843,
"ast_hash": "4787d84f22ac0257204e8ddbdd421140",
"mtime": 1791875688.6889038,
"ast_hash": "4d2624826f32e0c1af544e450117fcc6",
"semantic_hash": ""
},
"h5/js/head-pose.js": {
@@ -55,8 +55,8 @@
"semantic_hash": ""
},
"h5/server.js": {
"mtime": 1791800754.2112646,
"ast_hash": "9983e6c978b3357e31bbfce59202dcde",
"mtime": 1791873518.1209404,
"ast_hash": "d2b79f3c00c8a4eaecc3c74b1c1e4dea",
"semantic_hash": ""
},
"h5/test/build_fixtures.py": {
@@ -135,13 +135,13 @@
"semantic_hash": ""
},
"h5/README.md": {
"mtime": 1791811100.15898,
"ast_hash": "4b8b63aa8184ead90d868aff34e9a6e3",
"mtime": 1791873518.1209404,
"ast_hash": "4acf1f915e74619da8124d47eeb4d971",
"semantic_hash": ""
},
"h5/index.html": {
"mtime": 1791821148.115547,
"ast_hash": "770a726605078c6c0eebd95304e8e954",
"mtime": 1791875481.336928,
"ast_hash": "40dc2f9b93aceaa4283f27b550659731",
"semantic_hash": ""
},
"h5/vendor/faceapi/README.md": {
@@ -770,8 +770,8 @@
"semantic_hash": ""
},
"h5/test/e2e-browser.mjs": {
"mtime": 1791773638.010959,
"ast_hash": "9327216df7c2e80f2615cb7f800b7f1a",
"mtime": 1791875688.6889038,
"ast_hash": "ca6e17bc320f31adb34e5b9e0ed28f81",
"semantic_hash": ""
},
"h5/assets/svg/prod-badge.svg": {
@@ -1230,8 +1230,8 @@
"semantic_hash": ""
},
"h5/js/account-client.js": {
"mtime": 1791774062.6429472,
"ast_hash": "3197b952f29f9e45176d4300082adaae",
"mtime": 1791875481.336928,
"ast_hash": "d296f1dfb67a31adfde3221d48439186",
"semantic_hash": ""
},
"h5/js/camera-geometry.js": {
@@ -1250,23 +1250,23 @@
"semantic_hash": ""
},
"h5/lib/accounts.js": {
"mtime": 1791806985.2188702,
"ast_hash": "a8116f973f1e01dcbfbe2e1e26c7c826",
"mtime": 1791872963.6609082,
"ast_hash": "cfc1a9590dc7e1ee9a8faf2fa23d6eaa",
"semantic_hash": ""
},
"h5/test/accounts.js": {
"mtime": 1791770576.0104883,
"ast_hash": "d927f5d1486b56505ba4da49ec6a769c",
"mtime": 1791873124.7889152,
"ast_hash": "85fe766a7c305ffee3a8c904e038d8a6",
"semantic_hash": ""
},
"h5/test/report-persistence.mjs": {
"mtime": 1791793231.819183,
"ast_hash": "24636f96fd5cd576cde11ef9e911f3ff",
"mtime": 1791873250.4369178,
"ast_hash": "1d543844b6961902d3cfcafa713b9a3b",
"semantic_hash": ""
},
"h5/test/ui-20261009.mjs": {
"mtime": 1791800811.939266,
"ast_hash": "778edb5314c2d35ae75c05344b96f532",
"mtime": 1791875688.6929038,
"ast_hash": "22779f48c489e35943f89eae9a81c17b",
"semantic_hash": ""
},
"tools/build_report_copy.py": {
@@ -1530,8 +1530,8 @@
"semantic_hash": ""
},
"h5/test/wechat-browser.mjs": {
"mtime": 1791800811.939266,
"ast_hash": "616790aae9cf9b41d30ee8b8da979cb0",
"mtime": 1791875570.3129308,
"ast_hash": "3f5bcc6b56b3c28ecd7fb244ca645865",
"semantic_hash": ""
},
"docs/2026-10-11\u63d0\u4ea4\u524dReview.md": {
@@ -1545,8 +1545,8 @@
"semantic_hash": ""
},
"h5/test/production-loop.mjs": {
"mtime": 1791773638.010959,
"ast_hash": "0d0d46ec3ecfce9e767e7f31050befe2",
"mtime": 1791875833.91293,
"ast_hash": "60cd5e73b5600b731b0e01b0e12e12f4",
"semantic_hash": ""
},
"docs/\u7ebf\u4e0a\u5fae\u4fe1\u767b\u5f55\u90e8\u7f72\u8bb0\u5f55-2026-10-11.md": {
@@ -1555,8 +1555,8 @@
"semantic_hash": ""
},
"h5/test/browser-failures.mjs": {
"mtime": 1791773638.010959,
"ast_hash": "8cb2774864d3d59bd5a418ad61819129",
"mtime": 1791875688.6929038,
"ast_hash": "00147b9f66e099be169cb84bf6331e66",
"semantic_hash": ""
},
"docs/\u7ebf\u4e0a\u95ed\u73af\u6d4b\u8bd5-2026-10-11.md": {
@@ -1585,8 +1585,8 @@
"semantic_hash": ""
},
"h5/test/login-performance.mjs": {
"mtime": 1791800811.939266,
"ast_hash": "ffe9df947564cdef24e8f842cbc577f1",
"mtime": 1791875688.6889038,
"ast_hash": "1f0d6e798cf1df853eb1309a171b2122",
"semantic_hash": ""
},
"h5/assets/img/ui-20261009/bg-history-h5.webp": {
@@ -1825,8 +1825,8 @@
"semantic_hash": ""
},
"h5/test/customer-feedback-fourth.mjs": {
"mtime": 1791809137.130926,
"ast_hash": "a07ebd722efaf2d8b6e382f566a5f649",
"mtime": 1791875538.8449328,
"ast_hash": "4aa0aa1bbb49a2272abd459014646b62",
"semantic_hash": ""
},
"tools/export_fourth_feedback_assets.py": {
@@ -1890,23 +1890,23 @@
"semantic_hash": ""
},
"h5/js/profile-store.js": {
"mtime": 1791810317.830946,
"ast_hash": "5723356fa753e9a66fdee448e617390e",
"mtime": 1791872891.6128974,
"ast_hash": "67650e3352c06e2a9ab8965d8da3d421",
"semantic_hash": ""
},
"h5/test/profile-interaction.mjs": {
"mtime": 1791820047.336818,
"ast_hash": "2efa96cee5dcf3671644b1b82f890ee3",
"mtime": 1791873124.7889152,
"ast_hash": "4f1fcae2e04530f3a85b33e5ee12453d",
"semantic_hash": ""
},
"h5/test/profile-store.js": {
"mtime": 1791810317.8349462,
"ast_hash": "66a0e7d6ddc5eee9cd69c097257746d1",
"mtime": 1791873040.5609126,
"ast_hash": "412a4fc48325d5ad886994c0e22adad0",
"semantic_hash": ""
},
"docs/\u57fa\u7840\u4fe1\u606f\u8bb0\u5fc6\u4e0e\u80a4\u8d28\u62d6\u52a8-2026-10-12.md": {
"mtime": 1791810600.0989332,
"ast_hash": "cec2f2e829751e8b5d2e5e40c348f5e4",
"mtime": 1791873518.1209404,
"ast_hash": "e6e11bfd6642c38c51d11ebe85499b98",
"semantic_hash": ""
},
"docs/\u62a5\u544a\u7b49\u5f85\u9875\u5b9e\u62cd\u80cc\u666f-2026-10-12.md": {
@@ -1973,5 +1973,20 @@
"mtime": 1791821522.1000154,
"ast_hash": "517105aaf52427102a325e0450142446",
"semantic_hash": ""
},
"h5/test/guest-privacy.mjs": {
"mtime": 1791875538.8409328,
"ast_hash": "29ab19bbdfe2379c641554c2c68e526f",
"semantic_hash": ""
},
"h5/test/wechat-fixture.cjs": {
"mtime": 1791873124.7849152,
"ast_hash": "bf1d2d3b6c76743b42c9fcfa3f18444d",
"semantic_hash": ""
},
"docs/\u6e38\u5ba2\u4e0d\u7559\u5b58\u4e0e\u5f39\u7a97\u62a4\u7406\u95f4\u8ddd\u8c03\u6574-2026-10-13.md": {
"mtime": 1791875833.91693,
"ast_hash": "056111d384a7d74c51a9187063e03bdf",
"semantic_hash": ""
}
}
+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;
};