fix(auth): harden WeChat login and explicit identity switching

This commit is contained in:
xsl
2026-10-12 10:07:54 +08:00
parent 0ec5e5dc32
commit d68f841f0e
16 changed files with 1484 additions and 735 deletions
@@ -1,9 +1,11 @@
# 微信登录接入与公网部署说明
更新:2026-10-11。当前环境为 Windows 宿主机 + 内网 WSL。
更新:2026-10-12。当前环境为 Windows 宿主机 + 内网 WSL。
线上状态补充:`https://1me.xajuzi.com/state/` 已部署并配置真实微信凭据,服务器检查记录见 `线上微信登录部署记录-2026-10-11.md`。下文的内网缺配置说明针对本机开发环境。真实微信授权尚待客户端验证。
2026-10-12 白名单复测已通过,基础凭据有效。最新修正和真机验证步骤见[微信登录联调记录](微信登录联调-2026-10-12.md)。已有本机用户可从「我的报告」→「切换登录方式」进入微信登录;微信会话失效不会自动恢复为本机账号,两个账号的报告不自动合并。
## 结论与当前阻碍
已完成微信网页授权代码,但**没有完成真实微信账号联调**。当前未配置 `WECHAT_APP_ID`、`WECHAT_APP_SECRET`、`PUBLIC_BASE_URL`,也没有可验证的公众号网页授权域名配置。内网 `http://192.168.3.25:8080/` 不能作为本次正式授权部署地址。因此本地启用「本机浏览器登录」,不会模拟微信登录成功。
+53
View File
@@ -0,0 +1,53 @@
# 微信登录联调:2026-10-12
站点:`https://1me.xajuzi.com/state/`;服务器:`ubuntu@1me.xajuzi.com`。
## 白名单与凭据
- 用户再次确认已将服务器出口 IP `49.233.222.47` 加入公众号 IP 白名单。
- 从生产服务正在运行的进程环境读取凭据,在服务器调用微信 `cgi-bin/stable_token`,使用 `force_refresh:false`,未强制刷新已有令牌。
- 返回有效 `access_token`,未返回 `40164`;复测剩余有效时间为 7168 秒。已确认该配置的 AppID 与 `wx645d9a7573f1fe9a` 一致,AppID/AppSecret 校验通过。
- 密钥继续留在 root 私有文件 `/etc/juzi/wechat.env`(600);服务监听 `127.0.0.1:8080`,站点地址包含 `/state/`。没有将密钥或令牌写入代码、日志、文档或前端。
- 基础接口令牌成功不等于真实用户网页授权成功;后者仍要验证公众号网页授权权限、网页授权域名以及真实微信客户端回调。
## 本轮修正
1. 「我的报告」增加「切换登录方式」,复用现有登录页。已有本机用户可以进入微信登录;重新勾选协议后才发起授权。只切换所选账号,不自动合并、迁移或删除报告;返回可继续原会话。
2. 本地只记录最近一次有效登录的方式(`juzi.auth.kind.v1`),不存 OpenID 或微信令牌。微信会话失效后,不能用遗留本机随机凭据自动登录另一个账号;需显式重新选择登录方式。同一微信重新登录仍对应同一服务端账号。
3. 微信返回错误或返回后 Cookie 未保留时,保持登录页并说明原因,不允许用本机自动恢复掩盖失败。处理后移除 URL 的一次性登录结果参数,避免刷新后反复显示旧错误。
4. 服务端拒绝微信响应中的无效 OpenID、异常 HTTP 状态等错误响应;网络失败、平台失败、state 校验失败均不签发会话。
5. 增加 `[auth/wechat]` 运行事件:`callback_rejected`、`exchange_failed`、`login_success`。仅记录错误种类、微信数值错误码、HTTP 状态和是否新账号;不记录 code/state、Cookie、OpenID、密钥或令牌。
## 自动验证
```bash
node h5/test/accounts.js
node h5/test/wechat-browser.mjs
node h5/test/report-persistence.mjs
node h5/test/recommendation.js
node h5/test/analyze-timeout.js
H5_BASE_URL=https://1me.xajuzi.com/state/ node h5/test/wechat-production.mjs
```
- 账号测试覆盖协议、同源校验、state 绑定/一次性使用、跨账号隔离、账号持久化;新增无效 code、空响应、非字符串 OpenID、HTTP 500 和网络错误均拒绝登录的验证。
- 真实 HTTPS + `/state/` 浏览器测试仅模拟微信上游,覆盖本机→微信→本机显式切换、原报告保留、同一微信跨两个独立浏览器读取同一报告、另一微信无法读取、Cookie 丢失不串号、失败提示不被本机自动恢复掩盖。
- 报告保存与回看、前后台一致性、126 个推荐用例及检测超时回归通过;不调用收费 Face++。
- 公网预检脚本只使用刻意无效的 code,验证授权 URL/绑定 Cookie、真实微信拒绝无效换码、state 防重放、同源与协议校验、匿名报告拒绝及后台保护;不创建微信身份,不表示真人授权成功。
## 真实微信验收与排查
目前尚不能将模拟授权和基础凭据通过视为真人登录已完成。服务器与无头浏览器不能替用户产生真实微信授权 code,也不在生产环境伪造微信身份。
1. 手机微信内打开 `https://1me.xajuzi.com/state/`,不要选择「在浏览器打开」。
2. 若已有本机登录,进入「我的报告」→「切换登录方式」;勾选协议,点击「微信登录」。
3. 登录后进入「我的报告」,身份提示应为「同一微信账号可跨手机查看」。首次使用微信身份时列表可能为空:本机账号历史报告不会自动合并。
4. 同一微信重新登录或换另一台手机查看,账号和已保存微信报告应一致;无需为了验证登录再次调用收费检测。
5. 如微信自身页面提示域名或 scope 权限错误,请核对对应 AppID 公众号的「网页授权域名」是否为 `1me.xajuzi.com`、是否有网页授权权限;这些设置不能由域名校验文件存在或基础接口令牌通过来推断。
服务端排查命令:
```bash
sudo journalctl -u juzi-h5.service --since '10 minutes ago' --no-pager | rg '\[auth/wechat\]'
```
只需反馈页面是否进入首页/报告列表,或错误页面截图。不要传授权 code、Cookie、OpenID、AppSecret 或接口令牌。
@@ -2,6 +2,8 @@
时间:2026-10-11(北京时间)。服务器:`ubuntu@1me.xajuzi.com`,页面:`https://1me.xajuzi.com/state/`。
2026-10-12 最新状态:白名单已生效,生产服务器成功获取微信基础接口令牌,AppID/AppSecret 校验通过;新增账号切换、会话失效防串号和回调诊断,详见[本轮联调记录](微信登录联调-2026-10-12.md)。下文 `40164` 是历史结果,真实微信客户端授权仍待验证。
## 后续更新:已配置真实凭据
用户提供凭据后,已将其写入 `/etc/juzi/wechat.env` 并重启服务。该文件仅 root 可读写(600),目录为 700;临时凭据文件已移除,未写入代码库或前端。
+17 -11
View File
@@ -4,24 +4,24 @@
"2": "app.js",
"3": "skin-score.js",
"4": "i",
"5": "n",
"5": "r",
"6": ".runAndExtendWithFaceDetections",
"7": "Box",
"8": "admin.js",
"9": "__awaiter",
"10": "AgeGenderNet",
"10": "TinyYolov2Base",
"11": "lib/accounts.js",
"12": "server.js",
"13": "files",
"14": "FaceLandmarks68",
"14": "FaceLandmarks",
"15": ".runAndExtendWithFaceDetection",
"16": "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范",
"17": "extractFaces",
"18": "1'me 在线肌肤测试 H5",
"19": "head-pose.js",
"20": "build_fixtures.py",
"21": "disposeUnusedWeightTensors",
"22": "ComputeAllFaceDescriptorsTask",
"21": "AgeGenderNet",
"22": ".loadFromDisk",
"23": "facepp-skinanalyze(旷视皮肤分析专业版调用技能)",
"24": "巨子测肤 · 1'me 语音播报内容设计文档 V1.0",
"25": "mobile-select.iife.min.js",
@@ -42,7 +42,7 @@
"40": ".predictAgeAndGender",
"41": "test/accounts.js",
"42": "export_psd_assets.py",
"43": ".loadFromDisk",
"43": ".toString",
"44": "start-local.sh",
"45": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"46": "PredictAllFaceExpressionsWithFaceAlignmentTask",
@@ -54,7 +54,7 @@
"52": "region-data.js",
"53": "护理建议(全量文本,按需求摘抄)",
"54": "UI 对齐 10.09 设计稿:调研与实施计划",
"55": "extractFaceTensors",
"55": "pn",
"56": "report-ui.js",
"57": "regression.js",
"58": ".dispose",
@@ -64,7 +64,7 @@
"62": "提交前 Review(2026-10-11)",
"63": "e2e-browser.mjs",
"64": "account-client.js",
"65": "k",
"65": "微信登录接入与公网部署说明",
"66": "wechat-browser.mjs",
"67": "ui-20261009.mjs",
"68": "analyze-timeout.js",
@@ -77,9 +77,15 @@
"75": "prepare_h5_assets.py",
"76": "TinyYolov2",
"77": "drawFaceExpressions",
"78": ".toString",
"78": "n",
"79": "isValidNumber",
"80": "线上微信登录部署记录",
"81": "ComputeSingleFaceDescriptorTask",
"82": "PredictAllFaceExpressionsTask"
"81": "wechat-production.mjs",
"82": "ComputeAllFaceDescriptorsTask",
"83": "10.09 新版 H5 实施与验收",
"84": "微信登录联调:2026-10-12",
"87": "产品推荐规则:肤龄基准与边界调整",
"92": "部署",
"97": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
"98": "PredictSingleFaceExpressionsWithFaceAlignmentTask"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "73743c18a4a7fca9", "1": "cb6ea50bd4e4a5ca", "2": "9cf893b45c314643", "3": "1f737ca8edb978f2", "4": "55697fad20675b78", "5": "be544c1e1f2ddb64", "6": "46204fb2e4d50c53", "7": "fcfa9f1898866dc8", "8": "1ecf47531e1d507b", "9": "2e6b02a61343f092", "10": "563202faef7b3421", "11": "edc9f95405704875", "12": "d3e946c5907aa577", "13": "7e3b558a587a4ebe", "14": "15606ce5d8eec23a", "15": "5a9014c4a7f87699", "16": "95add6a975c0cb72", "17": "2405e1edb003a83e", "18": "300b45cf926eb704", "19": "0a373ad2197fd1a6", "20": "aecf2fce44ba49c5", "21": "d3cd661c24264482", "22": "3dd68cce72af5314", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "6b6c40a418e18aed", "27": "eab427ce9a5219e9", "28": "00f6d1f8841af9c4", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "23f1d406f6c779ae", "36": "8e5e97153c881130", "37": "cb8f26249485324a", "38": "e2d12f8289f83819", "39": "fdd95375b4a5ddb3", "40": "a2b13160223b5a98", "41": "4bc370be8afd7db1", "42": "ffd960136fde00ad", "43": "00dab8704da757dc", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "e042d7fe7351bcb9", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "0d376c2f52a725e0", "56": "91d7ab49459e0b92", "57": "f2a40b5f1b29f669", "58": "675690fb61ef5bc7", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "18ffa2c1feddfd10", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "86101547c05f96b9", "65": "b7874aef5ca3f2ee", "66": "2b44d0efd8eb25c5", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "bf75bfc99d4f9e34", "71": "4195478d5c528371", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "29790cf710874dd0", "77": "a0bc88eb14b70170", "78": "42c9bc548e269a96", "79": "1c27e5f448dc4f9c", "80": "059d6ba8ca7ec3a0", "81": "7ac475ea9bc280f8", "82": "e800d832c255899a"}
{"0": "4e953aa5accd7c7a", "1": "cb6ea50bd4e4a5ca", "2": "9cf893b45c314643", "3": "1f737ca8edb978f2", "4": "5f6112d5f635312b", "5": "eef22a8f3d286cb0", "6": "435ca504c453b5ca", "7": "cd3c9a7feaf8faee", "8": "1ecf47531e1d507b", "9": "abb09e22736891ca", "10": "b78b7363e38a9a47", "11": "4a490abd5e503318", "12": "d3e946c5907aa577", "13": "7e3b558a587a4ebe", "14": "b3282012327aedbb", "15": "8bd61518dd267641", "16": "95add6a975c0cb72", "17": "fceb16a66797d550", "18": "9dec2afb5aedd74b", "19": "0a373ad2197fd1a6", "20": "aecf2fce44ba49c5", "21": "e6363b2654e6584b", "22": "caf0f067881ef97e", "23": "90fac00055070ce2", "24": "f85b5b1634224c86", "25": "46e8db3e6e3f37ea", "26": "83b0fd3b14363e4d", "27": "eab427ce9a5219e9", "28": "3c28e8d4be0a55e3", "29": "c608f1c6492e1cb1", "30": "73c13793fca09f56", "31": "fbbcfc059751cc6a", "32": "f6bcf56c4589316b", "33": "75f750c5b7d9f63d", "34": "f024ff7942935690", "35": "23f1d406f6c779ae", "36": "a3859fc25c864a81", "37": "cb8f26249485324a", "38": "9a25b1230b5fe649", "39": "fdd95375b4a5ddb3", "40": "a2b13160223b5a98", "41": "4bc370be8afd7db1", "42": "ffd960136fde00ad", "43": "15d6968c6dfb2e19", "44": "852c1bb2446edd9c", "45": "92ab3e5134ed8685", "46": "65718282b3c32109", "47": "e594ecaa8e7d1538", "48": "e042d7fe7351bcb9", "49": "1f976711cda158dc", "50": "bbb2b57d72922b16", "51": "eb6a182204867275", "52": "2ec3c8c088568ba9", "53": "68c45056378341a7", "54": "c58d41e8e580fdc6", "55": "1ac1eedae197df20", "56": "91d7ab49459e0b92", "57": "f2a40b5f1b29f669", "58": "36e826942fb5c839", "59": "891f59cda3708718", "60": "a505b7de6257a4e8", "61": "ed382bc83b2567aa", "62": "1366ae24c7e5897d", "63": "c256b43f75d7809e", "64": "ce0e68dab611f022", "65": "6281db8274251503", "66": "769f05ad8584c01f", "67": "3e126d8deaf6e200", "68": "6b91f3679541de79", "69": "e511846eeba03076", "70": "bf75bfc99d4f9e34", "71": "4195478d5c528371", "72": "c84f196a7bd35861", "73": "2b887bf601ccfb9f", "74": "ae2a154311e1f53a", "75": "69fdd23278518884", "76": "c96276581a8c98d4", "77": "979dcc63399620ad", "78": "9c8639405659c6ae", "79": "1c27e5f448dc4f9c", "80": "315325cde1352e98", "81": "37c559741825be74", "82": "3dd68cce72af5314", "83": "1284897282eb47a3", "84": "3f747b286aec1e85", "87": "e5fa7717989eda80", "92": "7dc1445ea84a39a8", "97": "01f68dfc34a29d32", "98": "8c471c7baeaaca54"}
+109 -79
View File
@@ -1,16 +1,16 @@
# Graph Report - juzi (2026-10-12)
## Corpus Check
- 66 files · ~4,184,136 words
- 68 files · ~4,185,405 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1188 nodes · 2566 edges · 83 communities (66 shown, 17 thin omitted)
- 1204 nodes · 2589 edges · 89 communities (72 shown, 17 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 179 edges (avg confidence: 0.53)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `7518dc23`
- Built from commit: `0ec5e5dc`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -20,24 +20,24 @@
- app.js
- skin-score.js
- i
- n
- r
- .runAndExtendWithFaceDetections
- Box
- admin.js
- __awaiter
- AgeGenderNet
- TinyYolov2Base
- lib/accounts.js
- server.js
- files
- FaceLandmarks68
- FaceLandmarks
- .runAndExtendWithFaceDetection
- 巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范
- extractFaces
- 1'me 在线肌肤测试 H5
- head-pose.js
- build_fixtures.py
- disposeUnusedWeightTensors
- ComputeAllFaceDescriptorsTask
- AgeGenderNet
- .loadFromDisk
- facepp-skinanalyze(旷视皮肤分析专业版调用技能)
- 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
- mobile-select.iife.min.js
@@ -57,7 +57,7 @@
- .predictAgeAndGender
- test/accounts.js
- export_psd_assets.py
- .loadFromDisk
- .toString
- start-local.sh
- PredictAllAgeAndGenderWithFaceAlignmentTask
- PredictAllFaceExpressionsWithFaceAlignmentTask
@@ -67,7 +67,7 @@
- call_skinanalyze_3view.sh
- 护理建议(全量文本,按需求摘抄)
- UI 对齐 10.09 设计稿:调研与实施计划
- extractFaceTensors
- pn
- report-ui.js
- regression.js
- .dispose
@@ -76,7 +76,7 @@
- 提交前 Review(2026-10-11)
- e2e-browser.mjs
- account-client.js
- k
- 微信登录接入与公网部署说明
- wechat-browser.mjs
- ui-20261009.mjs
- analyze-timeout.js
@@ -86,11 +86,17 @@
- build_report_copy.py
- TinyYolov2
- drawFaceExpressions
- .toString
- n
- isValidNumber
- 线上微信登录部署记录
- ComputeSingleFaceDescriptorTask
- PredictAllFaceExpressionsTask
- wechat-production.mjs
- ComputeAllFaceDescriptorsTask
- 10.09 新版 H5 实施与验收
- 微信登录联调:2026-10-12
- 产品推荐规则:肤龄基准与边界调整
- 部署
- PredictSingleAgeAndGenderWithFaceAlignmentTask
- PredictSingleFaceExpressionsWithFaceAlignmentTask
## God Nodes (most connected - your core abstractions)
1. `__awaiter()` - 59 edges
@@ -119,11 +125,11 @@
## Import Cycles
- None detected.
## Communities (83 total, 17 thin omitted)
## Communities (89 total, 17 thin omitted)
### Community 0 - "face-api.js"
Cohesion: 0.02
Nodes (35): cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), dr(), en(), FaceDetection() (+27 more)
Nodes (32): cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), dr(), en(), extractBoundingBoxes() (+24 more)
### Community 1 - "线上闭环测试(不含真实微信授权)"
Cohesion: 0.25
@@ -139,31 +145,35 @@ Nodes (45): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens()
### Community 4 - "i"
Cohesion: 0.09
Nodes (42): a(), bf(), cf(), d(), da(), dn(), DrawBox(), drawContour() (+34 more)
Nodes (27): b(), ba(), ca(), da(), dn(), ee(), et(), fa() (+19 more)
### Community 5 - "n"
Cohesion: 0.11
Nodes (37): ae(), b(), be(), bi(), ce(), de(), e(), Ef() (+29 more)
### Community 5 - "r"
Cohesion: 0.15
Nodes (34): a(), bf(), cf(), d(), drawContour(), dt(), e(), ea() (+26 more)
### Community 6 - ".runAndExtendWithFaceDetections"
Cohesion: 0.33
Nodes (5): DetectAllFacesTask(), extendWithFaceDetection(), isWithFaceDetection(), isWithFaceLandmarks(), resizeResults()
### Community 7 - "Box"
Cohesion: 0.10
Nodes (11): Box(), extendWithFaceDescriptor(), extendWithFaceDetection(), extendWithFaceLandmarks(), FaceLandmarks(), FaceRecognitionNet(), isWithFaceDetection(), isWithFaceLandmarks() (+3 more)
Cohesion: 0.16
Nodes (3): Box(), ComputeSingleFaceDescriptorTask(), extendWithFaceDescriptor()
### Community 8 - "admin.js"
Cohesion: 0.21
Nodes (28): ageText(), api(), bind(), closeDetail(), collectFilters(), dimsHtml(), doRetry(), dSec() (+20 more)
### Community 9 - "__awaiter"
Cohesion: 0.14
Nodes (13): __awaiter(), extendWithFaceExpressions(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow() (+5 more)
Cohesion: 0.11
Nodes (15): __awaiter(), bufferToImage(), ComposableTask(), extendWithFaceExpressions(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult(), FaceRecognitionNet(), fetchImage() (+7 more)
### Community 10 - "AgeGenderNet"
Cohesion: 0.05
Nodes (31): AgeGenderNet(), conv(), conv$1(), convDown(), convLayer(), convLayer$1(), convNoRelu(), convWithBatchNorm() (+23 more)
### Community 10 - "TinyYolov2Base"
Cohesion: 0.10
Nodes (22): boxPredictionLayer(), convLayer(), convWithBatchNorm(), decodeBoxesLayer(), depthwiseConvLayer(), depthwiseSeparableConv$1(), fullyConnectedLayer(), getCenterCoordinatesAndSizesLayer() (+14 more)
### Community 11 - "lib/accounts.js"
Cohesion: 0.17
Nodes (19): atomic(), body(), cookie(), cookies(), crypto, fs, json(), login() (+11 more)
Nodes (20): atomic(), body(), cookie(), cookies(), crypto, fs, json(), login() (+12 more)
### Community 12 - "server.js"
Cohesion: 0.08
@@ -173,25 +183,25 @@ Nodes (49): accounts, appendRecord(), callFacepp(), CRED, csvCell(), DATA_DIR, d
Cohesion: 0.12
Nodes (15): files, analyze-fail, analyzing, countdown-321, hint-far, hint-front, hint-hold, hint-near (+7 more)
### Community 14 - "FaceLandmarks68"
Cohesion: 0.19
Nodes (3): FaceLandmarks5(), FaceLandmarks68(), getCenterPoint()
### Community 14 - "FaceLandmarks"
Cohesion: 0.05
Nodes (25): conv(), conv$1(), convDown(), convLayer$1(), convNoRelu(), denseBlock3(), denseBlock4(), depthwiseSeparableConv() (+17 more)
### Community 15 - ".runAndExtendWithFaceDetection"
Cohesion: 0.33
Nodes (5): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), detectAllFaces(), DetectSingleFaceTask()
Cohesion: 0.23
Nodes (6): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), DetectAllFaceLandmarksTask(), detectAllFaces(), DetectSingleFaceTask()
### Community 16 - "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范"
Cohesion: 0.15
Nodes (12): 1. 基准与尺寸换算, 2. 色彩规范, 3. 字体规范, 4. 组件切图(assets/svg/), 5. 图片素材(assets/images/,共 15 张), 6. 页面清单(design/ 下有整屏 PNG 预览), 7. 目录结构, 8. 还原建议 (+4 more)
### Community 17 - "extractFaces"
Cohesion: 0.19
Nodes (19): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+11 more)
Cohesion: 0.17
Nodes (20): awaitMediaLoaded(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractFaceTensors(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+12 more)
### Community 18 - "1'me 在线肌肤测试 H5"
Cohesion: 0.05
Nodes (33): 10.09 新版 H5 实施与验收, 按确认项实现, 文档未定义部分的实施口径, 素材再生成, 验证命令, 产品推荐规则:肤龄基准与边界调整, 决策顺序, 后台与前端一致性 (+25 more)
Cohesion: 0.17
Nodes (12): 10.09 更新, 1'me 在线肌肤测试 H5, Admin API(统一返回 `{ok:false,message}` 错误), 后台查看(admin.html), 密钥配置, 已知占位清单, 技术说明, 拍照交互(动态捕捉自动三连拍) (+4 more)
### Community 19 - "head-pose.js"
Cohesion: 0.33
@@ -201,9 +211,13 @@ Nodes (8): assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal(),
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.06
Nodes (33): boxPredictionLayer(), decodeBoxesLayer(), depthwiseConvLayer(), disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9() (+25 more)
### Community 21 - "AgeGenderNet"
Cohesion: 0.05
Nodes (28): AgeGenderNet(), disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1() (+20 more)
### Community 22 - ".loadFromDisk"
Cohesion: 0.24
Nodes (4): fetchJson(), getModelUris(), loadWeightMap(), TinyFaceDetector()
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
Cohesion: 0.20
@@ -218,16 +232,16 @@ Cohesion: 0.20
Nodes (16): _arrayLikeToArray(), _arrayWithHoles(), _arrayWithoutHoles(), _classCallCheck(), _createClass(), _defineProperties(), h(), _iterableToArray() (+8 more)
### Community 26 - "wi"
Cohesion: 0.20
Nodes (12): Ci(), Ei(), Fi(), Ii(), ki(), pe(), Qt(), ri() (+4 more)
Cohesion: 0.28
Nodes (9): Ci(), Ei(), Fi(), Ii(), ki(), pe(), ri(), wi() (+1 more)
### Community 27 - "gen_voice.py"
Cohesion: 0.30
Nodes (11): ndarray, Path, gen_countdown(), main(), normalize_clip(), 倒计时单独按字生成再拼接,避免「三、二、一」整句被读成播报腔、与提示音气质漂移。 与提示音共用同一 speaker + instruct;句间留约…, 裁掉过长首尾静音,保留 ≤150ms 边沿,避免提示显迟钝。, 响度归一 −16 LUFS + 转 MP3 48k CBR mono 22050Hz。 (+3 more)
### Community 28 - "C"
Cohesion: 0.08
Nodes (33): bn(), Bo(), C(), cd(), cl(), co(), eo(), fn() (+25 more)
Cohesion: 0.10
Nodes (30): Bo(), C(), cd(), cl(), co(), eo(), fo(), Ft() (+22 more)
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
Cohesion: 0.29
@@ -242,17 +256,13 @@ Cohesion: 0.60
Nodes (4): main(), 递归缩放 result 内全部坐标:{left,top,width,height} 矩形与 {x,y} 点,与原数据同为整数。, scale_coords(), to_data_url()
### Community 36 - "ao"
Cohesion: 0.33
Nodes (6): ao(), Do(), io(), oo(), so(), To()
Cohesion: 0.40
Nodes (5): ao(), Do(), io(), so(), To()
### Community 37 - "UI 外发版 10.09 图片资源"
Cohesion: 0.40
Nodes (4): UI 外发版 10.09 图片资源, 前端素材对应, 文件位置, 重新导出
### Community 38 - "FaceLandmark68Net"
Cohesion: 0.17
Nodes (3): FaceLandmark68Net(), PredictSingleAgeAndGenderWithFaceAlignmentTask(), PredictSingleFaceExpressionsWithFaceAlignmentTask()
### Community 39 - "UI交互界面调整"
Cohesion: 0.40
Nodes (4): 1meAI测肤旷视UI问题反馈, UI交互界面调整, 交互, **设计**
@@ -269,9 +279,9 @@ Nodes (9): assert, create, fs, http, os, path, ReportUI, SkinScore (+1 more)
Cohesion: 0.70
Nodes (4): bounds(), layer_at(), main(), save_image()
### Community 43 - ".loadFromDisk"
### Community 43 - ".toString"
Cohesion: 0.29
Nodes (3): ComposableTask(), getModelUris(), TinyFaceDetector()
Nodes (6): ae(), FaceMatch(), la(), pr(), pt(), vt()
### Community 44 - "start-local.sh"
Cohesion: 0.50
@@ -293,9 +303,9 @@ 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 - "extractFaceTensors"
### Community 55 - "pn"
Cohesion: 0.29
Nodes (3): DetectAllFaceLandmarksTask(), extractFaceTensors(), isTensor3D()
Nodes (7): bn(), fn(), ln(), pn(), un(), wn(), xn()
### Community 56 - "report-ui.js"
Cohesion: 0.90
@@ -306,12 +316,12 @@ Cohesion: 0.60
Nodes (5): diffCtx(), loadJSON(), main(), okNum(), okStr()
### Community 58 - ".dispose"
Cohesion: 0.16
Nodes (12): extractBoundingBoxes(), getSizesForScale(), iou(), nonMaxSuppression(), nonMaxSuppression$1(), normalize$1(), rescaleAndNormalize(), scale() (+4 more)
Cohesion: 0.17
Nodes (12): extendWithFaceLandmarks(), getSizesForScale(), iou(), nonMaxSuppression(), nonMaxSuppression$1(), normalize$1(), ObjectDetection(), rescaleAndNormalize() (+4 more)
### Community 61 - "NeuralNetwork"
Cohesion: 0.06
Nodes (28): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractFCParamsFactory() (+20 more)
Cohesion: 0.07
Nodes (26): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory(), extractFCParamsFactory() (+18 more)
### Community 62 - "提交前 Review(2026-10-11)"
Cohesion: 0.40
@@ -322,16 +332,16 @@ Cohesion: 0.29
Nodes (4): EXPECTED, fails, pageErrors, pwRoot
### Community 64 - "account-client.js"
Cohesion: 0.90
Nodes (4): browser(), init(), read(), request()
Cohesion: 0.80
Nodes (5): browser(), init(), read(), rememberKind(), request()
### Community 65 - "k"
Cohesion: 0.13
Nodes (16): ba(), ca(), fa(), gn(), k(), kn(), pa(), tt() (+8 more)
### Community 65 - "微信登录接入与公网部署说明"
Cohesion: 0.22
Nodes (7): 公网服务器配置, 公网真机验收清单, 反向代理与后台保护, 已完成的链路, 微信登录接入与公网部署说明, 数据、迁移与备份, 结论与当前阻碍
### Community 66 - "wechat-browser.mjs"
Cohesion: 0.40
Nodes (4): createAccounts, h5, require, root
Cohesion: 0.25
Nodes (6): createAccounts, h5, ReportUI, require, root, SkinScore
### Community 68 - "analyze-timeout.js"
Cohesion: 0.20
@@ -342,35 +352,55 @@ Cohesion: 0.50
Nodes (3): artifacts, errors, samples
### Community 77 - "drawFaceExpressions"
Cohesion: 0.50
Nodes (3): drawFaceExpressions(), FaceExpressions(), isWithFaceExpressions()
Cohesion: 0.22
Nodes (6): DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), FaceExpressions(), isWithFaceExpressions()
### Community 78 - ".toString"
Cohesion: 0.50
Nodes (3): FaceMatch(), la(), pr()
### Community 78 - "n"
Cohesion: 0.11
Nodes (29): be(), bi(), ce(), de(), Ef(), er(), fe(), Ie() (+21 more)
### Community 79 - "isValidNumber"
Cohesion: 0.33
Nodes (5): bgrToRgbTensor(), Dimensions(), isValidNumber(), isValidProbablitiy(), isWithGender()
### Community 80 - "线上微信登录部署记录"
Cohesion: 0.29
Cohesion: 0.33
Nodes (6): 初次部署时的凭据查找记录(现已由用户提供), 初次部署的线上检查(历史结果), 后续更新:已配置真实凭据, 已执行, 用户确认加入白名单后的复测, 线上微信登录部署记录
### Community 81 - "wechat-production.mjs"
Cohesion: 0.60
Nodes (4): api(), base, callback(), start()
### Community 83 - "10.09 新版 H5 实施与验收"
Cohesion: 0.29
Nodes (5): 10.09 新版 H5 实施与验收, 按确认项实现, 文档未定义部分的实施口径, 素材再生成, 验证命令
### Community 84 - "微信登录联调:2026-10-12"
Cohesion: 0.29
Nodes (5): 微信登录联调:2026-10-12, 本轮修正, 白名单与凭据, 真实微信验收与排查, 自动验证
### Community 87 - "产品推荐规则:肤龄基准与边界调整"
Cohesion: 0.33
Nodes (6): 产品推荐规则:肤龄基准与边界调整, 决策顺序, 后台与前端一致性, 实施与验证范围, 已明确的要求, 已确认的三个条件
### Community 92 - "部署"
Cohesion: 0.67
Nodes (3): Windows 本机部署(提测用), 线上部署(juzi.xiangsilian.com), 部署
## Knowledge Gaps
- **183 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+178 more)
- **190 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+185 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **17 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 `k()` connect `k` to `face-api.js`, `app.js`, `i`, `n`, `lib/accounts.js`?**
_High betweenness centrality (0.061) - this node is a cross-community bridge._
- **Why does `r()` connect `n` to `face-api.js`, `i`, `AgeGenderNet`, `server.js`, `.toString`, `wi`, `C`?**
_High betweenness centrality (0.050) - this node is a cross-community bridge._
- **Why does `renderReport()` connect `app.js` to `k`, `n`?**
_High betweenness centrality (0.047) - this node is a cross-community bridge._
- **Why does `k()` connect `i` to `face-api.js`, `app.js`, `r`, `.toString`, `lib/accounts.js`, `n`, `C`?**
_High betweenness centrality (0.063) - this node is a cross-community bridge._
- **Why does `r()` connect `r` to `face-api.js`, `i`, `.toString`, `server.js`, `n`, `FaceLandmarks`, `C`?**
_High betweenness centrality (0.055) - this node is a cross-community bridge._
- **Why does `renderReport()` connect `app.js` to `i`, `n`?**
_High betweenness centrality (0.048) - 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?**
@@ -378,4 +408,4 @@ _Questions this graph is uniquely positioned to answer:_
- **Are the 7 inferred relationships involving `t()` (e.g. with `renderReport()` and `e()`) actually correct?**
_`t()` has 7 INFERRED edges - model-reasoned connections that need verification._
- **What connects `source`, `document_size`, `pages` to the rest of the system?**
_183 weakly-connected nodes found - possible documentation gaps or missing edges._
_190 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+1043 -601
View File
File diff suppressed because it is too large Load Diff
+26 -16
View File
@@ -40,8 +40,8 @@
"semantic_hash": ""
},
"h5/js/app.js": {
"mtime": 1791735440.264282,
"ast_hash": "bd18e424efb5c8d037b64d67f869b198",
"mtime": 1791770434.3399014,
"ast_hash": "174b15c983614449ca67a7e5b81a8bbf",
"semantic_hash": ""
},
"h5/js/head-pose.js": {
@@ -140,8 +140,8 @@
"semantic_hash": ""
},
"h5/index.html": {
"mtime": 1791735440.2604818,
"ast_hash": "444fca45ad900710607cb15b852c0305",
"mtime": 1791770434.3399014,
"ast_hash": "cfdedacf49c9a83c23155abb39fc7447",
"semantic_hash": ""
},
"h5/vendor/faceapi/README.md": {
@@ -1230,8 +1230,8 @@
"semantic_hash": ""
},
"h5/js/account-client.js": {
"mtime": 1791646011.3632386,
"ast_hash": "1aad127f864693ee873d59b177386b33",
"mtime": 1791770434.3399014,
"ast_hash": "d14c4ec5374a8ada79e0e7cd1cc294a0",
"semantic_hash": ""
},
"h5/js/camera-geometry.js": {
@@ -1250,13 +1250,13 @@
"semantic_hash": ""
},
"h5/lib/accounts.js": {
"mtime": 1791735369.9274576,
"ast_hash": "1a0677e439938aa2e9e4decf38fad568",
"mtime": 1791770434.3399014,
"ast_hash": "af809199fbaae6318fb71fe602ddc102",
"semantic_hash": ""
},
"h5/test/accounts.js": {
"mtime": 1791735546.0452847,
"ast_hash": "aa05cb7d924b1ddbe8834faeab24588f",
"mtime": 1791770576.0104883,
"ast_hash": "d927f5d1486b56505ba4da49ec6a769c",
"semantic_hash": ""
},
"h5/test/report-persistence.mjs": {
@@ -1290,8 +1290,8 @@
"semantic_hash": ""
},
"docs/\u5fae\u4fe1\u767b\u5f55\u63a5\u5165\u4e0e\u516c\u7f51\u90e8\u7f72\u8bf4\u660e.md": {
"mtime": 1791730674.7561882,
"ast_hash": "6e8f9f9a8b941790adccde55be9b125c",
"mtime": 1791770706.5669558,
"ast_hash": "b290838863e54d0d6261cb4e43cc0904",
"semantic_hash": ""
},
"h5/assets/fonts/misans/README.md": {
@@ -1530,8 +1530,8 @@
"semantic_hash": ""
},
"h5/test/wechat-browser.mjs": {
"mtime": 1791710791.703553,
"ast_hash": "7d44f0e7fee4a30c6654db57de57923e",
"mtime": 1791770609.8948362,
"ast_hash": "9e3344d5618e59e5e765d16d4b0903b0",
"semantic_hash": ""
},
"docs/2026-10-11\u63d0\u4ea4\u524dReview.md": {
@@ -1550,8 +1550,8 @@
"semantic_hash": ""
},
"docs/\u7ebf\u4e0a\u5fae\u4fe1\u767b\u5f55\u90e8\u7f72\u8bb0\u5f55-2026-10-11.md": {
"mtime": 1791730745.132436,
"ast_hash": "056f5b8e0ee8920285d9cf8b1764f3f5",
"mtime": 1791770706.5669558,
"ast_hash": "7a475de28d6a21509d18bf30a3b29080",
"semantic_hash": ""
},
"h5/test/browser-failures.mjs": {
@@ -1573,5 +1573,15 @@
"mtime": 1791735707.6717124,
"ast_hash": "69c1278c6c8eea5866982940e20e421c",
"semantic_hash": ""
},
"docs/\u5fae\u4fe1\u767b\u5f55\u8054\u8c03-2026-10-12.md": {
"mtime": 1791770818.4989572,
"ast_hash": "55253912b649c3ca707acdf2631a9e7f",
"semantic_hash": ""
},
"h5/test/wechat-production.mjs": {
"mtime": 1791770851.3389251,
"ast_hash": "ba457695243430e271cfd7927ef22541",
"semantic_hash": ""
}
}
+1
View File
@@ -35,6 +35,7 @@
<p class="identity-note" id="identity-note"></p>
<div id="history-list" aria-live="polite"></div>
<button class="history-retry" data-action="history-retry">刷新列表</button>
<button class="history-retry" data-action="switch-login">切换登录方式</button>
<small class="font-credit">本应用使用 MiSans 字体</small>
</div>
</section>
+9 -5
View File
@@ -1,8 +1,9 @@
/* Browser identity is a random credential, never a fingerprint or a report. */
window.AccountClient = (function () {
'use strict';
var key = 'juzi.identity.v1', user = null;
function read() { try { return localStorage.getItem(key); } catch (_) { return null; } }
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 (_) {} }
function request(url, data) {
return fetch(url, { credentials: 'same-origin', method: data ? 'POST' : 'GET',
headers: data ? { 'Content-Type': 'application/json' } : {},
@@ -13,13 +14,16 @@ window.AccountClient = (function () {
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; return v;
user = v.user; rememberKind(); return v;
});
}
function init() {
function init(options) {
return request('api/auth/session').then(function (v) {
user = v.user;
if (!user && read()) return browser(true).catch(function (e) {
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;
+30 -7
View File
@@ -1304,7 +1304,13 @@
startCamera(); // 复用启停与预热(camSeqCam 防竞态)
},
'history': function () { openHistory(); },
'history-retry': function () { renderHistoryList(); }
'history-retry': function () { renderHistoryList(); },
'switch-login': function () {
historySeq++;
$('#login-consent').checked = false;
$('#login-message').textContent = '微信与本机浏览器为独立账号,报告不会自动合并。请选择登录方式;返回可保留当前登录。';
showScreen('s-login', 'push');
}
};
document.addEventListener('click', function (e) {
@@ -1427,14 +1433,31 @@
history.replaceState({ screen: 's-login' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐
renderCamStep();
if (!(/[?&]mock=1/.test(location.search) && /[?&]screen=report/.test(location.search))) {
var loginError = /[?&]login_error=([^&#]*)/.exec(location.search);
var loginSuccess = /[?&]login=success(?:&|$)/.test(location.search);
var oauthReturn = !!loginError || loginSuccess;
loginBusy = true;
AccountClient.init().then(function () {
if (AccountClient.user()) showScreen('s-splash', 'replace');
else $('#login-message').textContent = /[?&]login_error=/.test(location.search)
? '微信登录未完成,可重试或使用本机浏览器登录。'
: '本机登录仅限当前浏览器,不支持换手机找回。';
AccountClient.init({ restoreBrowser: !oauthReturn }).then(function () {
if (loginError) {
var code = loginError[1];
$('#login-message').textContent = code === 'state'
? '登录链接已过期或会话丢失,请在同一微信窗口重新点击微信登录。'
: code === 'network' ? '暂时无法连接微信服务,请稍后重新登录。'
: '微信授权未完成,请重试;如仍失败,请将错误页面截图发给管理员。也可选择本机浏览器登录。';
} else if (loginSuccess && (!AccountClient.user() || AccountClient.user().kind !== 'wechat')) {
$('#login-message').textContent = '微信返回后登录会话未能保存,请在同一微信窗口重新登录。';
} else if (AccountClient.user()) showScreen('s-splash', 'replace');
else $('#login-message').textContent = '微信登录可跨手机查看报告;本机登录仅限当前浏览器。';
}).catch(function (e) { $('#login-message').textContent = e.message; })
.finally(function () { loginBusy = false; });
.finally(function () {
loginBusy = false;
if (oauthReturn) {
var clean = new URL(location.href);
clean.searchParams.delete('login'); clean.searchParams.delete('login_error');
if (clean.hash === '#wechat_redirect') clean.hash = '';
history.replaceState(history.state, '', clean.pathname + clean.search + clean.hash);
}
});
}
// 仅调试用:?mock=1&screen=report 直渲 mock 报告页(供无头浏览器截图验证布局,非正式入口)
// 有 MOCK_FIXTURE 时走 fixture(检测可视化同步出图),否则回退 SkinScore 内置样本
+15 -6
View File
@@ -80,6 +80,8 @@ module.exports = function createAccounts(root, env = process.env) {
limits.set(key, value); return ++value.count <= 60;
}
function redirect(res, suffix) { res.writeHead(302, { Location: prefix + suffix, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); res.end(); }
// Operational events only: never log code/state, cookies, OpenID, secrets or tokens.
function wechatEvent(event, fields = {}) { console.log('[auth/wechat] ' + event + ' ' + JSON.stringify(fields)); }
async function route(req, res, pathname) {
if (!pathname.startsWith('/api/auth/') && !pathname.startsWith('/api/reports')) return false;
try {
@@ -123,17 +125,24 @@ module.exports = function createAccounts(root, env = process.env) {
const state = url.searchParams.get('state') || '', code = url.searchParams.get('code');
const row = db.states[hash(state)], binding = cookies(req).juzi_oauth || '';
delete db.states[hash(state)]; save(); cookie(res, 'juzi_oauth', '', 0);
if (!available || !code || !row || row.expires < Date.now() || row.binding !== hash(binding)) { redirect(res, '?login_error=state'); return true; }
if (!available || !code || !row || row.expires < Date.now() || row.binding !== hash(binding)) {
wechatEvent('callback_rejected', { reason: 'state' }); redirect(res, '?login_error=state'); return true;
}
const tokenUrl = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
tokenUrl.search = new URLSearchParams({ appid: appId, secret, code, grant_type: 'authorization_code' }).toString();
let data;
try { const r = await fetch(tokenUrl, { signal: AbortSignal.timeout(10000) }); data = await r.json(); }
catch (_) { redirect(res, '?login_error=network'); return true; }
if (!data.openid || data.errcode) { redirect(res, '?login_error=wechat'); return true; }
let data, upstreamStatus;
try { const r = await fetch(tokenUrl, { signal: AbortSignal.timeout(10000) }); upstreamStatus = r.status; data = await r.json(); }
catch (_) { wechatEvent('exchange_failed', { reason: 'network' }); redirect(res, '?login_error=network'); return true; }
if (!data || typeof data.openid !== 'string' || !data.openid || data.errcode || (upstreamStatus && upstreamStatus !== 200)) {
wechatEvent('exchange_failed', { reason: 'wechat', errcode: data && Number.isInteger(data.errcode) ? data.errcode : null,
httpStatus: upstreamStatus || null });
redirect(res, '?login_error=wechat'); return true;
}
const identity = hash(appId + ':' + data.openid);
let user = db.users.find(u => u.kind === 'wechat' && u.identity === identity);
const newAccount = !user;
if (!user) { user = { id: crypto.randomUUID(), kind: 'wechat', identity, consent: new Date().toISOString(), reports: [] }; db.users.push(user); }
login(res, user); redirect(res, '?login=success');
login(res, user); wechatEvent('login_success', { newAccount }); redirect(res, '?login=success');
} else if (pathname.startsWith('/api/reports') && req.method === 'GET') {
const user = session(req);
if (!user) { json(res, 401, { ok: false, message: '请先登录' }); return true; }
+19
View File
@@ -76,6 +76,25 @@ const ReportUI = require('../js/report-ui');
return (await request('/api/auth/session', null, callback.cookie)).data.user;
}
assert.deepEqual(await wechat(), await wechat());
for (const scenario of [
{ data: { errcode: 40029 }, error: 'wechat' },
{ data: null, error: 'wechat' },
{ data: { openid: {} }, error: 'wechat' },
{ data: { openid: 'must-not-login' }, status: 500, error: 'wechat' },
{ network: true, error: 'network' }
]) {
global.fetch = (url, options) => String(url).startsWith('https://api.weixin.qq.com/')
? scenario.network ? Promise.reject(new Error('fixture network failure'))
: Promise.resolve({ status: scenario.status || 200, json: async () => scenario.data })
: realFetch(url, options);
const started = await request('/api/auth/wechat', { consent: true });
const state = new URL(started.data.url).searchParams.get('state');
const failed = await request('/api/auth/wechat/callback?code=bad&state=' + state, null, started.cookie);
assert.equal(failed.location, '/state/?login_error=' + scenario.error);
assert(!failed.cookie.includes('juzi_session='));
assert.equal((await request('/api/auth/session', null, failed.cookie)).data.user, null);
assert((await request('/api/auth/wechat/callback?code=bad&state=' + state, null, started.cookie)).location.includes('login_error=state'));
}
} finally { global.fetch = realFetch; }
accounts = create(root, { TRUST_LOOPBACK_PROXY: '1' });
// Malformed credentials exercise the limiter without creating 60 users.
+88 -4
View File
@@ -9,6 +9,8 @@ import { createRequire } from 'node:module';
import { pathToFileURL, fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const createAccounts = require('../lib/accounts');
const SkinScore = require('../js/skin-score');
const ReportUI = require('../js/report-ui');
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-wechat-test-'));
const h5 = fileURLToPath(new URL('../', import.meta.url));
@@ -35,12 +37,25 @@ try {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const base = 'https://skin.example.test:' + server.address().port + '/state/';
accounts = createAccounts(path.join(root, 'accounts'), { WECHAT_APP_ID: 'test-app', WECHAT_APP_SECRET: 'test-secret', PUBLIC_BASE_URL: base });
let upstreamError = false, openid = 'test-same-wechat-user';
global.fetch = async url => {
assert.equal(new URL(url).origin, 'https://api.weixin.qq.com');
return { ok: true, json: async () => ({ openid: 'test-same-wechat-user' }) };
return { ok: true, status: 200, json: async () => upstreamError ? { errcode: 40029 } : { openid } };
};
browser = await chromium.launch({ headless: true, args: ['--no-proxy-server', '--host-resolver-rules=MAP skin.example.test 127.0.0.1'] });
// Fetch in Chromium so its test-host resolver and cookie jar are both used.
async function api(page, route, data) {
return page.evaluate(async ({ route, data }) => {
const response = await fetch('api/' + route, { method: data ? 'POST' : 'GET',
headers: data ? { 'Content-Type': 'application/json' } : {}, body: data ? JSON.stringify(data) : undefined });
return { status: response.status, data: await response.json() };
}, { route, data });
}
let firstId;
const vm = SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, { realAge: 30 });
const saved = { id: 'RwechatCrossDevice', created_at: new Date().toISOString(), vm,
presentation: ReportUI.build(vm, SkinScore.MOCK_RESULT), photos: [null, null, null],
payload: { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE } };
for (let device = 0; device < 2; device++) {
const context = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 MicroMessenger/8.0', viewport: { width: 375, height: 726 } });
const page = await context.newPage();
@@ -56,22 +71,91 @@ try {
});
await page.goto(base);
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.waitForSelector('#s-splash.active');
browserId = await page.evaluate(() => AccountClient.user().id);
browserToken = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
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"]');
assert.equal(await page.locator('#login-consent').isChecked(), false);
assert((await page.locator('#login-message').textContent()).includes('报告不会自动合并'));
upstreamError = true;
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('微信授权未完成'));
assert.equal(await page.locator('#s-login.active').count(), 1, 'OAuth failure must not masquerade as browser login success');
await page.waitForFunction(() => !location.search.includes('login_error'));
upstreamError = false;
}
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
// Redirects may preserve WeChat's fragment; it is not sent to the backend.
await page.waitForURL(url => url.origin + url.pathname === base && url.searchParams.get('login') === 'success');
await page.waitForSelector('#s-splash.active');
await page.waitForFunction(() => !location.search.includes('login=success'));
const user = await page.evaluate(() => AccountClient.user());
assert.equal(user.kind, 'wechat');
if (firstId) assert.equal(user.id, firstId); else firstId = user.id;
if (device === 0) accounts.storeReport(user.id, saved);
const cookie = (await context.cookies()).find(c => c.name === 'juzi_session');
assert(cookie.secure && cookie.httpOnly && cookie.path === '/state/' && cookie.sameSite === 'Lax');
await page.click('[data-action="history"]');
await page.waitForFunction(() => document.querySelector('#identity-note').textContent.includes('同一微信账号'));
await page.waitForSelector('[data-report-id="RwechatCrossDevice"]');
assert.equal(await page.locator('[data-report-id="RbrowserOriginal"]').count(), 0);
assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(JSON.stringify(saved)));
if (device === 0) {
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), browserToken);
await context.clearCookies();
await page.reload();
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
assert.equal(await page.locator('#s-login.active').count(), 1, 'lost WeChat session must not silently restore the browser account');
assert.equal((await api(page, 'auth/session')).data.user, null);
await page.check('#login-consent');
await page.click('[data-action="login-wechat"]');
await page.waitForSelector('#s-splash.active');
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"]');
await page.waitForSelector('#s-splash.active');
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
await page.click('[data-action="history"]');
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
assert.equal(await page.locator('[data-report-id="RwechatCrossDevice"]').count(), 0);
assert.equal((await api(page, 'reports/' + saved.id)).status, 404);
// 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);
}
assert.deepEqual(errors, []);
await context.close();
}
console.log('PASS: HTTPS /state/ login → simulated WeChat → callback with bound Secure cookie → homepage/history; two browser contexts share WeChat identity');
// Different WeChat users must never see each other's reports.
openid = 'test-different-wechat-user';
const stranger = await browser.newContext({ ignoreHTTPSErrors: true });
const strangerPage = await stranger.newPage();
await strangerPage.goto(base);
const start = await api(strangerPage, 'auth/wechat', { consent: true });
const auth = new URL(start.data.url), callback = new URL(auth.searchParams.get('redirect_uri'));
callback.search = new URLSearchParams({ code: 'test-code', state: auth.searchParams.get('state') });
await strangerPage.goto(callback.href);
const strangerUser = (await api(strangerPage, 'auth/session')).data.user;
assert(strangerUser && strangerUser.id !== firstId);
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');
} finally {
global.fetch = realFetch;
if (browser) await browser.close();
+64
View File
@@ -0,0 +1,64 @@
// Public HTTPS preflight. Uses only deliberately invalid OAuth codes; creates no
// user/report, makes no paid Face++ call, and never simulates a production OpenID.
// H5_BASE_URL=https://1me.xajuzi.com/state/ node h5/test/wechat-production.mjs
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
assert(process.env.H5_BASE_URL, 'Set H5_BASE_URL explicitly for the production preflight');
const base = new URL(process.env.H5_BASE_URL);
assert.equal(base.protocol, 'https:');
assert(base.pathname.endsWith('/'), 'H5_BASE_URL must have a trailing slash');
const expectedAppId = process.env.WECHAT_EXPECTED_APP_ID || 'wx645d9a7573f1fe9a';
async function api(route, options = {}) {
return fetch(new URL('api/' + route, base), { ...options, redirect: 'manual', signal: AbortSignal.timeout(20000) });
}
const config = await api('auth/config');
assert.equal(config.status, 200);
assert.equal((await config.json()).wechat, true);
for (const [data, origin, status] of [
[{ consent: false }, base.origin, 400],
[{ consent: true }, 'https://invalid.example', 403]
]) {
const r = await api('auth/wechat', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin }, body: JSON.stringify(data) });
assert.equal(r.status, status);
}
async function start() {
const r = await api('auth/wechat', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: base.origin }, body: JSON.stringify({ consent: true }) });
assert.equal(r.status, 200);
const cookies = r.headers.getSetCookie();
const binding = cookies.find(c => c.startsWith('juzi_oauth='));
assert(binding && binding.includes('Secure') && binding.includes('HttpOnly') && binding.includes('SameSite=Lax'));
assert(binding.includes('Path=' + base.pathname + ';'));
assert(!cookies.some(c => c.startsWith('juzi_session=')));
const auth = new URL((await r.json()).url);
assert.equal(auth.origin + auth.pathname, 'https://open.weixin.qq.com/connect/oauth2/authorize');
assert.equal(auth.searchParams.get('appid'), expectedAppId);
assert.equal(auth.searchParams.get('scope'), 'snsapi_base');
assert.equal(auth.searchParams.get('response_type'), 'code');
assert.equal(auth.searchParams.get('redirect_uri'), new URL('api/auth/wechat/callback', base).href);
assert.equal(auth.hash, '#wechat_redirect');
assert(!auth.searchParams.has('secret'));
const state = auth.searchParams.get('state');
assert(/^[a-f0-9]{64}$/.test(state));
return { cookie: binding.split(';')[0], state };
}
async function callback(row, cookie, expectedError) {
const query = new URLSearchParams({ code: 'codex-invalid-' + randomUUID(), state: row.state });
const r = await api('auth/wechat/callback?' + query, { headers: cookie ? { Cookie: cookie } : {} });
assert.equal(r.status, 302);
assert.equal(r.headers.get('location'), base.pathname + '?login_error=' + expectedError);
assert(!r.headers.getSetCookie().some(c => c.startsWith('juzi_session=')), 'invalid callback must not issue a session');
}
const missingBinding = await start();
await callback(missingBinding, '', 'state');
const invalidCode = await start();
await callback(invalidCode, invalidCode.cookie, 'wechat');
await callback(invalidCode, invalidCode.cookie, 'state');
assert.equal((await api('auth/session')).status, 200);
assert.equal((await (await api('auth/session')).json()).user, null);
assert.equal((await api('reports')).status, 401);
assert.equal((await api('admin/records')).status, 401);
assert.equal((await fetch(new URL('admin.html', base))).status, 401);
assert.equal((await api('health')).status, 200);
assert.equal((await fetch(new URL('/MP_verify_kpx234XimQGtFPo6.txt', base))).status, 200);
console.log('PASS: public HTTPS WeChat config, consent/CSRF, correct authorization URL, Secure scoped cookies, missing binding, real invalid-code rejection, state replay rejection, anonymous report denial, admin protection and domain file; REAL client authorization remains untested');