Compare commits

...
47 Commits
Author SHA1 Message Date
xsl 0e34eeb1df fix: prevent duplicate titles on Taobao login page
Keep a single native title on the entry page even when the host ignores title clearing. Preserve centered headers elsewhere and normalize manifest line endings during shared-code checks.
2026-11-05 10:43:01 +08:00
xsl 873b45d3a5 fix: preserve legacy admin URLs across H5 channels
Keep local record and download paths compatible while retaining explicit external source selection. Record Taobao, H5, WeChat and rednote compatibility verification and baseline test failures.
2026-11-04 15:17:56 +08:00
xsl 774376fa16 feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
2026-11-04 15:07:51 +08:00
xsl 381da60ae4 chore: commit test-phase secrets so a new dev machine needs no manual copy
测试验证阶段,换开发机时不再需要手工拷贝两个机密文件。已与用户确认安全
要求不高,代价是密钥进入 git 历史,上线前必须清理。

入库(.gitignore 用 ! 例外放行):
- backend-java/src/main/resources/application-local.yml
  淘宝 AppKey/AppSecret + verify-sign + mode=forward + forward-base
- reference/facepp-skinanalyze/credentials.env
  旷视 FACEPP_API_KEY / FACEPP_API_SECRET / FACEPP_SKINANALYZE_URL
  (从服务器同名文件裁剪而来,只保留旷视相关行,去掉了其它项目条目)

其它密钥防护保持不变:apikey.*、.env、*.env 等仍被忽略。

上线前必做(已写入条待办第 6 项与 .gitignore 注释):
  1) 重置 AppSecret 与旷视 Key
  2) git filter-repo 从历史清除这两个文件
  3) 改回环境变量 / 保密字典注入

HANDOFF 1.5 节、待办第 6 项、交接检查清单已同步为「clone 即有」。
2026-11-01 23:53:11 +08:00
xsl 8bc6251899 docs(handoff): clarify where the two secret files come from
application-local.yml 在老开发机;credentials.env 只存在服务器
(老开发机上没有这个文件),需要 scp 取。补充了目标路径、验证命令
和「只做小程序端开发可以先不拷 credentials.env」的说明。
2026-11-01 23:51:04 +08:00
xsl 2eb0d8e807 docs(proposal): add Taobao product proposal decks and their generators
产品方案 PPT 是交付物,之前只存在于本机,换开发机后会丢,所以入库。

- 3 份 pptx:智能测肤方案(正版 1.3MB / 深色版 1.3MB)+ 购物小程序方案模版(0.1MB);
  模版是 build_taobao_proposal_from_template.py 的输入,必须一起入库
- 3 个生成脚本:把硬编码的 D:\juzi\... 改为相对仓库根(Path(__file__).parents[1]),
  渲染用的截图素材 temp/ppt_assets 属临时产物,加进 .gitignore
- build_taobao_proposal_pptx.py 是更早的一版(产物 AI智能测肤...pptx 当前不在仓库),
  保留用于追溯版式
- 新增 .gitignore:/sdk-java-35479221/(淘宝开放平台自动生成的 Java SDK,
  对应已废弃的 AppKey 35479221,当前架构走 HTTP 透传,不引 SDK)
2026-11-01 23:37:38 +08:00
xsl 43a5373233 docs: add development-machine handoff and server deploy configs
换开发机继续开发。新机器只需要拉仓库 + 自备工具链 + 单独拷贝两个机密文件,
服务器/聚石塔/SAE 全部沿用,不需要重建。

- docs/HANDOFF-2026-11-02.md:开发机交接(环境、仓库地图、三套测试、
  本地怎么跑、云端资源清单、上线方式、11 条关键结论、9 项待办、故障对照表)
- deploy/:服务器侧配置入库(原先只存在于机器上)
  - systemd/juzi-taobao.service(18081,独立数据目录与凭据文件)
  - nginx/tb-location.conf(/tb/ 反代 + 只放行聚石塔 NAT EIP)
  - add-tb-nginx.sh(幂等插入,先备份,nginx -t 通过才 reload)
  - README.md(重建与回滚步骤)
- tools/env-java.ps1:优先用仓库内便携工具链,缺失时回退 JAVA_HOME/PATH,
  换机器不用再下便携 JDK/Maven
- README.md:交接文档入口更新
2026-11-01 23:34:26 +08:00
xsl fed52930cc feat(miniapp): align UI with H5 and harden device debugging
五个页面按 h5/css 的 style.css → ui-20261009 → ui-20261012 覆盖顺序逐条重排,
换算规则固定为 calc(N * var(--u)) → 2N rpx,固定 px 的字号保持 px。

- app.acss:换回 H5 设计 token(方角小倒角按钮 10rpx、卡片 14rpx、.chev 折线箭头)
- splash/profile/history:底图与按钮文字图改用 H5 资源,隐私/政策弹窗按 H5 重排
- report:环形分用 conic-gradient、人脸裁切窗口叠加 face_maps、标记点百分比盒、
  侧脸皱纹 pending/unavailable 文案;用 Playwright 抓 H5 94 个选择器几何比对,
  整页高 2438.7 vs 2438.67
- camera:淘宝 <camera> 组件在真机整页崩溃(嵌了微信专有的 <cover-view>),
  改为默认系统相机 + `config.nativeCamera` 开关 + pages/camtest 冒烟页验证组件
- permissions:淘宝用带前缀的 scope.camera(裸名返回 5022 Scope:camera信息不存在),
  先试带前缀再退回裸名,拒绝后不再重复弹窗
- voice:非 HTTPS 资源导致的异常不再冒泡成整页崩溃;app.js 加开发版错误 toast
- 开发版入口:权限诊断(启动页/相机页)、相机组件测试
- 测试 20/20(新增权限与相机能力用例)
2026-11-01 23:30:38 +08:00
xsl f5c416dd9d feat(sae): add cloud-gateway adapters and forward mode (0.0.4)
SAE 上这一版只做云网关适配层:/api/taobao/* 字节级透传给 1me.xajuzi.com 上
现有的 H5 后端(旷视调用、评分、报告都留在那边),Java 不再重写测肤逻辑。

- TaobaoEnvelope:客户端要求的 data.ok + data.http_status 信封(HTTP 恒 200)
- TaobaoAuthController:challenge/session/me/logout(native 模式)
- TaobaoSessionService:一次性 challenge + 30 天 Bearer token,按 open_id 限流
  (网关后面所有请求同源 IP,按 IP 计数会变成全站共享 30 次/分钟)
- TaobaoForwardController:mode=forward 时透传,保留原始 query 与 body 字节
  (签名覆盖原始 body,重新序列化会让验签失败)
- TaobaoSignatureVerifier:缺省 sign_method 按 md5 校验
- 打包名固定为 taobao-mvp-backend-0.0.4-forward,SAE 不需要任何环境变量
- pom 版本 0.0.4;mvn test 11/11(新增 8 个用例)
- docs:更新 SAE 操作步骤,新增本次交接文档
2026-11-01 23:30:35 +08:00
xsl 65ba3cc7a5 fix(taobao): default cloud gateway signature to the md5 form
真实网关请求不带 sign_method(2026-11-01 用 nginx access.log 抓到的真实请求离线复算确认),
缺省签名形式是 MD5(secret + content + secret),也就是 sign_method=md5 的算法,
而不是 MD5(content + secret)。旧实现按「空方法」走了后者,必然 mismatch,
真机登录一直卡在 401「淘宝身份校验失败」。

- h5/lib/taobao-auth.js:缺省 method 归一为 md5
- h5/test/taobao-auth.js:把抓包参数固化成回归测试(含旧算法的反例与 verify 正反例)
2026-11-01 23:30:29 +08:00
xsl ac0b7ca42b feat(sae): add Java MVP backend and Taobao connectivity page
Spring Boot 3.5.6 / Java 17 JAR with cloud gateway envelope and ping/whoami/echo; Taobao gateway signature verification matching the Node implementation; /pages/mvp page, splash dev entry and local transport contract test; SAE + 聚石塔 runbook and portable JDK/Maven setup; Windows 11 wmic shim for Taobao DevTools.
2026-10-31 18:35:04 +08:00
xsl 459e21dad4 docs(rednote): record verified isolated production deployment 2026-10-19 23:04:28 +08:00
xsl 4af825a5e4 feat(rednote): add isolated persistent browser H5 entry 2026-10-19 22:59:10 +08:00
xsl 9eec4369da feat(taobao): add native mini program with shared backend
Preserve H5 flows while adding Taobao identity, native capture, reports and isolated local development. Fix canceled province selection and expired-session logout. Record passing local regressions, two pre-existing H5 assertion failures and pending platform validation.
2026-10-19 20:14:31 +08:00
xsl d07f8cc1bb save 2026-10-17 17:01:46 +08:00
xsl bb6da17f54 fix(recommendation): use displayed skin age across reports and admin 2026-10-16 19:03:25 +08:00
xsl 571d78a2ed ui(report): show wrinkle loading hints over side photos 2026-10-16 16:45:07 +08:00
xsl 9181069643 perf(report): load side wrinkle layers after report entry 2026-10-16 16:34:11 +08:00
xsl b19811f974 fix(camera): add 30-second scan waiting watchdog 2026-10-16 16:07:53 +08:00
xsl 5e07985b19 fix(camera): remove post-capture photo revalidation 2026-10-16 16:01:25 +08:00
xsl 6a628f837b fix(camera): make lighting checks advisory only 2026-10-16 15:51:07 +08:00
xsl 9ff7a582af feat(admin): track capture-to-report conversion funnel 2026-10-16 15:45:13 +08:00
xsl 03a8907b4e fix(camera): lower frontal and side confidence thresholds 2026-10-16 15:35:53 +08:00
xsl 30c90f8df5 fix(camera): relax initial frontal face recognition 2026-10-16 15:27:36 +08:00
xsl 8365ef459f fix(report): remove eye conditions from contour conclusions 2026-10-16 11:05:34 +08:00
xsl 98d6189f5c final save 2026-10-15 22:16:01 +08:00
xsl 49db1a6f5b fix(camera): relax pitch and roll acceptance limits 2026-10-15 21:16:46 +08:00
xsl c6d65493b6 fix(camera): set accepted face brightness to 110–220 2026-10-15 20:51:19 +08:00
xsl fbc556d1a6 fix(report): move side photo preview fill upward 2026-10-15 20:47:41 +08:00
xsl 8115db4ec2 fix(camera): constrain brightness and adjust side photo previews 2026-10-15 20:41:28 +08:00
xsl 480fed40cc fix(camera): raise minimum face brightness to 100 2026-10-15 20:23:45 +08:00
xsl 5632ac43d7 fix(report): cap dimension scores and match zoom crops to thumbnails 2026-10-15 20:17:07 +08:00
xsl 482fb4280d fix(h5): relax capture angles and annotate acne marks 2026-10-15 19:55:21 +08:00
xsl 757b96dfad feat(report): add annotated wrinkle views and restore capture countdown 2026-10-15 19:45:21 +08:00
xsl 2d80f6dfd0 feat(report): unify display grades and add contour side photos 2026-10-15 17:14:05 +08:00
xsl 60f0965c5a feat(scoring): use independent tone hydration minima and focus-first care 2026-10-15 14:40:59 +08:00
xsl 77493bdff5 feat(h5): align report care and single-product section with updated design 2026-10-15 01:30:45 +08:00
xsl 60ae6c97ef feat(h5): update skin scoring, age display and capture countdown 2026-10-15 00:20:48 +08:00
xsl d8c67762da feat(h5): calculate integer total from weighted dimensions across reports and admin 2026-10-14 14:25:17 +08:00
xsl 61a7f7c726 fix(h5): enlarge regular brand text for optical alignment 2026-10-13 22:40:04 +08:00
xsl fc711e3b1c fix(h5): add comma after brand name on splash and photo guide 2026-10-13 20:29:40 +08:00
xsl 6acc541cd8 fix(h5): unify regular slogan typography on splash and photo guide 2026-10-13 19:01:00 +08:00
xsl 84a92137aa fix(h5): use replacement flip icon on report scanning screen 2026-10-13 18:04:32 +08:00
xsl 7381306139 fix(h5): optimize camera flipping and angle haptics, refresh splash branding 2026-10-13 17:57:55 +08:00
xsl e71368db6d fix(h5): show raw skin age, support Harmony touch pickers and add WeChat logout 2026-10-13 16:12:54 +08:00
xsl 4e42e33994 fix(h5): rename guest entry button to 游客登陆 2026-10-13 15:28:07 +08:00
xsl 1fdbb575b0 feat(h5): allow guest access without login and retain history only for WeChat 2026-10-13 15:23:23 +08:00
344 changed files with 50242 additions and 4056 deletions
+30 -3
View File
@@ -1,19 +1,46 @@
# 密钥与敏感配置 —— 绝不入库
# 密钥与敏感配置
apikey.bash
apikey.*
aipkey.bash
.env
.env.*
*.env
credentials.env
# ⚠️ 2026-11-02 测试验证阶段的例外(刻意入库,方便切换开发机)
# reference/facepp-skinanalyze/credentials.env —— 旷视 Face++ 凭据
# backend-java/src/main/resources/application-local.yml —— 淘宝 AppKey/AppSecret
# 约束:
# 1) 仅限内网私有仓库 git.xiangsilian.com/xsl/juzi,不要 fork 到公开仓库;
# 2) 正式上线前必须重置密钥 → 从 git 历史清除 → 改回环境变量/保密字典注入;
# 3) 不要复制到聊天、文档、工单等任何外部渠道。
# 下面两条用 ! 覆盖上面的通配规则(必须放在被覆盖的规则之后)。
!reference/facepp-skinanalyze/credentials.env
!/backend-java/src/main/resources/application-local.yml
# 运行时产物
node_modules/
*.log
__pycache__/
aipkey.bash
h5/data/
temp/
# Java MVP 构建产物
/backend-java/target/
# 便携工具链(体积大,本机安装,不入库)
/tools/jdk17/
/tools/maven/
/tools/downloads/
# 大型设计源文件,不入库
new_feedback/第三轮反馈20261012/UI外发版10.09.psd
# 淘宝开放平台自动生成的 Java SDK(对应已废弃的 AppKey 35479221,当前架构不引 SDK)
/sdk-java-35479221/
# 提案 PPT 生成时用的截图素材(临时产物,可重新截取)
/temp/ppt_assets/
# graphify 本地缓存与日期快照(可重建,无需入库)
graphify-out/cache/
graphify-out/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/
+28
View File
@@ -0,0 +1,28 @@
# 巨子测肤 · H5 与淘宝小程序
本仓库包含两个入口,共用 Node 后端、Face++ 检测、评分与报告话术。
- `h5/`:现有浏览器/微信公众号 H5、管理后台,以及共享后端 `server.js`。
- `taobao/`:淘宝原生小程序。开发、联调、测试说明见 [taobao/README.md](taobao/README.md)。
- `backend-java/`:SAE + 淘宝云网关的 Java 最小连通性 MVP;操作步骤见 [docs/SAE-Java-MVP-操作步骤.md](docs/SAE-Java-MVP-操作步骤.md)。
- 当前交接(**换开发机先读这份**):[docs/HANDOFF-2026-11-02.md](docs/HANDOFF-2026-11-02.md)。
- 上一轮交接:[docs/HANDOFF-2026-11-01.md](docs/HANDOFF-2026-11-01.md)。
- 服务器侧配置(systemd / nginx `/tb/` 白名单 / 安装脚本):[deploy/README.md](deploy/README.md)。
- `rednote/`:复用 H5 的持久浏览器身份入口,独立运行和存储。说明见 [rednote/README.md](rednote/README.md)。
- `h5/js/skin-score.js`、`report-ui.js`、`report-copy.js`:业务规则的源文件。小程序内的副本由同步脚本生成并检查哈希。
- `h5/lib/taobao-*.js`:淘宝网关验签、短期上传/异步任务和独立 API 路由。
- `docs/`、`reference/`、`design/`、`feedback/`、`new_feedback/`:需求、接口资料、设计与历轮反馈;早期文档的规则可能已被后续实现替代。
- `backend/docs/`:后台需求资料;可运行的共享服务实际在 `h5/server.js`。
- `temp/`:本地测试数据、预览和截图,已忽略,不提交。
兼容边界:允许共享后端扩展,但保持 H5 的页面、微信公众号链接、微信登录、游客不留存和历史报告行为。淘宝使用独立账号来源,不把淘宝账号与微信账号自动合并。
本轮工作只在本地开发和样本验证,未部署或修改线上服务器。启动本地隔离环境:
```powershell
cd taobao
npm ci
npm run dev:backend
```
H5 本地入口为 http://127.0.0.1:18180;这个环境固定使用测试样本,不调用真实 Face++。生产 H5 的运行说明仍见 [h5/README.md](h5/README.md)。
+56
View File
@@ -0,0 +1,56 @@
# taobao-mvp-backend
淘宝小程序 + 淘宝云网关 + SAE 的最小 Java 后端验证工程。
## 快速启动
```powershell
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
cd D:\juzi\backend-java
mvn -DskipTests package
java -jar target\taobao-mvp-backend-0.0.2.jar
```
启动后:
- 健康检查:<http://127.0.0.1:8080/actuator/health>
- 云网关测试接口:`POST http://127.0.0.1:8080/api/taobao/ping`
## 接口
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | `/actuator/health` | SAE 健康检查 |
| GET/POST | `/api/taobao/ping` | 返回统一 envelope,验证连通性 |
| POST | `/api/taobao/whoami` | 返回网关注入的 query 参数(open_id/app_key/env/sign 等) |
| POST | `/api/taobao/echo` | 原样回显 JSON body,验证 POST/Content-Type |
统一响应:
```json
{"success":true,"data":{"ok":true}}
```
失败:
```json
{"success":false,"errorCode":"SIGN_INVALID","errorMessage":"..."}
```
## 环境变量(SAE)
| 变量 | 本地 | 云端 |
|---|---|---|
| `TAOBAO_APP_KEY` | 可空 | 淘宝开放平台 AppKey |
| `TAOBAO_APP_SECRET` | 可空 | AppSecret,只放服务端 |
| `TAOBAO_VERIFY_SIGN` | `false` | `true` |
云端必须用 `TAOBAO_VERIFY_SIGN=true`,否则任何知道地址的人都能直接调用接口。
## 说明
- 本工程只实现连通性 MVP:`ping/whoami/echo`。
- 真实的 `challenge/session/photo/skin-analyze/analysis/reports` 尚未迁移;现有 Node 实现见 `../h5/lib/taobao-routes.js`,可作为 Java 迁移的接口契约参考。
- SAE 部署时选 **JAR 包部署 + 标准 Java 应用运行环境 + Open JDK 17**,不要选 WAR/Tomcat/EDAS-Container。
- 端口 `8080`,健康检查 `/actuator/health`,至少 2 实例。
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.6</version>
<relativePath/>
</parent>
<groupId>com.juzi</groupId>
<artifactId>taobao-mvp-backend</artifactId>
<version>0.0.4</version>
<name>taobao-mvp-backend</name>
<description>Taobao mini program + SAE + cloud gateway minimal MVP backend</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<!-- 文件名带 forward 标识,避免和 0.0.2(native 模式)混淆 -->
<finalName>taobao-mvp-backend-0.0.4-forward</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package com.juzi.mvp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MvpApplication {
public static void main(String[] args) {
SpringApplication.run(MvpApplication.class, args);
}
}
@@ -0,0 +1,20 @@
package com.juzi.mvp.api;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* 淘宝云网关要求的统一响应结构。
* 成功:{"success":true,"data":{...}}
* 失败:{"success":false,"errorCode":"...","errorMessage":"..."}
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiResponse<T>(boolean success, T data, String errorCode, String errorMessage) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null, null);
}
public static <T> ApiResponse<T> error(String errorCode, String errorMessage) {
return new ApiResponse<>(false, null, errorCode, errorMessage);
}
}
@@ -0,0 +1,123 @@
package com.juzi.mvp.api;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.juzi.mvp.gateway.TaobaoSignatureVerifier;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
@RestController
@RequestMapping("/api/taobao")
public class MvpController {
private final TaobaoSignatureVerifier verifier;
private final ObjectMapper objectMapper;
public MvpController(TaobaoSignatureVerifier verifier, ObjectMapper objectMapper) {
this.verifier = verifier;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/ping", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> ping(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
if (!check.accepted()) {
return ApiResponse.error("SIGN_INVALID", check.message());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", true);
data.put("pong", true);
data.put("server", "java");
data.put("time", OffsetDateTime.now(ZoneId.of("Asia/Shanghai")).toString());
data.put("transport", isGateway(request) ? "gateway" : "direct");
data.put("appKey", param(request, "app_key"));
data.put("openId", param(request, "open_id"));
data.put("env", param(request, "env"));
data.put("signVerified", check.verified());
data.put("signMessage", check.message());
return ApiResponse.ok(data);
}
@GetMapping("/ping")
public ApiResponse<Map<String, Object>> pingGet(HttpServletRequest request) {
return ping(null, request);
}
@PostMapping(value = "/whoami", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> whoami(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
if (!check.accepted()) {
return ApiResponse.error("SIGN_INVALID", check.message());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", true);
data.put("transport", isGateway(request) ? "gateway" : "direct");
data.put("signVerified", check.verified());
data.put("signMessage", check.message());
data.put("params", sortedParams(request));
return ApiResponse.ok(data);
}
@PostMapping(value = "/echo", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> echo(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
if (!check.accepted()) {
return ApiResponse.error("SIGN_INVALID", check.message());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", true);
data.put("bytes", rawBody == null ? 0 : rawBody.getBytes(StandardCharsets.UTF_8).length);
data.put("body", parseBody(rawBody));
data.put("signVerified", check.verified());
return ApiResponse.ok(data);
}
private Object parseBody(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(raw, new TypeReference<Object>() {
});
} catch (Exception e) {
Map<String, Object> fallback = new LinkedHashMap<>();
fallback.put("_raw", raw);
fallback.put("_parseError", e.getMessage());
return fallback;
}
}
private boolean isGateway(HttpServletRequest request) {
return request.getParameter("app_key") != null;
}
private String param(HttpServletRequest request, String name) {
String value = request.getParameter(name);
return value == null ? "" : value;
}
private Map<String, String> sortedParams(HttpServletRequest request) {
Map<String, String> out = new TreeMap<>();
request.getParameterMap().forEach((key, values) -> {
if (values != null && values.length > 0 && !"sign".equals(key)) {
out.put(key, values[0]);
}
});
return out;
}
}
@@ -0,0 +1,123 @@
package com.juzi.mvp.api;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.juzi.mvp.gateway.TaobaoSignatureVerifier;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 淘宝小程序登录相关的四个接口:challenge / session / me / logout。
*
* <p>契约来自参考实现 h5/lib/taobao-routes.js 与客户端 taobao/utils/api.js:
* <ul>
* <li>HTTP 永远 200,逻辑状态放 data.http_status(见 {@link TaobaoEnvelope})。</li>
* <li>客户端不发身份,身份来自云网关注入的 query 参数(app_key / open_id / sign)。</li>
* <li>session 必须带 consent=true 和一次性的 challenge。</li>
* <li>登录后所有接口用 Authorization: Bearer &lt;token&gt;。</li>
* </ul>
*/
@RestController
@RequestMapping("/api/taobao")
@ConditionalOnProperty(name = "taobao.mode", havingValue = "native", matchIfMissing = true)
public class TaobaoAuthController {
private static final String OPEN_ID_PATTERN = "[A-Za-z0-9_-]{4,256}";
private static final String IDENTITY_FAILED = "淘宝身份校验失败,请重新授权登录";
private static final String SESSION_EXPIRED = "检测会话已失效,请重新登录";
private final TaobaoSignatureVerifier verifier;
private final TaobaoSessionService sessions;
private final ObjectMapper objectMapper;
public TaobaoAuthController(TaobaoSignatureVerifier verifier,
TaobaoSessionService sessions,
ObjectMapper objectMapper) {
this.verifier = verifier;
this.sessions = sessions;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/challenge", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> challenge(HttpServletRequest request) {
String id = sessions.challenge(sessions.rateKey(request));
if (id == null) {
return ApiResponse.ok(TaobaoEnvelope.fail(429, "操作频繁,请稍后重试"));
}
Map<String, Object> data = TaobaoEnvelope.ok(200);
data.put("challenge", id);
return ApiResponse.ok(data);
}
@PostMapping(value = "/session", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> session(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
Map<String, Object> body = parseBody(rawBody);
if (!Boolean.TRUE.equals(body.get("consent"))) {
return ApiResponse.ok(TaobaoEnvelope.fail(400, "请先阅读并同意隐私协议"));
}
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
if (!check.accepted()) {
return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED));
}
String openId = request.getParameter("open_id");
if (openId == null || !openId.matches(OPEN_ID_PATTERN)) {
// 只有经由云网关的请求才带 open_id;本地直连需要自己拼 ?open_id=...
return ApiResponse.ok(TaobaoEnvelope.fail(401, "未取得淘宝身份,请在手机淘宝内重新打开"));
}
Object challenge = body.get("challenge");
if (!(challenge instanceof String challengeId) || !sessions.consume(challengeId)) {
return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED));
}
TaobaoSessionService.Token token = sessions.issue(openId);
Map<String, Object> data = TaobaoEnvelope.ok(200);
data.put("token", token.value());
data.put("expires_in", token.expiresInSeconds());
data.put("user", user(token.userId()));
return ApiResponse.ok(data);
}
@PostMapping(value = "/me", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> me(HttpServletRequest request) {
TaobaoSessionService.Session session = sessions.find(TaobaoSessionService.bearerToken(request));
if (session == null) {
return ApiResponse.ok(TaobaoEnvelope.fail(401, SESSION_EXPIRED));
}
Map<String, Object> data = TaobaoEnvelope.ok(200);
data.put("user", user(session.userId()));
return ApiResponse.ok(data);
}
@PostMapping(value = "/logout", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> logout(HttpServletRequest request) {
sessions.revoke(TaobaoSessionService.bearerToken(request));
return ApiResponse.ok(TaobaoEnvelope.ok(200));
}
private Map<String, Object> user(String userId) {
Map<String, Object> user = new LinkedHashMap<>();
user.put("id", userId);
user.put("kind", "taobao");
return user;
}
private Map<String, Object> parseBody(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(raw, new TypeReference<Map<String, Object>>() {
});
} catch (Exception e) {
return Map.of();
}
}
}
@@ -0,0 +1,33 @@
package com.juzi.mvp.api;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 淘宝小程序客户端读取的 data 结构约定。
*
* <p>客户端(taobao/utils/api.js:13-16)只认 data.ok 和 data.http_status:
* HTTP 状态码永远是 200,逻辑状态(202/400/401/404/409/413/429)放在 data.http_status 里。
* 失败时 message 就是 toast 文案,status 401 还会触发客户端自动登出。
* 这与参考实现 h5/lib/taobao-routes.js:8 保持一致。
*/
public final class TaobaoEnvelope {
private TaobaoEnvelope() {
}
public static Map<String, Object> ok(int httpStatus) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", true);
data.put("http_status", httpStatus);
return data;
}
public static Map<String, Object> fail(int httpStatus, String message) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", false);
data.put("http_status", httpStatus);
data.put("message", message);
return data;
}
}
@@ -0,0 +1,117 @@
package com.juzi.mvp.api;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/**
* taobao.mode=forward 时启用:把 /api/taobao/* 业务接口字节级转发给外层 H5 后端(Node)。
*
* <p>为什么必须字节级透传:对端用「原始 query string + 原始 body」验淘宝签名
* (h5/lib/taobao-auth.js:16-29)。Java 一旦重新序列化 JSON 或重排 query,
* 签名字节就变了,session 会直接 401。所以这里不读 body 语义、不动 JSON。
*
* <p>/ping、/whoami、/echo 仍然由 {@link MvpController} 自己处理(更具体的映射优先)。
*/
@RestController
@ConditionalOnProperty(name = "taobao.mode", havingValue = "forward")
public class TaobaoForwardController {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
/** 云网关单请求 10 秒上限,留出余量。 */
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(9);
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
private final String forwardBase;
public TaobaoForwardController(@Value("${taobao.forward-base}") String forwardBase) {
this.forwardBase = forwardBase.endsWith("/")
? forwardBase.substring(0, forwardBase.length() - 1)
: forwardBase;
}
@RequestMapping("/api/taobao/{*path}")
public ResponseEntity<byte[]> forward(HttpServletRequest request) {
byte[] body = readBody(request);
String target = forwardBase + request.getRequestURI()
+ (request.getQueryString() == null ? "" : "?" + request.getQueryString());
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target))
.timeout(REQUEST_TIMEOUT)
.method(request.getMethod(), body.length == 0
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(body));
copyHeader(request, builder, "Content-Type");
copyHeader(request, builder, "Authorization");
HttpResponse<byte[]> response;
try {
response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
} catch (IOException e) {
return envelope(502, "测肤服务连接失败");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return envelope(502, "测肤服务被中断");
}
// 对端正常返回时已经是客户端要的信封(h5/lib/taobao-routes.js:8),原样回传。
if (looksLikeEnvelope(response.body())) {
return ResponseEntity.status(response.statusCode())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(response.body());
}
// 对端没部署 / 返回 404 页面 / 网关错误:包成统一信封,避免小程序 JSON.parse 失败。
return envelope(502, "测肤服务暂不可用(上游 " + response.statusCode() + ")");
}
private void copyHeader(HttpServletRequest request, HttpRequest.Builder builder, String name) {
String value = request.getHeader(name);
if (value != null && !value.isBlank()) {
builder.header(name, value);
}
}
private static byte[] readBody(HttpServletRequest request) {
try {
return request.getInputStream().readAllBytes();
} catch (IOException e) {
return new byte[0];
}
}
private static boolean looksLikeEnvelope(byte[] body) {
if (body == null || body.length == 0) {
return false;
}
String text = new String(body, StandardCharsets.UTF_8).stripLeading();
return text.startsWith("{") && text.contains("\"success\"");
}
private static ResponseEntity<byte[]> envelope(int status, String message) {
String json = "{\"success\":true,\"data\":{\"ok\":false,\"http_status\":" + status
+ ",\"message\":\"" + message + "\"}}";
return ResponseEntity.status(200)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(json.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,154 @@
package com.juzi.mvp.api;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 淘宝小程序会话:一次性的 challenge + 30 天有效的 Bearer token。
*
* <p>参考实现 h5/lib/taobao-session.js 的取值:challenge 60 秒、单次消费、
* 每来源 30 次/分钟;token 32 字节随机十六进制、有效期 30 天。
*
* <p>注意:全部状态都在进程内存里,SAE 只能单副本运行;多副本/发布不丢会话
* 需要换 Redis 或 RDS。这一点和现有 Node 实现一致,不是新增的退化。
*/
@Component
public class TaobaoSessionService {
private static final long CHALLENGE_TTL_SECONDS = 60;
private static final long TOKEN_TTL_SECONDS = 30L * 24 * 60 * 60;
private static final int MAX_OUTSTANDING_CHALLENGES = 1000;
private static final int RATE_LIMIT_PER_MINUTE = 30;
private final SecureRandom random = new SecureRandom();
private final Map<String, Instant> challenges = new ConcurrentHashMap<>();
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
private final Map<String, Rate> rates = new ConcurrentHashMap<>();
@Value("${taobao.app-key:}")
private String appKey;
private record Rate(Instant window, int count) {
}
public record Session(String userId, String openId, Instant expires) {
}
public record Token(String value, long expiresInSeconds, String userId) {
}
/**
* 限流计数用的来源标识。
*
* <p>云网关后面所有请求的来源 IP 都是同一个代理 IP,按 IP 计数会变成全站共享
* 30 次/分钟,所以在有 open_id 时优先按用户计数。
*/
public String rateKey(HttpServletRequest request) {
String openId = request.getParameter("open_id");
return openId != null && !openId.isBlank() ? "open:" + openId : "ip:" + request.getRemoteAddr();
}
/**
* 发放一次性 challenge;超出频率或存量上限时返回 null(调用方回 429)。
*/
public String challenge(String rateKey) {
sweep();
Instant now = Instant.now();
Rate rate = rates.compute(rateKey, (key, current) ->
current == null || !current.window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now)
? new Rate(now, 1)
: new Rate(current.window(), current.count() + 1));
if (rate.count() > RATE_LIMIT_PER_MINUTE || challenges.size() >= MAX_OUTSTANDING_CHALLENGES) {
return null;
}
String id = randomHex(24);
challenges.put(id, now.plusSeconds(CHALLENGE_TTL_SECONDS));
return id;
}
/**
* 消费 challenge;remove 返回非空即视为抢到,天然只成功一次。
*/
public boolean consume(String challenge) {
if (challenge == null || challenge.isBlank()) {
return false;
}
Instant expires = challenges.remove(challenge);
return expires != null && expires.isAfter(Instant.now());
}
public Token issue(String openId) {
String userId = userId(openId);
String value = randomHex(32);
sessions.put(value, new Session(userId, openId, Instant.now().plusSeconds(TOKEN_TTL_SECONDS)));
return new Token(value, TOKEN_TTL_SECONDS, userId);
}
public Session find(String token) {
if (token == null || token.isBlank()) {
return null;
}
Session session = sessions.get(token);
if (session == null) {
return null;
}
if (!session.expires().isAfter(Instant.now())) {
sessions.remove(token);
return null;
}
return session;
}
public void revoke(String token) {
if (token != null) {
sessions.remove(token);
}
}
/**
* 稳定的用户 id:sha256(app_key + ':' + open_id),与 Node 端 provider_key 口径一致。
*/
public String userId(String openId) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
String seed = (appKey == null ? "" : appKey) + ":" + openId;
return HexFormat.of().formatHex(digest.digest(seed.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
/** 从 Authorization: Bearer xxx 里取 token。 */
public static String bearerToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header == null) {
return null;
}
String prefix = "Bearer ";
return header.regionMatches(true, 0, prefix, 0, prefix.length())
? header.substring(prefix.length()).trim()
: null;
}
private void sweep() {
Instant now = Instant.now();
challenges.entrySet().removeIf(entry -> !entry.getValue().isAfter(now));
sessions.entrySet().removeIf(entry -> !entry.getValue().expires().isAfter(now));
rates.entrySet().removeIf(entry -> !entry.getValue().window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now));
}
private String randomHex(int bytes) {
byte[] buffer = new byte[bytes];
random.nextBytes(buffer);
return HexFormat.of().formatHex(buffer);
}
}
@@ -0,0 +1,21 @@
package com.juzi.mvp.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 仅用于本地浏览器/curl 调试。小程序原生请求不受 CORS 限制。
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("*")
.maxAge(3600);
}
}
@@ -0,0 +1,126 @@
package com.juzi.mvp.gateway;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* 淘宝云网关签名校验的最小实现。
*
* 规则(与 h5/lib/taobao-auth.js 一致):
* 1. 取全部 query 参数,去掉 sign 和空值,按 key 升序,拼成 key1value1key2value2...
* 2. 末尾追加原始请求体(未经重新序列化的 JSON 字符串)
* 3. sign_method=md5 时:MD5(secret + content + secret)
* sign_method 为空时:MD5(content + secret)
* hmac/hmac_md5:HMAC-MD5(secret, content)
* hmac_sha256:HMAC-SHA256(secret, content)
* 4. 结果转大写十六进制,与 sign 比较
*
* 本地直连没有 app_key/sign,默认 verify-sign=false 直接放行;
* 云端部署时设置 TAOBAO_VERIFY_SIGN=true 和 TAOBAO_APP_KEY/TAOBAO_APP_SECRET。
*/
@Component
public class TaobaoSignatureVerifier {
@Value("${taobao.app-key:}")
private String appKey;
@Value("${taobao.app-secret:}")
private String appSecret;
@Value("${taobao.verify-sign:false}")
private boolean verifySign;
public record Result(boolean accepted, boolean verified, String message) {
}
public Result verify(HttpServletRequest request, String rawBody) {
if (!verifySign) {
return new Result(true, false, "verify-sign=false, skipped (local/direct mode)");
}
if (appKey == null || appKey.isBlank() || appSecret == null || appSecret.isBlank()) {
return new Result(false, false, "TAOBAO_APP_KEY / TAOBAO_APP_SECRET is not configured");
}
Map<String, String> params = new TreeMap<>();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
String[] values = entry.getValue();
if (values == null || values.length == 0) {
continue;
}
if (values.length > 1) {
return new Result(false, false, "duplicate parameter: " + entry.getKey());
}
params.put(entry.getKey(), values[0]);
}
if (!appKey.equals(params.get("app_key"))) {
return new Result(false, false, "app_key mismatch");
}
String openId = params.getOrDefault("open_id", "");
if (!openId.matches("[A-Za-z0-9_-]{4,256}")) {
return new Result(false, false, "open_id missing or invalid");
}
String sign = params.getOrDefault("sign", "");
if (!sign.matches("[A-Fa-f0-9]{32,64}")) {
return new Result(false, false, "sign missing or invalid");
}
try {
String expected = sign(params, rawBody == null ? "" : rawBody, appSecret);
if (expected == null || expected.length() != sign.length()) {
return new Result(false, false, "unsupported sign_method");
}
boolean equal = MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
sign.toUpperCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8));
return equal ? new Result(true, true, "ok") : new Result(false, false, "signature mismatch");
} catch (Exception e) {
return new Result(false, false, "signature error: " + e.getMessage());
}
}
public static String sign(Map<String, String> params, String body, String secret) throws Exception {
// 真实网关请求不带 sign_method,缺省等价于 md5:
// MD5(secret + content + secret)。2026-11-01 用抓包参数离线复算确认。
String rawMethod = params.getOrDefault("sign_method", "");
String method = rawMethod.isEmpty() ? "md5" : rawMethod;
if (!Set.of("md5", "hmac", "hmac_md5", "hmac_sha256").contains(method)) {
return null;
}
StringBuilder content = new StringBuilder();
params.entrySet().stream()
.filter(e -> !"sign".equals(e.getKey()))
.filter(e -> e.getValue() != null && !e.getValue().isEmpty())
.sorted(Map.Entry.comparingByKey())
.forEach(e -> content.append(e.getKey()).append(e.getValue()));
content.append(body == null ? "" : body);
if ("hmac".equals(method) || "hmac_md5".equals(method) || "hmac_sha256".equals(method)) {
String algorithm = "hmac_sha256".equals(method) ? "HmacSHA256" : "HmacMD5";
Mac mac = Mac.getInstance(algorithm);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), algorithm));
return hex(mac.doFinal(content.toString().getBytes(StandardCharsets.UTF_8)));
}
String plain = secret + content + secret;
return hex(MessageDigest.getInstance("MD5").digest(plain.getBytes(StandardCharsets.UTF_8)));
}
private static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02X", b & 0xFF));
}
return sb.toString();
}
}
@@ -0,0 +1,16 @@
# Temporary validation only. This file is gitignored and must not be committed.
# It is baked into the JAR so SAE does not need TAOBAO_* environment variables.
# For the first end-to-end validation, signature verification is disabled because
# the current AppSecret is still suspect. Enable it only after resetting the
# AppSecret for AppKey 35486228 and updating the value below.
taobao:
app-key: "35486228"
app-secret: "b8bd05ebce2f8a34d28026b443ba3ed6"
verify-sign: false
# 淘宝小程序的真实测肤仍在 1me.xajuzi.com 那套 Node 服务上(旷视调用、评分、报告都在那边)。
# SAE 这边只做云网关适配层:把 /api/taobao/* 字节级透传过去。
# forward-base 对应 nginx 里的 location /tb/(只允许聚石塔 NAT 出口 IP 访问)。
mode: forward
forward-base: "https://1me.xajuzi.com/tb"
@@ -0,0 +1,35 @@
server:
port: 8080
spring:
application:
name: taobao-mvp-backend
profiles:
active: local
jackson:
time-zone: Asia/Shanghai
default-property-inclusion: non_null
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always
taobao:
# forward: /api/taobao/* 业务接口字节级透传给外层 H5 后端(Node),Java 只做网关适配层
# native: Java 自己实现(challenge/session/me/logout);测肤接口尚未实现
mode: ${TAOBAO_MODE:native}
forward-base: ${TAOBAO_FORWARD_BASE:https://1me.xajuzi.com/state}
# 云端部署时通过 SAE 环境变量注入;本地留空即可。
app-key: ${TAOBAO_APP_KEY:}
app-secret: ${TAOBAO_APP_SECRET:}
# 本地直连没有网关签名,保持 false;云端改成 true。
verify-sign: ${TAOBAO_VERIFY_SIGN:false}
logging:
level:
com.juzi.mvp: INFO
@@ -0,0 +1,62 @@
package com.juzi.mvp;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MvpApplicationTests {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
@Test
@SuppressWarnings("unchecked")
void pingReturnsCloudGatewayEnvelope() {
ResponseEntity<Map> response = rest.postForEntity(url("/api/taobao/ping"), Map.of(), Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("success", true);
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(data).containsEntry("server", "java").containsEntry("pong", true);
}
@Test
@SuppressWarnings("unchecked")
void echoReturnsRequestBody() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<Map> response = rest.postForEntity(
url("/api/taobao/echo"),
new HttpEntity<>("{\"hello\":\"world\"}", headers),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
Map<String, Object> body = (Map<String, Object>) data.get("body");
assertThat(body).containsEntry("hello", "world");
}
@Test
void actuatorHealthIsUp() {
ResponseEntity<String> response = rest.getForEntity(url("/actuator/health"), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("\"status\":\"UP\"");
}
}
@@ -0,0 +1,139 @@
package com.juzi.mvp;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 登录链路(challenge → session → me → logout)的端到端契约测试。
*
* <p>本地/测试环境 verify-sign=false,所以用 ?open_id= 模拟云网关注入的身份;
* 打开 verify-sign 后,同样的请求需要带正确的 sign。
*/
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
// 部署用的 application-local.yml 里写的是 mode: forward(生产走透传)。
// 这个用例测的是 Java 自己实现登录的那条路,所以显式钉住 native。
properties = "taobao.mode=native")
class TaobaoAuthFlowTests {
private static final String GATEWAY_QUERY =
"?app_key=35486228&open_id=AAG8-test-open-id&sign_method=hmac_sha256";
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
private HttpEntity<String> json(String body) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return new HttpEntity<>(body, headers);
}
private HttpEntity<String> json(String body, String token) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (token != null) {
headers.set("Authorization", "Bearer " + token);
}
return new HttpEntity<>(body, headers);
}
@SuppressWarnings("unchecked")
private Map<String, Object> data(ResponseEntity<Map> response) {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("success", true);
return (Map<String, Object>) response.getBody().get("data");
}
@SuppressWarnings("unchecked")
private String login() {
Map<String, Object> challenge = data(rest.postForEntity(
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
assertThat(challenge).containsEntry("ok", true).containsEntry("http_status", 200);
String challengeId = (String) challenge.get("challenge");
assertThat(challengeId).hasSize(48);
Map<String, Object> session = data(rest.postForEntity(
url("/api/taobao/session" + GATEWAY_QUERY),
json("{\"consent\":true,\"challenge\":\"" + challengeId + "\"}"),
Map.class));
assertThat(session).containsEntry("ok", true).containsEntry("http_status", 200);
assertThat((String) session.get("token")).hasSize(64);
assertThat(((Number) session.get("expires_in")).longValue()).isEqualTo(2592000L);
Map<String, Object> user = (Map<String, Object>) session.get("user");
assertThat(user).containsEntry("kind", "taobao");
assertThat((String) user.get("id")).hasSize(64);
return (String) session.get("token");
}
@Test
@SuppressWarnings("unchecked")
void challengeSessionMeLogoutRoundTrip() {
String token = login();
Map<String, Object> me = data(rest.postForEntity(
url("/api/taobao/me"), json("{}", token), Map.class));
assertThat(me).containsEntry("ok", true);
assertThat(((Map<String, Object>) me.get("user"))).containsEntry("kind", "taobao");
assertThat(data(rest.postForEntity(
url("/api/taobao/logout"), json("{}", token), Map.class))).containsEntry("ok", true);
Map<String, Object> afterLogout = data(rest.postForEntity(
url("/api/taobao/me"), json("{}", token), Map.class));
assertThat(afterLogout).containsEntry("ok", false).containsEntry("http_status", 401);
}
@Test
void sessionRejectsMissingConsent() {
Map<String, Object> challenge = data(rest.postForEntity(
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
Map<String, Object> session = data(rest.postForEntity(
url("/api/taobao/session" + GATEWAY_QUERY),
json("{\"consent\":false,\"challenge\":\"" + challenge.get("challenge") + "\"}"),
Map.class));
assertThat(session).containsEntry("ok", false).containsEntry("http_status", 400);
assertThat(session.get("message")).isEqualTo("请先阅读并同意隐私协议");
}
@Test
void challengeCannotBeReused() {
Map<String, Object> challenge = data(rest.postForEntity(
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
String body = "{\"consent\":true,\"challenge\":\"" + challenge.get("challenge") + "\"}";
assertThat(data(rest.postForEntity(url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class)))
.containsEntry("ok", true);
Map<String, Object> replay = data(rest.postForEntity(
url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class));
assertThat(replay).containsEntry("ok", false).containsEntry("http_status", 401);
}
@Test
void meWithoutTokenIsUnauthorized() {
Map<String, Object> me = data(rest.postForEntity(
url("/api/taobao/me"), json("{}"), Map.class));
assertThat(me).containsEntry("ok", false).containsEntry("http_status", 401);
assertThat(me.get("message")).isEqualTo("检测会话已失效,请重新登录");
}
}
@@ -0,0 +1,123 @@
package com.juzi.mvp;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* taobao.mode=forward 时,Java 必须把请求「字节级」转发给外层 H5 后端:
* 原始 query string、原始 body、Authorization 都要原样过去,回来的信封也要原样返回。
*
* <p>用一个本地 stub 冒充 Node 后端来验证这件事。
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class TaobaoForwardTests {
private static final HttpServer STUB;
private static volatile String lastQuery;
private static volatile String lastBody;
private static volatile String lastAuthorization;
private static volatile String lastPath;
static {
try {
STUB = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
STUB.createContext("/api/taobao/", exchange -> {
lastPath = exchange.getRequestURI().getPath();
lastQuery = exchange.getRequestURI().getRawQuery();
lastAuthorization = exchange.getRequestHeaders().getFirst("Authorization");
try (InputStream in = exchange.getRequestBody()) {
lastBody = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
byte[] payload = ("{\"success\":true,\"data\":{\"ok\":true,\"http_status\":200,"
+ "\"echo_path\":\"" + lastPath + "\","
+ "\"echo_query\":\"" + lastQuery + "\","
+ "\"echo_body\":" + (lastBody.isEmpty() ? "\"\"" : lastBody) + "}}")
.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json;charset=utf-8");
exchange.sendResponseHeaders(200, payload.length);
exchange.getResponseBody().write(payload);
exchange.close();
});
STUB.start();
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("taobao.mode", () -> "forward");
registry.add("taobao.forward-base", () -> "http://127.0.0.1:" + STUB.getAddress().getPort());
}
@AfterAll
static void stopStub() {
STUB.stop(0);
}
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
@Test
@SuppressWarnings("unchecked")
void forwardsBodyQueryAndAuthorizationVerbatim() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer abc123");
// 故意带空格和不常见字段顺序:任何重新序列化都会改变这里的字节
String body = "{\"consent\":true, \"challenge\":\"x-1\", \"note\":\"带空格\"}";
ResponseEntity<Map> response = rest.postForEntity(
url("/api/taobao/session?app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC"),
new HttpEntity<>(body, headers),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("success", true);
assertThat(lastPath).isEqualTo("/api/taobao/session");
assertThat(lastQuery).isEqualTo("app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC");
assertThat(lastBody).isEqualTo(body);
assertThat(lastAuthorization).isEqualTo("Bearer abc123");
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(data).containsEntry("ok", true).containsEntry("echo_path", "/api/taobao/session");
}
@Test
void localDiagnosticsEndpointsStayLocal() {
ResponseEntity<Map> ping = rest.postForEntity(url("/api/taobao/ping"), Map.of(), Map.class);
assertThat(ping.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) ping.getBody().get("data");
assertThat(data).containsEntry("server", "java");
assertThat(lastPath).isNotEqualTo("/api/taobao/ping");
}
}
@@ -0,0 +1,40 @@
package com.juzi.mvp.gateway;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.TreeMap;
import static org.assertj.core.api.Assertions.assertThat;
class TaobaoSignatureVerifierTest {
@Test
void md5SignatureMatchesExistingNodeImplementation() throws Exception {
Map<String, String> params = new TreeMap<>();
params.put("app_key", "app");
params.put("open_id", "open-account");
params.put("sign_method", "md5");
params.put("env", "online");
String body = "{\"consent\":true,\"challenge\":\"nonce\"}";
// 由 taobao/test/runtime.test.cjs 的同一组参数计算得出。
assertThat(TaobaoSignatureVerifier.sign(params, body, "secret"))
.isEqualTo("E673310E672C9EB7E918857C6C64D247");
}
@Test
void missingSignMethodUsesTheMd5Form() throws Exception {
// 真实网关请求不带 sign_method(2026-11-01 抓包确认),缺省等价于 md5(secret+content+secret)。
Map<String, String> params = new TreeMap<>();
params.put("app_key", "app");
params.put("open_id", "open-account");
params.put("env", "online");
String body = "{\"consent\":true,\"challenge\":\"nonce\"}";
assertThat(TaobaoSignatureVerifier.sign(params, body, "secret"))
.isEqualTo("DD2B95886A5E8F61D6111A5659E4FBD5");
}
}
@@ -0,0 +1 @@
https://1me.xajuzi.com/media/videos/261026-shuangcang5-mobile-v2.html
@@ -0,0 +1 @@
https://1me.xajuzi.com/media/videos/261026-shuangcang5-46ae3089.html
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
实际淘宝 IDE 模拟器运行截图(非真机截图)。login-centered.png 从完整 IDE 窗口截图中裁取模拟器屏幕,未修改页面内容。
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

@@ -0,0 +1,11 @@
# 报告侧脸大图对照
- mini-contour.png:当前小程序轮廓大图的浏览器投影。
- mini-wrinkles.png:当前小程序皱纹大图的浏览器投影。
- h5-contour.png:同一项目示例照片经 H5 reportZoomSource 实际函数生成的大图。
这些是使用项目示例照片及实际页面代码生成的浏览器预览,并非淘宝真机截图,也不能作为提审运行截图。原生图片组件最终显示效果仍需手机验证。
当前实现将 H5 裁切规则通过报告专用 canvas 生成独立位图,放大层只对位图 aspectFit,不再使用原生图片组件模拟裁切。预览脚本执行实际 report-side-zoom 模块的绘制命令。导出失败时等比显示完整原照片。
最新调整:按用户要求,mini-contour.png 和 mini-wrinkles.png 图片填充宽高均缩小至前一版的 80%,居中后向下移动缩放后图片高度的 5%;h5-contour.png 保留 H5 原规则作为历史对照,因此不再代表两者应当完全相同。
@@ -0,0 +1,5 @@
同一张项目示例照片的浏览器对照预览,不是淘宝真机运行截图,不用于冒充提审截图。
mini-contour.png:真实小程序报告放大图 AXML/ACSS 与控制器数据的浏览器投影。
mini-wrinkles.png:皱纹视图同一放大规则的浏览器投影(示例数据)。
h5-contour.png:使用线上 H5 reportZoomSource 原始函数及本地对应 CSS 生成的对照。
修复:不再在尺寸获取失败时按 3:4 拉伸;复用已读取的自然尺寸,未知尺寸使用 aspectFit,图片 onLoad 后补齐真实几何。
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,4 @@
2 张淘宝 IDE 模拟器实际运行截图,未使用 H5 投影、Mock 报告或重绘界面。仅裁去 IDE 外框,未修改界面内容。
01:登录页。02:未勾选协议点击游客体验后,真实显示“请先阅读并同意用户协议和隐私政策”。
格式 PNG,单张 239×423,均小于 4MB,满足用户提供的最大尺寸 2160×3840。
后续主页与档案运行截图需要先手动完成模拟器中的淘宝登录验证。
Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+148
View File
@@ -0,0 +1,148 @@
# deploy —— 1me.xajuzi.com 上的「淘宝专用」Node 实例
这份目录存放**不在 git 里自动生效**的服务器侧配置:systemd unit、nginx 片段、安装脚本。
换机器/换人接手时,按本文重建即可。
## 淘宝拍照语音静态资源
`taobao-voice-media.py` 用于修复 `/state/assets/voice/` 的媒体响应。当前已部署:清单内 14 个公开 MP3 和 manifest.json 从 `/home/ubuntu/juzi/h5/assets/voice/` 复制到 `/var/www/juzi-voice-20261102/`,Nginx 在原 HTTPS server 增加该路径的 alias,返回 `audio/mpeg` 并支持 Range/206。此步骤只 reload Nginx,不重启三个业务应用;原 `/state/` 页面、后台认证、数据目录禁用及 `/tb/` 白名单保留。
在服务器使用 `sudo python3 /tmp/juzi-voice-media-20261102.py` 执行上传后的脚本;脚本验证清单和路径、拒绝源/目标符号链接、备份配置并执行 `nginx -t`,验证或 reload 失败会回滚。原站点配置备份为 `/etc/nginx/sites-available/juzi.voice-20261102.bak`。若日后更新语音文件,重新执行脚本刷新公开副本。14 条线上音频完整内容已与本地文件逐一比对一致。
客户端声音状态见 `[voice] VOICE-20261102-02` 真机 Console 日志;事件包括 loading/canplay/play_requested/playing/error。日志不包含原生错误对象、账号、照片或完整 URL。
## 拓扑
### 公开视频(2026-11-02)
用户指定的 `261026改双仓5.mp4` 已上传,源文件 SHA256 为 `46ae3089e4da884f03313b71809814813b88f1abc92d374dc8210f01db10b026`。视频为 2160×3840 H.264/AAC,约 32 秒;仅用 FFmpeg `-c copy -movflags +faststart` 调整封装,不重编码。
- 服务器文件:`/var/www/juzi-media/videos/261026-shuangcang5-46ae3089.mp4`,SHA256 `64790107cce2f78e68668474fb06d2e11acf3d76d9e05b7ccec4f588e89eb02e`。
- 播放页:`https://1me.xajuzi.com/media/videos/261026-shuangcang5-46ae3089.html`。
- 二维码:项目内 `deliverables/261026-shuangcang5/video-qr.png`,已解码确认指向上述播放页。
- 发布脚本:`deploy/publish-video.py`;Nginx 为 `/media/videos/` 单独 alias,禁止目录索引及写入方法,支持 MP4 MIME、Range/206。
- 本次配置备份:`/etc/nginx/sites-available/juzi.public-video-20261102T113656914226Z.bak`。仅 reload Nginx,三个业务服务未重启。
- 验证:上传文件哈希一致、公网 HTML 200、MP4 206/32 字节范围请求正常;移动视口 Chromium 已实际开始播放,视频无解码错误。手机淘宝/微信内的具体播放体验仍以扫码为准。
微信反馈(能开页、播放卡住)后,另发布 `261026-shuangcang5-mobile-v2.mp4/.html`:720×1280、H.264 Main Level 3.1、AAC、faststart,5,329,575 字节,SHA256 `9f34e5a568230e87fb206f101467db9eb22d31c5951cedb26d02eacad709e33c`。保留原 4K 文件;旧 HTML 也改为播放兼容版,备份在 `/tmp/juzi-public-video-20261102-46ae3089/legacy-player-before-v2.html`。新二维码 `deliverables/261026-shuangcang5/video-qr-mobile-v2.png` 指向新的 HTML 路径以避开旧页面缓存。新增用户点击播放按钮及内联属性、等待/失败提示和直接视频链接;移动视口浏览器通过真实按钮点击已开始播放 720×1280 视频,微信真机仍待用户扫码验证。
```
淘宝小程序(taobao/,AppKey 35486228)
→ 淘宝云网关(注入 app_key / open_id / sign,单请求 10s / 128KB 上限)
→ 聚石塔云应用 1me-net(62829) → 私网 CLB 192.168.65.185:80
→ SAE 应用 nnn(taobao-mvp-backend-0.0.4-forward.jar,mode=forward,纯透传)
→ 聚石塔 NAT 网关出网(EIP 47.92.113.176)
→ https://1me.xajuzi.com/tb/ (nginx 白名单只放行上面这个 EIP)
→ juzi-taobao.service(Node,127.0.0.1:18081,本文档这套配置)
→ 旷视 Face++ skinanalyze_pro
```
生产 H5(`/state/`)和红书渠道(`/rednote/`)跑在同机另外两个 systemd 服务上,**8080/8081 端口,和本实例完全隔离**。
## 三个服务的分工
| 服务 | 端口 | 代码目录 | 用途 |
|---|---|---|---|
| `juzi-h5.service` | 8080 | `/home/ubuntu/juzi/h5` | 生产 H5(微信登录、报告、后台) |
| `juzi-rednote.service` | 8081 | `/home/ubuntu/juzi-rednote/current/h5` | 小红书渠道隔离实例 |
| `juzi-taobao.service` | 18081 | `/home/ubuntu/juzi-tb/h5` | **本目录新增**:淘宝小程序专用 |
## 重建步骤
### 1. 代码
`/home/ubuntu/juzi-tb` 是**独立的一份 h5 代码**(不是生产目录的软链),来自本仓库的 `h5/`。
部署方式(本地执行,把仓库里的 h5 打包传上去):
```powershell
# 本地仓库根目录
tar -czf temp/juzi-h5.tgz --exclude=h5/data --exclude=h5/test --exclude=h5/scripts -C . h5
scp temp/juzi-h5.tgz ubuntu@1me.xajuzi.com:/tmp/
```
```bash
# 服务器上
sudo mkdir -p /home/ubuntu/juzi-tb /home/ubuntu/juzi-tb-data
sudo chown -R ubuntu:ubuntu /home/ubuntu/juzi-tb /home/ubuntu/juzi-tb-data
chmod 700 /home/ubuntu/juzi-tb-data
tar -xzf /tmp/juzi-h5.tgz -C /home/ubuntu/juzi-tb
# 旷视凭据复用生产那份(gitignored,不随仓库走)
mkdir -p /home/ubuntu/juzi-tb/reference/facepp-skinanalyze
cp /home/ubuntu/juzi/reference/facepp-skinanalyze/credentials.env \
/home/ubuntu/juzi-tb/reference/facepp-skinanalyze/credentials.env
chmod 600 /home/ubuntu/juzi-tb/reference/facepp-skinanalyze/credentials.env
```
> `h5/server.js` 启动时如果找不到旷视 key 会直接 `exit(1)`,所以这一步不能漏。
### 2. 淘宝凭据
```bash
sudo mkdir -p /etc/juzi
sudo tee /etc/juzi/taobao.env >/dev/null <<'EOF'
TAOBAO_APP_KEY=35486228
TAOBAO_APP_SECRET=<35486228 的 AppSecret,向项目负责人索取>
EOF
sudo chmod 600 /etc/juzi/taobao.env && sudo chown root:root /etc/juzi/taobao.env
```
这个文件被 `h5/lib/taobao-auth.js` 读取,用于校验云网关签名。**没有它,`/session` 一律 401。**
### 3. systemd
```bash
scp deploy/systemd/juzi-taobao.service ubuntu@1me.xajuzi.com:/tmp/
ssh ubuntu@1me.xajuzi.com "sudo install -m 644 -o root -g root /tmp/juzi-taobao.service /etc/systemd/system/ \
&& sudo systemctl daemon-reload && sudo systemctl enable --now juzi-taobao && systemctl is-active juzi-taobao"
```
### 4. nginx
```bash
# 本地
scp deploy/add-tb-nginx.sh deploy/nginx/tb-location.conf ubuntu@1me.xajuzi.com:/tmp/
# 服务器
ssh ubuntu@1me.xajuzi.com "sed -i 's/\r\$//' /tmp/add-tb-nginx.sh && bash /tmp/add-tb-nginx.sh /tmp/tb-location.conf"
```
脚本是幂等的(检测到 `location /tb/` 就跳过),会先备份成 `juzi.bak-tb-<时间戳>`。
### 5. 验收
```bash
# 服务器本机(127.0.0.1 在白名单里)
curl -sk -H 'Host: 1me.xajuzi.com' https://127.0.0.1/tb/api/health
# 期望 {"ok":true}
# 公网(应被白名单挡掉)
curl -s -o /dev/null -w '%{http_code}\n' https://1me.xajuzi.com/tb/api/health
# 期望 403
# 生产 H5 不受影响
curl -s -o /dev/null -w '%{http_code}\n' https://1me.xajuzi.com/state/api/health
# 期望 200
```
最后在聚石塔 SAE 容器里再验一次(这是唯一走 NAT 的路径):
```bash
curl -s https://1me.xajuzi.com/tb/api/health # 期望 {"ok":true}
```
## 回滚
```bash
sudo systemctl disable --now juzi-taobao
sudo cp /etc/nginx/sites-available/juzi.bak-tb-<时间戳> /etc/nginx/sites-available/juzi
sudo nginx -t && sudo systemctl reload nginx
```
生产两个服务完全不受影响(端口、目录、数据都是独立的)。
## 数据
- 运行数据:`/home/ubuntu/juzi-tb-data`(700)—— 照片 `photos/`、原始返回 `results/`、记录 `records.jsonl`、账号 `accounts/`
- 与生产的 `/home/ubuntu/juzi/h5/data` 完全分开,两边用户/报告不互通
- **没有任何自动清理**:每次检测约 10–12MB 常驻磁盘,长期要换 OSS + RDS
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# 幂等地把 deploy/nginx/tb-location.conf 插入 /etc/nginx/sites-available/juzi。
# 只做「插入一段 location」,不改动任何已有 location;先备份,nginx -t 通过才 reload。
#
# 用法(在 1me.xajuzi.com 上):
# scp deploy/add-tb-nginx.sh deploy/nginx/tb-location.conf ubuntu@1me.xajuzi.com:/tmp/
# ssh ubuntu@1me.xajuzi.com "sed -i 's/\r$//' /tmp/add-tb-nginx.sh && bash /tmp/add-tb-nginx.sh"
set -euo pipefail
CONF=/etc/nginx/sites-available/juzi
SNIPPET=${1:-/tmp/tb-location.conf}
STAMP=$(date +%Y%m%d%H%M%S)
BACKUP="$CONF.bak-tb-$STAMP"
if grep -q 'location /tb/' "$CONF"; then
echo "SKIP: /tb/ 已存在,不重复插入"
exit 0
fi
if [ ! -f "$SNIPPET" ]; then
echo "缺少片段文件: $SNIPPET" >&2
exit 1
fi
sudo cp "$CONF" "$BACKUP"
echo "BACKUP: $BACKUP"
sudo python3 - "$CONF" "$SNIPPET" <<'PY'
import sys
conf_path, snippet_path = sys.argv[1], sys.argv[2]
text = open(conf_path, encoding='utf-8').read()
snippet = open(snippet_path, encoding='utf-8').read()
block = "# BEGIN taobao-debug 2026-11-01\n" + snippet + "# END taobao-debug 2026-11-01\n\n"
marker = " location / {\n"
if marker not in text:
sys.exit("marker 'location / {' not found")
text = text.replace(marker, block + marker, 1)
open(conf_path, 'w', encoding='utf-8').write(text)
print("INSERTED")
PY
echo "--- nginx -t ---"
sudo nginx -t
echo "--- reload ---"
sudo systemctl reload nginx
echo "RELOADED"
+25
View File
@@ -0,0 +1,25 @@
# 插入到 /etc/nginx/sites-available/juzi 的 HTTPS server { } 块内。
# 作用:给淘宝小程序云网关一个专用入口 /tb/,反代到 juzi-taobao.service(127.0.0.1:18081)。
#
# 为什么要 allow 白名单:/tb/ 暴露在公网,后面是真实旷视计费调用。
# 只允许聚石塔 NAT 的出口 EIP(以及本机自测)访问,其余一律 403。
#
# 注意:nginx 会剥掉 /tb/ 前缀,所以 /tb/api/taobao/session → Node 收到 /api/taobao/session,
# 与 h5/server.js 的路由完全一致。
location = /tb {
return 301 /tb/;
}
location /tb/ {
# 聚石塔 NAT 网关绑定的 EIP。换环境时改这一行。
allow 47.92.113.176;
# 本机自测(curl -H 'Host: 1me.xajuzi.com' https://127.0.0.1/tb/api/health)
allow 127.0.0.1;
deny all;
proxy_pass http://127.0.0.1:18081/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 150s;
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Publish an explicitly selected public video and player; reload nginx only."""
import argparse
import hashlib
from pathlib import Path
import re
import shutil
import subprocess
from datetime import datetime, timezone
PUBLIC = Path('/var/www/juzi-media/videos')
CONFIG = Path('/etc/nginx/sites-available/juzi')
MARKER = '# BEGIN juzi-public-videos'
BLOCK = ''' # BEGIN juzi-public-videos
location ^~ /media/videos/ {
alias /var/www/juzi-media/videos/;
autoindex off;
types { video/mp4 mp4; text/html html; }
default_type application/octet-stream;
charset utf-8;
add_header X-Content-Type-Options nosniff;
add_header Cache-Control "public, max-age=3600";
limit_except GET HEAD { deny all; }
}
# END juzi-public-videos
'''
def checked(args):
subprocess.run(args, check=True, capture_output=True, text=True)
def digest(path):
result = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
result.update(chunk)
return result.hexdigest()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('staging')
parser.add_argument('stem')
parser.add_argument('sha256')
args = parser.parse_args()
assert re.fullmatch(r'[a-z0-9-]{1,80}', args.stem)
assert re.fullmatch(r'[a-f0-9]{64}', args.sha256)
staging = Path(args.staging)
assert staging.resolve() == staging and staging.parent == Path('/tmp')
assert staging.name.startswith('juzi-public-video-') and not staging.is_symlink()
sources = [staging / (args.stem + suffix) for suffix in ('.mp4', '.html')]
assert all(p.is_file() and not p.is_symlink() for p in sources)
assert digest(sources[0]) == args.sha256, 'uploaded video checksum mismatch'
assert not CONFIG.is_symlink()
original = CONFIG.read_text()
if MARKER not in original:
assert 'location ^~ /media/videos/' not in original
assert original.count(' location /state/ {') == 1
for directory in [PUBLIC.parent, PUBLIC]:
assert not directory.is_symlink()
directory.mkdir(mode=0o755, exist_ok=True)
assert directory.resolve() == directory
for source in sources:
target = PUBLIC / source.name
assert not target.is_symlink()
if target.exists():
assert digest(target) == digest(source), 'refusing to overwrite a different published file'
else:
shutil.copyfile(source, target)
target.chmod(0o644)
if MARKER not in original:
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
backup = CONFIG.with_name('juzi.public-video-' + stamp + '.bak')
assert not backup.exists()
shutil.copy2(CONFIG, backup)
CONFIG.write_text(original.replace(' location /state/ {', BLOCK + ' location /state/ {', 1))
try:
checked(['nginx', '-t'])
checked(['systemctl', 'reload', 'nginx'])
except Exception:
shutil.copy2(backup, CONFIG)
checked(['nginx', '-t'])
checked(['systemctl', 'reload', 'nginx'])
raise
print('nginx backup: ' + str(backup))
else:
assert BLOCK in original, 'existing video location differs; inspect before changing'
checked(['nginx', '-t'])
print('video sha256: ' + digest(PUBLIC / sources[0].name))
print('player: https://1me.xajuzi.com/media/videos/' + args.stem + '.html')
if __name__ == '__main__':
main()
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=1'me taobao mini program backend (Node, /tb/ debug instance)
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/juzi-tb/h5
# 独立于生产 juzi-h5(8080)/juzi-rednote(8081):独立端口、独立数据目录、独立凭据文件
Environment=JUZI_ENTRY_MODE=state
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=18081
Environment=JUZI_DATA_ROOT=/home/ubuntu/juzi-tb-data
Environment=TRUST_LOOPBACK_PROXY=1
# 内容格式:
# TAOBAO_APP_KEY=35486228
# TAOBAO_APP_SECRET=<35486228 的 AppSecret>
# 权限 600 root,绝不入库。
EnvironmentFile=/etc/juzi/taobao.env
ExecStart=/usr/bin/node /home/ubuntu/juzi-tb/h5/server.js
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
UMask=0077
[Install]
WantedBy=multi-user.target
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Serve public voice assets with MP3 MIME/range support; keep app services running.
Run on the existing Ubuntu host using sudo. Only manifest-listed public files
are copied. Nginx config is backed up and rolled back if validation/reload fails.
"""
import json
from pathlib import Path
import re
import shutil
import subprocess
SOURCE = Path('/home/ubuntu/juzi/h5/assets/voice')
PUBLIC = Path('/var/www/juzi-voice-20261102')
CONFIG = Path('/etc/nginx/sites-available/juzi')
BACKUP = CONFIG.with_name('juzi.voice-20261102.bak')
MARKER = '# BEGIN juzi-voice-media 20261102'
BLOCK = ''' # BEGIN juzi-voice-media 20261102
location ^~ /state/assets/voice/ {
alias /var/www/juzi-voice-20261102/;
types { audio/mpeg mp3; application/json json; }
default_type application/octet-stream;
add_header Cache-Control "public, max-age=3600";
limit_except GET HEAD { deny all; }
}
# END juzi-voice-media 20261102
'''
def checked(command):
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode:
raise RuntimeError('command failed: ' + ' '.join(command) + '\n' + result.stderr)
def main():
manifest = json.loads((SOURCE / 'manifest.json').read_text())
names = manifest['files']
assert names and all(isinstance(name, str) and re.fullmatch(r'[a-z0-9-]+', name) for name in names)
files = [SOURCE / (name + '.mp3') for name in names] + [SOURCE / 'manifest.json']
assert all(file.is_file() and not file.is_symlink() and file.resolve().parent == SOURCE.resolve() for file in files)
original = CONFIG.read_text()
if MARKER not in original:
assert not BACKUP.exists(), 'existing backup must be preserved'
assert original.count(' location /state/ {') == 1
PUBLIC.mkdir(mode=0o755, parents=True, exist_ok=True)
assert PUBLIC.resolve() == Path('/var/www/juzi-voice-20261102')
for file in files:
target = PUBLIC / file.name
assert not target.is_symlink()
shutil.copyfile(file, target)
target.chmod(0o644)
if MARKER not in original:
shutil.copy2(CONFIG, BACKUP)
CONFIG.write_text(original.replace(' location /state/ {', BLOCK + ' location /state/ {', 1))
try:
checked(['nginx', '-t'])
checked(['systemctl', 'reload', 'nginx'])
except Exception:
shutil.copy2(BACKUP, CONFIG)
checked(['nginx', '-t'])
checked(['systemctl', 'reload', 'nginx'])
raise
else:
checked(['nginx', '-t'])
print(json.dumps({'publicFiles': len(files), 'nginxConfigValid': True,
'backup': str(BACKUP), 'businessServicesRestarted': False}))
if __name__ == '__main__':
main()
+273
View File
@@ -0,0 +1,273 @@
# 交接文档:淘宝小程序 + 聚石塔 SAE + Java MVP
> 日期:2026-11-01 凌晨
> 给下一个 agent:先读「一句话状态」和「明天先做什么」,再按需读后面。
## 一句话状态
**云端链路已经打通**:开放平台服务测试 `POST /api/taobao/ping` 返回:
```json
{
"success": true,
"data": {
"server": "java",
"signMessage": "verify-sign=false, skipped (local/direct mode)",
"appKey": "35486228",
"transport": "gateway",
"ok": true,
"pong": true,
"env": "test",
"signVerified": false
},
"errorCode": null,
"errorMessage": null
}
```
SAE `nnn` 上跑的是 `0.0.2` JAR,SAE 环境变量已清空,JAR 内置 `verify-sign:false`,所以 `signVerified:false` 是**预期**。
**还没做** IDE 真机预览;**签名校验尚未启用**(之前的 `signature mismatch` 未解决,先绕过)。
## 明天先做什么
1. **IDE 真机预览**,验证小程序 → 云网关 → 聚石塔云应用 → 私网 CLB → SAE 整条链路(第 5.1 节)。
2. 真机通过后,再决定是否开签名:重置 35486228 的 AppSecret → 更新 `application-local.yml` → `verify-sign:true` → 打 `0.0.3` 上传(第 5.3 节)。
3. 最后提交这轮未提交的代码(**不要提交 `application-local.yml`**)并 push。
## 当前链路
```text
淘宝小程序(taobao/)
→ @tbmp/mp-cloud-sdk(cloudEnv: test)
→ 淘宝云网关(AppKey 35486228)
→ 聚石塔云应用 1me-net(ID 62829,默认云应用)
→ 私网 CLB lb-k2j324dwux1svaf2akwqx / 192.168.65.185:80
→ SAE 应用 nnn(namespace cn-zhangjiakou:my-namespace)
→ Java Spring Boot 0.0.2,容器 8080
```
## 关键资源 / ID
| 资源 | 值 |
|---|---|
| 开放平台应用(当前正确) | `1'me 肌肤状态检测`,AppKey **35486228** |
| 旧应用(不再使用) | `1'me肌肤检测`,AppKey 35479221;旧 Secret 不要再用 |
| 聚石塔云应用 | `1me-net`,ID **62829**,默认应用;生产/测试 SLB 都是 `192.168.65.185:80` |
| 聚石塔云应用(未用) | `app-new`,ID 62861;没接 SAE/CLB,别用 |
| SAE 应用 | `nnn`,ID `7c9f2d2a-95d0-4879-afad-323d30f85d30` |
| SAE 命名空间 | `cn-zhangjiakou:my-namespace` |
| VPC | `vpc-8vb5mcvenp8jwa1roqcsa`(`JST_cn-zhangjiakou_vpc-new`) |
| vSwitch | `vsw-8vbna5zb2rq03gaeiymch`(可用区 A) |
| 安全组 | `sg-8vbh8c96h0kotpu7y26r`(“聚石塔官方创建ssh”),需放行 8080 |
| 实例规格 | 1 Core 2 GiB |
| 代码包 | `taobao-mvp-backend-0.0.2.jar`,版本号 `1793466846250` |
| SAE 环境变量 | **已清空**(重要,见“不要做什么”) |
## 已验证 / 未验证
### 已验证
- 后端本地:`mvn test` 4/4(0.0.1 时;版本改 0.0.2 后只 `-DskipTests` 打包,未重跑)。
- 前端:`npm test` 17/17;`taobao/scripts/mvp-local-check.cjs` 本地直连 Java 通过。
- JAR 本地启动:`/actuator/health` UP,`/api/taobao/ping` 正常。
- SAE `nnn` 部署 `0.0.2` 成功,服务测试返回上面的 JSON。
- `signVerified:false` 是预期;`signMessage` 是固定文案,不代表直连;`transport:"gateway"` 证明走了网关。
### 未验证
- IDE **真机预览**(云应用 SDK 模拟器不支持,必须真机)。
- 开启签名后的 `signVerified:true`。
- 真实测肤接口(challenge/session/photo/skin-analyze/analysis/reports/logout)仍在 Node,Java 只有 `ping/whoami/echo`。
## 代码 / 配置状态
### Git
- 已推送:`ac0b7ca feat(sae): add Java MVP backend and Taobao connectivity page`(origin/main 同步)。
- 工作区未提交改动(11 个文件,`git diff --stat`):
- `.gitignore`(忽略 `application-local.yml`)
- `README.md`(交接文档入口)
- `backend-java/README.md`
- `backend-java/pom.xml`(version `0.0.2`)
- `backend-java/src/main/resources/application.yml`(`spring.profiles.active: local`)
- `docs/SAE-Java-MVP-操作步骤.md`(0.0.2 + 4.3.1 更新 JAR)
- `taobao/config.js`(云端 test)
- `taobao/pages/mvp/mvp.js`(显示 cloudType)
- `taobao/pages/splash/splash.js`(云端 test 也显示 MVP 入口)
- `taobao/test/transport.test.cjs`(tower 测试)
- `taobao/utils/transport.js`(tower 模式)
**`application-local.yml` 在 `.gitignore` 里,未跟踪,不要提交。** 它包含 AppSecret;JAR 里也包含,临时方案。
### 后端
- `backend-java/`,Spring Boot 3.5.6 / Java 17。
- `application.yml`:`spring.profiles.active: local`。
- `application-local.yml`:
- `taobao.app-key: "35486228"`
- `taobao.app-secret: <当前 Secret>`
- `taobao.verify-sign: false`
- JAR:`backend-java/target/taobao-mvp-backend-0.0.2.jar`(23.6MB),包含 `BOOT-INF/classes/application-local.yml`。
- 云端已上传该 JAR;SAE 环境变量已清空(否则覆盖 JAR 配置)。
- 接口:`GET/POST /api/taobao/ping`、`POST /api/taobao/whoami`、`POST /api/taobao/echo`、`GET /actuator/health`。
- 签名校验:`TaobaoSignatureVerifier`,算法与 `h5/lib/taobao-auth.js` 一致;`verify-sign:false` 时跳过。
### 前端
- `taobao/config.js` 当前是云端 test:
- `environment: 'development'`
- `transport: 'cloud'`
- `cloudType: 'tower'`
- `cloudEnv: 'test'`
- `cloudAppId: ''`(默认云应用;若 SDK 报默认不存在,填 `62829`)
- `mvpLocal: false`
- `baseUrl: 'https://miniappcloud.taobao.com'`(tower 模式仅占位,不参与请求)
- `assetBase: 'http://127.0.0.1:18180/assets/'`(真机上底图可能加载失败,不影响 MVP 入口)
- `taobao/utils/transport.js`:`cloudType === 'tower'` 时不传 `domain`、不要求 HTTPS、`cloudAppId` 可空;`cloudType === 'empty'` 保持旧行为(空应用/外部服务)。
- `taobao/pages/mvp/mvp`:MVP 页面;`mvpLocal` 时可强制直连 8080,云端时走 transport cloud。
- `taobao/pages/splash/splash.js`:`showMvpDebug = mvpLocal || (transport === 'cloud' && environment !== 'production')`;当前 development+cloud,入口显示。
- `taobao/app.json` 已注册 `pages/mvp/mvp`。
- `taobao/scripts/mvp-local-check.cjs`:本地直连契约测试(独立配置,不依赖 `config.js`)。
- `taobao/test/transport.test.cjs`:17 个测试含 tower 模式。
### 工具 / 环境
- JDK 17:`D:\juzi\tools\jdk17\jdk-17.0.21.0.2+1`
- Maven 3.9.9:`D:\juzi\tools\maven\apache-maven-3.9.9`
- Node 22.23.2:`C:\Program Files\nodejs`
- PowerShell 执行策略 Restricted;先:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
```
- Taobao IDE 的 `wmic` 兼容 shim:`C:\Users\youmi\.local\bin\wmic.cmd`;重建脚本 `D:\juzi\tools\fix-taobao-ide-wmic.ps1`。IDE 已能打开。
## 5. 明天详细步骤
### 5.1 IDE 真机预览(第一优先级)
1. 确认 IDE 当前小程序是 **35486228**(`1'me 肌肤状态检测`),不是 35479221。
2. 重新编译。
3. 点顶部「预览」,手机淘宝扫码(**不要用模拟器**;`cloud.application.httpRequest` 模拟器不支持)。
4. 启动页右上角点「SAE MVP 连通性」。
5. 页面自动调 `/api/taobao/ping`,预期:
- `transport: "gateway"`
- `server: "java"`
- `signVerified: false`(当前 JAR 配置)
- `appKey: "35486228"`
6. 如果成功,MVP 链路验收完成;记录截图。
### 5.2 真机常见错误
| 现象 | 排查 |
|---|---|
| `cloud init failed` | IDE 当前 App 不是 35486228;云应用没设默认;`cloudEnv` 不是 test |
| `cloudAppId` 默认不存在 | `taobao/config.js` 的 `cloudAppId` 填 `62829` |
| `Cannot find module '@tbmp/mp-cloud-sdk'` | `npm.cmd ci`,IDE 里「构建 npm」 |
| 模拟器报 SDK 不支持 | 正常,改用真机预览 |
| `请先配置淘宝云应用和 HTTPS 服务地址` | `cloudType` 不是 tower 或 transport 不是 cloud |
| 真机底图不显示 | `assetBase` 是 127.0.0.1,正常;MVP 入口仍可点 |
| `SIGN_INVALID` | 只会在 `verify-sign:true` 时出现;当前 false,不应出现 |
### 5.3 开签名(真机通过后再做)
1. 开放平台打开 **35486228** 的应用,重置 AppSecret,复制新值。
2. 更新 `backend-java/src/main/resources/application-local.yml`:
- `app-secret: 新值`
- `verify-sign: true`
3. 改 `pom.xml` 版本为 `0.0.3`,重新打包:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
cd D:\juzi\backend-java
mvn clean -DskipTests package
```
4. 上传 `target\taobao-mvp-backend-0.0.3.jar` 到 SAE `nnn`,部署。
5. 服务测试 `POST /api/taobao/ping`,期望 `appKey:35486228`、`signVerified:true`。
6. 如果仍 `signature mismatch`:
- 确认 SAE 没有残留 `TAOBAO_*` 环境变量(它们会覆盖 JAR);
- 确认 Secret 是 35486228 的当前值,没有空格/换行;
- 点服务测试「请求详情」,核对 `sign_method`、URL query、body;
- 需要时按 `docs/SAE-Java-MVP-操作步骤.md` 第 6 节算法手工复算(仓库里目前没有 `check-gateway-sign.cjs`,之前回答提到过但未创建)。
### 5.4 提交代码
真机通过后:
- 提交这 11 个改动(`pom.xml` 0.0.2、config、transport、splash、mvp、test、docs、两个 README、.gitignore、application.yml),外加本文档 `docs/HANDOFF-2026-11-01.md`。
- **不要** `git add application-local.yml`;它在 `.gitignore` 里。
- push 到 `origin/main`;远端证书已续期(notAfter 2027-01-29),凭据有效。
## 6. 不要做什么 / 已知坑
1. **不要再创建“云托管/新建云应用”**:那是云应用套件,会新建 CPU/容器并要求开通「容器镜像服务个人版」,和现有 SAE `nnn` 不是一回事。用户已提工单确认过。
2. **不要创建聚石塔容器集群/ECS**:入淘引导的「创建集群」是旧经典流程;SAE 不需要。
3. **不要用 `app-new` 62861**:没接 SAE/CLB;用 `1me-net` 62829。
4. **不要用 35479221 的 AppSecret**:当前是 35486228;旧 Secret 不通用。
5. **不要再加 `TAOBAO_VERIFY_SIGN=true` 环境变量**:会覆盖 JAR 的 `verify-sign:false`,回到 `SIGN_INVALID`。要么全删环境变量用 JAR,要么三者都配且 Secret 正确。
6. **不要在模拟器测云应用**:必须真机预览。
7. **不要把 AppSecret 写进小程序代码、git、聊天**:`application-local.yml` 已 gitignore;JAR 里带 Secret 只是临时验证,验证完重置。
8. **主应用其他页面现在不能云测**:Java 只有 ping/whoami/echo;challenge/session/photo/skin-analyze 等还在 Node。只测 MVP 页。
9. **`signMessage:"verify-sign=false..."` 不是直连**:固定文案;看 `transport`。
10. **SAE 应用 `nnn` 的“更多”里没有部署入口时**:用完整 SAE 控制台 `https://saenext.console.aliyun.com/cn-zhangjiakou/app-list/micro`,或应用列表行 `nnn` 的 ⋮;上传走「部署应用 → 代码包部署」。
11. **云托管入淘引导的「关联云应用」灰按钮**:不要按它去创建集群;已经通过「云应用列表 → 关联云应用」关联成功(1me-net 默认)。
12. **`new-new` SAE 应用/自动 VPC**:之前失败的应用,未使用;可删,别拿它当后端。
13. **`application-local.yml` 是临时方案**:正式环境改回环境变量/保密字典,重置 Secret;JAR 不要外发。
## 7. 常用命令
```powershell
# 环境
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
# 后端构建
cd D:\juzi\backend-java
mvn clean -DskipTests package
# 本地启动
java -jar target\taobao-mvp-backend-0.0.2.jar
# 本地健康/接口
curl.exe http://127.0.0.1:8080/actuator/health
curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:8080/api/taobao/ping
# 前端测试
cd D:\juzi\taobao
npm.cmd test
node scripts\mvp-local-check.cjs
```
## 8. 下一阶段(MVP 验收后)
1. 把 `h5/lib/taobao-routes.js` 的接口迁到 Java:
- `POST /api/taobao/challenge`
- `POST /api/taobao/session`
- `POST /api/taobao/me`
- `POST /api/taobao/photo`(64KiB 分片)
- `POST /api/taobao/skin-analyze`(异步任务)
- `POST /api/taobao/analysis`(轮询)
- `POST /api/taobao/reports`
- `POST /api/taobao/logout`
2. 所有 `/api/taobao/**` 统一信封 + 签名;128KB/10s 限制。
3. 账号/历史/图片存 RDS/OSS,不放容器本地盘。
4. 保留 H5 Node 路由不变,Java 只接淘宝;以现有 Node 实现为契约。
5. 接 SLS 日志、Actuator 健康检查、告警,再切正式流量。
## 9. 交接检查清单
- [ ] 真机预览 `transport:"gateway"`、`server:"java"`
- [ ] 记录真机截图/返回
- [ ] 决定是否开签名;若开,重置 Secret、0.0.3、`signVerified:true`
- [ ] 提交 10 个改动,push
- [ ] 清理 `new-new`/`app-new`/旧 35479221(确认无用后)
- [ ] 重置 AppSecret,移除 JAR 内 Secret,改回环境变量/保密字典
- [ ] 开始迁移真实接口
---
**给下一个 agent 的第一句话**:当前服务测试已通过(`transport:"gateway"`、`server:"java"`、`signVerified:false`),先做 IDE 真机预览;不要开签名、不要创建云托管/集群,先读第 6 节“不要做什么”。
+313
View File
@@ -0,0 +1,313 @@
# 开发交接 —— 换一台开发机继续做(2026-11-02)
> **写给**:在新 Windows 机器上接手的人(或未来的 agent)
> **不涉及**:服务器 / 聚石塔 / SAE 都不用重建,云端资源全部现成可用
> **上一份**:[HANDOFF-2026-11-01.md](HANDOFF-2026-11-01.md)(那一轮只到「链路打通 + Java MVP」,UI 和真机测肤是这一轮做的)
---
## 0. 一句话状态
**真机全链路已跑通**:淘宝小程序 → 云网关(AppKey 35486228)→ 聚石塔云应用 1me-net → 私网 CLB → SAE `nnn`(只做透传)→ 聚石塔 NAT 出网(EIP `47.92.113.176`)→ `https://1me.xajuzi.com/tb/` → 现有 H5 的 Node 服务 → 旷视 Face++,**三视图拍摄后报告正常返回**。
小程序五个页面的 UI 已按 H5 重做(像素级对齐,见下)。剩下的是相机组件验证和一批工程化工作,清单在第 8 节。
---
## 1. 新机器准备
### 1.1 必装
| 工具 | 版本 | 用途 |
|---|---|---|
| Git | 任意近期版本 | 拉代码 |
| Node.js | **18+(推荐 20/22)** | `taobao/` 测试、`h5/` 本地跑、零依赖 |
| JDK | **17** | `backend-java/` 编译与测试 |
| Maven | 3.9.x | 同上 |
| 淘宝小程序开发者工具 | 最新 | 真机预览(**模拟器不支持云应用 SDK**) |
| PowerShell 7(可选) | 7.x | 仓库里的脚本按 pwsh 语法写 |
### 1.2 拉代码
```powershell
git clone https://git.xiangsilian.com/xsl/juzi.git
cd juzi
```
> 远端证书有效期到 2027-01-29;凭据失效时重新配置 Git 凭据管理器。
### 1.3 小程序依赖(必做,否则真机报 `Cannot find module`)
```powershell
cd taobao
npm ci
```
`@tbmp/mp-cloud-sdk` 固定 1.5.10,只有这一个运行期依赖。
### 1.4 工具链
仓库自带 `tools/env-java.ps1`,查找顺序:仓库内便携工具链 → 环境变量 → PATH。
```powershell
Set-ExecutionPolicy -Scope Process Bypass
. .\tools\env-java.ps1 # 输出 JAVA_HOME / MAVEN_HOME / java -version
```
- 用系统安装的 JDK 17 + Maven 即可,**不需要**再下便携版
- 想复原老机器的布局:把 JDK 放 `tools/jdk17/`、Maven 放 `tools/maven/apache-maven-3.9.9/`(这两个目录 gitignore)
### 1.5 机密文件 —— **已随仓库提交(测试阶段例外)**
> 2026-11-02 决定:测试验证阶段优先「换机不用手工拷贝」,下面两个文件**已入库**。
> 代价是密钥进入了 git 历史,正式上线前必须按本节末尾的清单清理。
| 文件(仓库内相对路径) | 内容 | 用途 | 缺了会怎样 |
|---|---|---|---|
| `backend-java/src/main/resources/application-local.yml` | 淘宝 AppKey/AppSecret + `verify-sign` + `mode: forward` + `forward-base` | 打 JAR 时把配置塞进包里(SAE 不需要环境变量) | JAR 没有 AppKey/Secret,`/session` 一定 401;只跑 `/ping`、不部署则不影响 |
| `reference/facepp-skinanalyze/credentials.env` | `FACEPP_API_KEY` / `FACEPP_API_SECRET` / `FACEPP_SKINANALYZE_URL` | `h5/server.js` 启动时读取旷视凭据 | 本机跑 H5 后端时启动即 `exit(1)`;只做小程序端开发不需要 |
克隆下来就都有了,**新机器不需要再手工拷这两个文件**。
**唯一还要自己配的是 SSH 密钥**(用于部署 Node 侧、看日志、跑 `deploy/` 里的脚本):
```powershell
ssh-keygen -t ed25519 -C "dev@newmachine"
# 把 ~/.ssh/id_ed25519.pub 内容追加到服务器 ~/.ssh/authorized_keys
ssh ubuntu@1me.xajuzi.com "systemctl is-active juzi-taobao"
```
**共享仓库的约束**(`.gitignore` 里也写了):
1. 仅限内网私有仓库 `git.xiangsilian.com/xsl/juzi`,**不要 fork 到公开仓库 / 推送到 GitHub**;
2. 不要把这些值复制到聊天、文档、工单、issue;
3. 正式上线前必须做三件事(列在待办第 6 项):
- 在开放平台**重置** AppSecret、在旷视控制台**重置** API Key
- 用 `git filter-repo` 把这两个文件从历史里清除,并 `push --force`
- 改回「环境变量 / 保密字典 / KMS」注入(`application.yml` 里已经支持 `TAOBAO_*` 与 Spring 占位符)
### 1.6 淘宝 IDE 的一个已知坑(Windows)
老机器上 IDE 曾因 `wmic` 缺失打不开,用 `tools/fix-taobao-ide-wmic.ps1` 生成过一个 shim(`C:\Users\<你>\.local\bin\wmic.cmd`)。新机器如果 IDE 报 `install miniapp extension fail: ... wmic ...`,跑这个脚本。
---
## 2. 仓库地图
```
juzi/
├── taobao/ 淘宝小程序(当前主战场)
│ ├── config.js ★ 环境/传输配置(cloud+tower+test、assetBase)
│ ├── app.acss ★ 全局设计 token(对齐 H5 的 .btn-primary/.card/.chev/.popup)
│ ├── pages/
│ │ ├── splash/ 启动页(登录、隐私弹窗、开发版入口)
│ │ ├── profile/ 档案填写(出生/性别/地区/肤质轮播)
│ │ ├── camera/ 拍照页(蒙版取景 or 系统相机兜底)
│ │ ├── report/ 报告页(环形分、7 视角、叠加层、标记点)
│ │ ├── history/ 历史报告
│ │ ├── mvp/ 连通性调试页(ping/whoami/echo)
│ │ └── camtest/ ★ 相机组件冒烟页(只放 <camera>,判断组件是否可用)
│ ├── utils/
│ │ ├── transport.js 云网关/direct 两种传输 + 信封解包
│ │ ├── api.js 9 个接口封装(challenge/session/me/photo/skin-analyze/…)
│ │ ├── permissions.js ★ 权限(scope 前缀、诊断 diagnose())
│ │ ├── image.js 1280 长边 + JPEG q0.88 压缩
│ │ ├── voice.js 语音播报(异常静默)
│ │ └── report-model.js payload → 报告页 data
│ └── test/ 20 个 node:test 用例
├── h5/ ★ 真正的测肤后端(Node,零依赖)
│ ├── server.js 入口:Face++ 调用、评分、报告、admin
│ ├── lib/taobao-*.js ★ 淘宝云网关路由/验签/会话(关键算法在这里)
│ ├── js/skin-score.js 评分引擎(6 类 → 3 维,4:3:3 加权)
│ └── test/ 一堆独立测试脚本(没有统一 runner)
├── backend-java/ SAE 上的透传层(Spring Boot 3.5.6 / Java 17)
│ ├── src/main/java/com/juzi/mvp/api/
│ │ ├── TaobaoForwardController.java ★ mode=forward 时字节级透传
│ │ ├── TaobaoEnvelope.java 客户端要的 data.ok + data.http_status
│ │ ├── TaobaoAuthController.java native 模式的登录接口(当前未启用)
│ │ └── TaobaoSessionService.java challenge + Bearer token
│ └── src/main/java/com/juzi/mvp/gateway/TaobaoSignatureVerifier.java 验签算法
├── deploy/ ★ 服务器侧配置(systemd / nginx / 安装脚本)
├── docs/ 交接与实施文档(本文件所在)
└── tools/ 构建与素材脚本
```
---
## 3. 三套测试
```powershell
# 1) 小程序:20 个用例
cd taobao ; npm test
# 2) Java:11 个用例(含验签向量的回归测试)
cd backend-java ; mvn test
# 3) H5 单测(独立脚本,逐个跑)
node h5/test/taobao-auth.js # 网关签名,必须 PASS
```
**改动提交前请保证**:`taobao` 20/20 + `backend-java` 11/11。老机器最后一次全绿是在 `fed5293`。
> `taobao/test/frontend.test.cjs` 会用 vm 真跑页面 JS,页面 data 字段改名要同步改测试。
---
## 4. 本地怎么跑
### 4.1 H5 后端(Node,测肤真正的实现)
```powershell
cd h5
$env:PORT=8099 ; $env:HOST='127.0.0.1' ; $env:JUZI_ENTRY_MODE='state'
$env:JUZI_DATA_ROOT='..\temp\h5-data'
node server.js
# http://127.0.0.1:8099/api/health → {"ok":true}
```
启动时缺旷视 key 会直接退出。只想调 UI/接口时给假值即可(真实检测会失败,但路由可用):
```powershell
$env:SKIN_API_KEY='dummy' ; $env:SKIN_API_SECRET='dummy'
```
### 4.2 Java(SAE 那一层)
```powershell
. .\tools\env-java.ps1
cd backend-java
mvn spring-boot:run # 或 mvn -DskipTests package 后 java -jar target\*.jar
curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:8080/api/taobao/ping
```
- 默认 `mode: native`(Java 自己实现登录,便于单独调接口)
- 部署用的 `application-local.yml` 里写的是 `mode: forward`
- 本地想看透传:`$env:TAOBAO_MODE='forward'` + `$env:TAOBAO_FORWARD_BASE='http://127.0.0.1:8099'`
### 4.3 小程序
1. 淘宝 IDE 导入仓库里的 `taobao/`(`mini.project.json` 的 `appid` 留空,以 IDE 账号为准)
2. **确认 IDE 当前小程序是 35486228(`1'me 肌肤状态检测`)**
3. `config.js` 保持 `transport: 'cloud'` / `cloudType: 'tower'` / `cloudEnv: 'test'`
4. 顶部「预览」→ **手机淘宝扫码**(模拟器不支持 `cloud.application.httpRequest`)
5. 启动页右上角有三个**开发版入口**(`environment !== 'production'` 时才显示):
| 入口 | 作用 |
|---|---|
| 权限诊断 | 打印 `my.canIUse`、`getSetting` 的 authSetting、`authorize` 的原始返回(真机上唯一能看到这些的地方) |
| 相机组件测试 | 整页只有一个 `<camera>`,用来判断组件是否可用 |
| SAE MVP 连通性 | `ping/whoami/echo`,看 `transport:"gateway"`、`server:"java"` |
---
## 5. 云端资源(只读,无需重建)
| 资源 | 值 |
|---|---|
| 开放平台应用 | `1'me 肌肤状态检测`,AppKey **35486228** |
| 旧应用(废弃) | `1'me肌肤检测` 35479221 —— **不要再用它的 Secret** |
| 聚石塔云应用 | `1me-net`,ID **62829**,默认应用 |
| 私网 CLB | `lb-k2j324dwux1svaf2akwqx` / `192.168.65.185:80` |
| SAE 应用 | `nnn`,`cn-zhangjiakou:my-namespace` |
| 代码包 | `taobao-mvp-backend-0.0.4-forward.jar`(**环境变量全空**,配置都在包里) |
| VPC / vSwitch | `vpc-8vb5mcvenp8jwa1roqcsa` / `vsw-8vbna5zb2rq03gaeiymch`(可用区 A) |
| NAT 出口 EIP | **47.92.113.176**(nginx `/tb/` 白名单按它放行) |
| 服务器 | `ubuntu@1me.xajuzi.com`,Node v20.20.2 |
| 生产 H5 | `https://1me.xajuzi.com/state/` → `juzi-h5.service`(8080) |
| 红书渠道 | `https://1me.xajuzi.com/rednote/` → `juzi-rednote.service`(8081) |
| 淘宝专用 | `https://1me.xajuzi.com/tb/` → `juzi-taobao.service`(18081) |
---
## 6. 改完怎么上线
### 6.1 小程序
IDE 编译 → 预览(自测)→ 「上传」发版本。**不需要碰服务器。**
### 6.2 Java(SAE)
```powershell
. .\tools\env-java.ps1 ; cd backend-java
mvn clean package # 测试会自动跑
# 产物 target\taobao-mvp-backend-0.0.4-forward.jar
```
SAE 控制台 → `nnn` → 部署应用 → 代码包部署 → 上传 JAR(**版本号要和上次不同**,否则会被当成同一个版本)→ 环境变量保持全空。
### 6.3 Node(`/tb` 实例)
见 [deploy/README.md](../deploy/README.md) 第 1 节。核心三步:
```powershell
tar -czf temp/juzi-h5.tgz --exclude=h5/data --exclude=h5/test -C . h5
scp temp/juzi-h5.tgz ubuntu@1me.xajuzi.com:/tmp/
```
```bash
ssh ubuntu@1me.xajuzi.com "tar -xzf /tmp/juzi-h5.tgz -C /home/ubuntu/juzi-tb && sudo systemctl restart juzi-taobao"
```
> ⚠️ **不要动** `/home/ubuntu/juzi`(生产 H5)和 `/home/ubuntu/juzi-rednote`。我们那份是独立拷贝 `/home/ubuntu/juzi-tb`。
---
## 7. 这一轮的关键结论(踩过的坑,别重复踩)
1. **云网关签名算法**:真实请求**不带 `sign_method`**,缺省等价于 `MD5(secret + content + secret)`(`content` = 排序后的 `key+value` 拼接 + **原始 body 字节**)。之前按 `MD5(content + secret)` 实现,是 10 月底那个「signature mismatch」的真因。改在 [h5/lib/taobao-auth.js](../h5/lib/taobao-auth.js)。
2. **淘宝的 scope 是带前缀的** `scope.camera` / `scope.album`;传裸名 `camera` 返回 `{"errorCode":"5022","errorMessage":"Scope:camera信息不存在"}`。
3. **`<camera>` 组件在真机上会让整页崩溃**,且 `my.canIUse('camera')` 返回 `yes` 是**假阳性**。当前默认走系统相机(`config.nativeCamera = false`)。怀疑原因是相机里嵌了**微信专有的 `<cover-view>`**,已改成兄弟节点 + 普通 view 覆盖,并加了 `pages/camtest` 冒烟页——**这个还没在真机验证,是新机器的第一件事**。
4. **网关限制**:单请求 **10 秒 / 128KB**。所以 `skin-analyze` 必须 **202 + 1 秒轮询(最长 90 秒)**,不能同步等旷视。
5. **旷视 Face++**:正式 key 有并发窗口,必须**串行 + 4 秒间隔**;60 秒超时,失败/5xx/并发超限重试 1 次;返回值含 `face_maps`,4000px 素材时约 3.2MB(真实链路客户端压到 1280,约 200–300KB)。
6. **`/api/taobao/reports` 详情带 3 张 base64 照片**,历史报告页有超时风险(网关大响应未验证)。
7. **会话/任务全在内存**:SAE 只能单副本;`juzi-taobao` 重启后登录态与进行中的任务都会丢。
8. **没有任何清理策略**:每次检测在磁盘留 10–12MB,长期必须换 OSS。
9. **转发必须字节级透传**:签名覆盖原始 body 和 query,Java 一旦重新序列化 JSON 就会 401。
10. **生产那台 Node 是旧代码**,没有 `/api/taobao/*`;我们另外起 `/home/ubuntu/juzi-tb`(18081)跑新代码,用 nginx `/tb/` 分流。
11. **图片资源**走 `https://1me.xajuzi.com/state/assets/`(`config.assetBase`)。之前是 `127.0.0.1:18180`,真机上全部加载失败。
---
## 8. 待办(建议顺序)
| # | 事项 | 说明 |
|---|---|---|
| 1 | **真机验证 `pages/camtest`** | 能出实时画面 → 把 `config.nativeCamera` 改 `true`,拍照页立刻有蒙版取景;仍然崩 → 维持系统相机 |
| 2 | `/reports` 详情瘦身 | 现在透传 3 张 base64 原图(约 0.5MB+),改缩略图或 OSS URL |
| 3 | 存储工程化 | 照片/原始 JSON/记录 → OSS + RDS;现在全在容器/单机磁盘,无清理 |
| 4 | 旷视请求裁剪 | `return_maps` 请求 10 层但报告只用 6 层,可少传 4 层省带宽与落盘 |
| 5 | WebP 转码(可选) | 实测:6 层图 WebP q80 省 54%,叠 50% 缩放省 84%;**先真机验证 `<image>` 支持 WebP data URL** |
| 6 | 密钥治理(**上线前必做**) | 两个密钥文件目前随仓库提交(见 1.5)。上线前:重置 AppSecret 与旷视 Key → `git filter-repo` 从历史清除 → 改回环境变量/保密字典;服务器 `/etc/juzi/taobao.env` 与 JAR 内嵌 Secret 一并处理 |
| 7 | Java 端可选迁移 | `mode=native` 的登录接口已写好并测过(4 个用例),可把 auth 从 Node 搬到 Java;测肤仍留在 Node |
| 8 | 仓库收拾 | `sdk-java-35479221/`、三个 `.pptx`、`tools/build_taobao_proposal_*.py` 仍未跟踪,建议加 ignore 或删除 |
| 9 | 启用签名校验(Java 侧) | 当前 forward 模式下由 Node 验签;若切 `native` 或要 `/ping` 也验签,把 `verify-sign` 打开即可(算法已修复) |
---
## 9. 故障对照表
| 现象 | 原因 | 处理 |
|---|---|---|
| 真机 `cloud init failed` | IDE 当前 app 不是 35486228 / 云应用没设默认 | 确认 IDE 账号与 AppKey |
| `淘宝身份校验失败,请重新授权登录` | 云网关签名不匹配 | 检查服务器 `/etc/juzi/taobao.env` 的 AppSecret;算法见第 7 节第 1 条 |
| `请先配置淘宝云应用和 HTTPS 服务地址` | `cloudType` 不是 `tower` 或 `transport` 不是 `cloud` | 看 `config.js` |
| 页面一片灰没有图 | `assetBase` 指向 127.0.0.1 | 应为 `https://1me.xajuzi.com/state/assets/` |
| 点拍照整页崩(「系统遇到一点问题」) | `<camera>` 组件 | 保持 `nativeCamera: false`,用系统相机/相册 |
| 报告页白屏 | `payload` 为空或 `report-model` 抛错 | 看启动页「SAE MVP 连通性」是否 `server:"java"` |
| SAE 侧 `上游 403` | 容器出口 IP 不在 nginx `/tb/` 白名单 | `curl -s https://myip.ipip.net` 取新 IP,改 nginx 后 reload |
| `Cannot find module '@tbmp/mp-cloud-sdk'` | 没装依赖 / IDE 没构建 npm | `npm ci`,IDE「工具 → 构建 npm」 |
| Java 打包报找不到 `application-local.yml` | 机密文件没拷过来 | 见 1.5 |
---
## 10. 交接检查清单
- [ ] `npm ci`(taobao)跑过
- [ ] `tools/env-java.ps1` 能加载出 JDK17 + Maven
- [ ] `application-local.yml` 与 `credentials.env` 已在仓库里(clone 即有,无需手拷)
- [ ] `taobao` 20/20、`backend-java` 11/11、`node h5/test/taobao-auth.js` PASS
- [ ] IDE 打开 `taobao/` 并确认 AppKey 是 35486228
- [ ] 真机预览能看到图片(说明资产域名通)
- [ ] 真机点「权限诊断」→ `authorize(scope.camera)=ok`
- [ ] 真机点「相机组件测试」→ 记录结果,决定 `nativeCamera` 开关
- [ ] 能 `ssh ubuntu@1me.xajuzi.com`,`systemctl is-active juzi-taobao` 为 active
---
**给接手者的第一句话**:先按第 1 节把机器装好、把两个机密文件拷过来,跑通三套测试;然后去真机验 `camtest`——那是唯一还悬着的技术问题,也决定拍照页最终形态。
+39
View File
@@ -0,0 +1,39 @@
# 淘宝适配合入主分支的兼容性验证
日期:2026-11-04。主分支及 origin/main 基线为 381da60,淘宝适配提交为 774376f。
## 兼容边界
- H5 的 index.html、js/app.js、js/accounts.js、js/skin-score.js、js/report-ui.js 与 CSS 相对主分支没有改动。淘宝专用裁切、倒计时、商品链接与页眉位于 taobao/,未替换 H5 版本。
- 共享 accounts.js 仅新增临时淘宝游客 Bearer 会话;微信 Cookie/OAuth 及用户报告接口保留。淘宝登录与游客入口仍校验网关,游客不持久化账号和历史报告。
- 后台汇总按数据来源区分同 ID 记录,外部淘宝数据只读;实际检测及身份写入仍使用各实例自己的数据目录。
- 本次发现并修正本地后台 URL 的兼容差异:local 数据的详情、下载、重试、照片链接不追加 source=local,沿用旧路径;外部淘宝来源保留 source=taobao。
## 已通过验证
- node --test --test-reporter=dot taobao/test/*.test.cjs rednote/test/*.test.cjs
- node taobao/test/h5-compatibility.cjs:H5 游客隐私、HTTPS /state/ 下模拟微信 OAuth、同账号跨设备报告、不同账号隔离、Cookie 丢失及失败处理、注销和重新登录、旧报告不变、异步皱纹加载。
- node rednote/test/browser.cjs:/state/ 和 /rednote/ 双实例隔离、浏览器身份恢复、历史报告、后台照片和原始 JSON 路径、独立注销;红书操作不改变原 H5 数据。
- JUZI_ADMIN_BROWSER_QA=1 下 admin-channels、backend、rednote backend 测试:后台渠道筛选、详情、照片来源、导出、同 ID 隔离、外部只读保护、目录边界。
- 本地链接修正后,重新运行 H5 report-persistence 浏览器测试及上列红书/后台检查,均通过。
- node taobao/scripts/sync-shared.cjs --check:评分、报告文案和共享拍摄规则与 H5 一致。
- git diff --check 通过。
测试使用隔离数据目录、模拟微信上游和检测结果,不写线上用户数据、不调用收费检测。
## 主分支已有的旧测试问题
对当前候选与 git archive main 导出的隔离基线执行同一组 31 个 h5/test/*.js 脚本,两者均为 27 通过、4 失败,失败集合和原因一致,无新增失败:
- factual-copy.js:旧轮廓文案预期与当前主分支文案不同。
- tone-dark-circle.js:旧预期 9.1,当前主分支计算值为 9.25。
- scan-wait-timeout.js:抽取代码的 VM 测试上下文缺 savedAccount,导致测试未进入报告页。
- report-copy-cases.js:需要通过 H5_COPY_CASE_FILE 提供仓库外的私有案例;当前未配置。
未为通过这些旧测试而改动 H5 业务文案或评分。原始运行日志保存在 temp/merge-compatibility-20261104/,不入库。
## 验证限制与合并方式
微信真实平台授权、淘宝真机相机/页眉/分享最终效果仍需发布前手机验收,本地模拟不等同实际平台验收。本次仅合并本地 Git,不部署服务器、不发布小程序、不推送远程。
合并前主分支备份:codex/main-before-taobao-20261104(381da60)。主分支可快进到适配分支,不需覆盖已有提交或改写历史。
+465
View File
@@ -0,0 +1,465 @@
# 淘宝小程序 + Java + SAE 最小 MVP 操作步骤
> 目标:先用一个最小页面,把 **淘宝小程序 → 淘宝云网关(或本地直连)→ Java 后端 → 统一 envelope 返回** 这条链路跑通。
>
> 本文不包含:真实测肤算法、Face++、账号体系、图片分片上传。这些等链路验证通过后,再按现有 Node 实现的接口契约迁到 Java。
>
> 当前时间:2026-10。淘宝开放平台在 2026-05 把聚石塔的部署运维迁到了 ACK/SAE,网上 2023 年以前的“容器部署平台”教程不要照抄。
---
## 0. 本次交付物
| 路径 | 作用 |
|---|---|
| `backend-java/` | Spring Boot 3.5.6 + Java 17 的最小后端,含 `ping/whoami/echo` |
| `backend-java/src/main/java/com/juzi/mvp/gateway/TaobaoSignatureVerifier.java` | 云网关签名校验,算法与现有 Node 实现一致 |
| `taobao/pages/mvp/` | 淘宝小程序连通性页面,直接复用现有 `utils/transport.js` |
| `taobao/config.js` | 新增 `mvpLocal` / `mvpBaseUrl`,不影响主应用原来的 18180 配置 |
| `taobao/app.json` | 注册 `pages/mvp/mvp` |
| `tools/env-java.ps1` | 一键加载 JDK 17 + Maven 3.9.9 |
| `backend-java/README.md` | 后端速查 |
| `docs/SAE-Java-MVP-操作步骤.md` | 本文 |
现有 H5、Node 后端、淘宝主流程页面均未改动;只新增 MVP 页面和两个配置字段。
---
## 1. 环境安装
本机已有:
- Node.js `v22.23.2`:`C:\Program Files\nodejs`
- JDK 17:`D:\juzi\tools\jdk17`
- Maven 3.9.9:`D:\juzi\tools\maven\apache-maven-3.9.9`
PowerShell 默认执行策略是 `Restricted`,直接运行 `.ps1` 或 `npm.ps1` 会被拦。每次新开窗口先执行:
```powershell
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
```
然后确认:
```powershell
java -version
mvn -version
node -v
npm.cmd -v
```
预期:
- `java version "17.x"`
- `Apache Maven 3.9.9`
- `v22.23.2`
- `10.9.x`
> 如果 `env-java.ps1` 报 “JDK 17 not found”,检查 `D:\juzi\tools\jdk17\<jdk目录>\bin\java.exe` 是否存在;脚本会自动向下找一层目录。
>
> 不要用系统里的 JDK 25 来跑这个 MVP;SAE 运行时选 17,编译也用 17,避免 `UnsupportedClassVersionError`。
---
## 2. 后端本地启动
```powershell
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
cd D:\juzi\backend-java
mvn -DskipTests package
java -jar target\taobao-mvp-backend-0.0.2.jar
```
第一次 `mvn package` 会下载 Spring Boot 依赖,耐心等几分钟。看到类似日志即成功:
```text
Tomcat started on port 8080 (http)
Started MvpApplication in ...
```
想跑自动化测试(签名算法 + envelope + health):`mvn test`。
### 2.1 另开一个窗口验证
```powershell
curl.exe http://127.0.0.1:8080/actuator/health
curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:8080/api/taobao/ping
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8080/api/taobao/echo -ContentType 'application/json' -Body '{"hello":"world"}' | ConvertTo-Json -Depth 5
```
> PowerShell 调原生 `curl.exe` 时,`-d` 里的双引号可能被吞掉(会看到 `{hello:world}` 解析失败);发 JSON 建议用 `Invoke-RestMethod`,或者把 body 写进文件后用 `--data-binary "@body.json"`。
预期 `ping`:
```json
{
"success": true,
"data": {
"ok": true,
"pong": true,
"server": "java",
"time": "2026-10-30T...+08:00",
"transport": "direct",
"appKey": "",
"openId": "",
"env": "",
"signVerified": false,
"signMessage": "verify-sign=false, skipped (local/direct mode)"
}
}
```
`echo` 预期能看到 `body.hello = "world"`。
> 所有 `/api/taobao/**` 返回的都是云网关要求的 envelope;`/actuator/health` 是给 SAE 健康检查用的普通 JSON,不是 envelope。
### 2.2 不装 IDE 也能跑的前端契约测试
```powershell
cd D:\juzi
node taobao\scripts\mvp-local-check.cjs
```
这个脚本用 Node 模拟 `my.request`,加载真实的 `taobao/utils/transport.js`,直连 8080,验证 `ping/echo/whoami` 和 `unwrap` 解包。预期输出 `PASS`,其中 `ping.server = "java"`。如果这里都不过,先别开 IDE。
---
## 3. 前端本地联调(淘宝 IDE)
### 3.1 安装依赖
```powershell
cd D:\juzi\taobao
npm.cmd ci
```
如果 `npm.cmd` 不在 PATH,用全路径:
```powershell
& "C:\Program Files\nodejs\npm.cmd" ci
```
`@tbmp/mp-cloud-sdk` 已经是 `1.5.10`,与当前 npm 最新版一致。
### 3.2 IDE 导入与启动页
1. 打开淘宝小程序开发者工具,导入 `D:\juzi\taobao`。
2. 填入真实小程序 AppID(`mini.project.json` 里的 `appid` 也可以留空,以 IDE 设置为准)。
3. 工具 → 构建 npm(把 `node_modules` 构建成小程序可用的包)。
4. 本地 `mvpLocal: true` 时,启动页右上角会出现「SAE MVP 连通性」入口,点它即可进入;也可以把启动页临时设为 `pages/mvp/mvp`。
5. 用**桌面模拟器**跑;手机上的 `127.0.0.1` 指向手机自己,连不到你电脑。
### 3.3 当前配置
`taobao/config.js` 新增了:
```js
mvpLocal: true,
mvpBaseUrl: 'http://127.0.0.1:8080',
```
`pages/mvp/mvp.js` 在 `onLoad` 时如果 `mvpLocal === true`,会临时把 `transport` 改成 `direct`、`baseUrl` 改成 `mvpBaseUrl`,离开页面时恢复。这样不会破坏主应用原来指向 `18180` 的 Node 后端配置。
进入页面后会自动调用一次 `/api/taobao/ping`,也可以点三个按钮:
- `POST /api/taobao/ping`
- `POST /api/taobao/whoami`
- `POST /api/taobao/echo`
预期页面显示 `server: "java"`、`pong: true`。
> 如果 IDE 拦截 localhost 请求,打开 IDE 的“不校验合法域名”/“开发环境不校验请求域名”开关(不同版本叫法不同)。桌面模拟器一般可以直接访问回环地址。
### 3.4 这一步验证了什么
- 小程序的 `utils/transport.js` 能走 `direct` 分支;
- `my.request` 能发 `POST application/json`;
- `transport.unwrap` 能正确解包 `{success,data}`;
- Java 后端端口、路径、返回结构都对。
---
## 4. 云端链路(SAE + 聚石塔云应用)
### 4.1 先理解三层关系
```text
淘宝小程序
└─ @tbmp/mp-cloud-sdk
└─ 淘宝云网关(校验 appkey/sign,注入 open_id/access_token)
└─ 聚石塔云应用(应用/环境/流量接入)
└─ SAE(真正的 Java 容器,跑 JAR)
```
- **SAE 只是算力层**,不是“建一个公网 URL 就能被小程序调用”。
- 小程序要调通,SAE 应用必须挂在**聚石塔云应用**下,或者走**空应用(不入塔)+ 域名白名单**。
- 2026-05-11 公告:原聚石塔“应用创建链路”2026-06-20 下线,部署运维迁到 ACK/SAE;聚石塔保留云应用管理、环境管理、流量接入。
- 参考:[迁移通知](https://api.alidayu.com/support/announcementDetail.htm?source=search&id=25839)、[新链路快速创建云应用](https://open.taobao.com/doc.htm?docId=122332&docType=1)、[云服务产品概述](https://miniapp.open.taobao.com/docV3.htm?docId=118541&docType=1)。
### 4.2 构建 JAR
```powershell
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
cd D:\juzi\backend-java
mvn -DskipTests clean package
```
产物:
```text
D:\juzi\backend-java\target\taobao-mvp-backend-0.0.2.jar
```
### 4.3 SAE 创建应用
在 SAE 控制台“创建应用 → 代码包部署 → 设置代码包部署”里按下面选:
| 配置项 | 选择 |
|---|---|
| 技术栈语言 | **Java** |
| 代码包类型 | **JAR 包部署** |
| 应用运行环境 | **标准 Java 应用运行环境** |
| Java 环境 | **Open JDK 17** |
| 文件上传方式 | 上传 JAR 包 |
| 启动命令 | 默认 `java -jar` |
| 应用端口 | **8080** |
| 健康检查 | HTTP `GET /actuator/health`,期望 200 |
| 实例数 | **2**(至少 2,避免单点) |
| 时区 | **Asia/Shanghai** |
| 地域/命名空间/VPC | 用聚石塔创建云应用时分配的那套,不要自己新建默认命名空间 |
**不要选**:
- WAR 包部署
- `apache-tomcat-*`
- `EDAS-Container-*`
- Open JDK 25
原因:WAR/Tomcat 是给外置 Tomcat 的老项目;EDAS-Container 是 HSF/Pandora Boot 专用;Open JDK 25 与 Spring Boot 3.5 的兼容性没有 17 稳。
环境变量(SAE → 应用设置 → 环境变量):
```text
TAOBAO_APP_KEY=你的AppKey
TAOBAO_APP_SECRET=你的AppSecret
TAOBAO_VERIFY_SIGN=true
```
> `TAOBAO_APP_SECRET` 只能放服务端,绝不能进小程序代码或 `config.js`。
建议 JVM 参数(按实例规格调整,堆小于容器内存):
```text
-Xms512m -Xmx1024m -Duser.timezone=Asia/Shanghai
```
### 4.3.1 更新代码包(重新上传 JAR)
应用创建之后,更新 JAR 的入口:
1. SAE 应用 `nnn` → 右上角「部署应用」/「变更」→「代码包部署」→ 上传新 JAR → 确定 → 部署。
2. 如果右上角没有,左侧「变更记录」→「创建变更」→ 代码包部署。
3. 聚石塔资源视图偶尔不显示部署按钮,可直接用完整 SAE 控制台:
```text
https://saenext.console.aliyun.com/cn-zhangjiakou/app-list/micro
```
选 `my-namespace` → `nnn` → 部署应用。
每次上传的 JAR 文件名/版本要不同,SAE 才会当成一个新版本。`pom.xml` 的 `<version>` 已改成 `0.0.2`,产物是:
```text
taobao-mvp-backend-0.0.2.jar
```
SAE 部署页的「版本」可以填 `0.0.2` 或 `20261101-2`。不要重复上传同名同版本的包。
### 4.4 流量接入
- 云应用走**内网私网 SLB**:聚石塔“流量接入”里绑定私网 SLB,后端端口 8080。
- 不要给云应用开公网入口;公网只用于你自己调试。
- 如果后端要调 TOP/奇门/外部 API,给 SAE 配 **NAT 网关或 EIP**,否则容器只入不出,调用会超时。
- 数据库在 RDS 时,把 SAE 的网段/安全组加进 RDS 白名单。
### 4.5 聚石塔/开放平台关联
1. 聚石塔控制台 → 云应用 → 新建云应用(按 2026-05 新链路向导)。
2. 部署方式选 SAE;创建测试环境、正式环境。
3. 在 SAE 里把 JAR 部署到对应应用/环境。
4. 聚石塔 → 流量接入 → 绑定私网 SLB(测试/正式各一套,或共用一个正式 SLB)。
5. 开放平台控制台 → 云托管 → 关联云应用 → 选择你的云应用和对应 SLB。
6. 在“服务测试”里调用 `POST /api/taobao/ping`,确认返回 `success: true`。
7. 在 `taobao/config.js` 里把 `mvpLocal` 改成 `false`,并配置:
```js
transport: 'cloud',
baseUrl: 'https://你的HTTPS域名',
cloudAppId: '控制台拿到的 cloudAppId',
cloudEnv: 'test', // 先 test,验收后改 online
```
8. 进入 `pages/mvp/mvp`,点 `ping`,看到 `transport: "gateway"`、`signVerified: true` 即通。
> 账号必须一致:创建云应用的聚石塔账号,要和开放平台小程序账号一致;子账号看不到云应用。小程序上线后默认云应用不能再改,选择时确认好。
### 4.6 空应用(不入塔)作为备选
如果这个 SAE 应用不在聚石塔,不能直接“关联云应用”。只能走空应用:
1. 开放平台 → 云托管 → 云应用管理 → 新建空应用。
2. 关联一个**没有敏感数据权限**的 AppKey。
3. 设置域名白名单,提交审批(HTTPS、已备案域名)。
4. 调用时 `exts.domain` 传你的域名;现有 `transport.js` 已经会传 `domain: config.baseUrl`。
注意:现有 `transport.js` 的 cloud 分支还要求 `config.cloudAppId` 非空。如果空应用控制台没有给 cloudAppId,需要先把这行校验放宽(或暂时先用聚石塔云应用)。
空应用只适合内容/工具/轻服务;电商交易、用户敏感数据类大概率不适合,还是得入塔。
---
## 5. 云网关接口契约(Java 后端必须遵守)
现有 Node 实现已经按这个契约写了,Java 迁移时照抄:
```json
{
"success": true,
"data": {},
"errorCode": "xxxx",
"errorMessage": "xxxx"
}
```
- 只支持 **GET/POST**;POST 必须 `application/json`。
- 请求体 **≤128KB**;超时默认 3s、最大 10s。
- 平台会往 URL 追加 `app_key`、`open_id`、`access_token`、`sign`、`env`、`mini_app_id` 等系统参数;自定义参数不要重名。
- 成功时小程序 SDK 只把 `data` 返回给页面;`errorCode/errorMessage` 在失败时返回。
- 发布前把 `cloudEnv` 从 `test` 改成 `online`;默认云应用上线后不能改。
- 图片/长任务不要塞进一个请求:现有实现是 64KiB 分片上传 + 异步任务 + 轮询。
参考:[云应用调用](https://open.m.taobao.com/docs/doc.htm?treeId=622&articleId=118538&docType=1)。
---
## 6. 签名校验
Java 的 `TaobaoSignatureVerifier` 与现有 `h5/lib/taobao-auth.js` 算法一致:
1. 取全部 query 参数,排除 `sign` 和空值,按 key 升序,拼成 `key1value1key2value2...`。
2. 末尾追加**原始 JSON body**(不要重新序列化)。
3. `sign_method=md5`:`MD5(secret + content + secret)`;`sign_method` 为空:`MD5(content + secret)`;`hmac*` 用 HMAC。
4. 结果转大写十六进制,与 `sign` 比较。
本地 `TAOBAO_VERIFY_SIGN=false`,没有 `app_key/sign`,直接放行,返回 `signVerified:false`。
云端 `TAOBAO_VERIFY_SIGN=true`:
- `app_key` 必须等于 `TAOBAO_APP_KEY`;
- `open_id` 必须符合 `[A-Za-z0-9_-]{4,256}`;
- `sign` 必须是 32–64 位十六进制;
- 重复 query 参数直接拒绝;
- 校验失败返回 `success:false, errorCode:"SIGN_INVALID"`。
如果云端一直 `SIGN_INVALID`,按顺序查:
1. SAE 环境变量是否真的注入(重启后生效)。
2. `TAOBAO_APP_KEY` 是否和云应用/小程序一致。
3. 网关用的 `sign_method` 与后端是否匹配。
4. body 是否被反向代理/框架重新序列化(必须保留原始字节)。
5. 是否有重复的 `open_id`/`app_key` 参数。
---
## 7. 常见问题
| 现象 | 排查 |
|---|---|
| `UnsupportedClassVersionError` | 编译 JDK 高于 SAE 运行时;统一用 17 |
| `404` | 路径不对;云应用 path 是否带 `/api/taobao`;WAR 的 context path |
| `502/504` | SAE 端口不是 8080、健康检查失败、SLB 后端端口不对 |
| `success:false` + `SIGN_INVALID` | 见第 6 节 |
| `cloud init failed` | IDE 未登录、AppID/cloudAppId 不匹配、cloudEnv 不对 |
| `请先配置淘宝云应用和 HTTPS 服务地址` | `transport:'cloud'` 但 `baseUrl` 不是 HTTPS 或 `cloudAppId` 为空 |
| `直连接口仅用于本机联调` | `transport:'direct'` 但 `baseUrl` 不是 localhost/127.0.0.1 |
| `Cannot find module '@tbmp/mp-cloud-sdk'` | `npm.cmd ci` 后,在 IDE 里执行“构建 npm” |
| 手机真机请求失败 | 127.0.0.1 是手机自己;真机必须走云端 HTTPS |
| 请求超过 10 秒 | 云网关最大 10s;改异步任务 + 轮询 |
| 返回体超过 128KB | 分页/裁剪字段,不要把大图 base64 放进响应 |
### 7.1 淘宝 IDE 报 `install miniapp extension fail: ... wmic ...`
Windows 11 24H2 起默认移除了 `wmic.exe`,而淘宝开发者工具在安装小程序扩展时会执行:
```text
wmic path win32_VideoController get caption
```
找不到 `wmic` 就报这个错。本机已经放了一个兼容 shim:
```text
C:\Users\youmi\.local\bin\wmic.cmd
```
修复步骤:
1. 完全退出淘宝开发者工具(任务管理器里不要留 `淘宝开发者工具.exe`)。
2. 重新打开 IDE,再导入 `D:\juzi\taobao`。
如果 shim 丢了,重新运行:
```powershell
powershell -ExecutionPolicy Bypass -File D:\juzi\tools\fix-taobao-ide-wmic.ps1
```
永久方案(需要管理员 PowerShell):
```powershell
DISM /Online /Add-Capability /CapabilityName:WMIC~~~~
```
装完后 `C:\Windows\System32\wmic.exe` 会优先于 shim,shim 可以删除。如果 DISM 提示找不到这个能力,说明系统镜像没带 WMIC FoD,继续用 shim 即可。
---
## 8. 下一步(MVP 通过后)
1. 把 `h5/lib/taobao-routes.js` 的接口逐个迁到 Java:
- `POST /api/taobao/challenge`
- `POST /api/taobao/session`
- `POST /api/taobao/me`
- `POST /api/taobao/photo`(64KiB 分片)
- `POST /api/taobao/skin-analyze`(异步任务)
- `POST /api/taobao/analysis`(轮询)
- `POST /api/taobao/reports`
- `POST /api/taobao/logout`
2. 所有 `/api/taobao/**` 统一走签名校验 + envelope。
3. 账号/历史/图片存到 RDS/OSS,不要放容器本地磁盘。
4. 保留 H5 的 Node 路由不变,淘宝走 Java;两边共用同一套业务规则时,先以现有 Node 实现为契约。
5. 接 SLS 日志、Actuator 健康检查、告警,再切正式流量。
---
## 9. 命令速查
```powershell
# 每个新窗口
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
# 后端
cd D:\juzi\backend-java
mvn -DskipTests clean package
java -jar target\taobao-mvp-backend-0.0.2.jar
# 本地验证
curl.exe http://127.0.0.1:8080/actuator/health
curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:8080/api/taobao/ping
# 前端依赖
cd D:\juzi\taobao
npm.cmd ci
```
+453
View File
@@ -0,0 +1,453 @@
## 2026-11-04 登录页固定只显示原生标题
用户真机再次确认未登录页仍出现两个标题,说明不能根据 setNavigationBar 的成功回调认定原生首页标题已经清空。本轮登录页明确启用 nativeTitleOnly,首次 Page 数据即 showTitle=false,进入、返回、旋转和成功/失败回调都不再启用自绘标题。登录页 JSON 和运行时都保留 1'me肌肤状态检测 原生标题,公共白色页眉背景、安全区及页面高度不变。其余五个业务页仍使用居中标题,登录页采用淘宝原生对齐方式,不承诺居中。
143 项相关回归通过,新增“平台返回成功但实际未隐藏标题”、API 不存在、失败和生命周期重入场景,保证登录页不再次绘制第二个标题。IDE 重新编译后实际登录页只看到一个左侧原生标题;手机效果仍待新预览确认。共享逻辑检查同时发现 Git 在 Windows 检出 manifest.json 为 CRLF 后误报,已仅对清单文本比较归一化换行;实际五份共享 JS 仍逐字节校验,不放宽业务代码一致性。
## 2026-11-04 未登录首页重复标题的动态清理
用户反馈未登录登录页出现两个标题。此前仅 JSON defaultTitle=零宽空格,尚未在生命周期调用 setNavigationBar,不能证明首页平台标题已在真机隐藏。本轮在公共页眉包装器的 onLoad/onShow/onReady 调用 my.setNavigationBar({title:零宽空格}),不修改导航背景及返回/胶囊;保留原页面生命周期、Promise 返回值与业务登录流程。回调用独立序号及可见性隔离,离页及旧回调不能更改现页标题状态。
登录页额外启用 nativeTitleFallback:若平台明确拒绝清理(fail、抛错或 Promise reject),隐藏自定义标题文字,保留平台原生标题,避免叠加;该降级情况下不能承诺原生标题居中。非登录页沿用现有居中标题。保留背景品牌图片和登录按钮。新增页面进入/返回时的原生清理、旧回调隔离、拒绝/异常不阻断业务测试,相关回归通过。真机是否实际接受标题清理仍待新预览验证,未将模拟器结果等同真机。
## 2026-11-04 全部业务页白色页眉标题水平居中
用户要求所有页面顶部标题左右居中。官方 app.json window 与 setNavigationBar 文档未提供标题对齐参数,未使用猜测的 titleAlign 等字段。改为保留原生透明导航及其返回/分享/关闭控件,将原生 defaultTitle 设置为零宽空格,使用公共 components/brand-header.axml + app.acss 显示 1'me肌肤状态检测;标题容器左右对称预留 76px,按全屏中心对齐,白底黑字。
utils/page-header.js 包装业务页注册,保留并调用原生命周期,onLoad/onResize 根据 statusBarHeight(或 safeArea.top)及 titleBarHeight 计算页眉安全区。六页正文/取景根节点避让页眉;档案表单高度收缩使用扣除页眉后的高度,相机初始蒙版尺寸同样扣除页眉,最终仍由已挂载 .cam 真实尺寸校正。分享文字和页眉标题共用同一常量,避免原生空标题影响分享。
完整回归及共享 H5 校验通过,新增实际模板/样式的 320/375/414 三屏宽、20/59px 状态栏检查,覆盖全屏中心、安全区及生命周期保留。淘宝 IDE 编译完成,模拟器实际登录页确认白色页眉标题居中、系统时间和原生右侧胶囊保留且不重叠。截图 deliverables/centered-header-20261104/login-centered.png 为真实 IDE 模拟器运行截图,明确非真机;手机刘海/动态岛安全区效果仍需实测。
官方参考:https://developer.alibaba.com/docs/doc.htm?articleId=117457&docType=1&treeId=785
## 2026-11-04 分享卡片大标题显式统一
用户确认不带撇号出现在分享卡片大标题。原 app.js 全局 onShareAppMessage 已返回 1'me肌肤状态检测,不能将问题简单归因为源码漏字或断言全局钩子不受支持。官方文档 articleId=118909 明确支持 Page 分享及 App 全局回退。为明确各页输出,六个业务页面均新增页面级 onShareAppMessage,共用 utils/share.js,title 和 desc 均为 1'me肌肤状态检测,path 固定 pages/login/login,不携带报告/照片/档案/会话信息;App 全局钩子也共用此函数。
138 项前端、会话商品、页面交互、拍照测试通过,包括逐页检查分享配置与登录页标题一致、分享对象无私人参数及每次返回独立对象。此次修改不涉及开放平台登记的小程序名称。真机分享卡片最终标题仍需从新预览版本重新分享验证,不能根据代码测试直接声称客户端已修好。
## 2026-11-04 倒计时锁定,不再因识别变化取消
用户反馈稍动仍取消倒计时,要求移除这项判断。此前 countdownReady 仍检查单脸、源帧边界、分数与新鲜度,本轮移除倒计时阶段的全部姿态/位置/清晰度/人脸数复查。正式 native-face 启动倒计时前保持原检测和稳定门槛;开始后暂停新推理,在途结果不能改变倒计时,到零补光结束直接 takePhoto,不再进入 nativeCaptureWait。离页、切换镜头、身份状态变化、相机停止/故障仍通过会话与序号取消拍摄。移除已不用的 countdownReady 元数据。
专项回归覆盖无新识别结果仍拍摄、角度/位置/置信度/人脸消失不打断倒计时、在途结果不取消,以及离页/切换/故障取消。完整测试及共享 H5 校验通过。H5/备用 AR 判定未改动;手机效果仍需扫码实测。
## 2026-11-04 推荐商品切换直播间链接
用户提供新链接 https://e.tb.cn/h.jhEroZP98ha4Res,短链页面中的目标 item.taobao.com/item.htm 明确返回 id=1090510453077。更新 config.productItemId 及 productItemIds 三档统一目标,商品为用户提供的“【李佳琦直播间】1'me以我自在有势挺括双仓精华抗皱”。保留推荐评分、文案与 tb.openDetail 交互。106 项会话/商品/拍照适配测试通过,覆盖三档相同商品、重复点击、返回报告及游客直接进入商品。实际手机商品详情打开效果尚未实测。
## 2026-11-03 两处侧脸缩略图填充缩小 10%
用户要求轮廓/皱纹下面的两张小图脸部缩小。sideThumbnailStyle 的图片缩放由 cover*2.8 调整为 cover*2.52(原宽高的 90%),未读取尺寸时的 .side-img 兜底 transform 同步为 2.52;画框尺寸、纵向锚点及左右顺序不变。两处共用同一份 sides 数据及样式,原照片与皱纹标注同步缩放。大图仍为 cover*1.12、居中下移图片高度 5%,不受小图调整影响。64 项相关测试通过,缩略图覆盖横/竖/方照片与三个屏幕宽度,并验证相对原填充为 90%。
## 2026-11-03 侧脸大图缩小 20%,下移调整为 5%
用户要求减小主体:轮廓/皱纹侧脸大图原填充尺寸宽高均乘 0.8(原 cover*1.4 改为 cover*1.12),保持居中,再下移缩放后图片高度的 5%。大图窗口大小不变,原照片和标注同步使用该矩形;缩略图及拍照流程不变。64 项相关测试通过,包含尺寸为原 80%、5% 下移、跨图片比例及屏幕宽度检查。
## 2026-11-03 两处侧脸大图统一居中后下移 10%
用户要求皱纹页卡也采用居中裁切,并统一将大图填充区域向下移动图片高度的 10%。轮廓/皱纹侧脸大图共用 rectangle:left=(画框宽-缩放图片宽)/2,top=(画框高-缩放图片高)/2+缩放图片高*0.1。10% 取图片显示高度,不取画框高度;原照片和对应标注共用位置。保留倍率、缩略图和拍照逻辑。64 项相关测试通过,覆盖三种图片比例及三个屏幕宽度、实际 canvas 位移、标注对齐与回调隔离。
## 2026-11-03 轮廓侧脸放大改为图片居中
用户最新明确要求轮廓小图放大直接截图片中间,替代此前 H5 纵向偏移规则。轮廓大图使用 centerCrop,横纵位置均为(画框尺寸-缩放后图片尺寸)/2,取消向上部取样的偏移;保留原缩放倍率和等比展示。仅轮廓侧脸大图生效,缩略图、皱纹大图及拍照流程不变。新增横/竖/方图居中断言与实际轮廓 canvas 绘制中心验证,64 项报告相关回归通过。手机效果仍待重新编译后确认。
## 2026-11-03 侧脸放大改用独立位图
用户真机仍反馈轮廓侧脸大图只剩脑门和鼻子;此前浏览器几何一致不能证明淘宝原生组件显示一致,不能据此声称已修复真机问题。本轮按 H5 reportZoomSource 的绘制路径生成独立 JPEG:新增 utils/report-side-zoom.js 和报告专用离屏 canvas(与拍照 canvas 隔离),源照片/该侧标注使用相同矩形绘制;放大层只对生成图片 aspectFit,不再执行原生 overflow 图片裁切。读取尺寸优先 getImageInfo,回退原照片已知尺寸,导出图片的 onLoad 尺寸不能覆盖原图缓存。
生成期间或 API 不支持/导出失败,显示完整原照片 aspectFit,不再回退旧的激进原生裁切。导出有互斥、generation/账号/可见性守卫;旧结果不能重新打开大图。63 项相关测试通过,包括 H5 九种尺寸矩形对照、实际绘制命令与照片/标注对齐、关闭后的回调隔离。截图脚本改为执行实际 bake 模块绘制命令,输出仍明确为浏览器预览,不是真机截图。相机启动和识别本轮未修改;原生显示仍需用户重新编译后实测。
## 2026-11-03 倒计时后允许正常移动
用户要求取消倒计时后轻微移动导致无法拍摄的规则。正式 native-face 流程开始倒计时前仍按原角度/居中/距离与稳定帧判定;倒计时、补光、快门等待期间改用 countdownReady,只检查清晰的单人面部及其源画面边界,不再因角度、居中或距离偏离原阈值重新计时。新鲜帧 1.5 秒、性能故障停止、离页/切换镜头取消、账号隔离及原生拍照互斥均保留。
新增回归:启动后转头仍完成一次原生拍照、人脸消失取消;保留旧帧与在途识别等待测试。相机/质量/前端相关 97 项测试通过,共享 H5 文件校验通过。修改仅限小程序原生识别链路,未改共享 H5 判定或备用 AR 流程;手机实际效果仍待预览确认。
## 2026-11-03 扫描品牌黄与侧脸大图原生布局
用户确认相机目前恢复;本轮未修改识别、启动、角度或抓拍逻辑。实时扫描带 RGB 改为品牌黄 245/240/120,动画时序不变。
用户继续反馈侧脸大图裁切靠上。轮廓/皱纹大图改为最终屏幕尺寸的裁切窗口,图片和标注使用相同百分比定位,不再依赖小尺寸 overflow 容器整体 transform 放大。保留 H5 reportZoomSource 的 .5 fillScale 与 .25 contentDown 规则。新增浏览器几何对照覆盖 320/375/414 三宽及竖/横/方形照片九种组合,与 H5 drawImage 矩形误差小于 .15 CSS px;60 项相关测试及共享校验通过。浏览器截图已更新,淘宝真机裁切效果仍需核验,尚不能断言原生 transform 是唯一根因。
## 2026-11-03 侧脸小图点开后大图比例修正
用户反馈轮廓/皱纹侧脸大图扁平。检查发现原大图在 getImageInfo 不可用或失败时默认 3:4,而 image 使用 scaleToFill;当实际照片比例不同,会产生拉伸。实际手机是否命中此失败分支尚未采到日志,不据此断言全部真机根因。
现小图缓存自然宽高,放大优先复用;尺寸未知时显示 aspectFit,不再猜测 3:4,真实图片 onLoad 后补齐 H5 1.4 倍居中裁切及 .25 高度下移几何。大图基础照片始终等比;原照片与其标注层、左右顺序保持关联。尺寸回调带源图片及 zoom generation/账号校验,关闭或离页后不能重开。59 项相关测试及 H5 共享校验通过。
输出 deliverables/report-side-zoom-20261103/,以项目 contour-demo/left.jpg 和真实 AXML/ACSS/控制器数据生成浏览器投影,并用线上 reportZoomSource 函数生成 H5 对照;明确标记非淘宝真机截图。相机用户补充为恢复按钮常驻且点击也无法拍摄,需继续依据其错误提示定位,不能只归因于测试入口。
## 轮廓/皱纹侧脸小图裁切还原
已重新读取线上 `/state/css/ui-20261012.css` 和 `/state/js/app.js`。H5 两张侧脸缩略图使用居中 cover、translateY(-25%) scale(2.8),图框 104.35u×92.25u。此前小程序虽使用相同变换字符串,仍依赖原生 image 的 aspectFill/transform 执行效果,用户反馈实际不一致;尚不能确定具体原生差异机制。
现按照片自然尺寸显式计算 cover 后 2.8 倍的宽高,left=(208.7-imageWidth)/2,top=184.5/4-imageHeight/2(rpx),原生 image 使用 scaleToFill 和 transform:none。照片与侧脸皱纹图层共享同一 photoStyle;图片加载和 getImageInfo 均能更新自然尺寸,异步皱纹刷新保留已知几何,离页/旧照片/账号切换的回调不能写入。左右反向顺序、主图、放大图和拍照逻辑保持当前实现。
57 项相关测试通过,包含以真实 H5 CSS 在 Chromium 中计算 cover/transform 作为对照,320/375/414px 屏宽与竖图/横图/方图共 9 组显示几何误差均小于 0.15 CSS px(布局取整差异)。共享 H5 校验通过;淘宝真机实际效果仍待新版普通预览确认。
## 平台首屏性能检测(任务 20406447)
用户提供版本 0.0.1、2026-11-02 19:53:33 的平台报告:华为 P30/Android,得分 85,明确不通过项为首屏渲染 4565ms(标准 <2000ms);包体约 1099KiB,RSS 67MB、vmSize 242MB、FPS/CPU 指标均在报告阈值内。仅凭总耗时无法确定唯一瓶颈,不能将相机识别或语音确定为根因。
本轮只优化首屏:登录背景、品牌和游客图标三张 H5 原图以原始字节复制到 taobao/assets/startup(总 131632 字节),登录改用本地相对路径;App.onLaunch 移除无首屏用途的语音播放器创建,voice.play 的现有按需创建逻辑保留。没有修改相机代码、页面样式、登录校验或云环境。公网图片本机抽测约 0.29–0.37 秒,不代表平台 P30 网络性能。
新增回归验证首屏素材与 H5 字节一致、启动不加载语音模块/创建播放器;完整 node test/*.test.cjs 及共享 H5 校验通过。需要重新上传新的开发版本并重跑平台性能检测,尚无优化后真实首屏成绩,不能声称已达到 2000ms。
## 语音真机定位续查(16:46)
用户再次确认正式拍照仍无语音,拍照正常。IDE Console 仅存 VOICE-20261102-02 的 UNAVAILABLE/category=domain;不能据此断言域名白名单拒绝。VOICE-20261102-04 增加固定 operation 和受限 errorType,将 create、listen_ended/error/play/canplay、set_source、play 分开定位,不记录原始错误或 URL。补充 SDK appxDomain TypeError 回归测试;语音与拍照适配共 78 项测试通过。未修改相机、UI、交互;未声称声音已恢复。
IDE 已生成真机调试码版本 **0.1.2611021646.10**,待手机重新扫码进入正式拍照页,读取新版本错误日志后继续修复。服务器 MP3 MIME/Range 先前已修复,不再次修改云端设置。
# H5 → 淘宝小程序适配实施记录(2026-11-02)
## 报告返回手势、顶部安全区及列表核对
按用户要求,报告详情页配置 `gestureBack:"NO"`,保留原生左上角返回;同时开启报告/列表二级页原生导航栏 `navigationBarForceEnable:true`、`transparentTitle:"none"`。报告内容顶部原 56rpx 增至 72rpx,并以系统安全区为下限;历史列表保留 H5 的 56rpx 顶距并增加安全区下限。页卡横向手势及页面纵向滚动保持,不用全页 touchmove 拦截来禁用返回。需真机确认宿主执行手势设置及标题位置。
列表页对照 H5 的 history-content、account-status-history、history-count、identity-note、history-item、history-detail、history-retry 源码,主要布局和尺寸一致;淘宝身份和游客仅本次查看提示为既定渠道差异。当前不以“听到咔嚓”认定引导语音通过。用户另反馈调试版相机停在“开启实时相机”,已询问点击后的结果,尚不能判为相机功能回归。
真机 Console 已出现 VOICE-20261102-02 的 error/category=domain。此分类来自筛选后的错误文字,不能单独断言平台域名白名单问题;版本 VOICE-20261102-03 进一步区分 APPX_DOMAIN_CONTEXT、DOMAIN_REJECTED、JS_CONTEXT_ERROR 等安全错误指纹,需后续日志确定。播放器发声仍待验证。
## 真机仍无声:音频原生就绪事件与资源服务修正
用户已确认正式流程仍完全无声,手机媒体音量和静音模式已排除;此前的代码单测不能代替真机发声确认。核对安装的淘宝 SDK,音频上下文具有 `onCanPlay/onCanplay` 与 `onPlay` 事件。按[淘宝购物小程序音频规范](https://developer.alibaba.com/docs/doc.htm?articleId=121184&docType=1&treeId=776),移除淘宝不支持的 `obeyMuteSwitch` 属性设置;监听可播放事件后再调用 play,两个大小写版本均兼容,重复/迟到的就绪事件不能重播旧音频。继续使用每条新实例和单通道队列。
新增 `VOICE-20261102-02` 开发日志,区分 loading、canplay、play_requested、playing、error,记录公开语音 ID 和经筛选的错误代码/类别;不打印错误原对象、URL、照片或账号信息。不增加业务 UI 控件。播放器诊断可通过 `voice.diagnostics()` 获取安全快照。
已确认原 HTTPS MP3 返回 `application/octet-stream`,Range 请求也返回整文件 200。部署 `deploy/taobao-voice-media.py` 修正仅 `/state/assets/voice/` 静态路径,公开目录 `/var/www/juzi-voice-20261102/` 仅包含清单内 14 个 MP3 和 manifest.json,Nginx 负责 audio/mpeg、206 与 Range。配置备份 `/etc/nginx/sites-available/juzi.voice-20261102.bak`;已通过 nginx -t 并 reload,H5/小红书/淘宝业务服务进程均未重启。14 条音频分段请求均返回 206 和正确类型,原主页 200、后台 401、数据路径 404,保护规则不变。源 server.js 同步补充 MP3/AAC MIME,未部署整份 H5 业务代码。
完整 244 项回归通过,共享规则校验通过;同时补齐了 WebGL 离线测试包中新增亮度取样模块的显式允许列表。IDE 正在生成本次代码的真机调试码,待手机扫码后核对原生播放事件与实际声音,不把这些改动表述成真机已修复。
## 主流程人脸亮度检测
检查发现 H5 的光线规则已复用,但此前普通相机识别结果不含亮度,正式调用也未传入 luma,因此只有固定“光线充足”引导,实时明暗提示未生效。现复用本地人脸识别已复制、转换好的小尺寸 RGB 帧,在首个人脸框内以 16×16 网格取样,使用 H5 的 .299R + .587G + .114B 均值;不新增 Canvas、Tensor、整帧复制或网络上传。
识别结果携带仅一个聚合 luma 值,质量适配器接入 H5 既有规则,侧脸放宽判定也保留此值:低于 110 提示“建议到光线明亮处拍摄”,高于 220 提示“光线过强,建议避开强光”,成功姿态时按 H5 附加“保持不动即可拍摄”。明暗均为建议,不作为拍摄否决条件;单人、完整取景、角度和新鲜度限制不变。缺失/无脸/无效输入保持未知,不伪装为亮度 0。只评估人脸画面明暗,不代表物理照度、逆光或光照均匀度检测。
96 项相关回归通过,覆盖不同背景、人脸 ROI、RGB 权重、通道格式归一、真实模型亮度输出、前/侧脸提示,以及过暗/过亮时仍可自动取图;需重编译预览后确认手机实际提示。此次不调整界面布局或已有语音文件。
## 正式拍照语音排查与恢复
用户反馈拍照没有语音。线上 step1-front、step2-rightface、countdown-321 三个 MP3 均可通过 HTTPS 读取,返回 200;手机实际无声原因仍需新版真机确认。代码检查发现普通相机及 AR 姿态结果未接入 `VOICE.hint`,现按 H5 映射补齐正对、左右转头、距离及保持不动提示;同提示不逐帧重复播放,换步/倒计时清理过期提示。正式三步引导、拍摄完成、分析与失败语音保留。倒计时先等步骤引导收尾,最长等待 3 秒后继续,保留三秒视觉计时与取图逻辑;等待期间继续识别真实帧,姿态失效取消等待,旧回调不重启拍摄,语音不可用时不阻断流程。
播放器不再每次先 stop 同一个实例再复用。改为单通道、每条播放结束或打断后释放实例,下一条重新创建;旧实例迟到的 ended/error 不可清除新语音队列,同步回调错误不再导致空对象异常。可写属性设置播放音量,支持时关闭遵循静音开关,平台不支持的属性失败不会阻断拍照。测试页继续遵守此前的静音、无上传规则,不把专项测试页当作正式语音验收。
新增播放器和主流程语音回归,验证无 stop-before-first-play、停止后不复用、迟到回调隔离、排队/去重、异常资源释放及正式转头提示触发;未调整 UI 或语音文件内容。
本轮完整 236 项回归及共享 H5 校验通过,IDE 自动编译完成;手机实际听音仍待重新编译预览后的正式流程确认。
## H5 排版与交互还原
已通过只读 HTTPS 获取线上 `/state/` 的 index.html、style.css、ui-20261012.css、swipe-track.js;统一 CRLF/LF 后与本地内容一致。以这些源码为基准修正此前遗漏,不改变评分、报告文案、照片处理或推荐规则。
- 报告三组详情卡原先仅渲染当前面板、只能点击标签。改为全部面板挂载的横向轨道,检测图区域支持跟手拖动、短滑回弹、快划切换、边界回弹及标签联动,竖向手势仍用于阅读页面;文字区不绑定横滑。滑动中保持卡片高度,结束后按当前面板实测高度调整;侧脸标注异步完成后重新测量,避免裁切文案。
- 检测图和侧脸图新增与 H5 一样的点按放大/再次点击关闭。放大保留当前裁切、标记和图层;滑动释放不误触放大。离页、切换账号或缩放尺寸时取消未完成的手势/图片回调,避免私有图片重新弹出。
- 档案肤质轮播从“松手才跳页”补为 H5 跟手轨道,箭头、圆点同步,取消/纵向/多指手势不误选。
- 正常主页、登录和拍照页不再展示开发工具入口;需要专项验收时,开发环境显式启用 `cameraDebug:true` 可显示入口。主页游客状态恢复“游客模式”,去掉额外的游客操作按钮,保留 H5 的开始检测/我的报告;游客可从历史页进入淘宝登录,关闭小程序后不保存本次会话。报告页去掉 H5 没有的附加历史/结束体验按钮,历史入口统一从主页进入。
完整 227 项测试通过,新增 8 项针对手势、页卡高度、图层状态、放大与身份切换的行为回归。只读浏览器投影预览覆盖 320/375/414px 的主页、档案、报告布局,未见页面横向溢出,页卡宽高与当前面板吻合;预览脚本与截图保存在 `temp/check-h5-ui-layout.cjs`、`temp/h5-ui-restoration-20261102/`,使用项目测试样本,不能替代淘宝真机渲染/手势验收。共享 H5 校验通过。需重编译预览后核对真机横滑及放大体验。
## 正式主流程接入与回归
主流程沿用登录 → 启动页授权 → 档案 → 普通前置相机自动三连拍 → 压缩/上传 → 报告,入口为不带 debug 参数的 `/pages/camera/camera`。原页面与操作保持不变。首次进入、相机授权状态尚未确定时,自动衔接平台相机授权再开始取景;已明确拒绝时不反复请求,仍使用原权限提示与相册入口。
补充游客和淘宝账号两条多页面集成回归:运行实际登录、档案、拍照、上传客户端及报告页面代码,仅相机帧/JPEG、传输服务响应与平台 API 使用固定测试替身。验证游客不请求账号授权、不持久化档案/会话,正式账号保存自己的档案,普通业务三连拍完成后按 front/left/right 上传,只提交一个 request_id,渲染三组七项报告并打开 `1088839442533`;退出游客清除本次报告。完整 219 项回归通过,共享 H5 与本地模型产物校验通过;没有发起真实付费检测。云网关到真实服务和手机报告结果仍待验收。
## 当前状态:用户确认自动三连拍通过
用户已反馈“自动三连拍已经好了”,将最近交付的前置相机三步自动抓拍标记为当前手机真机通过;不推广为所有机型、后置或 AR 路线已通过。正式业务配置从临时上传优先恢复为 `photoInputMode:'camera'`,沿用同一普通相机识别与拍照实现;相册入口保留,页面布局和交互不变。正式业务离线回归使用实际配置验证三张照片只触发一次报告请求。
本轮再次确认远端 `juzi-taobao` 为 active,18081 `/api/health` 返回正常;这不代表小程序云网关已通。下一项仍是游客云服务访问、三图上传和手机真实报告,尚未完成端到端验收。
## 前置相机第二、第三张方向校准(LOCAL-FACE-20261102-09)
用户真机确认第二、第三张的角度方向相反:提示向左转头时,实际接受的是向右转头。普通前置相机的本地姿态判定现传入 `mirrored:true`,翻转 yaw 符号,等价于对调两个侧脸步骤接受的原始角度方向。步骤仍是正脸/右脸/左脸,原 UI、转头提示、遮罩、照片标签及提交顺序不变;后置相机仍用原映射,AR 对照及共享 H5 文件不变。保留 `08` 的放宽阈值,不以取消方向检查来放行。
相关 71 项测试通过,覆盖前置左右方向互换、错误方向提示、后置方向保留、小角度自动三连拍及照片输出顺序。随后用户已确认自动三连拍可用。
## 性别选择控件对齐 H5
档案页原先使用系统 `picker mode=selector`,现改为页面内底部弹层的单列 `picker-view`,复用已有出生年月/地区弹层容器。对照 `h5/js/app.js` 的 `genderPicker` 与 `h5/css/style.css` 的 mobile-select 样式:标题“性别”,选项“女/男”,左右“取消/确定”,50px 标题栏、220px 滚轮、44px 淡黄色选中框、90% 内容宽度及安全区留白。触发框沿用原样,滚动仅更新草稿,确定后更新性别,取消不更改字段;再次打开定位已保存选项。出生年月与地区的原实现保持不变。需重新编译预览后确认手机实际观感。
## 最新真机反馈与角度调整(LOCAL-FACE-20261102-08)
用户提供 `07` 真机诊断:WebGL 自测及预热通过,使用 `quad_strip`,本次识别约 70ms,已自动拍下一张;第二步仍为 `quality.reason=angle`。因此初始化及性能已在该设备本次测试通过,左右三连拍尚未完成。
按用户要求先放宽转头条件:仅在淘宝普通相机本地识别中,将侧脸 yaw 范围由 24–84° 放宽到 12–100°,侧脸 pitch 上限 29° → 40°、roll 上限 22° → 30°。只接受原单人、清晰度、完整取景和居中检查都通过后的侧脸角度失败;方向、正脸条件、450ms 稳定保持、倒计时及 1.5 秒新鲜度限制保留。共享 H5 文件、正式上传优先配置、页面布局与按钮交互不变。`08` 仍需真机确认能否完成三连拍。
## 最新约束:保持原有 UI 和交互,优先完成上传与报告
用户要求 UI 和交互与原来保持一致。本次撤回此前新增的上传面板、登录协议样式、动态按钮文案、入口版本标签和额外提示弹窗;恢复原相册/快门/切换镜头控制条、取景引导和步骤提示。保留接口修复及同步异常后解除按钮锁定的逻辑,不因排障新增业务页面或改变页面布局。
游客入口真机错误已确认为 `FAIL BIZ TARGET SERVICE_ERROR:.访问目标服务异常`。淘宝专用 Node 健康,最近日志未见对应新请求,仍需核对云应用、私网 CLB 和 SAE Java 转发服务;尚未确认云端根因,也未完成手机端报告验收。用户可通过此前提供的阿里云 SAE 控制台链接登录;代开浏览器的系统命令被自动权限检查拒绝,未绕过。
用户已调整优先级:实时抓拍继续保留,先验收三图上传闭环。`photoInputMode:'upload'` 在正式业务页跳过尚未通过真机验收的 GPU 初始化,沿用原相册图标选图、快门调用系统相机和三步引导,不显示独立上传面板。复用压缩、分片上传、同一 request_id 重试及报告跳转;游客身份与本次会话规则保持不变。开发版实时识别入口独立保留。
本轮联合测试 209 项通过;后续文案微调的相机回归 60 项通过,共享 H5 规则校验通过。已将匹配的 server.js、lib/*.js、js/*.js 更新到淘宝专用 `/home/ubuntu/juzi-tb/h5`,服务 `juzi-taobao`、端口 18081。备份为 `/home/ubuntu/juzi-tb-backup-upload-20261102.tar.gz`,数据目录 `/home/ubuntu/juzi-tb-data` 保留。发布检查曾因幂等标记查错文件触发自动回滚,修正后重新部署成功。健康检查、游客路由的同意门槛及 challenge 已在服务器通过;未调用真实付费检测,手机三图生成报告仍待验收。未部署 H5/小红书服务或统一后台。
手机验收:重新编译并预览 → 扫新码 → 正常游客体验或淘宝登录 → 开始检测并填写档案 → 依次选择三张照片 → 查看报告和商品跳转。此轮无需进入“相机测试”。以下相机版本记录为此前专项历史。
## 目标与基准
- 以 `https://1me.xajuzi.com/state/` 的页面、评分、七项报告、三视角拍摄和等待动画为基准。
- 源码起点 `381da60`,工作分支 `codex/taobao-h5-adaptation`。已重新 fetch;远端当前与此起点相同。
- 线上 HTML、评分及报告呈现文件已核对。本地 `app.js` 多了小红书渠道兼容,拍摄规则来自同一份 H5 源码。
- 淘宝账号替代微信;保留游客体验,游客报告仅本次可见。三档推荐文案保留,全部跳商品 `1088839442533`。
- 游客入口不需要淘宝账号登录/账号授权,不读取账号资料;仅在同意隐私告知后建立临时检测会话,相机权限在实际拍摄时申请。账号恢复不能阻塞游客按钮,迟到的旧账号结果不能覆盖游客流程。
- 最新用户选择:颜色校验仅保留为可选开发工具,不作为拍照/导出/报告门槛。缺少格式且严格匹配四通道的帧使用应用默认 RGBA;平台明确格式优先。
- **实时识别抓拍是首版验收要求。当前主线为普通前置相机;手机已识别人脸且姿态合格,CPU 优化版单帧从约 10.7 秒降至约 4.25 秒,仍未满足实时要求,首版尚未验收。正在验证原生 WebGL 加速候选。**
## 平台测肤接入:新增前置核对(2026-11-02)
已核对淘宝公开的[《肌肤测试》接入文档](https://developer.alibaba.com/docs/doc.htm?treeId=635&articleId=118965&docType=1)。页面更新于 2023-01-29,因此具体可用能力、申请入口和运营联系人仍需以目标应用后台及当前运营确认为准。
文档分别列出平台算法与自带算法两条路线。项目实际使用 Face++ 测肤服务及自有评分、报告逻辑,按“自带算法”路线准备;该路线仍有商家资质、ISV 调用资质、方案确认和测肤白名单流程。用户已反馈“尚未申请/目前不清楚”,当前按测肤白名单未申请、ISV 资质待确认处理;不能以已有 AppKey、可预览或普通相机可调用代替开通确认。
### 修正后的推进顺序
1. 核实天猫品牌/美容护肤主营类目、对应应用及 ISV 资质,确认自带算法测肤白名单进度。申请材料说明现有算法供应商、三视角采集、用途、数据流和游客仅本次查看规则;提交申请、联系运营由商家确认后执行。
2. 获得该应用实际开放的拍照组件及接入契约,确认能否实时取景、获得人脸点位或识别结果、自动触发抓拍、连续采集三视角并返回可送检照片。**首版 H5 式实时识别抓拍目标不变**;若组件限制影响目标,先确认平台支持的实现方式,再确定正式相机方案。
3. 增加拍摄前的品牌方人脸采集告知和明确授权,覆盖正式账号、游客及相机测试入口。当前登录页展示的是 Face++ 政策,不等于品牌方采脸弹窗。需要补齐品牌主体名称、完整面部图像用途、14 周岁承诺、肖像使用范围、实际第三方处理情况、品牌隐私链接及退出/删除联系方式;拒绝后不启动采集。官方模板的营销共享条款需按实际业务与当前审核口径确认,不能直接扩张实际共享用途。
4. 在平台要求的测肤结果边界适配一级 `skinType`:0 干性、1 中性、2 混合偏干、3 混合偏油、4 油性。先确认回传接口和现有算法结果的语义,再加适配;不直接改 H5 历史结构,也不用问卷答案冒充算法检测结果。品牌七项报告继续沿用。
5. 平台接入、采集授权与相机能力一起验收,再进行真实三连拍、报告、商品回跳及部署验收。
文档没有写明“所有普通相机调用一律禁止”,也没有证明原生 `ar-camera` 就是白名单开放的测肤拍照组件。目前的 AR 实现和测试页保留为技术验证;即使真机有帧、能导出 JPEG,也不能据此认定测肤业务已获平台准入。正式拍摄组件方案仍待上述信息确认。
### 对接说明草稿(尚未提交)
业务为 1'me 以我品牌的淘宝小程序肌肤检测及产品推荐,拟采用自带算法接入:第三方 Face++ 提供检测能力,品牌系统负责评分、七项报告及推荐,算法服务不是品牌完全自研。参考 H5 为 `https://1me.xajuzi.com/state/`,首版要求页内实时识别、稳定后自动抓拍正脸/右脸/左脸三张图,随后生成报告并跳转指定天猫商品。
拟使用平台对该应用开放的测肤拍照组件,保留 H5 的引导与报告规则。图像经品牌后端送算法服务;淘宝与 H5 的身份、报告存储隔离,管理后台可只读汇总。游客按当前实现仅在本次会话处理,品牌后端不将其照片和报告写入持久目录;这不代表已确认第三方算法服务的保留规则。正式账号的照片/报告保留及删除规则仍需确定,再同步到隐私告知。
商家需补充:应用名称及 AppID、品牌运营主体、店铺主体及主营类目、开发主体/ISV 信息、品牌隐私政策链接与用户联系渠道。申请时不附密钥或用户照片。
需向当前平台运营确认的问题:
- Face++ 加品牌自有评分的接入分类、所需资质及当前申请流程是什么?
- 开通后提供哪个插件/组件、版本和技术文档?是否支持三视角实时识别抓拍、自定义引导、合适的图像导出和自带算法送检?若由组件内置识别抓拍,如何取得结果和处理取消/失败?
- 现有原生 `ar-camera` 能否用于该业务,还是必须使用另行开放的测肤组件?不能把两者视为同一权限。
- 采脸授权的现行文案、品牌及第三方披露要求是什么?游客是否有额外接入规则?
- `skinType` 通过哪个接口/事件返回,是否要求同时回传其他结果字段?
### 白名单申请的执行路径
优先由店铺负责人联系已有的天猫美容护肤行业运营,说明要申请“自带算法测肤拍照组件白名单”,请其确认当前测肤产品运营及接入要求。没有对接人时,可登录[淘宝开放平台支持中心](https://open.taobao.com/supportCenter)创建接入咨询工单,请求转接对应业务负责人;[官方支持中心说明](https://developer.alibaba.com/docs/doc.htm?articleId=849&docType=1&treeId=456)确认开发者可在线提交问题/需求。工单用于咨询和转接,正式白名单仍按业务运营给出的流程申请。
建议材料包括店铺链接与类目、品牌和开发主体、ISV 资质状态、目标应用名称及 AppID/AppKey、Face++ 与品牌评分的数据流、三视角实时抓拍流程、采集授权和隐私规则。先取得方案确认,再向当前运营指定的渠道提交白名单申请;通过后核对获批应用、组件/接口范围及技术文档。
公开测肤文档中的运营花名来自 2023 年,未公布邮箱,不推测联系地址。文档“申请插件”链接指向[能力中心](https://open.taobao.com/ability),本次未能验证登录后的申请按钮及能力 ID 63 的开放状态。也可从[文档关联提问入口](https://developer.alibaba.com/support/create_or_edit_problem.htm?docId=118965&docType=1)登录后咨询。
可发给运营/工单的说明:
> 我们计划为 1'me 以我品牌接入淘宝小程序肌肤检测,使用第三方 Face++ 检测及品牌自有评分、报告,申请自带算法路线的测肤拍照组件白名单。首版需要页内实时识别、稳定后自动抓拍正脸/右脸/左脸三张图,并支持默认前置和前后置切换。请协助确认商家及 ISV 资质要求、当前申请入口/材料和业务对接人,并提供对应组件文档。应用名称/AppID/AppKey、品牌主体及开发主体待补充。
## 已实施
1. 独立登录首屏、淘宝/游客两入口、协议、失败重试、正式账号签名恢复和宿主换号隔离。
2. 游客临时 Bearer 仅内存保存;账号库、照片、原始结果、报告和侧脸缓存不落盘。商品页往返保留本次报告,主动结束或冷启动清理。
3. 通过网关完整签名与单用 challenge 建立游客,不要求 open_id,不接受客户端自报身份。正式 `/me` 对照当次签名身份与旧 Bearer,避免宿主换号后继续显示旧报告。
4. 上传和临时任务按 Bearer 会话隔离;稳定 request_id/photo_refs 复用原任务。队列满不消费照片,提交响应丢失重试不会重新收费;过期任务返回明确错误。
5. 拍照蒙版从 H5 原始 SVG 轮廓生成本地 PNG,包含三方向定位线;图标、完成卡、扫描等待画面与 H5 对齐。`scripts/build-camera-assets.cjs` 可重建。
6. 补齐原来缺失的 `compress-canvas`;补压缩超时、迟到回调隔离、上传/恢复阶段提示及错误重试。切后台后继续查询原任务。
7. 推荐跳转统一固定商品 ID,防重复点击,提供缺 API/失败反馈;新报告、游客报告、历史报告的内存状态保持。
8. 后台可按显式配置只读汇总 H5 与淘宝的数据源,支持渠道筛选、分页、统计、详情、照片、原始 JSON 和 CSV。外源重试禁止写入。详见 [后台部署说明](TAOBAO-ADMIN-CHANNELS.md)。
游客入口补充修复:登录页恢复旧账号期间允许直接选择游客;选择后立即失效旧账号恢复、清除旧 Bearer 和本地账号缓存。游客 challenge/guest-session 不携带旧账号 Bearer、不调用 `scope.userInfo` 授权;旧 `/me` 成功或 401、以及其迟到页面回调均不能重定向/覆盖游客流程,游客请求失败也不会自动恢复旧账号。用户未同意告知时仍保留原会话并提示同意,不提前切换身份。相关 API 时序与页面回归共 32 项通过。服务器游客接口仍需部署配套版本后才能手机验收。
## AR 实时相机
普通 `camera` 的实时帧监听在旧官方文档中标为暂不支持。本轮改用淘宝原生 `ar-camera`,模式 `faceDetection2D`,事件 `onARFrame` 返回人脸框及 106 点坐标。
### 默认前置与镜头切换(初始排查,最新映射见后续更新)
用户反馈页内实时相机实际打开后置。原先的 `devicePosition: 'front'` 只绑定普通 `camera`,没有传给正在使用的 `ar-camera`;AR 分支的切换按钮也只弹出提示。现已向 AR 组件显式绑定驼峰属性 `devicePosition`,默认请求前置;正式拍照页右下角的翻转图标增加“切换镜头”文字,测试页也提供同名按钮。
点击切换会卸载旧组件,再按新方向挂载;取消旧倒计时、清除旧帧,并按相机会话标记丢弃旧组件的帧/停止/错误事件。已拍好的照片和当前拍摄步骤保留。人脸原始坐标仍沿用现有约定,不仅凭镜头方向推断坐标是否镜像。
本次镜头改动的相机、测试页及前端相关回归共 **28 项通过**,覆盖切换取消倒计时、旧组件事件隔离、已拍照片保留,以及测试页切镜头/重开时共享画布导出串行。这些是逻辑验证,不代表真实镜头已切换。
证据边界:淘宝缓存文档只描述按 `mode` 自动选择相机,没有列出方向参数。`devicePosition` 的明确字段及默认后置来自 [Taro 官方 Alipay 适配源码](https://github.com/NervJS/taro/blob/main/packages/taro-platform-alipay/src/components.ts#L155-L164),因此本轮是基于共享组件线索的兼容修正,**尚未证实淘宝宿主接受该参数**。没有调用未证实的 AR 切镜头方法,也没有直接用另一套 `<ar>` 替换识别与图像导出链。必须在手机确认实际镜头改变,不能以页面状态变化或本地测试通过代替验收。
用户随后重新编译预览反馈“默认仍是后置”,所以当前前置请求补丁未达到目标,镜头控制问题仍未解决;不把请求方向状态当作实际镜头状态,也不认定未开白名单就是该故障的原因。下一步需要目标应用实际可用组件的方向控制契约及真机能力确认。
### 继续排查:已复现并修复的本地问题
1. 正式拍摄页原先在切后台时解除拍摄忙状态,但旧原生画布导出还可能继续。已用持久 Promise 队列串行化 AR 导出和普通三图压缩;队列跨后台/恢复保留,旧照片不能恢复到新会话。测试实际延迟 `canvasPutImageData` 回调,验证不会并发写同一画布。
2. 已复现补光后的 300ms 内相机报错/停止,定时器仍能采用旧帧进入下一步。现在会使未完成抓拍的序号失效、清除倒计时/补光/旧帧;已提交分析任务继续运行。
3. 三张照片拍完后,相机原先仍在等待动画背后运行。现在进入报告处理时卸载实时组件并清空帧引用;分析失败时保持关闭,主动“重新拍摄”才恢复。
4. 云 SDK 的 `init` 或请求 Promise 若不结束,原有轮询截止时间无法中断当前等待。传输层已新增独立超时:初始化 15 秒,每次云/本机请求 12 秒;迟到结果被忽略,初始化失败可重新建立。重试仍沿用原任务/request_id,不在超时处理里自动发起新检测。
上述是已复现逻辑缺陷的本地修复,不等于用户手机上的报告卡住根因全部确认,也未解决默认后置问题。
### 新的两组真机诊断
- 原 AR 测试页版本为 `AR-DIAG-20261102-01`,展示可读取的设备/淘宝/SDK 版本、实际 context 方法名、原生事件类型和白名单元信息。收到不符合标准结构的回调也会计数,避免将“有回调但解析失败”误认为没有帧。帧计数保存在 JS 中,页面与元信息扫描最多每 400ms 更新一次;点击“复制诊断”才写剪贴板,文字不含图像、landmarks 或身份。登录首屏入口已从左上角小字改为醒目的黄色“相机测试(开发版)”按钮,仍仅在开发环境显示。
- 从该页新增“普通相机对照测试”,对应 `pages/camnative/camnative`。默认请求普通 `camera` 的前置,支持前后置重挂,按本机淘宝 SDK 实现调用 `onCameraFrame(callback)` 返回的 listener 的 `start/stop`。区分“方法存在”“start 成功”和“实际收到尺寸、字节非空的原始帧”;3/10 秒无帧给出明确状态。手动拍照仅作本页临时预览,无账号/上传/业务存储依赖。
- 普通相机对照页只验证取景、原始帧与照片能力,**不作为实时人脸识别三连拍的替代方案**。有原始帧也不代表已有可用人脸关键点或已取得测肤业务准入。
### 淘宝专用后端只读核对
本次通过现有 SSH 授权只读连接 `ubuntu@1me.xajuzi.com`,确认 `juzi-taobao` 为 active,回环 `18081/api/health` 的 `ok=true`。读取 `/home/ubuntu/juzi-tb/h5` 四个业务文件的哈希/功能标记后,确认与本地版本不同:已部署路由没有 `guest-session`,会话模块没有本轮 `request_id` 幂等实现。没有读取凭据、用户照片或报告,也没有重启/部署/修改远端。
因此新版游客和完整幂等链路仍需匹配的 Node 版本才能进行手机验收。相机诊断页无需后端,可先独立验证。
本次代码完成后运行 **87 项自动测试全部通过**,共享 H5 规则校验通过;淘宝 IDE 再次本地编译完成,界面显示 0 错误、0 警告。没有调用真实 Face++ 检测。默认前置、真实切换和完整三连拍仍等待用户手机的两组诊断结果。
### 用户真机反馈与颜色校验(后续更新)
用户已确认测试页为 AR 相机真机验收,计数持续增加,默认后置但按钮可以切换。其提供的旧测试页文字显示:720×1280、一张人脸、几何姿态判断通过,`capturedImage` 为 3,686,400 字节的 ArrayBuffer,未带像素格式;JPEG 导出报“相机原始图像格式尚未确认”。这个长度等于宽×高×4,可确认四通道长度匹配,但不能据此决定 R/B 或 alpha 的位置。默认方向和导出问题仍单独处理,不直接反转枚举值。
已修正两个有本机淘宝 SDK 源码依据的画布兼容问题:JPEG 结果兼容直接字符串及 `dataURL`/`dataUrl`/`base64` 外壳,验证 JPEG MIME、标准 Base64 与 JPEG 标记;像素写入优先使用淘宝 `ctx.putImageData`,保留探测到的 `my.canvasPutImageData` 兼容路径。四通道原始像素恰巧以 JPEG/PNG 标记字节开头时,不能误按压缩图片解码。
AR 测试页现为 **AR-DIAG-20261102-04**:顶部固定“复制诊断信息”和“导出照片”,复制失败仍展示可长按选择的脱敏文字。按用户后续要求,默认直接导出,不再要求做颜色校验。`config.arPixelFormat='RGBA'` 只补缺少格式且严格匹配四通道的图像;平台明确格式、开发页显式预览格式及当前运行内可选确认的格式优先,显式未知格式和不匹配的字节长度仍报错。应用默认值不是已确认的平台通道规范,诊断分别记录 default 与 confirmed。
颜色校验移至正文高级可选区域:明确点击才冻结一次原图,不复制 anchors,并串行生成最长边 480 的 RGBA/BGRA/ARGB/ABGR 预览。可选确认仅留在本次运行、仅用于同一图像字段路径,不写 Storage、不上传;不做校验或冷启动后也可使用应用默认值。正式导出保留最长边 1280,跨机型颜色与方向仍需后续验收。
用户进一步确认,切到实际自拍画面时页面显示“请求后置镜头”。据此将当前 AR 的 `config.arFrontPosition` 设为 `back`,测试页默认请求该值;正式页分开逻辑镜头 `devicePosition` 与 AR 请求 `arDevicePosition`,默认逻辑前置对应此请求值,切换后使用另一请求值。轮廓和扫描线的镜像继续按逻辑镜头计算;普通 `camera` 仍按其标准 front/back 属性控制。该配置来自当前真机反馈,新版本首次启动是否直接自拍及其他机型映射仍需验证,不能视为淘宝所有设备统一反向。
普通相机对照页同步补充固定复制入口和可选择文字后备,版本为 **CAM-NATIVE-20261102-02**。本次相关修改后 **126 项自动测试全部通过**,共享规则校验通过。仍需真机验证候选颜色、方向和 JPEG;未部署 Node 或调用真实 Face++。
默认导出与 AR 请求映射修改后,再次运行 **131 项自动测试全部通过**,共享 H5 规则校验通过。新增覆盖未校验直接导出、默认值不覆盖平台格式或可选确认、正式三连拍采用默认格式,以及反向 AR 请求不改变逻辑蒙版/普通相机。此次仍未部署 Node 或进行真实检测。
手机更新预览时,需在 IDE“预览”弹窗生成新的预览二维码,再扫码核对上述版本号;只重新编译本地模拟器不能当作手机已更新。用户此前提供的页面尚没有新版本号/复制按钮,不以该旧页面验收新入口。
点位转换使用官方图示的语义对应:轮廓取 0、2…32,眉鼻眼取 33…63,嘴取 84…103。H5 的头部姿态、距离、居中及稳定性判断原样同步到 `vendor/`,由哈希校验,未将未注明单位的原生 Euler 值当作角度。
`useCapturedImage` 只在官方文档中承诺返回图像 ArrayBuffer,未完整注明字段和像素格式。实现读取实际帧结构,支持可识别 JPEG/PNG 和明确标注的 RGBA/BGRA/ARGB/ABGR 四通道图像;按用户最新选择,缺少格式且严格匹配四通道的图像可使用应用默认值直接导出。显式未知格式或尺寸/字节不匹配仍明确报错。没有调用未记载的 AR `takePhoto` 方法。
### 真机验收步骤
后续手机反馈已确认:AR 反向请求映射仍未实现默认自拍;普通 `camera` 独立诊断则默认前置、可切换、可拍照出图,并且原始帧计数持续增加。因此继续验证“普通前置相机 + H5 本地人脸模型 + 原生 JPEG 自动三连拍”路线,不能把原 AR 请求映射标为修复成功。
用户反馈复制按钮与系统时间重叠后,两个诊断页的固定工具栏均新增顶部安全间距:读取可用状态栏/安全区/标题栏高度,最少留出 112px,正文同步下移。普通相机版号更新为 **CAM-NATIVE-20261102-03**,AR 页为 **AR-DIAG-20261102-05**。同时修正普通诊断页版号在 `Page()` 后才赋值导致初始页面可能不显示版号的问题。两页相关 39 项本地回归通过;真实手机点击位置仍待新预览验证。用户已要求跳过颜色校验,不再把此操作作为本轮测试步骤。
### 普通相机本地识别接线(最新实现)
`config.captureMode` 已切为 `native-face` 开发候选:普通 `camera` 默认请求 front,单次实时帧推理后复用 H5 的姿态、居中、距离和稳定判断,再倒计时并用同一相机 `takePhoto` 获取 JPEG。正/右/左三张接入既有顺序压缩及单次任务提交;手机已提供普通帧宽高/字节数,实际本地识别、方向和耗时仍须手机验证。显式未知格式、额外行填充及旋转标记会报错,不静默改成手动拍照。
`utils/native-face.js` 使用从 H5 原有 face-api 和量化权重构建的本地 CPU 副本:无需 DOM、网络、Node 模块或 Face++。无 DOM 环境下以仓库本地参考图真实跑过检测及 68 点,专项 6 项通过;模型占 68 个 tensor、2,200,644 字节,最后一个引擎释放后回到 0。CPU 耗时受环境影响,本机直接 Node 连续参考帧约 369–552ms,隔离 VM 约 1s;均不代表手机表现。构建脚本去掉了库内会被 IDE 静态解析的 Node-only require 分支,保存原版权声明、许可与来源 SHA。
帧缓冲在返回宿主前同步缩采样为独立 RGB,避免原生复用缓冲改变待处理图像;并发推理前先跳过忙帧。倒计时末若检测未完成,最多等 1.5 秒复核新结果,仍从原始帧采集时刻判断新鲜度;连续 3 次识别超过 1.5 秒会明确停止。切后台、换镜头、相机异常及报告处理清理监听和倒计时,旧推理/旧快门回调不能推进新会话。
本轮最终 **163 项自动测试全部通过**,H5 共享规则逐字节校验及本地模型构建校验通过。没有调用真实检测或部署服务。手机仍需验收帧格式、速度、方向、自动三连拍及报告闭环;当前服务器缺少新版游客接口的部署事实不变。
后续用户提供 `AR-DIAG-20261102-05` 的 iPhone15,2/iOS 27.0.1/淘宝 10.68.10/SDK 2.9.4 诊断,`frameEvents=0`、context 未观测。该记录属于 AR 页,并非普通相机元信息;无帧不能推断能力不支持。为减少误入,登录与首页开发测试入口现直接进入普通相机,AR 页普通相机按钮移至固定工具栏。两测试模式用 `redirectTo` 切换,避免反复堆叠页面;原生拍照或 AR 导出进行中不允许切换。用户当前手机版本仍可使用正文旧“普通相机对照测试”入口,无需为取得元信息反复生成二维码。
### 普通相机真机帧已取得
用户随后复制 `CAM-NATIVE-20261102-03`:同一设备上 `requestedPosition=front`,`takePhoto/onCameraFrame/listener.start/stop` 均存在,监听启动成功,145 个回调均有有效原始字节。图像为 **540×960、2,073,600 字节**,等于宽×高×4,未报告 pixelFormat/format。这证明有紧密四通道长度的连续图像,不能仅凭长度认定通道排列;按用户要求沿用应用 RGBA 默认值,不要求颜色校验。`nativeReady=false` 只表示该事件未观测,正式流程不依赖此标记;当前这份诊断未发起手动拍照,照片能力仍来自此前用户真机反馈。
真实模型新增同尺寸竖屏回归:参考图等比居中放入 540×960,缺格式采用默认值,并在 detect 调用后立即复用清空原生缓冲。实际检测出 1 脸和 68 点,输出坐标合法,tensor 无增长;模型专项现 **7 项通过**。本机隔离环境约 937ms,仍不代表手机性能。
普通诊断页更新为 **CAM-NATIVE-20261102-04**,顶部增加“开始自动三连拍测试”。该入口使用独立 `debug=local-face` 开发模式复用正式相机的识别、稳定判定、倒计时、正/右/左自动抓拍和 JPEG 压缩,完成后仅本页临时预览,不要求账号/档案、不上传照片、不创建检测任务、不播放远程语音。已有手动 `debug=camera` 保持原来的本机直连开关。测试入口不等于正式报告验证;已有账号的应用全局前台恢复仍可能独立进行 `/me` 校验,因此不宣称整个应用全程零网络。
自动验收版本为 **LOCAL-FACE-20261102-01**,复制入口避开状态栏,包含安全的 stage/failureStage、模型耗时、人脸计数、拍照计数及独立 canIUse 信号。正式相机属性与已验证普通诊断页对齐为 `mode=normal`、`frame-size=medium`。新增隔离回归验证不调用账号 API/getApp/discardAnalysis,不改变其他正在进行的检测;旧模式开关和生产环境登录门槛不放宽。最终 **169 项自动测试全部通过**,共享 H5 规则和模型构建校验通过;没有真实检测或部署操作。
### 手机性能阻塞与后续优化
用户在 `LOCAL-FACE-20261102-01` 实测:`canIUse.camera/createCameraContext=yes`,6 次处理后在正脸阶段因 performance 停止;最后一帧 **10,734ms、1 脸、quality=ok**,源图仍为 540×960/2,073,600 字节。连续 3 个结果超过 1.5 秒后正确停止,照片计数 0。问题是当前识别链路耗时,不能以姿态合格认定抓拍可用,也不能把结果完成时间改作源帧时间或直接放宽门限。
新版诊断 `LOCAL-FACE-20261102-02` 分开记录原始回调、忙时跳过、实际检测次数及 snapshot/model/tensor/detector/landmark/total 耗时,区分配置请求值和引擎实际值,补运行时版本。正常推理期间的额外 100 个回调只跳过、不复制图像、不刷新 UI;桥接的大帧交付和 GC 仍需实测排查。
连续预览改用官方 [camera 的 small 帧尺寸](https://developer.alibaba.com/docs/doc.htm?articleId=120279&docType=1&treeId=776),降低原生到 JS 的图像传输量,原生正式拍照仍保留 high JPEG。轻量 68 点模型来自 [face-api 官方权重](https://github.com/justadudewhohacks/face-api.js/tree/master/weights),单独存于 `reference/native-face-models` 并记录来源/SHA,H5 原始模型不改。定位输入为 160、缩采样最长边保留 320;本地等比双脸图在 128 输入只检出 1 脸,故未用该更低尺寸作为默认值。
CPU 优化仅将旧 TFJS padding 的逐标量坐标换算改为连续 typed-array 块复制,数值运算顺序不变;1×1 卷积实验未取得稳定收益,未纳入发布候选。原 540×960 参考竖屏的同图隔离基准约 full256 934ms → tiny160+pad-copy 302ms(约 3.1 倍),模型张量内存约 2.20MB → 1.08MB。更低 maxSide 192 仅省约 24ms,未采用。上述是本地基准,手机的输入帧大小、计算时间及自动抓拍仍待 `LOCAL-FACE-20261102-02` 验收。
上一轮还完成 `temp/native-face-webgl-prototype.cjs` 离线原型:显式 WebGL1 注入可在屏蔽 DOM 的模块中通过矩阵乘法、卷积、读回及真实 68 点模型,数值自测确实执行 shader;没有网络请求。但这不证明淘宝手机原生 GL 已兼容。原型阶段尚未启用 enableSkia 或 GPU;在本轮 CPU 真机复验后,已继续实现下述 WebGL 候选。MNN 插件未接入。
本轮最终 **174 项自动测试全部通过**,共享 H5 规则与模型生成校验通过。独立复核额外对照 250 组 padding,覆盖 rank 1–5、空维、TypedArray 偏移和特殊常量;多模型失败恢复与最终释放均通过。源帧 1.5 秒新鲜度、连续 3 次慢结果停止、多人拒绝和调试页无上传规则保持不变。
### 原生 WebGL 候选(后续开发)
用户在 `LOCAL-FACE-20261102-02` 复验:small 帧已实际降为 **360×640/921,600 字节**,tiny/160/pad-copy 均生效;处理 3 帧后仍停在 performance,最后一帧总耗时 **4,253ms**,其中 snapshot 8、model 29、tensor 0、detector 2,373、landmark 1,843ms。人脸及姿态合格,照片计数仍为 0。这证明减少传输量已生效,而本轮主要瓶颈在模型 CPU 计算,不能继续用同一 CPU 方案宣称实时通过。
下一候选 `LOCAL-FACE-20261102-03` 使用官方 [同层/离屏 Canvas](https://developer.alibaba.com/docs/doc.htm?articleId=118999&docType=1&treeId=776) 的原生 WebGL1 入口。按文档启用 `app.json.window.enableSkia:"true"`,配置明确请求 WebGL;不能仅以 API 存在或后端名称验收。初始化先做真实矩阵、卷积和 typed-array 读回数值自测,然后预热模型;数值错误、缺能力或上下文丢失时明确停止,不自动回到已证实过慢的 CPU。手机兼容性及速度仍待真机验证。
本机 SDK 的 legacy `createCanvasContext` 有原生 Canvas 兼容路由,保留现有 JPEG 压缩方式,不引入未证实的 2D 导出接口;文件读取结果补 JPEG 标记和标准 Base64 验证,防止把 PNG/坏数据标成 JPEG。真实手机仍需验证开启 Skia 后三张 JPEG 压缩、方向与覆盖层。
图形引擎的离线真实 WebGL 回归已验证:数值自测确实执行 shader 并读回;tiny/full 可共享同一后端;GPU 存活时拒绝切换 CPU;真实参考图输出 1 脸和 68 点;初始化缺浮点能力、读回错误及上下文失效均明确失败。挂起真实 Tensor 读回期间销毁 owner 不会提前删除资源,操作结束后旧结果被拒绝,模型及 Buffer/Texture/Framebuffer/Shader/Program/Renderbuffer 均释放;新会话可以重建。以上使用隔离 Chromium 软件 GL,网络请求为 0,不能当作淘宝手机支持或速度的证据。
相机在初始化时显示“正在校验图形加速并预热模型…”,模型就绪后才监听真实帧,初始化 30 秒未结束会停止并保留可复制诊断。GPU 错误会使原引擎退役,待其初始化/推理结束后才能重建,避免复用失效的缓存或在旧回调仍运行时删除资源。JPEG 的回调与 Promise 双通道都受同一完成标记保护,原生 binding 的 Promise 拒绝会被处理,不引发未处理拒绝。
本轮联合回归 **190 项全部通过**,其中 4 项使用真实隔离 WebGL,覆盖数值自测、模型、失败恢复、共享后端、挂起销毁、GPU 资源释放及重建。H5 共享规则与模型生成校验通过。没有上传手机照片、进行真实 Face++ 调用或部署后端;手机 GPU 自测、实时速度和三张 JPEG 闭环尚待版本 `03` 验收。
### WebGL 真机数值兼容(后续开发)
用户在 `LOCAL-FACE-20261102-03` 返回:原生 Canvas/WebGL 上下文创建及后端注册已走通,矩阵自测执行了 4 次绘制,但严格数值校验失败,`MATMUL_FAILED`,没有开启真实帧监听或拍照。旧诊断把错误长度、非有限值和误差统一归此码;`pixelReads=0` 当时只在卷积成功后更新,**不能解读为完全没有像素读回**。当前不能认定手机支持可用 GPU 推理,也不能绕过自测继续抓拍。
离线兼容实验故意破坏 `readPixels(FLOAT)` 返回后,默认路径确实矩阵失败;关闭 `WEBGL_DOWNLOAD_FLOAT_ENABLED` 时,TFJS 1.7 的真实备用路径将浮点结果编码为 RGBA 字节并用 `UNSIGNED_BYTE` 读回,矩阵/卷积及完整 68 点模型可通过。同样可关闭 `WEBGL_PACK` 使用非打包 shader。依据为 [TFJS 1.7 官方后端实现](https://github.com/tensorflow/tfjs/blob/tfjs-core-v1.7.0/tfjs-core/src/backends/webgl/backend_webgl.ts) 与 [开关定义](https://github.com/tensorflow/tfjs/blob/tfjs-core-v1.7.0/tfjs-core/src/backends/webgl/flags_webgl.ts),未强造 HAS_WEBGL、WebGL 版本或浮点能力。
版本 `LOCAL-FACE-20261102-04` 按 `default` → `packed_byte`(仅禁用浮点下载)→ `unpacked_byte`(再禁用打包 shader)逐档执行原有严格合成矩阵/卷积/读回自测,每次失败先释放后端和图形对象,通过才加载模型及预热;三种均不通过时明确停止,不回退 CPU,不放宽 1.5 秒源帧新鲜度。新增诊断包括 profile、attemptCount、合成输入的长度、非有限值数、误差及校验和,以及 floatReads/byteReads。合成指标和兼容配置仅在初始化阶段缓存,后续模型检测返回不能覆盖;不复制照片或人脸坐标。
版本 `04` 的联合回归 **194 项全部通过**,含 6 项隔离真实 WebGL 测试:浮点读回损坏后进入字节读回、前两档失败后进入非打包字节读回、三档失败停止、两条兼容路径与 CPU 参考图的人脸框/置信度/68 点几何对照,以及每次换档前图形对象释放。共享 H5 规则、模型生成及差异格式校验通过,淘宝 IDE 本地重新编译完成(DevServer: Done)。这些本地结果不代表手机 GPU、速度或自动三连拍已通过。
### 原生 WebGL 读回检查(版本 05)
用户在 `LOCAL-FACE-20261102-04` 真机复验:三档均 `MATMUL_FAILED`,最后 `unpacked_byte` 记录 5 次绘制、1 次 `UNSIGNED_BYTE` 读回,输出长度 256、非有限值 0、244 项不匹配、最大绝对误差 22、预期校验和 2786/实际 0。帧监听和照片计数均为 0。日志呈全零特征;聚合数据不能单独证明每个元素都为零,更不能据此确定是绘制、上传还是读回出错。此前代码在数值不匹配后即抛错,尚未检查该次 GL 错误,不能将“未报告 GL 错误”解读为“没有 GL 错误”。
版本 **LOCAL-FACE-20261102-05** 根据[淘宝官方 Canvas 注意事项](https://developer.alibaba.com/docs/doc.htm?articleId=121761&docType=1&treeId=829)使用 `getContext('webgl',{enable_flip_y_after_read_pixels:false})`,关闭宿主默认附加的 flipY。隔离实验中,按较大 canvas 高度转换较小 framebuffer 的读取坐标可以产生全零且 `getError=0`;这说明该路径值得验证,**尚未证实手机内部存在这种坐标转换**。
TF 初始化前增加 2×2 RGBA8 清屏/读回、非对称 shader、字节纹理及浮点纹理上传采样检查,使用固定非零合成输入和预填目标哨兵值,区分未写回 TypedArray、错误像素、shader 编译/链接、纹理上传及 framebuffer 问题。基础检查失败即停止并释放资源;通过后仍需通过原有严格矩阵/卷积数值自测、兼容档选择及模型预热。没有降低数值阈值、放宽 1.5 秒新鲜度或自动回到 CPU。
新增诊断保留 `nativeProbe` 阶段与规范化错误、各基础检查结果、哨兵改写数、不匹配数、预期/实际校验和、读回返回值类型枚举、GL 错误和 framebuffer 状态。TF 自测在数值判定前采集 GL 状态;这些固定合成数据只在初始化时缓存,实际人脸检测不能覆盖,不复制照片、像素或人脸坐标。手机兼容性、根因、速度及自动三连拍仍待版本 `05` 验收。
本轮 `05` 联合回归 **199 项全部通过**,含 8 项隔离真实 WebGL 测试。新增测试验证默认坐标转换模拟的全零现象、关闭翻转后的非对称小 FBO 及完整人脸模型,并覆盖未修改读回出参、仅返回 ArrayBuffer/TypedArray/Promise、编译/链接返回 0、顶点上传、字节/浮点纹理故障共 9 类原生预检失败。失败时未注册 TF 后端、未加载模型,各类 GPU 对象释放。共享 H5 规则及模型构建校验通过,淘宝 IDE 本地重新编译完成(DevServer: Done)。实际手机结果仍待确认。
### 原生绘制与兼容选路(版本 06)
用户在 `LOCAL-FACE-20261102-05` 返回:原生清屏读回通过,非对称 shader 检查失败,`changedBytes=16`、3 项不匹配、预期校验和 2244/实际 2040,`getError=0`、FBO 状态 36053(完整)。实际校验和与原清屏颜色的 2040 相同。离线真实 GL 中,停止索引绘制、剔除所有三角形或把顶点上传清零均可复现这一组指标,因此它不能唯一确定绘制未覆盖、坐标、缓冲区上传或 GL 状态中的哪一项是手机根因。帧数及自动照片仍为 0。
版本 **LOCAL-FACE-20261102-06** 显式规范化 GL 绘制状态及颜色写入,在每次绘制前清黑以区分旧背景和实际绘制。先以不依赖 `gl_FragCoord` 的恒色 shader 分别验证 `drawArrays` 与索引绘制,再执行原非对称 shader、字节/浮点纹理检查。新增 `constantArrays`/`constantIndexed` 结果、可选顶点/索引缓冲区字节数及固定合成的四个 RGBA uint32 像素值;缓冲区元数据查询不额外作为硬门槛。
原生兼容策略按 `standard` → `buffer_copy`(将上传视图的字节紧密拷为标准 `bufferData` ArrayBuffer)→ `framebuffer_y`(仅为已绑定 FBO 补偿 viewport/scissor Y)→ `combined` 自测选择。仅原生 `VALUE_MISMATCH` 允许尝试下一路,其余错误明确停止;不能按手机型号认定某路可用。四路在同一个上下文上进行,每次检查结束清理本路图形对象;后续 TF 的 `default`/`packed_byte`/`unpacked_byte` 仍最多创建三个上下文。任何被选中的原生策略都须通过全部原生检查及后续原有严格矩阵/卷积数值校验和真实模型预热,保留 1.5 秒源帧新鲜度,不回退 CPU。
诊断同时保留 `nativeInitialStage`/`nativeInitialCode` 与最终 `nativeStrategy`,避免兼容通过后丢失最初失败信息。四像素、缓冲区及分层结果均来自固定合成输入,只在初始化时读取缓存,实际人脸检测不能覆盖。实际手机的选路、兼容性、速度及完整自动三连拍仍待版本 `06` 验收。
本轮 `06` 联合回归 **204 项全部通过**,其中 9 项为隔离真实 WebGL 测试。四种原生策略分别在第 1/2/3/4 次选中;模拟继承绘制状态、typed view 上传失效、viewport/scissor 坐标转换及组合情况后,实际模型和 CPU 人脸框/置信度/68 点几何对照通过。紧密 ArrayBuffer 拷贝保留子视图偏移和长度;索引绘制不执行时明确停在 `constant_indexed`,不会因 `drawArrays` 通过而准入。10 类原生故障保持注册后端/加载模型前停止,所有张量和跟踪 GPU 对象最终为 0。共享 H5 规则、模型生成及差异格式校验通过;淘宝 IDE 本地重新编译完成(DevServer: Done)。上述不能替代版本 `06` 的手机验收。
### 固定四边形的索引兼容(当前候选 07)
用户在 `06` 返回 `nativeInitialStage=constant_indexed`、`VALUE_MISMATCH`,由执行顺序可知标准路径清屏和普通恒色绘制已通过。索引缓冲为 12 字节,但长度不能证明其内容和原生索引执行正确。最后 `buffer_copy_framebuffer_y` 的 `constant_arrays` 黑色失败属于后续候选,不能覆盖初始故障。真实帧数、照片仍为 0。
`LOCAL-FACE-20261102-07` 在每次原生索引绘制前显式重新绑定索引缓冲。若标准普通绘制通过、索引数值失败,则在相同 native 策略下验证四顶点 `TRIANGLE_STRIP`,保持原非对称像素、字节/浮点纹理和矩阵/卷积严格自测及真实模型预热。依据固定四点与 `[0,1,2,2,1,3]` 的[上游 TFJS 数据](https://raw.githubusercontent.com/tensorflow/tfjs/tfjs-core-v1.7.0/tfjs-core/src/backends/webgl/gpgpu_util.ts),第二个 strip 三角形与原第二三角形同为 `(2,1,3)`;这只适用于自有固定 quad。
实际转换同时要求精确的 `TRIANGLES/6/UNSIGNED_SHORT/offset0` 参数,以及已追踪上传的 12 字节固定索引内容。视图只读取其字节范围;未知缓冲、不同索引、数字分配、部分更新或删除后不再准入,错误为 `DRAW_UNSUPPORTED`。诊断明确 `drawStrategy=indexed/quad_strip` 和实际 strip 绘制计数;替代路径的 `constantIndexed` 保持 `not_run`,另以 `constantQuad` 表示替代输出,不宣称原生索引接口通过。原生自测最多 8 次且共用一个上下文,逐路释放检查对象;1500ms 新鲜度和不回退 CPU 的规则不变。手机是否能完成初始化、速度及三连拍仍待 `07` 验收。
本轮 `07` 联合回归 **207 项全部通过**,其中 11 项为隔离真实 WebGL 测试。模拟原生索引绘制完全无效后,标准原生策略在第 2 次原生检查选中 `quad_strip`,原矩阵/卷积、模型预热、实际人脸及 68 点与 CPU 几何对照通过。另验证 10 类错误调用/索引/缓冲状态均不会发出替代绘制,正确的 typed-view 字节边界仍能识别;资源和张量最终为 0。共享 H5 规则、模型生成及差异格式校验通过,淘宝 IDE 本地重新编译完成(DevServer: Done)。手机版本 `07` 尚未验收。
1. 在电脑淘宝 IDE 顶部点“预览”,等待预览弹窗打开。
2. 在弹窗内点橙色“重新编译并预览”,等待生成新二维码;仅本地重新编译不会更新旧的手机预览。
3. 手机打开淘宝,使用“扫一扫”扫描这个新二维码,进入本次小程序预览。
4. 在手机登录首屏点黄色“相机测试(开发版)”;已进入首页时在开发工具区点同名入口,进入普通相机诊断。
5. 在“普通 camera · 独立诊断”确认版号 `CAM-NATIVE-20261102-04`,点顶部“开始自动三连拍测试”。
6. 进入后确认版号为 `LOCAL-FACE-20261102-07`。若出现“开启实时相机”,点一次,并由用户处理系统相机权限。先等待基础图形检查、数值校验和模型预热,约 30 秒内无需重复点击。
7. 将正脸居中并保持不动,等待自动倒计时及第一张拍摄;随后按页面提示分别露出右脸、左脸,完成三张自动拍摄和压缩。
8. 卡住、报错或完成后,点页面上方的“复制识别诊断”,回到聊天粘贴文字;无需发送照片或校验颜色。后续再验证镜头切换、方向、切后台恢复及拒绝授权。
9. 相机路线通过后,在配套服务版本下验收稳定识别 → 3 秒倒计时 → 三步完成 → 报告,分别测试游客和淘宝账号。再次核对左右脸语义与图层坐标。
## 已运行的验证
- 淘宝前后端、游客无落盘、幂等、账号隔离、AR 几何、相机生命周期及后台汇总共 **61 项本地自动测试全部通过**;共享 H5 评分/报告/拍摄规则哈希一致。
- H5 四组浏览器回归:游客不留存、HTTPS 微信登录/退出/跨设备、报告/后台/CSV、异步侧脸图层。
- Java Maven `clean package`,11 项测试通过;转发层原样支持新增路径,无需新增 Java 业务接口。
- IDE 曾发生桌面进程崩溃;已发现 Crashpad 转储,JavaScript 日志未记录对应原因。重新打开后再次编译成功(0 错误、0 警告),未新增转储;尚未确定首次闪退根因,不直接归为 AR/GPU。
## 部署顺序与边界
淘宝专用 Node 已按上方“最新目标”记录备份并更新游客、签名恢复和幂等代码;H5 及小红书服务尚未更新。本轮未迁移或删除业务数据,手机完整报告及真实网关仍待验收。
1. 先完成平台测肤资质/白名单及开放拍照组件核对,补齐采集授权;在平台支持的组件方案上通过实时识别、图像导出和三视角抓拍验收。现有 AR 验证只是其中一项技术验证。
2. 部署淘宝专用 Node,保留独立数据目录;验证新登录/游客/三图报告,不向 H5 生产数据目录写入。
3. 发布含本轮后台改动的 H5 管理服务,并明确配置只读 `JUZI_ADMIN_TAOBAO_DATA_ROOT`,保留原 nginx 管理员认证及 `/tb/` 网关入口限制。
4. 运行大报告/历史详情的真实网关与低内存手机测试。前台最近 5 份只是索引规则,后台照片和快照尚无自动清理;保留周期、备份、删除另行确定,不能直接合并两个文件目录。
5. 验收正式/测试环境隔离、资源域名、商品详情能力、新老报告及游客回跳;关闭开发入口。按现有交接要求处理测试凭据后再正式发布。
### 明确未完成
- 平台自带算法路线的商家/ISV 资质和白名单状态确认、正式拍照组件契约、品牌采脸授权及平台结果字段适配。
- 普通相机在手机上的本地模型耗时、转头方向、覆盖层和完整自动三连拍。AR 保留为可选对照,其默认自拍和图像导出仍未验收。
- 手机上的新游客接口和原系统相机报告故障最终复验(本地回归已覆盖,线上尚未更新)。
- 固定商品真机跳转、真实网关大响应及后台跨源上线配置。
- 照片保留/清理周期、数据库/对象存储迁移;本轮没有自动删除旧数据。
+40
View File
@@ -0,0 +1,40 @@
# H5 / 淘宝统一后台:数据源与部署
统一后台增加“全部渠道 / H5 / 淘宝小程序”筛选,覆盖记录、统计、失败记录及 CSV 导出。用户账号、照片、报告仍由各自业务服务保存,不做账号合并,也不改变 H5 用户历史。
## 默认行为
未配置额外数据源时,后台继续读取当前服务的 `JUZI_DATA_ROOT`,现有 CSV 列顺序和末尾“渠道”保持不变。新接口返回数据来源标识,不公开服务器目录。
## 接入淘宝只读数据源
在承载管理后台的 **H5 服务**环境中配置绝对路径,例如:
```ini
JUZI_ADMIN_TAOBAO_DATA_ROOT=/home/ubuntu/juzi-tb-data
```
需要部署本次修改的 `h5/server.js`、`h5/lib/admin-sources.js`、`h5/admin.html` 和 `h5/js/admin.js`,然后按原部署流程重启该 H5 服务。淘宝业务服务继续使用原数据目录。管理后台进程需要拥有该目录的只读权限;两个服务不得共用同一可写 JSON 目录。
该配置只增加管理读取,不修改淘宝账号库或检测文件,不迁移、不删除历史数据。配置的额外目录必须存在,与当前数据目录独立,并位于 H5 静态应用目录之外;配置错误时服务启动会明确失败。
后台仍经原有 nginx 管理员认证入口访问,保持业务服务监听回环地址;不要为汇总功能放开 `/tb/` 网关白名单或取消后台认证。小红书隔离实例不接入此额外数据源。
## 路由与数据口径
- 每条汇总记录包含 `source`、`admin_key` 和只读状态。两个数据源即使有相同记录 ID,也能分别打开正确详情、照片和原始 JSON。
- 详情和媒体请求通过后台已有路径的 `source=local` 或 `source=taobao` 选取已配置的数据源;HTTP 请求不能指定任意文件目录。
- 全局列表先合并再筛选、稳定排序和分页。CSV 沿用所有记录筛选条件,导出日期可另选;接入额外数据源后,仅在原有列末尾追加“数据来源、记录唯一键”。
- 按渠道统计检测记录及拍照流程;游客不留存的检测不会伪装成已保存报告。拍照漏斗是匿名客户端事件,不能视为人数或计费次数。淘宝尚未上报漏斗事件时显示零事件,不据此推断淘宝无检测。
- 外部数据源为只读:详情和失败页关闭重试按钮,服务端也拒绝外源重试。重试必须在原服务后台进行,避免写入错误渠道。
- “用户最近 5 份报告”是用户历史展示范围,不是后台磁盘保留期限。本次不会删除旧照片、原始结果、报告快照或检测记录;清理与备份周期需另行约定。
## 验证
```powershell
node --test taobao/test/admin-channels.test.cjs
$env:JUZI_ADMIN_BROWSER_QA = '1'
node --test taobao/test/admin-channels.test.cjs
```
测试使用独立临时数据目录和伪造图片,不请求 Face++,也不访问或修改线上数据。浏览器验证截图位于 `temp/taobao-adaptation/admin-channels.png`。
@@ -0,0 +1,24 @@
# 七项护理选三与关注优先
护理候选项按文档顺序为轮廓、皱纹、肤色、平滑度、水分、泛红、痘痘。使用未四舍五入的百分制分数,缺失/无效项剔除。
肤色独立采用color_score(扣斑后的色素80%+黑眼圈20%);水分独立采用已有交叉校验后的水分项。其他项使用各自现有P。肤色分析文案和护理分档同步使用独立肤色项,黑眼圈描述仍按真实检测程度;水分独立分档保持。小项P仅用于展示与护理选择,不调整维度或综合分。
1. 依小项P升序选择全局最低三项;同分时,当前优先关注大维度中的项优先,再按上述文档顺序固定选择。
2. 只在入选三项中,将属于优先关注大维度的项整体前置;组内P仍低分优先。不额外插入高分的关注项。
3. 各项按自己的分档取护理和生活方式全文拼接。低于80改善、80至低于90良好、90以上优秀;保留痘痘脓包/结节改善档、有皮损不优秀等现有文案约束。
4. 不足三项按有效数量展示,全部缺失显示原有无建议提示。
用户提供的本轮护理全文同步到reference和report-copy。原文比对发现皱纹改善档生活方式需删除旧版开头“保证睡眠,”,本次已修正,其余沿用用户提供文本。
新presentation新增careRule=seven-lowest-three-focus-first-v1;care条目保留key、name、dim、P、level、priority供核查,页面仅展示原有编号和正文。产品三选一、原始评分、历史快照和历史推荐不变;历史保存的护理文案不批量重生成。
## 真实案例回放
R20261015-111214-JKNS:关注紧塑力。肤色58.8、皱纹73、平滑度88.9为全局最低三项,最终皱纹→肤色→平滑度;水分97不入选,总分94不变。
R20261014-171215-CYLF:关注紧塑力。皱纹65、平滑度87.6、肤色88.8入选,最终顺序不变;水分93不入选,总分94不变。
## 验证
care-selection.js覆盖关注项两项/一项/零项、全部关注项、同分选取与固定顺序、原始小数、不足三项/全缺失、肤色水分独立档位、痘痘约束、全文一致与输入不变。同步复测肤色、水分、账号、持久化、历史快照、推荐、报告底部长文案布局和旧评分回归。
@@ -0,0 +1,13 @@
# 产品推荐按报告展示肌龄
规则版本 `2026-10-16-display-skin-age-v2`,替代原来的原始肌龄推荐依据。
推荐年龄必须与报告顶部显示的肌肤年龄一致:使用当前实际年龄 ±5 岁展示修正规则后的肤龄;没有实际年龄则显示原始肤龄,并使用同一个值推荐。原始肌龄缺失时展示值也缺失,不以实际年龄代替推测产品。
仅改变年龄输入,原有分档和边界调整条件保留:<35 岁基础 I、35~不足50 岁基础 II、≥50 岁基础 III;32~不足35 / 47~不足50 岁紧塑力低于8升一档;35~38 / 50~53 岁紧塑力至少9且透亮感或稳韧力任一至少9降一档;不足32固定 I;其余按展示肤龄。不改变评分、年龄显示规则或报告结论。
新报告保存新推荐。历史报告重新打开、后台列表/详情/筛选/产品分布及 CSV 按当前展示肌龄只读更新产品推荐;历史检测记录与原始报告快照不批量重写,分数、结论与护理顺序保留。后台明确标示「展示肌龄(推荐依据)」,原始肌龄仅供复核。
案例 R20261016-152835-5C0Q:实际44、原始肌龄34、展示肌龄39;按39岁推荐主场精华 II,不再按34岁推荐 I。
验证包括推荐边界/未舍入分数、真实案例等价数据、缺失年龄、新旧报告产品一致、前后台筛选和 CSV、原始数据不变以及异步皱纹浮层回归。不调用付费检测接口。
+2
View File
@@ -1,5 +1,7 @@
# 产品推荐规则:肤龄基准与边界调整
> 2026-10-16 更新:以下为原始肌龄依据的历史规则说明。当前已改为报告展示肌龄,分档和边界条件保留;以 [产品推荐改用展示肌龄-2026-10-16.md](产品推荐改用展示肌龄-2026-10-16.md) 为准。
状态:2026-10-12 用户已确认三个边界条件;已实现。规则版本:`2026-10-12-skin-age-v1`。
## 已明确的要求
@@ -0,0 +1,13 @@
# 侧脸皱纹浮层异步加载
主测肤请求只等待主接口、评分及报告保存,不再同步等待左右侧脸的皱纹浮层。正面浮层随主接口返回。前端在报告切屏并经过两帧绘制后,才调用 `POST api/wrinkle-views`,获取该报告所属任务的左右侧脸标注。
加载期间保留两张侧脸原图,各在图片上覆盖半透明「皱纹标注 / 加载中…」提示,不在图片下方重复显示加载文字。完成或失败后自动移除提示浮层,图片尺寸、裁切和点击查看大图规则不变。完成后只更新侧脸图片,不重算分数、不改结论或护理建议。失败/超时仅退回原图提示,报告继续使用。扫描等待页 30 秒兜底只针对主报告,不受浮层任务影响;可选浮层请求前端预算 65 秒,服务端两次顺序请求仍各最多 20 秒、间隔 4 秒。
使用随机任务令牌并绑定服务器会话所属账号;不同账号无法获取他人的标注。重复/并发请求共用同一任务,成功或失败结果都缓存,不自动重新收费调用。微信任务元数据及完成结果持久化,补写原报告 payload、后台标注状态和原始结果,不修改 vm/presentation。服务重启后可继续尚未启动的任务;已标记运行但被重启打断的任务不自动重跑,避免重复调用。老报告无任务令牌时不自动补检。
游客任务、照片及结果仅存内存,五分钟后清除,不写游客身份或报告到磁盘。任务从未被报告页请求时,不调用额外侧脸检测。微信任务可从已有报告快照恢复照片,无需用户重新上传。离开报告或切换账号后,迟到结果不修改当前页面;返回同一报告可显示已加载数据。
后台 `cost_ms` 现在代表主报告检测链路,额外浮层耗时记录为 `wrinkle_cost_ms`。正常案例从进入报告的等待中移走固定的两次 4 秒间隔及两次侧脸请求耗时,但不承诺固定秒数,不减少已实际执行的接口调用数量。
验证使用隔离数据目录和模拟上游,不产生付费检测:主响应只有一次上游调用、报告显示后才请求侧脸、总共三次模拟调用、重复并发与重启缓存、不同账号隔离、游客无磁盘写入、离开/返回和失败降级,以及评分和报告结论不变。
@@ -0,0 +1,29 @@
# 原始肌龄显示与鸿蒙滚轮兼容
## 范围
- 生物可视年龄直接展示算法原始肌龄,取消实际年龄 ±3 的展示修正。原始肌龄缺失显示 `--`,不以实际年龄或旧展示值补齐,不额外四舍五入。
- 新报告 `skinAgeDisplay` 与 `skinAgeRaw` 相同,保留兼容字段 `ageClampNote` 但值为空。推荐规则、评分和其他话术不变。
- 历史报告回看优先读取快照 `vm.skinAgeRaw`;更老的快照缺该字段时,从保存的 `payload.result.skin_age` 读取。忽略旧的钳制展示值,不改写存量快照,不重算历史推荐/文案。后台列表、详情、CSV 的展示肌龄也改为读取原始肌龄,避免前后台不一致。
- `mobile-select@1.4.0` 原来通过 UA 字符串选择 touch/mouse,无法识别不带 Android 的 OpenHarmony/ArkWeb。保留上游版本与 MIT 声明,添加本地补丁:通过 `navigator.maxTouchPoints > 0` 或 `ontouchstart` 能力选择触摸事件,无触摸能力则使用鼠标事件;不再按系统名称判断。
- 同时按事件的 `touches/changedTouches` 读取坐标,不依赖全局 `TouchEvent` 构造函数,避免无该构造函数的鼠标环境报错。弹层挂载、页面禁滑和滚轮联动逻辑不变。
## 回归
```bash
node h5/test/raw-skin-age.js
node h5/js/skin-score.js
node h5/test/recommendation.js
node h5/test/regression.js
node h5/test/report-persistence.mjs
node h5/test/harmony-picker.mjs
node h5/test/profile-interaction.mjs
node h5/test/guest-privacy.mjs
node h5/test/report-touch-layout.mjs
```
鸿蒙专项使用 Chromium 的真实触摸/鼠标分发配合不同 UA 与能力配置,逐项验证年、月、省、市四轮、地区联动、确认回填、重开定位和表单提交。覆盖 Android 触摸、OpenHarmony 5、OpenHarmony 6+微信、桌面 UA 的触摸设备、无 TouchEvent 构造函数的桌面鼠标、Android UA 的无触摸环境。
这属于 UA/能力兼容回归,不等同于鸿蒙真机或 ArkWeb 内核验证。测试使用隔离临时目录或拦截账号请求,不写线上报告,不调用付费检测。历史报告测试故意保留旧展示值 57 岁,确认 H5/后台改显示原始肌龄且原文件字节不变。
用户已确认发版。本次与《微信退出登录-2026-10-13.md》的修改一起提交,采用 Git bundle 快进同步到 `/home/ubuntu/juzi`,重启 `juzi-h5.service` 后校验线上资源、健康与浏览器交互。不代用户推送 origin,不改写既有报告或调用线上付费检测。摄像头切换不在本次修改范围内。
+9
View File
@@ -0,0 +1,9 @@
# 取消每张照片拍后的二次检测
删除 `checkStill` 以及实时抓拍、系统相机/相册选图路径对它的调用。保存/读取照片成功后直接进入该张拍摄完成与下一步流程,不再检测已保存照片的人脸、角度、亮度或等待检测模型。
保留实时预览阶段的人脸识别、角度、位置与稳定性检测;光线仍仅提醒。保留 321 动画、补光、空帧/图片读取失败提示和取消/后台中断的过期回调保护。三张完成后的测肤报告接口调用不变,该接口仍可能因无法检测照片等原因返回失败。
拍摄观测诊断现在记录最后一次通过的预览帧,明确标记 `check_stage: preview`;系统相机/文件及手动降级无预览数据时标记 `check_stage: none`,不伪造拍后检测通过。策略版本 `2026-10-16-preview-only-v11`。
验证:真实本地模型自动三连拍时禁止调用静态照片检测;文件路径直接推进及取消保护;321 倒计时与流程漏斗回归。不调用付费接口。
+25
View File
@@ -0,0 +1,25 @@
# 后台北京时间统一
## 修改
- 后台记录列表、失败与异常、详情的创建/更新时间统一显示北京时间(UTC+8)。
- 日期筛选和CSV导出筛选按北京自然日计算,例如10月14日覆盖UTC的10月13日16:00至10月14日15:59:59.999。
- 完整带时区ISO查询仍按其实际时间点比较,完整结束时间包含该毫秒;不带时区的完整时间按北京时间解释。
- 今日检测量、近14日趋势按北京日期归类,不再直接截取UTC日期。
- CSV创建时间转换为北京时间,列标题明确时区;列表筛选、趋势和导出说明也标注北京时间。
- 前后端共享 `js/beijing-time.js`。原始记录、UTC时间字段及接口时间不做批量修改,评分和推荐不变。
- 无效日期及颠倒区间返回400,不产生误导结果。
## 验证
```sh
TZ=UTC node h5/test/beijing-time.js
TZ=America/New_York node h5/test/beijing-time.js
TZ=Asia/Shanghai node h5/test/beijing-time.js
node h5/test/admin-beijing-browser.mjs
node h5/test/report-persistence.mjs
```
独立测试服务使用临时记录目录,服务进程设为纽约时区,浏览器分别使用洛杉矶、UTC、上海时区。固定“当前时间”为北京时间凌晨00:30(UTC仍为前一天),验证北京午夜前后毫秒边界、单日/单边/带时区筛选、CSV、今日及每日统计、创建与更新时间显示、无效日期。测试后逐字比较原始UTC记录不变,无真实账号/报告或付费检测调用。
当前本地实现,尚未提交或部署。
@@ -1,5 +1,7 @@
# 基础信息记忆与肤质拖动
> 2026-10-13 调整:以下偏好缓存仅对微信登录生效。游客仅保留当前页面操作中的临时表单值,不写缓存、不回填历史;刷新后重新填写。详见《游客不留存与弹窗护理间距调整-2026-10-13.md》。
## 范围
- 出生年月、性别、地区每次点滚轮“确定”即保存,不必拍照或提交报告。
@@ -0,0 +1,13 @@
# 展示肌龄限制±5岁
用户已确认:实际年龄25岁、接口肌龄40岁,展示30岁。
展示肌龄 = max(实际年龄−5, min(实际年龄+5, 原始肌龄))。
该限制仅影响展示,不修改接口原始肌龄,不用于产品推荐,不调整三个维度或综合得分。后台同时保留原始肌龄与展示肌龄,列表、详情和CSV与报告页使用同一规则。新报告存储展示值;历史报告读取时按原始肌龄和当时填写的实际年龄应用新展示规则,不改写历史文件或推荐。
缺少原始肌龄显示`--`,不拿实际年龄替代;缺少实际年龄时展示原始肌龄。保留原始小数精度,不另外取整。
同时移除肤色图片上的黄褐斑、雀斑检出状态浮层(HTML、CSS和渲染逻辑),不改变其评分参数与色素图30%透明度。
验证覆盖:±5边界、上下限、缺失值、小数、原始数据不变、历史展示值不作为输入、推荐继续使用原始肌龄;后端记录/列表/详情/CSV以及历史报告一致性;真实色斑案例图片无浮层。
+11
View File
@@ -0,0 +1,11 @@
# 微信退出登录
- 首页与「我的报告」的状态区,仅微信已登录时显示「退出登录」。游客模式不显示,不恢复旧的「切换登录方式」按钮。
- 点击使用系统确认框提示:确认退出微信登录?历史报告将保留,重新微信登录后可查看。取消不调用退出接口。
- 确认后调用现有 `api/auth/logout`,撤销当前服务端会话和 Cookie;成功才清除前端登录状态并回到入口页。失败提示错误,保留当前页面/身份并允许重试;请求期间禁用退出按钮,防止重复提交。
- 不删除账号、报告快照或微信偏好缓存。退出后重新登录同一微信,仍可查看原有历史报告。
- 退出时清空历史列表并作废在途历史请求,限制旧报告的返回导航;旧微信请求迟到也不能在游客身份下打开报告。
验证:`node h5/test/wechat-browser.mjs`(模拟微信、真实 HTTPS Cookie)覆盖取消、接口失败、确认退出、服务端会话撤销、返回键、迟到报告、游客不显示退出及重新微信登录后快照文件字节不变。`node h5/test/guest-privacy.mjs`、`node h5/test/report-persistence.mjs` 回归游客与历史报告。
用户已确认与原始肌龄显示、鸿蒙滚轮兼容一起发版。发布沿用 Git bundle 快进同步和服务重启,不代用户推送 origin,不删除历史报告。微信退出流程使用隔离模拟账号验证;线上不操作真实微信账号、报告或付费接口。
@@ -0,0 +1,26 @@
# 恢复321与色斑案例复测
本地修改,尚未部署。未调用付费检测接口,未修改线上记录。
## 拍摄提示音
每次网页摄像头抓拍先播放已有 countdown-321.mp3,等待真正的 ended 事件,再执行白屏补光和抓帧。沿用流程音队列,上一条步骤引导先播完;倒计时期间锁定快门、停止姿态提示,禁止重复触发。退出、页面隐藏使令牌失效,不再拍摄。拍下后的本地照片复验保持不变。
网页内自动和降级手动抓拍适用。系统相机原生快门不受网页控制;浏览器音频不可用时不阻断原有拍摄流程。
验证:node h5/test/countdown-capture-browser.mjs,三张逐张等待音频 ended、防重复快门、取消倒计时均通过。自动化控制音频结束事件,手机微信实际音量和播放仍需真机验证。
## 真实色斑案例
线上225条记录中207份原始结果可读取,所有结果 freckle.value 均为0,因此没有可用于验证的真实雀斑阳性案例。雀斑分类值没有提供雀斑数量,confidence 不等于雀斑个数,也不作为阳性依据。
选择棕色色斑最多的 R20261014-155810-LFB8 本地复测,brown_spot.count=81,freckle.value=0,仍展示雀斑未检出,不能把棕色色斑冒充雀斑。
- 色素分99;黄褐斑和雀斑均未检出,不扣该分类分。
- 黑眼圈分30;肤色项=99×80%+30×20%=85.2。
- 水分项79;肤色水润分类百分制=(85.2+79)/2=82.1,映射十分制9.46。
- 当前透亮感9.46,综合得分91。
- 色素图以30%透明度叠加,原始照片不降透明度。
- 无黄褐斑/雀斑区域坐标时只显示真实检出状态,不生成定位框。
H5_PIGMENTATION_CASE_DIR 指向私有临时案例目录运行 h5/test/pigmentation-case-browser.mjs,评分公式、真实标记、透明度和浏览器渲染验证通过。真实照片和原始返回不纳入 Git。
@@ -0,0 +1,9 @@
# 扫描等待页 30 秒兜底
从三张照片完成、打开扫描等待页开始计时,最多等待 30 秒。超时关闭扫描页、停止相机与播报、清空本轮照片、解锁操作,提示「检测服务开小差了,请重试」,返回档案页,由用户再次开始拍照。
独立页面看门狗覆盖网络未返回及扫描动画未完成;回调也检查实际截止时间,避免定时器延迟时误显示迟到报告。请求自身超时同步设为 30 秒,无 AbortController 的旧内核也会结束等待。成功、主动取消或页面隐藏时清理定时器并尽可能中止请求;超时先作废流程令牌,迟到结果不能切回报告页。
超时记入已有拍摄漏斗「明确失败」,不新增自动重试或检测调用。中止浏览器请求不保证已受理的服务端/上游调用停止,后台仍可能完成该次检测,且可能产生原有调用费用;服务端检测预算未更改。相比旧版 150 秒前端请求预算,超过 30 秒的慢检测现在也会提示重拍。
验证涵盖 29.999 秒成功、30 秒未返回、接口成功但动画不结束、迟到结果、取消、旧内核缺少 AbortController、成功后不再弹超时及定时器清理。
@@ -0,0 +1,22 @@
# 报告底部 Figma 还原
设计文件 53PGFOThvhZwf2TPUHAcUz,稳韧力组1:47、底部组1:65。仅本地修改,尚未提交或部署。
- 大标题前移到日常护理上方、左对齐;日常护理及推荐产品合为一个自适应白卡。
- 标题1:369为两行文字组;按用户最新确认,两行文字各自居中,整体按白卡居中并保留1设计像素偏移。文字组随屏幕缩放,字号与上方稳韧力统一为22.106597px、行距28.738542px,不再随宽屏放大文字。
- 护理建议改为日常护理,删除固定结束语,保留按检测结果生成的护理内容。
- 白卡内增加推荐产品小标题。仍仅显示算法选中的一款,不生成产品列表或切换入口。
- 三款副标题分别为焕活光泽 透亮紧致、恢复弹性 紧实利落、强韧屏障 轮廓向上,不再显示双仓精华液第二行。
- 免责声明移到白卡下方,补充仅根据您最近的测肤记录推荐。
- 稳韧力标题、页签及段落字体/行距按设计尺寸微调;动态检测图、文案、评分和跟手滑动不变。
- 历史报告依旧使用当时推荐档位与护理文本,仅按该档位更新产品展示文案和素材;不改写保存数据。
## 静态素材核对
ui-20261015/product-25.png 对应1:69(481×511),product-35.png 对应1:75(481×511),product-50.png 对应1:87(481×511),product-badge.png 对应1:71(101×77)。全部为设计工具导出原始PNG,保留透明边角和原始裁切,无运行时临时素材URL;网页每次只加载选中的产品图。
产品卡按945×511比例,右侧图按481×511比例,徽标按101×77比例。布局以1080px设计稿折算375px基准并随屏幕缩放。用户最新确认产品标题恢复原来14.467593px,灰色副标题按同一比例(约1.041667)放大到12.013166px,行距同比放大;320px屏幕也保持同一字号,已核对三款文字均不遮挡图片。
## 验证
report-bottom-figma.mjs 覆盖三款选中产品、320/375/414/480宽度、元素顺序、白卡嵌套、图片/徽标比例、文字不遮挡图片、历史推荐对象不变、缺少肌龄与长护理内容。另运行报告持久化、游客隐私、产品推荐及报告滑动/布局回归。三款375px截图位于私有临时测试目录,不纳入Git。
@@ -0,0 +1,58 @@
# 实测描述与旧护理兼容:修复复测
> 本文记录的是此前扩大修改范围的版本,现已被用户收窄决定取代,不代表当前待发布文案。最终范围见《文案最终范围-仅皱纹优秀与痘痘检出-2026-10-15.md》:其他文案及护理建议原文均已恢复;分档与旧护理兼容保留。
日期:2026-10-15。此文为《线上10例报告文案对照》的修复后补充,不覆盖第一次测试暴露问题的记录。
## 本次修改
- 十分制显示分档仍为低于8改善、8至不足9良好、9及以上优秀,评分/综合分/推荐算法不变。
- 七项结论采用整体状态评价+实际检测数据,不再根据高分断言没有皱纹、泛红、色素沉积、眼袋或有很强的屏障能力;低分也不推断静态纹固化、中面部容积流失等未直接证实的状态。
- 皱纹所有档位保留皱纹及细纹数量;轮廓所有档位保留下颌线角度、实际眼袋与黑眼圈等级;水分保留分数、缺水面积和有效严重度;泛红保留实测面积。
- 痘痘正计数如实显示数量、种类及整体状态,零计数与缺失分开。检出脓包/结节仍提示具体种类,不凭数量推断炎症严重程度。
- 旧护理条目没有key时按七项的准确名称映射新版护理。保持旧项目、数量及顺序;不擅自把旧两项扩充成三项,不重新推荐产品。未知名称保留原文,不按数组位置乱配。
- 文案版本 `score-summary-factual-detail-v1`,与分档版本独立;即使已有新的8/9分档标签,旧文案也能刷新。
- 后台缺少原始数据无法刷新时明确显示“保存快照,未刷新文案”,不冒称已更新。原始报告、数据库及旧算法摘要不覆盖。
## 同一批10条真实案例复测
只读线上取数,使用保存的VM进行展示层回放,不调用付费检测。10条共70个页卡、25条护理建议,通过档位、实测数量保留、旧名称映射、源对象不可变、推荐不变及护理顺序不变检查。
|案例|复测|护理项目与新档位|
|---|---|---|
|R20261015-144549-7CNL|通过|泛红(优秀)→皱纹(优秀)→轮廓(优秀)|
|R20261015-134214-Y6L7|通过|轮廓(优秀)→皱纹(优秀)|
|R20261015-112350-ZLOF|通过|肤色(优秀)→水分(优秀)→泛红(良好)|
|R20261015-111214-JKNS|通过|皱纹(优秀)→肤色(良好)→水分(优秀)|
|R20261015-105142-4UTP|通过|皱纹(良好)→平滑度(优秀)|
|R20261015-105002-HVEV|通过|皱纹(优秀)→平滑度(优秀)|
|R20261015-104945-ZC5V|通过|皱纹(优秀)→泛红(优秀)|
|R20261015-104729-BLXO|通过|皱纹(优秀)→肤色(优秀)→水分(优秀)|
|R20261015-104209-V2J6|通过|皱纹(优秀)→肤色(优秀)→水分(优秀)|
|R20261015-103650-FVQA|通过|皱纹(优秀)→泛红(良好)|
## 新版代表文案
### R20261015-144549-7CNL
- 轮廓(9.4,优秀):面部轮廓整体状态较好,建议维持紧致护理并关注局部变化;左右下颌线角度分别为左 112.18°/ 右 112.67°;眼袋明显、黑眼圈中度。
- 皱纹(9.3,优秀):全脸共检测到皱纹 10 条、细纹 10 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。
- 泛红(9.3,优秀):泛红面积 4%;整体泛红项状态较好,建议维持温和护理,持续关注泛红变化。
### R20261015-111214-JKNS
- 肤色(8.8,良好):检测到当前肌肤色素分 66,存在中度黑眼圈;整体肤色状态较稳定,建议持续关注局部暗沉与肤色均匀度。
- 水分(9.9,优秀):水分分 97;缺水面积 3%,缺水严重度 3,整体水分状态较好,建议维持补水与保湿习惯。
### R20261015-103650-FVQA
- 泛红(8.4,良好):泛红面积 12.5%;整体泛红项状态较稳定,建议保持温和护理并关注局部变化。
## 自动化回归
- `node h5/test/factual-copy.js`:实际参数、缺失与零值、旧名称护理、旧两项不扩充、未知项不乱配。
- `H5_COPY_CASE_FILE=/private/ten-copy-cases.json node h5/test/report-copy-cases.js`:真实10例只读展示回归;私有样例不进入Git。
- `H5_ACNE_MAX_TEST=1 node h5/test/report-persistence.mjs`:浏览器H5/后台/历史回看、筛选与导出,旧名称护理更新且快照文件字节不变。
本地修改完成,尚未提交或上线。此前“9条旧护理未刷新”的问题已修复,不代表本次把历史护理重新选成新规则三项。
@@ -0,0 +1,32 @@
# 报告文案统一按十分制分档
> 后续确认:文案仅修改皱纹优秀档及痘痘有皮损时的描述,其他模板和护理原文恢复原版。本文中关于额外眼袋/明显缺水文案调整的描述已被收回,最终范围见《文案最终范围-仅皱纹优秀与痘痘检出-2026-10-15.md》。分档规则不变。
## 原因与规则
案例 R20261015-144549-7CNL 的紧塑力 P=76、S=9.28(显示9.3),轮廓 P=79、S=9.37。旧文案按百分制80/90分档,而十分制使用7+3P/100,导致9.x仍显示改善。
用户确认:分数计算保持不变,报告文案和护理建议改为按页面实际显示的一位小数分档:低于8为改善,8到不足9为良好,9及以上为优秀。使用与页面相同的两位存储精度及一位显示舍入,例如S=8.95显示9.0,文案为优秀。新规则标识 `display-score-8-9-v1`。
## 覆盖范围
- 七项:轮廓、皱纹、肤色、平滑度、水分、泛红、痘痘;文案与对应护理建议复用同一档位。
- 肤色、水分仍使用各自独立分数;保留水分严重度交叉校验,不改评分公式。
- 痘痘不再通过“有脓包/结节”或“有皮损”独立降档,实际计数和中重度皮损提示保留,不能以优秀文案声称未检出。
- 轮廓眼袋、肤色黑眼圈、平滑度毛孔黑头按实际检测描述;明显缺水也不因文案等级较高就声称水润。
- 后台列表、七项评分、三维度评分、筛选、统计、CSV采用一致分档。
- 原始算法中的旧六分类等级、摘要及生活方式保留为审计数据,后台明确标注为旧版,不作为当前报告文案分档依据。
## 历史数据
旧报告打开时仅刷新展示文案、护理建议对应档位,不改原始数据、评分、产品推荐、护理项目选择和顺序,不覆盖历史快照。后台详情在具备原始返回时同样刷新;缺少原始数据时保留原有文案,不臆造检测数据。
## 真实案例复核
R20261015-144549-7CNL:紧塑力仍显示9.3,轮廓9.37按显示9.4归为优秀,皱纹9.28按显示9.3归为优秀。轮廓仍如实显示“眼袋明显、黑眼圈中度”,痘痘仍显示实际皮损,不改变计分、综合分和推荐规则。
## 回归
`node h5/test/display-grade.js` 覆盖七项、8/9临界舍入、空值、9.3案例、真实眼袋、旧报告不可变及历史推荐/顺序保留。
`H5_ACNE_MAX_TEST=1 node h5/test/report-persistence.mjs` 覆盖新记录落库、前后台展示、筛选、统计、CSV、历史报告刷新且不覆写原始快照。
+9
View File
@@ -0,0 +1,9 @@
# 拍摄光线仅提示
亮度低于 110 时显示「建议到光线明亮处拍摄,保持不动即可拍摄」;高于 220 时显示「光线过强,建议避开强光,保持不动即可拍摄」。110–220 不提示光线问题。
亮度不再是实时捕捉或拍后 JPEG 校验的拦截条件,无法读出亮度也不单独拦截。其他人脸置信度、角度、位置、距离和稳定性要求不变;过暗导致无法识别人脸时仍需要调整,不保证检测接口接受所有极暗或过曝照片。
保留亮度观测值,供后台校准使用;不改变报告计分、结论或付费接口调用次数。策略版本 `2026-10-16-light-advisory-v10`。
验证包含三视图暗/亮边界测试、实时暗光提示、保持暗光完成倒计时和拍后校验、实际本地模型自动三连拍;不调用付费检测。
@@ -0,0 +1,24 @@
# 拍摄角度不合格震动提醒
按用户确认的规则实现,纳入 2026-10-13 相机与首页更新发版。
- 实时检测确认单人、清晰度、距离与居中等前置条件通过,但角度不合格连续约 1 秒后,调用一次 30ms 轻震。
- 持续角度不合格时,相邻提醒至少间隔 4 秒;不使用独立定时器,只有收到新检测结果才可能提醒。
- 合格、无人脸、其他质量问题或检测失败立即取消震动并重置连续计时;漏帧也中断计时。检测结果间隔超过 2 秒会重新累计,避免推理卡顿被误认为持续不合格。
- 自动拍摄、手动快门和拍摄完成均不再震动。
- 拍摄锁、摄像头切换/未就绪、离开拍照页及切后台禁止提醒。停止检测、步骤重置或开始拍摄时取消尚未结束的震动。
- 前后提示保持 4 秒限频,不因角度短暂合格、镜头切换或步骤重置绕过冷却。
- 不支持接口、接口拒绝或抛出错误时静默忽略,不影响原有文字、语音和自动拍摄。
没有改变拍摄角度、距离、亮度、稳定时间或评分推荐算法。不是所有手机都能产生物理震动,微信/鸿蒙需真机复验,iOS 当前按无震动降级。
验证命令:
```sh
node h5/test/customer-feedback-second.js
node h5/test/customer-feedback-fourth.js
node h5/test/camera-flip-browser.mjs
node h5/test/capture-quality-browser.mjs
```
测试涵盖定时边界、持续限频、合格取消、无人脸、非角度问题、拍摄/切换/后台隔离、不支持/拒绝安全降级,以及相机翻转和真实本地模型三连拍回归。浏览器测试使用模拟相机和拦截的身份/报告接口,不调用付费检测、不写真实账号或报告。
@@ -0,0 +1,9 @@
# 拍照至报告页统计
后台「统计概览」新增累计及今日拍摄流程漏斗:开始拍照 → 正面拍完 → 右脸拍完 → 三张拍完 → 提交检测 → 收到成功结果 → 实际进入报告页。
每次点击「开始拍照」生成随机流程 ID,按流程及阶段去重;不是人数。只有报告渲染、切屏且报告页处于 active 状态后才计入成功进入,浏览历史报告与 mock 演示不计入。今日按流程开始时间的北京时间归属。显示全程转化率、成功进入平均耗时、收到成功结果但尚未进入、明确失败、主动取消、后台中断、尚未完成。中断可与其他状态重叠,未完成包括仍在进行中的流程,不能直接视为失败。
数据从上线开始积累,不回填历史。匿名埋点存于受静态访问保护的 `h5/data/capture-funnel.jsonl`,仅包含随机流程 ID、固定阶段、客户端耗时、服务端接收时间。不保存账号、照片、档案或任意错误文本。已登录游客也可统计,不保存游客身份。接口校验同源及会话;客户端上报仅作为运营观测,不能作为计费或检测成功的权威依据。
每次发送累计阶段快照,支持重复及乱序到达;使用 keepalive,失败不阻断拍摄。断网、关闭页面仍可能漏报,尚未提供离线重传;成功率为已收到的观测值。没有增加检测调用,没有改变报告结论和多脸规则。
@@ -0,0 +1,25 @@
# 最终确认:只改两处文案
用户要求:仅修改“皱纹·优秀档”“痘痘·有皮损时”,其余文案不改。此前七项整体改写已撤回,护理建议原文也全部恢复。
|项目|原文|最终修改|
|---|---|---|
|皱纹优秀档|面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。|全脸共检测到皱纹 {wr_cnt} 条、细纹 {fl_cnt} 条;整体纹路状态较好,建议维持日常淡纹与保湿护理。|
|痘痘有皮损时|随档位使用旧评价文案,优秀模板为未检出,旧逻辑通过降档避免套用。|检测到 {acne_cnt} 处皮损({acne_detail}){heavy_note},局部皮损仍需关注。|
`heavy_note` 沿用原有脓包/结节提示。不修改零计数、计数缺失、部分计数缺失时的原有保护。
## 保留的逻辑修复
- 七个小项文案与护理建议按十分制显示分数分档:低于8改善、8至低于9良好、9及以上优秀。不改变评分公式。
- 痘痘不再单独降档,实际皮损数量与种类保留。
- 旧护理条目按已知名称兼容,更新对应档位的原版护理全文;项目、数量、顺序和产品推荐不重选,原始快照不覆盖。
- 肤色、水分继续独立分档;水分交叉校验、原有毛孔/黑头实测数量保护、黑眼圈实测程度替换保持原实现,不增加其他文案分支。
## 验证
- `node h5/test/factual-copy.js`:以发布版本60f0965的文案快照逐字比较,全部护理模板及其他20个结论模板保持不变,验证两处文案、缺失值、历史护理名称与不可变数据。
- `H5_COPY_CASE_FILE=/private/ten-copy-cases.json node h5/test/report-copy-cases.js`:同批10例、70个页卡、25条护理建议,分档、两处文案、旧护理匹配、历史推荐及顺序检查通过。私有样例不入Git。
- 浏览器及持久化测试继续覆盖前后台展示、历史回看、筛选、CSV与快照不改写。
本文件记录最终确认范围。实际发布版本及部署状态以 Git 提交和服务器发布检查为准。
@@ -0,0 +1,14 @@
# 水分文案独立分档
问题案例 R20261015-111214-JKNS:接口水分97、缺水严重度3、缺水面积3%,色素66、黑眼圈30,合成肤色水润77.9。旧版水分文案跟随合成分档,错误套用改善档的含水量偏低、缺水比较严重。
## 本次修正
- 水分展示文案及其护理/生活方式建议独立使用水分项:低于80改善,80至低于90良好,90及以上优秀。
- 沿用原有交叉校验:水分分≥80且缺水严重度≥60时,按100−严重度评估,并明确提示分数与缺水指标不一致,不默默套用缺水文案。
- 改善档不再固定描述严重缺水。严重度为有效0~100时,≥60描述缺水程度较高,其他值仅报告严重度;缺失省略。缺水面积缺失也不当成0。
- 缺失或无效水分分不产生水分档位及水分护理建议。
- 三维度、综合得分、产品推荐、护理类别的选取规则均不变。水分展示项P现在为自己的水分项,不再伪装为合成分。
- 新生成报告与本地原始数据回放使用新文案。历史已保存的presentation文案保留,不批量改写文件;如需纠正历史报告展示,应另行确定范围。
参考文案与生成文件同步修改。water-copy.js覆盖真实矛盾值、80/90边界、色素不影响水分文案、严重度条件、交叉校验、缺失和输入不变;复测报告持久化、账号隔离、推荐与肤色文案。
@@ -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` 生成。
@@ -0,0 +1,38 @@
# 痘痘按三视图各类型最大数量计分
## 规则
正脸读取 `result.<type>`,侧脸读取 `left_side_result.left_acne_info.<type>` / `right_side_result.right_acne_info.<type>`。每类分别取有效数量的最大值,不累加,也不是选皮损总数最多的照片。
| 类型 | 字段 | 每处扣分 |
|---|---|---:|
| 闭口 | closed_comedones | 1 |
| 丘疹 | acne | 2 |
| 脓包 | acne_pustule | 4 |
| 结节 | acne_nodule | 6 |
| 痘印 | acne_mark | 1.5 |
计数优先 `.count`,缺失时兼容已有矩形数量回退;负数、非整数、非有限数不计为有效数量。明确0才算未检出。
`计数扣分=min(50, Σ各类最大数量×权重)`。
正脸 `score_info.acne_score` 的既有100/85/70/55分档映射不变,侧脸整体评分不合并。
`痘痘P=min(正脸评分映射值,100−计数扣分)`;某一项缺失时仅用其他有效项;计数全部缺失则仅用正脸评分,二者都缺失不生成痘痘分。
报告文案与计分共用合并数量,侧脸检出脓包/结节也影响相应提示,计数不完整不声称完全未检出。这里的数量是三视图各类型最大值汇总,不等同于跨视图去重后的真实全脸总数。
## 后台和历史
新记录保存 `acne_scoring`,版本 `three-view-type-max-v1`,包括五类正/左/右数量、最大值、来源视图、权重、每项扣分、扣分合计/封顶结果、完整性和正脸评分/映射值。
后台详情展示计分明细表。报告快照同步保存用于展示的变量。旧记录和快照不迁移、不改写,历史推荐保持原样。
## 验证
- `node h5/test/acne-three-view.js`:分类分别取最大而非相加、不同类型来自不同视图、并列值、缺失/零/非法数量、计数回退、正脸评分约束、50分封顶、侧脸中重皮损文案与源数据不变。
- `H5_ACNE_MAX_TEST=1 node h5/test/report-persistence.mjs`:隔离合成三视图验证计分、报告文案、后台明细、保存/回看、CSV、历史不可变。
- `node h5/test/regression.js`:旧case2期望显式更新为侧脸闭口1处参与扣分;原始fixture不改写。
- 私有案例目录回放:`H5_WRINKLE_CASE_DIR=<private directory> node h5/test/wrinkle-case-browser.mjs`。
`R20261014-171215-CYLF`:闭口为0/1/0→取1,丘疹为0/1/1→取1,其他三类为0。扣3分,痘痘P=97、S=9.91,稳韧力由原9.97变为9.91。与待上线的整体皱纹规则合用,紧塑力8.95、透亮感9.63,综合得分`round(8.95×4+9.63×3+9.91×3)=94`(仅皱纹修改时为95)。
当前本地实现,未提交或部署;此前待发布的北京时间、整体皱纹评分修改继续保留。所有回放使用隔离数据和固定接口返回,不调用付费检测、不写线上记录。
@@ -0,0 +1,16 @@
# 皱纹三视图标注
- 报告皱纹页卡展示正面大图和两张侧脸缩略图,并保留各自标注。正面放大沿用缩略图的裁切;侧脸放大在缩略图的填充基础上缩小50%,净向下移动缩放后图片高度的25%(在之前下移50%的基础上先向上调20%,再向上调5%)。大图外框和报告小图不变。
- 每张照片只使用对应照片的皱纹增强图或真实标注坐标,不将正面标注投射到侧脸。
- 新检测优先复用三视图接口已提供的侧脸标注;缺失时分别以左右侧脸为主图补取,每侧最多调用一次。通常新增两次计费调用。
- 补取串行执行,每次前间隔 4 秒,每侧最多等待 20 秒;整个检测按 120 秒预算限制补取,给现有 150 秒前端超时预留余量。因此生成报告会增加等待时间。
- 补取失败不影响主报告、评分或产品推荐。该侧展示原图并明确说明缺少标注;后台记录每侧获取状态和错误代码。
- 微信用户保存报告时同时保存侧脸标注,历史回看不重复调用检测接口。旧报告不自动补取、不产生额外费用。游客仍不保存报告或照片。
- 补取结果仅用于图片展示,不参与七个小维度、三个大维度、综合评分或产品推荐计算。
## 验证
- 单元测试:请求照片与标注一一对应、顺序调用、复用已有标注、缺图、接口失败、超时、预算不足、不污染评分结果。
- 浏览器回归:320/375/414/480 宽度,正面浮层保留、左右独立浮层与大图、真实坐标兜底、缺图/缺标注提示、清理旧照片。
- 报告持久化回归:三次调用、响应与缓存标注一致、用户报告隔离及后台查看。
- 已使用获授权的真实案例分别补取左右侧脸,确认均返回独立皱纹增强图和标注坐标。
@@ -0,0 +1,37 @@
# 皱纹改用接口整体评分
按用户要求修改:皱纹百分制P直接采用 `score_info.wrinkle_score`(兼容顶层同名字段),取值0~100。不再计算 `100-最差分区值`,也不再用分区严重度限制该整体分。分区指标、计数、标记仍保留供查看。
- 皱纹S仍为 `7+3×P÷100`;紧塑力仍取轮廓和皱纹中的较差项。
- 缺失或无效的整体评分不生成皱纹分,不静默回退分区旧规则。
- 新报告/记录标记 `api-overall-v1`,后台详情说明来源。
- 修正维度一位小数显示中的浮点舍入:8.95显示9.0,9.45显示9.5;内部S、评分公式和综合得分计算精度不变。
- 不改写既有线上历史记录或快照。此次历史案例使用原始接口返回,在本地隔离服务复算;没有重新调用旷视或新增线上检测。
- 本次是用户指定的业务评分口径,不代表已核实供应商对该字段评分方向的定义。
## 案例复算
`R20261014-171215-CYLF`:
| 项目 | 原保存值 | 本地新规则 |
|---|---:|---:|
| 接口整体皱纹分 | 65 | 65(输入未改变) |
| 皱纹P | 0 | 65 |
| 皱纹S | 7 | 8.95 |
| 轮廓S | 9.73 | 9.73 |
| 紧塑力S | 7 | 8.95(显示9.0) |
| 透亮感S | 9.63 | 9.63 |
| 稳韧力S | 9.97 | 9.97 |
| 加权综合得分 | 87 | 95 |
`8.95×4+9.63×3+9.97×3=94.6`,四舍五入为95。
## 验证
`test/wrinkle-overall.js` 独立验证新评分来源、0/100边界、无分区仍可评分、严重度不影响整体分、缺失不回退、原输入不修改、维度舍入。
`test/wrinkle-case-browser.mjs` 只读取显式提供的私有样例目录(环境变量 `H5_WRINKLE_CASE_DIR`),以隔离临时微信身份和数据库回放接口返回,核对后台保存与报告页显示、生成截图。真实照片和原始数据不提交Git。
原Excel回归与引擎内置合成样例固定整体皱纹输入为旧P,隔离其他分类/文案的回归;原始fixture文件保持不变,新规则由独立测试和真实案例回放覆盖。
当前本地修改,尚未提交、部署。与此前待发布的后台北京时间修改共同保留在工作区。
@@ -0,0 +1,29 @@
# 相机翻转性能优化
本次优化纳入 2026-10-13 相机与首页更新发版。
## 修改范围
- 相机首帧 readyState ≥ 2 且宽高有效后即可显示,检查周期 50ms;去掉原有三轮 300ms 分辨率预热等待。
- 翻转过程中保留上一帧及原来的镜像方向,显示“正在切换摄像头…”。静帧仅保存在内存 Canvas,恢复预览或离开页面后清空,不上传、不持久化。
- 启动/翻转时禁用翻转按钮,拍摄和步骤推进期间拒绝翻转。
- 新镜头失败、首帧等待超时或浏览器明确返回错误的 facingMode 时,尝试恢复原镜头一次;恢复也失败才降级到系统相机。
- 翻转及恢复请求设置 12 秒超时,初次权限询问不设置该超时。首帧单独设置 12 秒超时,不再超时强行认为成功。迟到的流及时停止。
- 检测循环增加代次令牌,旧镜头推理结果不可更新新镜头状态或启动第二条循环;旧推理未结束时不叠加新推理。
- 模型加载回调也校验镜头令牌,避免旧回调覆盖切换状态。
拍摄角度、亮度、尺寸、清晰度、稳住时间、照片编码、评分和推荐规则均未修改。
## 验证
- `node h5/test/camera-flip-browser.mjs`:模拟相机验证首帧显示时延、静帧、重复点击、拍摄锁、失败/忽略 facingMode/超时恢复、旧推理隔离、离开后迟到流释放。
- `node h5/test/capture-quality-browser.mjs`:真实本地模型、三角度自动拍摄、最终 JPEG 复验、语音顺序、摄像头释放通过。
- `node h5/test/login-performance.mjs`:登录轻量加载、相机模型按需加载/失败重试等通过。
- 无付费检测调用、真实账号和报告写入。
模拟相机对比:原实现首帧后额外等待约 900ms,两轮优化后约 57~91ms(受测试机器调度影响)。不是手机真机性能结论。
## 仍需真机验证
微信 Android、iOS、鸿蒙:连续翻转、后置转前置、翻转时返回/切后台、拒绝相机权限、拍摄中点击翻转。
摄像头硬件/微信浏览器重新启动本身的耗时无法消除;不提供 getSettings().facingMode 的浏览器只能依赖 facingMode ideal 选择,无法保证识别其是否忽略请求。
@@ -0,0 +1,439 @@
# 线上最近10条成功案例:报告文案对照
测试日期:2026-10-15。对比线上已保存的报告快照(服务器代码60f0965)与本地待发布版 `display-score-8-9-v1`。
方式:只读SSH取数,复用保存的报告VM和原始检测字段,调用实际 `ReportUI.forDisplay` 展示路径;不是重新付费检测,也不是10台实机测试。本次未上线、未改线上记录。
## 结论
- 10条、70个页卡;46个档位变化,47段页卡文案变化。
- 三维度原分、综合分、保存的数据对象、产品推荐、原有护理项目及顺序未被修改。
- 9条旧报告缺少护理条目的key,现有刷新逻辑仍会保留旧版护理文案,未能同步新分档。此项未通过,需补充旧结构兼容后再发版。
- 9条旧报告的肤色/水分页卡原P字段都存了同一个“肤色水润”合成分;新版展示改用独立肤色、水分值。这是历史结构差异,不是三维度评分或总分被改写。
- 皱纹优秀档仍沿用已确认文案,不显示检出的皱纹/细纹数量。若希望高分报告也展示实际数量,需要另外调整优秀档文案,不能把“有计数”直接等同于“有明显静态深纹”。
## 10条总览
表中分数依次为紧塑力/透亮感/稳韧力;小项为原档位→新档位,仅列发生变化的档位。
|案例ID|三维度分数|综合分|档位变化|护理兼容|
|---|---|---:|---|---|
|R20261015-144549-7CNL|9.3/9.6/9.3|94|轮廓 改善→优秀;皱纹 改善→优秀;肤色 良好→优秀;泛红 改善→优秀;痘痘 良好→优秀|通过:护理文案同步新档位|
|R20261015-134214-Y6L7|9.2/9.8/9.9|96|轮廓 改善→优秀;皱纹 改善→优秀;痘痘 良好→优秀|未通过:旧结构护理文案未刷新|
|R20261015-112350-ZLOF|9.8/9.6/8.7|94|肤色 良好→优秀;水分 良好→优秀;泛红 改善→良好;痘痘 良好→优秀|未通过:旧结构护理文案未刷新|
|R20261015-111214-JKNS|9.2/9.3/9.9|94|皱纹 改善→优秀;肤色 改善→良好;平滑度 良好→优秀;水分 改善→优秀;痘痘 良好→优秀|未通过:旧结构护理文案未刷新|
|R20261015-105142-4UTP|8.9/9.5/9.6|93|皱纹 改善→良好;肤色 良好→优秀;平滑度 良好→优秀;水分 良好→优秀;痘痘 良好→优秀|未通过:旧结构护理文案未刷新|
|R20261015-105002-HVEV|9.0/9.5/9.8|94|皱纹 改善→优秀;肤色 良好→优秀;平滑度 良好→优秀;水分 良好→优秀;痘痘 良好→优秀|未通过:旧结构护理文案未刷新|
|R20261015-104945-ZC5V|9.0/9.4/9.3|92|轮廓 良好→优秀;皱纹 改善→优秀;肤色 改善→优秀;水分 改善→优秀;泛红 改善→优秀;痘痘 良好→优秀|未通过:旧结构护理文案未刷新|
|R20261015-104729-BLXO|9.0/9.7/9.9|95|皱纹 改善→优秀;肤色 良好→优秀;水分 良好→优秀;痘痘 良好→优秀|未通过:旧结构护理文案未刷新|
|R20261015-104209-V2J6|9.1/9.7/9.8|95|皱纹 改善→优秀;肤色 良好→优秀;水分 良好→优秀;痘痘 良好→优秀|未通过:旧结构护理文案未刷新|
|R20261015-103650-FVQA|9.3/9.7/8.4|91|皱纹 改善→优秀;肤色 良好→优秀;水分 良好→优秀;泛红 改善→良好;痘痘 良好→优秀|未通过:旧结构护理文案未刷新|
## 逐条页卡文案全文对照
### R20261015-144549-7CNL
原报告版本:2026-10-09+feedback-2026-10-12+care-2026-10-15。产品:1'me 主场精华 I(保持不变)。
#### 轮廓:改善→优秀(新版十分制显示 9.4)
线上原文:面部轮廓紧致度有待提升,下颌线清晰度不足,左右下颌线角度分别为左 112.18°/ 右 112.67°;眼袋明显、黑眼圈中度;中面部容积略欠饱满,肌肤支撑力偏弱。
本地新版:面部轮廓紧致立体,下颌线清晰;眼袋明显、黑眼圈中度;中面部容积饱满,肌肤支撑力充足。
#### 皱纹:改善→优秀(新版十分制显示 9.3)
线上原文:全脸共检测到皱纹 10 条、细纹 10 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
本地新版:面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
#### 肤色:良好→优秀(新版十分制显示 9.6)
线上原文:检测到当前肌肤色素分 100,局部有色素沉积;存在中度黑眼圈,整体状态稳定,局部透亮感稍弱。
本地新版:检测到当前肌肤色素分 100,无明显色素沉积,存在中度黑眼圈;肤色均匀透亮。
#### 泛红:改善→优秀(新版十分制显示 9.3)
线上原文:泛红面积达 4%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。
本地新版:泛红面积 4%,无明显泛红、敏感迹象;对外界刺激耐受度佳,肌肤处于稳定的健康状态。
#### 痘痘:良好→优秀(新版十分制显示 9.9)
线上原文:检测到 4 处皮损(闭口粉刺3处、痘印1处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 4 处皮损(闭口粉刺3处、痘印1处),建议关注局部皮损并结合检测图片查看。
护理项目顺序:泛红→皱纹→轮廓。
护理「泛红」:改善→优秀。
线上原文:关注屏障修复与舒缓,精简护肤流程,选择温和洁面方式,修复受损角质层。若状态持续无明显好转,可寻求专业护肤建议。减少外界刺激,如酒精、香精、高浓度酸类精华等成分,避免冷热骤变刺激。
本地新版:持续规律护理并按季节阶段性调整,巩固皮肤耐受稳态。保持健康作息与均衡饮食,维持皮肤屏障稳态。
护理「皱纹」:改善→优秀。
线上原文:重视纹路管理,日常可使用含有肽类、胶原类成分的淡纹精华,针对眼周、法令纹等动态纹高发区域配合点按手法,遏制静态纹转化,帮肌肤恢复平滑细腻的状态。注意睡姿,减少面部长期受压;减少夸张表情,避免揉眼、托腮等表情性挤压习惯,减少纹路加深。
本地新版:以预防初纹为核心,搭配抗初老型精华,提前建立抗氧化/紧致护理习惯,延缓第一道静态纹出现。保持良好作息与运动习惯,坚持防晒,减轻光老化与糖化负担,维持肌肤稳定状态。
护理「轮廓」:改善→优秀。
线上原文:重点进行轮廓紧致管理,关注面中、苹果肌与下颌线区域。日常增加紧致精华步骤,配合由下向上的轻柔提升按摩,帮助肌肤维持弹润和支撑感,让轮廓看起来更紧实。保证充足、规律的睡眠,让肌肤在夜间充分修护;坚持防晒,减少紫外线带来的老化影响;减少熬夜。
本地新版:保持现有紧致抗衰护理节奏,日常叠加使用提亮精华,坚持防晒,加强光损伤防护与状态管理,持续关注面中疲态与轮廓支撑状态,让紧致状态更稳定、更持久。坚持防晒与规律作息,均衡饮食,保证优质蛋白、新鲜蔬果和健康脂肪摄入,适度运动。
### R20261015-134214-Y6L7
原报告版本:2026-10-09+feedback-2026-10-12。产品:1'me 主场精华 I(保持不变)。
#### 轮廓:改善→优秀(新版十分制显示 9.4)
线上原文:面部轮廓紧致度有待提升,下颌线清晰度不足,左右下颌线角度分别为左 111.24°/ 右 105.61°;眼袋明显、黑眼圈中度;中面部容积略欠饱满,肌肤支撑力偏弱。
本地新版:面部轮廓紧致立体,下颌线清晰;眼袋明显、黑眼圈中度;中面部容积饱满,肌肤支撑力充足。
#### 皱纹:改善→优秀(新版十分制显示 9.2)
线上原文:全脸共检测到皱纹 8 条、细纹 4 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
本地新版:面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
#### 痘痘:良好→优秀(新版十分制显示 9.9)
线上原文:检测到 3 处皮损(闭口粉刺2处、痘印1处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 3 处皮损(闭口粉刺2处、痘印1处),建议关注局部皮损并结合检测图片查看。
历史肤色/水分字段变化:肤色 P=92→86;水分 P=92→98。原三维度、综合分不变。
护理项目顺序:轮廓→皱纹。
护理兼容:旧条目只有name,没有key;现有本地forDisplay仍返回原护理文本,需要以稳定名称映射旧条目,或另行确认是否按新规则重新选三项。
### R20261015-112350-ZLOF
原报告版本:2026-10-09+feedback-2026-10-12。产品:1'me 主场精华 I(保持不变)。
#### 肤色:良好→优秀(新版十分制显示 9.4)
线上原文:检测到当前肌肤色素分 89,局部有色素沉积;存在中度黑眼圈,整体状态稳定,局部透亮感稍弱。
本地新版:检测到当前肌肤色素分 89,无明显色素沉积,存在中度黑眼圈;肤色均匀透亮。
#### 水分:良好→优秀(新版十分制显示 9.9)
线上原文:水分分 95;缺水面积 4.3%、肌肤含水量处于健康水平,整体水润度良好。
本地新版:水分分 95;角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
#### 泛红:改善→良好(新版十分制显示 8.7)
线上原文:泛红面积达 8.2%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。
本地新版:泛红面积 8.2%,肌肤存在局部轻微泛红;对外界环境有良好的耐受度,仅在接触强刺激物时偶有轻微反应。
#### 痘痘:良好→优秀(新版十分制显示 9.7)
线上原文:检测到 6 处皮损(闭口粉刺1处、丘疹2处、痘印3处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 6 处皮损(闭口粉刺1处、丘疹2处、痘印3处),建议关注局部皮损并结合检测图片查看。
历史肤色/水分字段变化:肤色 P=88→81;水分 P=88→95。原三维度、综合分不变。
护理项目顺序:肤色→水分→泛红。
护理兼容:旧条目只有name,没有key;现有本地forDisplay仍返回原护理文本,需要以稳定名称映射旧条目,或另行确认是否按新规则重新选三项。
### R20261015-111214-JKNS
原报告版本:2026-10-09+feedback-2026-10-12。产品:1'me 主场精华 I(保持不变)。
#### 轮廓:优秀→优秀(新版十分制显示 9.7)
线上原文:面部轮廓紧致立体,下颌线清晰;眼周无明显眼袋问题、黑眼圈中度;中面部容积饱满,肌肤支撑力充足。
本地新版:面部轮廓紧致立体,下颌线清晰;眼周眼袋初显、黑眼圈中度;中面部容积饱满,肌肤支撑力充足。
#### 皱纹:改善→优秀(新版十分制显示 9.2)
线上原文:全脸共检测到皱纹 25 条、细纹 27 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
本地新版:面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
#### 肤色:改善→良好(新版十分制显示 8.8)
线上原文:检测到当前肌肤色素分 66,存在色素沉着较显著,存在中度黑眼圈,整体透亮感不足。
本地新版:检测到当前肌肤色素分 66,局部有色素沉积;存在中度黑眼圈,整体状态稳定,局部透亮感稍弱。
#### 平滑度:良好→优秀(新版十分制显示 9.7)
线上原文:肌肤整体较为平滑,局部区域细腻度不足;检测到粗大毛孔 330 颗、黑头 17 颗,有轻微角质粗糙感。
本地新版:肌肤质感整体较好;本次检测到粗大毛孔 330 颗、黑头 17 颗,局部仍有可改善之处。
#### 水分:改善→优秀(新版十分制显示 9.9)
线上原文:水分分 97;缺水面积 3%、肌肤缺水比较严重 3,肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。
本地新版:水分分 97;角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
#### 痘痘:良好→优秀(新版十分制显示 9.9)
线上原文:检测到 4 处皮损(闭口粉刺4处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 4 处皮损(闭口粉刺4处),建议关注局部皮损并结合检测图片查看。
历史肤色/水分字段变化:肤色 P=77.9→58.8;水分 P=77.9→97。原三维度、综合分不变。
护理项目顺序:皱纹→肤色→水分。
护理兼容:旧条目只有name,没有key;现有本地forDisplay仍返回原护理文本,需要以稳定名称映射旧条目,或另行确认是否按新规则重新选三项。
### R20261015-105142-4UTP
原报告版本:2026-10-09+feedback-2026-10-12。产品:1'me 主场精华 I(保持不变)。
#### 皱纹:改善→良好(新版十分制显示 8.9)
线上原文:全脸共检测到皱纹 17 条、细纹 16 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
本地新版:全脸共检测到皱纹 17 条、细纹 16 条,面部整体平整,仅眼周或口周有细微的干纹,无明显静态深纹,纹路面积占比相对较低。
#### 肤色:良好→优秀(新版十分制显示 9.3)
线上原文:检测到当前肌肤色素分 84,局部有色素沉积;仅存在轻度黑眼圈,整体状态稳定,局部透亮感稍弱。
本地新版:检测到当前肌肤色素分 84,无明显色素沉积,仅存在轻度黑眼圈;肤色均匀透亮。
#### 平滑度:良好→优秀(新版十分制显示 9.5)
线上原文:肌肤整体较为平滑,局部区域细腻度不足;检测到粗大毛孔 332 颗、黑头 46 颗,有轻微角质粗糙感。
本地新版:肌肤质感整体较好;本次检测到粗大毛孔 332 颗、黑头 46 颗,局部仍有可改善之处。
#### 水分:良好→优秀(新版十分制显示 9.9)
线上原文:水分分 97;缺水面积 2.5%、肌肤含水量处于健康水平,整体水润度良好。
本地新版:水分分 97;角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
#### 痘痘:良好→优秀(新版十分制显示 9.6)
线上原文:检测到 4 处皮损(闭口粉刺2处、丘疹1处、痘印1处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 4 处皮损(闭口粉刺2处、丘疹1处、痘印1处),建议关注局部皮损并结合检测图片查看。
历史肤色/水分字段变化:肤色 P=86.9→76.8;水分 P=86.9→97。原三维度、综合分不变。
护理项目顺序:皱纹→平滑度。
护理兼容:旧条目只有name,没有key;现有本地forDisplay仍返回原护理文本,需要以稳定名称映射旧条目,或另行确认是否按新规则重新选三项。
### R20261015-105002-HVEV
原报告版本:2026-10-09+feedback-2026-10-12。产品:1'me 主场精华 I(保持不变)。
#### 皱纹:改善→优秀(新版十分制显示 9.0)
线上原文:全脸共检测到皱纹 16 条、细纹 11 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
本地新版:面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
#### 肤色:良好→优秀(新版十分制显示 9.3)
线上原文:检测到当前肌肤色素分 88,局部有色素沉积;仅存在轻度黑眼圈,整体状态稳定,局部透亮感稍弱。
本地新版:检测到当前肌肤色素分 88,无明显色素沉积,仅存在轻度黑眼圈;肤色均匀透亮。
#### 平滑度:良好→优秀(新版十分制显示 9.5)
线上原文:肌肤整体较为平滑,局部区域细腻度不足;检测到粗大毛孔 303 颗、黑头 44 颗,有轻微角质粗糙感。
本地新版:肌肤质感整体较好;本次检测到粗大毛孔 303 颗、黑头 44 颗,局部仍有可改善之处。
#### 水分:良好→优秀(新版十分制显示 9.9)
线上原文:水分分 98;缺水面积 1.4%、肌肤含水量处于健康水平,整体水润度良好。
本地新版:水分分 98;角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
#### 痘痘:良好→优秀(新版十分制显示 9.9)
线上原文:检测到 2 处皮损(闭口粉刺1处、痘印1处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 2 处皮损(闭口粉刺1处、痘印1处),建议关注局部皮损并结合检测图片查看。
历史肤色/水分字段变化:肤色 P=87.9→77.8;水分 P=87.9→98。原三维度、综合分不变。
护理项目顺序:皱纹→平滑度。
护理兼容:旧条目只有name,没有key;现有本地forDisplay仍返回原护理文本,需要以稳定名称映射旧条目,或另行确认是否按新规则重新选三项。
### R20261015-104945-ZC5V
原报告版本:2026-10-09+feedback-2026-10-12。产品:1'me 主场精华 I(保持不变)。
#### 轮廓:良好→优秀(新版十分制显示 9.7)
线上原文:面部轮廓清晰,下颌线较为清晰,眼周眼袋初显、黑眼圈中度;中面部容积无明显流失;肌肤处于健康状态,支撑力表现良好。
本地新版:面部轮廓紧致立体,下颌线清晰;眼周无明显眼袋问题、黑眼圈中度;中面部容积饱满,肌肤支撑力充足。
#### 皱纹:改善→优秀(新版十分制显示 9.0)
线上原文:全脸共检测到皱纹 15 条、细纹 8 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
本地新版:面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
#### 肤色:改善→优秀(新版十分制显示 9.4)
线上原文:检测到当前肌肤色素分 91,存在色素沉着较显著,存在中度黑眼圈,整体透亮感不足。
本地新版:检测到当前肌肤色素分 91,无明显色素沉积,存在中度黑眼圈;肤色均匀透亮。
#### 水分:改善→优秀(新版十分制显示 9.4)
线上原文:水分分 81;缺水面积 16.4%、肌肤缺水比较严重 19,肌肤含水量偏低,水润度不足,表面偏干燥,容易出现干纹。
本地新版:水分分 81;角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
#### 泛红:改善→优秀(新版十分制显示 9.3)
线上原文:泛红面积达 8.5%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。
本地新版:泛红面积 8.5%,无明显泛红、敏感迹象;对外界刺激耐受度佳,肌肤处于稳定的健康状态。
#### 痘痘:良好→优秀(新版十分制显示 9.6)
线上原文:检测到 7 处皮损(闭口粉刺2处、丘疹1处、痘印4处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 7 处皮损(闭口粉刺2处、丘疹1处、痘印4处),建议关注局部皮损并结合检测图片查看。
历史肤色/水分字段变化:肤色 P=79.9→78.8;水分 P=79.9→81。原三维度、综合分不变。
护理项目顺序:皱纹→泛红。
护理兼容:旧条目只有name,没有key;现有本地forDisplay仍返回原护理文本,需要以稳定名称映射旧条目,或另行确认是否按新规则重新选三项。
### R20261015-104729-BLXO
原报告版本:2026-10-09+feedback-2026-10-12。产品:1'me 主场精华 I(保持不变)。
#### 皱纹:改善→优秀(新版十分制显示 9.0)
线上原文:全脸共检测到皱纹 11 条、细纹 10 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
本地新版:面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
#### 肤色:良好→优秀(新版十分制显示 9.4)
线上原文:检测到当前肌肤色素分 88,局部有色素沉积;仅存在轻度黑眼圈,整体状态稳定,局部透亮感稍弱。
本地新版:检测到当前肌肤色素分 88,无明显色素沉积,仅存在轻度黑眼圈;肤色均匀透亮。
#### 水分:良好→优秀(新版十分制显示 10.0)
线上原文:水分分 99;缺水面积 0.8%、肌肤含水量处于健康水平,整体水润度良好。
本地新版:水分分 99;角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
#### 痘痘:良好→优秀(新版十分制显示 9.9)
线上原文:检测到 3 处皮损(闭口粉刺2处、痘印1处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 3 处皮损(闭口粉刺2处、痘印1处),建议关注局部皮损并结合检测图片查看。
历史肤色/水分字段变化:肤色 P=89.9→80.8;水分 P=89.9→99。原三维度、综合分不变。
护理项目顺序:皱纹→肤色→水分。
护理兼容:旧条目只有name,没有key;现有本地forDisplay仍返回原护理文本,需要以稳定名称映射旧条目,或另行确认是否按新规则重新选三项。
### R20261015-104209-V2J6
原报告版本:2026-10-09+feedback-2026-10-12。产品:1'me 主场精华 I(保持不变)。
#### 皱纹:改善→优秀(新版十分制显示 9.1)
线上原文:全脸共检测到皱纹 12 条、细纹 9 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
本地新版:面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
#### 肤色:良好→优秀(新版十分制显示 9.4)
线上原文:检测到当前肌肤色素分 89,局部有色素沉积;仅存在轻度黑眼圈,整体状态稳定,局部透亮感稍弱。
本地新版:检测到当前肌肤色素分 89,无明显色素沉积,仅存在轻度黑眼圈;肤色均匀透亮。
#### 水分:良好→优秀(新版十分制显示 9.9)
线上原文:水分分 97;缺水面积 2.5%、肌肤含水量处于健康水平,整体水润度良好。
本地新版:水分分 97;角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
#### 痘痘:良好→优秀(新版十分制显示 9.9)
线上原文:检测到 2 处皮损(闭口粉刺1处、痘印1处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 2 处皮损(闭口粉刺1处、痘印1处),建议关注局部皮损并结合检测图片查看。
历史肤色/水分字段变化:肤色 P=89.2→81.4;水分 P=89.2→97。原三维度、综合分不变。
护理项目顺序:皱纹→肤色→水分。
护理兼容:旧条目只有name,没有key;现有本地forDisplay仍返回原护理文本,需要以稳定名称映射旧条目,或另行确认是否按新规则重新选三项。
### R20261015-103650-FVQA
原报告版本:2026-10-09+feedback-2026-10-12。产品:1'me 主场精华 I(保持不变)。
#### 皱纹:改善→优秀(新版十分制显示 9.3)
线上原文:全脸共检测到皱纹 6 条、细纹 5 条,面中、口周、眼周等区域已出现静态纹倾向,纹路已呈现固化趋势,并非临时性干纹。
本地新版:面中、口周、眼周等各区域均无明显皱纹沟壑,无静态深纹与粗纹,各类纹路面积均处于极低水平,面部整体平整细腻。
#### 肤色:良好→优秀(新版十分制显示 9.5)
线上原文:检测到当前肌肤色素分 89,局部有色素沉积;存在中度黑眼圈,整体状态稳定,局部透亮感稍弱。
本地新版:检测到当前肌肤色素分 89,无明显色素沉积,存在中度黑眼圈;肤色均匀透亮。
#### 水分:良好→优秀(新版十分制显示 9.9)
线上原文:水分分 95;缺水面积 4.7%、肌肤含水量处于健康水平,整体水润度良好。
本地新版:水分分 95;角质层含水量充足,肌肤屏障锁水能力强,表面饱满水润,无干燥紧绷表现。
#### 泛红:改善→良好(新版十分制显示 8.4)
线上原文:泛红面积达 12.5%,肌肤泛红明显,敏感区域可见泛红;对外界刺激耐受度不足,容易受外界因素影响产生状态波动。
本地新版:泛红面积 12.5%,肌肤存在局部轻微泛红;对外界环境有良好的耐受度,仅在接触强刺激物时偶有轻微反应。
#### 痘痘:良好→优秀(新版十分制显示 9.7)
线上原文:检测到 6 处皮损(闭口粉刺2处、丘疹2处、痘印2处),以闭口粉刺、丘疹为主,无明显红肿痘痘;皮脂分泌基本正常,毛孔偶有轻微堵塞。
本地新版:检测到 6 处皮损(闭口粉刺2处、丘疹2处、痘印2处),建议关注局部皮损并结合检测图片查看。
历史肤色/水分字段变化:肤色 P=88.9→82.8;水分 P=88.9→95。原三维度、综合分不变。
护理项目顺序:皱纹→泛红。
护理兼容:旧条目只有name,没有key;现有本地forDisplay仍返回原护理文本,需要以稳定名称映射旧条目,或另行确认是否按新规则重新选三项。
## 代表性差异
- 7CNL:轮廓9.4由改善变优秀,同时保留眼袋明显、黑眼圈中度;皱纹9.3改优秀,原10条皱纹/10条细纹的计数不再出现在优秀文案。
- JKNS:水分97、缺水严重度3,线上原文仍写“肌肤缺水比较严重3”;新版改为水润充足。肤色独立分58.8→十分制8.8,改良好,而不是随水分变优秀。
- FVQA:泛红8.4由改善改良好,泛红面积12.5%仍保留。
## 发布前建议
先补齐旧护理条目的兼容,再重复这10例回归。保留历史推荐、分数和原始快照;若要让全部旧报告也采用新版“七选三”护理顺序,应单独确认,不能在仅改分档时默默重选。
@@ -0,0 +1,38 @@
# 综合得分:三维度按 4:3:3 加权
## 已确认公式
综合得分 = Math.round(紧塑力S × 4 + 透亮感S × 3 + 稳韧力S × 3)
- 三个维度为10分制,综合得分为100分制整数。
- 使用现有维度S分数(保留两位小数)计算,不先取页面显示的一位小数;最终四舍五入一次。
- 三维度任何一个缺失、非数值或越界,返回 null,页面显示 `--`,CSV为空;不按0分计、不重新分配权重。
- 不再读取旷视 `score_info.total_score` 或顶层 `total_score` 作为综合得分。原始接口数据仍原样保留供复核。
例如:紧塑力8、透亮感9、稳韧力8.5 → 84.5 → 85分。
## 生效位置
统一函数 `SkinScore.weightedTotal(dims)`,供评分引擎、H5报告页和后台复用。
- 新检测的 `vm.total` 与后台记录 `total_score` 保存加权结果,规则标记为 `dimensions-433-v1`。
- 后台列表列名“综合得分”,详情明确显示公式与精度规则,CSV列名“综合得分(4:3:3)”。
- 历史报告H5展示、后台列表/详情/CSV按已保存维度计算,不使用旧接口总分;不批量改写历史记录、报告快照或原始返回文件。
- 不修改维度评分、原始肌龄、文案或推荐产品规则;历史推荐保持不变。
- 历史快照和历史列表接口里既有的摘要总分不做迁移;用户页面不会使用该旧摘要分数,报告详情由统一函数计算显示。
## 本地验证
```sh
node h5/test/weighted-total.js
node h5/js/skin-score.js
node h5/test/regression.js
node h5/test/recommendation.js
node h5/test/raw-skin-age.js
node h5/test/accounts.js
H5_ARTIFACT_DIR=/tmp/juzi-weighted-total-20261014 node h5/test/report-persistence.mjs
```
使用临时数据目录、固定微信身份和接口样例进行测试,不调用付费检测,不读写真实线上报告。验证新记录保存、前后端展示一致、CSV、历史记录文件不变、缺失维度、安全隔离与既有产品/文案回归。
已获用户确认,纳入 2026-10-14 发版;部署后只读核对线上静态资源、报告页与后台接口,不调用真实付费检测或新增测试记录。
@@ -0,0 +1,11 @@
# 肤色文案与实际黑眼圈等级
仅调整报告展示文案,不改肤色计分、20%黑眼圈权重、综合得分或产品推荐。
肤色三档仍沿用肤色水润分类百分制原值:低于80=改善,80至低于90=良好,大于等于90=优秀;色素分占位符取现有 melanin。三档新模板同步到 reference/AI测肤报告页话术修改.md,再用 tools/build_report_copy.py 生成 report-copy.js,避免重生成时丢失本次调整。
黑眼圈描述独立读取 dark_circle_severity.value:0无明显黑眼圈,1仅存在轻度黑眼圈,2存在中度黑眼圈,3可见明显黑眼圈。仅接受0~3的整数;无效/缺失省略描述,不读 dark_circle.value,不用 dark_circle_score 推断。即使肤色优秀,也可能显示明显黑眼圈;即使肤色改善,也可能显示无明显黑眼圈。
消费者文案删除追加的黑眼圈分数及20%/80%权重说明;后台评分明细保留。新生成的报告与后台详情共用新展示模板,已保存的历史 presentation 保留,不迁移历史记录。
验证:color-copy.js 覆盖60种分档/程度组合、80/90边界、与黑眼圈分数相反的程度、缺失/无效程度、缺失色素分与不修改输入。另复测评分、色素透明度、真实色斑案例及报告持久化。
@@ -0,0 +1,30 @@
# 肤色项加入黑眼圈20%,色素图30%叠加
## 用户确认的评分口径
色素扣斑分=melanin_score-melasma×10-freckle×5。
肤色项=色素扣斑分×80%+dark_circle_score×20%。
肤色水润P=肤色项×50%+水分项×50%;水分项原有严重度交叉校验不变。
透亮感仍取平滑度和肤色水润中的较差项。没有拆分现有肤色/水分底层类别或新增维度。
黑眼圈缺失/无效时不编造分数,按其余有效权重归一,后台记录计算来源。报告肤色文案显示黑眼圈评分与20%权重,后台详情说明色素扣斑分、黑眼圈分、合成肤色项和水分项;新记录保存 `tone_scoring`(`color-dark-circle-20-v1`)。历史快照和评分不重写。
## 图像
- 肤色页原始照片保持不透明,brown_area 色素图在照片上使用 multiply 混合、globalAlpha=0.3。
- 没有色素图时,可用接口返回的 melanin_mark 真实坐标叠加;有色素图时不重复叠加同一份色素坐标。
- 黄褐斑和雀斑当前返回value/confidence分类结果,没有各自位置坐标。图上显示检出/未检出/未获取;只有实际收到该类型坐标时才绘制标记,不能借褐色斑坐标冒充黄褐斑/雀斑。
- 图层或坐标均缺失时降级为原始照片并注明,不填补不存在的检测区域。
- 30%只应用于肤色页检测叠加层,不改皱纹、平滑度、水分、泛红、痘痘图层或整体人脸照片的不透明度。
## 验证
- `node h5/test/tone-dark-circle.js`:80/20、外层50/50、先扣斑再加权、0分、缺失/非法值、来源记录、文案、输入不变。
- `node h5/test/tone-overlay-browser.mjs`:用固定颜色的合成测试图核对实际像素的30%混合,整张照片opacity仍为1;有/无分类值不伪造坐标;无图降级。
- `H5_ACNE_MAX_TEST=1 node h5/test/report-persistence.mjs`:新来源与前后端评分、文案保存回看、历史不改写。
- 私有CYLF案例本地回放截图:色素94、黑眼圈68、水分93,肤色项88.8,肤色水润90.9;平滑度87.6仍更低,所以透亮感仍9.63,叠加待发布的皱纹/痘痘规则,综合得分94。
- 旧Excel/参考断言将黑眼圈输入固定到原色素扣斑分,以隔离其他既有计算/文案回归;新规则由独立测试验证,不改写原始fixture。
所有测试使用临时服务、固定接口返回或样例图片,不发起付费调用、不写线上记录。当前本地修改,未提交或部署。
@@ -0,0 +1,19 @@
# 透亮感改为三项独立最低分
用户确认方案2:透亮感百分制P=min(独立肤色项,水分项,平滑度项),缺项剔除,三项全缺失则不评分。十分制沿用7+3×P/100、显示范围7~10、内部保留两位小数。综合得分仍按紧塑力4、透亮感3、稳韧力3四舍五入取整数。
独立肤色项继续使用色素扣斑后80%+黑眼圈20%;水分保留已有缺水交叉校验。新透亮感参与原有优先关注及肤龄边界产品推荐判断,未另改推荐规则。
为兼容旧文案、旧六分类接口及历史记录,cats.tone和旧版统计字段暂时保留。旧平均分不再进入新版透亮感、综合得分、优先关注、产品推荐或七项护理选择。正式报告使用七项展示,后台新记录新增sub_scores七项独立评分,并记录vita_score_rule=independent-color-water-smooth-min-v1;历史记录仍标注历史六分类,不以新版重算历史评分或推荐。
本次上线包括已确认的独立肤色/水分文案、七选三且关注优先的护理建议,以及产品标题恢复14.467593px/灰色副标题同比放大。三图轮播仍是本地试验,不纳入正式页面,不上传真实案例数据或私有预览脚本。
## 真实案例回放(当前规则)
|案例|旧透亮感S|新透亮感S|旧总分|新总分|推荐变化|
|---|---:|---:|---:|---:|---|
|R20261014-171911-O6YJ|9.26|9.09|88|88|仍25+|
|R20261015-111214-JKNS|9.34|8.76|94|93|仍25+|
|R20261014-171215-CYLF|9.63|9.63|94|94|仍25+|
JKNS优先关注由紧塑力变为透亮感,护理建议顺序变为肤色→平滑度→皱纹,符合关注项前置要求。测试仅回放现有返回,未重新调用付费检测。
@@ -1,4 +1,4 @@
# 1meAI测肤旷视UI问题反馈
# 1'meAI测肤旷视UI问题反馈
## UI交互界面调整
+118 -35
View File
@@ -4,28 +4,28 @@
"2": "app.js",
"3": "skin-score.js",
"4": "customer-feedback-second.js",
"5": "t",
"6": "n",
"5": "showScreen",
"6": "countdown-capture-browser.mjs",
"7": "test/profile-store.js",
"8": "admin.js",
"9": "__awaiter",
"10": "wechat-browser.mjs",
"10": "n",
"11": "lib/accounts.js",
"12": "server.js",
"13": "files",
"14": ".draw",
"15": "extractFaces",
"14": "normalize",
"15": "factual-copy.js",
"16": "巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范",
"17": "report-persistence.mjs",
"17": "extractFaces",
"18": "1'me肌肤状态检测 H5",
"19": "customer-feedback-fourth.js",
"20": "build_fixtures.py",
"21": "disposeUnusedWeightTensors",
"22": "k",
"21": "展示肌龄限制正负5岁-2026-10-14.md",
"22": "acne-three-view.js",
"23": "facepp-skinanalyze(旷视皮肤分析专业版调用技能)",
"24": "巨子测肤 · 1'me 语音播报内容设计文档 V1.0",
"25": "mobile-select.iife.min.js",
"26": "NeuralNetwork",
"26": "display-age-recommendation.js",
"27": "gen_voice.py",
"28": "C",
"29": "face-api.js 本地化依赖(h5/vendor/faceapi/)",
@@ -37,15 +37,16 @@
"35": "customer-feedback.js",
"36": "actualFont",
"37": "UI 外发版 10.09 图片资源",
"38": "FaceLandmark68Net",
"38": "最终确认:只改两处文案",
"39": "UI交互界面调整",
"40": "第四轮反馈修改与验收",
"41": "showScreen",
"41": "test/capture-funnel.js",
"42": "layer_at",
"43": "TinyYolov2Base",
"43": "t",
"44": "start-local.sh",
"45": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"46": "PredictAllFaceExpressionsWithFaceAlignmentTask",
"45": "dimension-cap.js",
"46": "zoom-fill.js",
"47": ".extractWeights",
"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": "综合得分:三维度按 4:3:3 加权",
"65": "微信登录接入与公网部署说明",
"66": ".runAndExtendWithFaceDetections",
"66": "capture-quality.js",
"67": "ui-20261009.mjs",
"68": "analyze-timeout.js",
"69": "misans/README.md",
@@ -72,47 +73,129 @@
"71": "报告顶部评分与年龄排版",
"72": "build_report_copy.py",
"73": "camera-geometry.js",
"74": "report-copy.js",
"74": "water-copy.js",
"75": "prepare_h5_assets.py",
"76": "报告页滑动防冲突-2026-10-13.md",
"77": "drawFaceExpressions",
"78": ".toString",
"77": "care-selection.js",
"78": "drawFaceExpressions",
"79": "登录反馈与加载优化(2026-10-12)",
"80": "线上微信登录部署记录",
"81": "wechat-production.mjs",
"82": "拍摄约束放宽与标题更新",
"83": "10.09 新版 H5 实施与验收",
"84": "微信登录联调:2026-10-12",
"85": "test/accounts.js",
"85": "相机翻转性能优化",
"86": "report-touch-layout.mjs",
"87": "产品推荐规则:肤龄基准与边界调整",
"88": "ui-20261012/manifest.json",
"89": "傅艳NEW与项思炼的会话 2026年10月12日.md",
"90": "customer-feedback.mjs",
"91": "wi",
"91": "scan-wait-timeout.js",
"92": "客户反馈修改(2026-10-12)",
"93": ".runAndExtendWithFaceDetection",
"93": "vizCompose",
"94": "第三轮反馈:PSD 对齐、验收及上线记录",
"95": "login-performance.mjs",
"96": "production-loop.mjs",
"99": "DetectSingleFaceLandmarksTask",
"100": "extractAllFacesAndComputeResults",
"102": "extractFaceTensors",
"95": "i",
"96": "capture-funnel-browser.mjs",
"97": "account-client.js",
"98": "extractFaceTensors",
"99": "extractAllFacesAndComputeResults",
"100": ".runAndExtendWithFaceDetection",
"101": "wechat-fixture.cjs",
"102": ".toString",
"103": "第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md",
"104": "export_fourth_feedback_assets.py",
"105": "capture-quality-browser.mjs",
"106": "第三轮反馈20261012/傅艳NEW与项思炼的会话 2026年10月12日.md",
"107": "基础信息记忆与肤质拖动",
"108": "profile-interaction.mjs",
"109": "submitPhotos",
"110": "doCapture",
"111": "i",
"110": "resetDetectState",
"111": "color-copy.js",
"112": "微信退出登录-2026-10-13.md",
"113": "swipe-track.js",
"114": "optimize_delivery_images.py",
"116": "convLayer",
"117": "startCamera",
"115": "痘痘按三视图各类型最大数量计分",
"116": "capture-no-recheck.js",
"117": "display-grade.js",
"119": "肤色文案与实际黑眼圈等级-2026-10-14.md",
"120": "disposeUnusedWeightTensors",
"121": ".draw",
"122": "startCamera",
"123": "评分圈内居中、基础信息页固定与正脸语音修复",
"124": "TinyYolov2",
"124": "admin-beijing-browser.mjs",
"125": "报告等待页实拍背景-2026-10-12.md",
"127": "height"
"126": "wrinkle-case-browser.mjs",
"127": "splash-brand-browser.mjs",
"128": "后台北京时间统一",
"129": "报告底部 Figma 还原",
"130": "wrinkle-overall.js",
"131": "原始肌龄显示与鸿蒙滚轮兼容",
"132": "harmony-picker.mjs",
"133": "recommendation.js",
"134": "camera-flip-browser.mjs",
"135": "恢复321与色斑案例复测",
"136": "七项护理选三与关注优先",
"137": "皱纹改用接口整体评分",
"138": "拍照报告转化统计-2026-10-16.md",
"139": "private-case-preview.mjs",
"140": "FaceLandmarks68",
"141": "实测描述与旧护理兼容:修复复测",
"142": "tone-dark-circle.js",
"143": "test/accounts.js",
"144": "肤色项加入黑眼圈20%,色素图30%叠加",
"145": "PredictAllAgeAndGenderWithFaceAlignmentTask",
"146": "NeuralNetwork",
"147": "拍摄角度不合格震动提醒-2026-10-13.md",
"148": "PredictAllFaceExpressionsWithFaceAlignmentTask",
"149": "report-persistence.mjs",
"150": "R20261015-104945-ZC5V",
"151": "wechat-browser.mjs",
"152": "customer-feedback-fourth.mjs",
"153": "tone-overlay-browser.mjs",
"155": "h5/README.md",
"156": "报告文案统一按十分制分档",
"157": "水分文案独立分档",
"158": "R20261015-111214-JKNS",
"159": "皱纹三视图标注",
"160": "age",
"161": "head-pose.js",
"162": "contour-eye-copy.js",
"163": "test/wrinkle-jobs.js",
"164": "PredictSingleAgeAndGenderWithFaceAlignmentTask",
"165": "PredictSingleFaceExpressionsWithFaceAlignmentTask",
"166": "扫描等待30秒兜底-2026-10-16.md",
"167": "线上最近10条成功案例:报告文案对照",
"168": "R20261015-103650-FVQA",
"169": "R20261015-105002-HVEV",
"170": "R20261015-105142-4UTP",
"171": "R20261015-144549-7CNL",
"172": "拍摄光线仅提示-2026-10-16.md",
"173": "R20261015-104209-V2J6",
"174": "R20261015-104729-BLXO",
"175": "R20261015-112350-ZLOF",
"176": "逐条页卡文案全文对照",
"177": "report-copy-cases.js",
"178": "login-performance.mjs",
"179": "contour-sides.mjs",
"180": "production-loop.mjs",
"181": "save",
"182": "capture-diagnostics.js",
"184": "wrinkle-three-views.mjs",
"185": "FaceLandmark68Net",
"187": "取消拍后校验-2026-10-16.md",
"188": "wi",
"189": "convLayer",
"190": "FaceProcessor",
"191": "extractConvParamsFactory",
"192": "convLayer$1",
"193": ".forwardInput",
"194": "wrinkle-async-browser.mjs",
"195": "AgeGenderNet",
"196": ".runAndExtendWithFaceDetections",
"197": "FaceExpressionNet",
"198": "FaceFeatureExtractor",
"199": "DetectSingleFaceLandmarksTask",
"200": "TinyXception",
"201": "TinyFaceFeatureExtractor",
"202": "TinyYolov2",
"205": "侧脸皱纹浮层异步加载-2026-10-16.md"
}
File diff suppressed because one or more lines are too long
+470 -160
View File
@@ -1,16 +1,16 @@
# Graph Report - juzi (2026-10-13)
# Graph Report - juzi (2026-10-16)
## Corpus Check
- 107 files · ~4,905,775 words
- 178 files · ~5,066,846 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)
- 1969 nodes · 3493 edges · 199 communities (154 shown, 45 thin omitted)
- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 235 edges (avg confidence: 0.58)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `4c478b16`
- Built from commit: `571d78a2`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -20,28 +20,27 @@
- app.js
- skin-score.js
- customer-feedback-second.js
- t
- n
- showScreen
- test/profile-store.js
- admin.js
- __awaiter
- wechat-browser.mjs
- n
- lib/accounts.js
- server.js
- files
- .draw
- extractFaces
- normalize
- factual-copy.js
- 巨子测肤 · 1'me 在线肌肤测试 — UI 设计规范
- report-persistence.mjs
- extractFaces
- 1'me肌肤状态检测 H5
- customer-feedback-fourth.js
- build_fixtures.py
- disposeUnusedWeightTensors
- k
- 展示肌龄限制正负5岁-2026-10-14.md
- acne-three-view.js
- facepp-skinanalyze(旷视皮肤分析专业版调用技能)
- 巨子测肤 · 1'me 语音播报内容设计文档 V1.0
- mobile-select.iife.min.js
- NeuralNetwork
- display-age-recommendation.js
- gen_voice.py
- C
- face-api.js 本地化依赖(h5/vendor/faceapi/)
@@ -51,21 +50,22 @@
- build-fixture.py
- customer-feedback.js
- UI 外发版 10.09 图片资源
- FaceLandmark68Net
- 最终确认:只改两处文案
- UI交互界面调整
- 第四轮反馈修改与验收
- showScreen
- test/capture-funnel.js
- layer_at
- TinyYolov2Base
- t
- start-local.sh
- PredictAllAgeAndGenderWithFaceAlignmentTask
- PredictAllFaceExpressionsWithFaceAlignmentTask
- dimension-cap.js
- zoom-fill.js
- .extractWeights
- 已修改
- mobile-select/README.md
- call_skinanalyze_3view.sh
- 护理建议(全量文本,按需求摘抄)
- UI 对齐 10.09 设计稿:调研与实施计划
- FaceLandmarks68
- guest-privacy.mjs
- report-ui.js
- regression.js
- .forwardInput
@@ -73,58 +73,136 @@
- 报告布局与跟手交互调整
- 提交前 Review(2026-10-11)
- e2e-browser.mjs
- start
- 综合得分:三维度按 4:3:3 加权
- 微信登录接入与公网部署说明
- .runAndExtendWithFaceDetections
- capture-quality.js
- ui-20261009.mjs
- analyze-timeout.js
- misans/README.md
- scan-placeholder.mjs
- 报告顶部评分与年龄排版
- build_report_copy.py
- water-copy.js
- 报告页滑动防冲突-2026-10-13.md
- care-selection.js
- drawFaceExpressions
- .toString
- 登录反馈与加载优化(2026-10-12)
- 线上微信登录部署记录
- wechat-production.mjs
- 拍摄约束放宽与标题更新
- 10.09 新版 H5 实施与验收
- 微信登录联调:2026-10-12
- test/accounts.js
- 相机翻转性能优化
- report-touch-layout.mjs
- 产品推荐规则:肤龄基准与边界调整
- ui-20261012/manifest.json
- 傅艳NEW与项思炼的会话 2026年10月12日.md
- wi
- scan-wait-timeout.js
- 客户反馈修改(2026-10-12)
- .runAndExtendWithFaceDetection
- vizCompose
- 第三轮反馈:PSD 对齐、验收及上线记录
- login-performance.mjs
- production-loop.mjs
- DetectSingleFaceLandmarksTask
- extractAllFacesAndComputeResults
- i
- capture-funnel-browser.mjs
- account-client.js
- extractFaceTensors
- extractAllFacesAndComputeResults
- .runAndExtendWithFaceDetection
- wechat-fixture.cjs
- .toString
- 第四轮反馈/傅艳NEW与项思炼的会话 2026年10月12日.md
- 第三轮反馈20261012/傅艳NEW与项思炼的会话 2026年10月12日.md
- 基础信息记忆与肤质拖动
- profile-interaction.mjs
- submitPhotos
- doCapture
- i
- convLayer
- resetDetectState
- color-copy.js
- 微信退出登录-2026-10-13.md
- 痘痘按三视图各类型最大数量计分
- capture-no-recheck.js
- display-grade.js
- 肤色文案与实际黑眼圈等级-2026-10-14.md
- disposeUnusedWeightTensors
- .draw
- startCamera
- 评分圈内居中、基础信息页固定与正脸语音修复
- TinyYolov2
- admin-beijing-browser.mjs
- 报告等待页实拍背景-2026-10-12.md
- height
- wrinkle-case-browser.mjs
- 后台北京时间统一
- 报告底部 Figma 还原
- wrinkle-overall.js
- 原始肌龄显示与鸿蒙滚轮兼容
- harmony-picker.mjs
- recommendation.js
- 恢复321与色斑案例复测
- 七项护理选三与关注优先
- 皱纹改用接口整体评分
- 拍照报告转化统计-2026-10-16.md
- private-case-preview.mjs
- FaceLandmarks68
- 实测描述与旧护理兼容:修复复测
- tone-dark-circle.js
- test/accounts.js
- 肤色项加入黑眼圈20%,色素图30%叠加
- PredictAllAgeAndGenderWithFaceAlignmentTask
- NeuralNetwork
- 拍摄角度不合格震动提醒-2026-10-13.md
- PredictAllFaceExpressionsWithFaceAlignmentTask
- report-persistence.mjs
- R20261015-104945-ZC5V
- wechat-browser.mjs
- customer-feedback-fourth.mjs
- h5/README.md
- 报告文案统一按十分制分档
- 水分文案独立分档
- R20261015-111214-JKNS
- 皱纹三视图标注
- age
- head-pose.js
- contour-eye-copy.js
- test/wrinkle-jobs.js
- PredictSingleAgeAndGenderWithFaceAlignmentTask
- PredictSingleFaceExpressionsWithFaceAlignmentTask
- 扫描等待30秒兜底-2026-10-16.md
- 线上最近10条成功案例:报告文案对照
- R20261015-103650-FVQA
- R20261015-105002-HVEV
- R20261015-105142-4UTP
- R20261015-144549-7CNL
- 拍摄光线仅提示-2026-10-16.md
- R20261015-104209-V2J6
- R20261015-104729-BLXO
- R20261015-112350-ZLOF
- 逐条页卡文案全文对照
- report-copy-cases.js
- login-performance.mjs
- production-loop.mjs
- save
- capture-diagnostics.js
- FaceLandmark68Net
- 取消拍后校验-2026-10-16.md
- wi
- convLayer
- FaceProcessor
- extractConvParamsFactory
- convLayer$1
- .forwardInput
- wrinkle-async-browser.mjs
- AgeGenderNet
- .runAndExtendWithFaceDetections
- FaceExpressionNet
- FaceFeatureExtractor
- DetectSingleFaceLandmarksTask
- TinyXception
- TinyFaceFeatureExtractor
- TinyYolov2
- 侧脸皱纹浮层异步加载-2026-10-16.md
## God Nodes (most connected - your core abstractions)
1. `__awaiter()` - 59 edges
2. `__generator()` - 59 edges
3. `n()` - 37 edges
4. `r()` - 35 edges
5. `i()` - 33 edges
3. `n()` - 39 edges
4. `r()` - 36 edges
5. `i()` - 34 edges
6. `t()` - 32 edges
7. `C()` - 32 edges
8. `toNetInput()` - 27 edges
@@ -138,103 +216,99 @@
h5/js/admin.js → h5/vendor/faceapi/face-api.js
- `bind()` --indirect_call--> `a()` [INFERRED]
h5/js/admin.js → h5/vendor/faceapi/face-api.js
- `checkStill()` --indirect_call--> `d()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `detectFrame()` --indirect_call--> `d()` [INFERRED]
h5/js/app.js → h5/vendor/faceapi/face-api.js
- `vizWrinkleMarks()` --indirect_call--> `key()` [INFERRED]
h5/js/app.js → h5/js/profile-store.js
## Import Cycles
- None detected.
## Communities (116 total, 25 thin omitted)
## Communities (199 total, 45 thin omitted)
### Community 0 - "face-api.js"
Cohesion: 0.02
Nodes (41): bgrToRgbTensor(), cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), decodeBoxesLayer(), Dimensions() (+33 more)
Nodes (46): boxPredictionLayer(), cn(), computeReshapedDimensions(), createBrowserEnv(), createFileSystem(), createNodejsEnv(), decodeBoxesLayer(), depthwiseConvLayer() (+38 more)
### Community 1 - "线上闭环测试(不含真实微信授权)"
Cohesion: 0.25
Nodes (7): 发现并修复, 可复现命令, 已通过的检查, 线上闭环测试(不含真实微信授权), 结论, 话术及仓库同步, 验证边界
### Community 2 - "app.js"
Cohesion: 0.11
Nodes (22): cancelSkinDrag(), esc(), goSkin(), openHistory(), renderHistoryList(), renderReport(), renderViz(), restartScanCycle() (+14 more)
Cohesion: 0.09
Nodes (22): cancelSkinDrag(), clearCaptureCountdown(), decode(), drain(), esc(), goSkin(), load(), loginAllowed() (+14 more)
### Community 3 - "skin-score.js"
Cohesion: 0.12
Nodes (45): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens(), calcSmooth(), calcTone(), calcWrinkles() (+37 more)
Cohesion: 0.07
Nodes (64): acneGrade(), buildViewModel(), calcAcne(), calcContour(), calcSens(), calcSmooth(), calcTone(), calcWrinkles() (+56 more)
### Community 4 - "customer-feedback-second.js"
Cohesion: 0.17
Nodes (10): assert, context, face, fs, geometry, original, path, source (+2 more)
Cohesion: 0.13
Nodes (12): assert, context, events, face, fs, geometry, missingHints, original (+4 more)
### Community 5 - "t"
Cohesion: 0.09
Nodes (31): be(), bi(), Bo(), ce(), cl(), de(), e(), Ef() (+23 more)
### Community 6 - "n"
Cohesion: 0.14
Nodes (37): a(), ae(), bf(), cf(), d(), drawContour(), dt(), ea() (+29 more)
### Community 5 - "showScreen"
Cohesion: 0.27
Nodes (10): enterGuest(), loadAppFont(), loginSucceeded(), logoutWechat(), openOverlay(), prepareScanArtwork(), renderAccountStatus(), resetReport() (+2 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)
Cohesion: 0.18
Nodes (32): ageText(), api(), bind(), captureQualityHtml(), closeDetail(), collectFilters(), dimsHtml(), doRetry() (+24 more)
### Community 9 - "__awaiter"
Cohesion: 0.13
Nodes (12): __awaiter(), ComposableTask(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow(), __generator(), getModelUris() (+4 more)
Cohesion: 0.12
Nodes (13): __awaiter(), ComposableTask(), fetchImage(), fetchJson(), fetchNetWeights(), fetchOrThrow(), __generator(), getModelUris() (+5 more)
### Community 10 - "wechat-browser.mjs"
Cohesion: 0.22
Nodes (6): createAccounts, h5, ReportUI, require, root, SkinScore
### Community 10 - "n"
Cohesion: 0.12
Nodes (48): a(), ae(), be(), bf(), ce(), cf(), d(), de() (+40 more)
### Community 11 - "lib/accounts.js"
Cohesion: 0.17
Nodes (20): atomic(), body(), cookie(), cookies(), crypto, fs, json(), login() (+12 more)
Cohesion: 0.16
Nodes (19): body(), cookie(), cookies(), crypto, fs, json(), login(), net (+11 more)
### Community 12 - "server.js"
Cohesion: 0.08
Nodes (52): accounts, appendRecord(), callFacepp(), CaptureDiagnostics, CRED, crypto, csvCell(), DATA_DIR (+44 more)
Cohesion: 0.06
Nodes (65): collect(), hasAnnotation(), accounts, appendRecord(), BeijingTime, callFacepp(), CaptureDiagnostics, captureFunnel (+57 more)
### Community 13 - "files"
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 - "normalize"
Cohesion: 0.26
Nodes (8): conv(), denseBlock3(), denseBlock4(), depthwiseSeparableConv(), mainBlock(), normalize(), reductionBlock(), __spreadArrays()
### Community 15 - "factual-copy.js"
Cohesion: 0.13
Nodes (13): assert, before, bytes, Copy, display, missing, Original, r (+5 more)
### 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 - "report-persistence.mjs"
Cohesion: 0.29
Nodes (6): child, dataRoot, fx, h5, ReportUI, require
### Community 17 - "extractFaces"
Cohesion: 0.19
Nodes (19): awaitMediaLoaded(), bufferToImage(), createCanvas(), createCanvasFromMedia(), extractFaces(), extractImagePatches(), getContext2dOrThrow(), getEnv() (+11 more)
### Community 18 - "1'me肌肤状态检测 H5"
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.12
Nodes (17): assert, brokenLandmarks, check(), Diagnostics, face(), HeadPose, held, ReportUI (+9 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 22 - "k"
Cohesion: 0.10
Nodes (21): b(), ba(), ca(), da(), fa(), gn(), k(), kn() (+13 more)
### Community 22 - "acne-three-view.js"
Cohesion: 0.14
Nodes (14): assert, capped, empty, heavy, keys, node(), partial, r (+6 more)
### Community 23 - "facepp-skinanalyze(旷视皮肤分析专业版调用技能)"
Cohesion: 0.20
@@ -248,17 +322,17 @@ Nodes (8): 1. 音频格式约定(已定), 2. 播报清单(14 条), 3. 播放策
Cohesion: 0.20
Nodes (16): _arrayLikeToArray(), _arrayWithHoles(), _arrayWithoutHoles(), _classCallCheck(), _createClass(), _defineProperties(), h(), _iterableToArray() (+8 more)
### Community 26 - "NeuralNetwork"
Cohesion: 0.05
Nodes (30): AgeGenderNet(), createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractConvParamsFactory() (+22 more)
### Community 26 - "display-age-recommendation.js"
Cohesion: 0.20
Nodes (9): assert, before, bytes, old, presentation, saved, SS, UI (+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.11
Nodes (25): ao(), C(), cd(), co(), Do(), eo(), fo(), Ft() (+17 more)
Cohesion: 0.09
Nodes (31): ao(), Bo(), C(), cd(), co(), Do(), eo(), fo() (+23 more)
### Community 29 - "face-api.js 本地化依赖(h5/vendor/faceapi/)"
Cohesion: 0.29
@@ -273,13 +347,17 @@ Cohesion: 0.60
Nodes (4): main(), 递归缩放 result 内全部坐标:{left,top,width,height} 矩形与 {x,y} 点,与原数据同为整数。, scale_coords(), to_data_url()
### Community 35 - "customer-feedback.js"
Cohesion: 0.06
Nodes (27): alternate, approved, assert, crop, details, excellent, fs, model (+19 more)
Cohesion: 0.05
Nodes (32): acneBefore, acneMarks, acneRaw, alternate, approved, assert, crop, details (+24 more)
### Community 37 - "UI 外发版 10.09 图片资源"
Cohesion: 0.40
Nodes (4): UI 外发版 10.09 图片资源, 前端素材对应, 文件位置, 重新导出
### Community 38 - "最终确认:只改两处文案"
Cohesion: 0.50
Nodes (3): 保留的逻辑修复, 最终确认:只改两处文案, 验证
### Community 39 - "UI交互界面调整"
Cohesion: 0.40
Nodes (4): 1meAI测肤旷视UI问题反馈, UI交互界面调整, 交互, **设计**
@@ -288,41 +366,61 @@ Nodes (4): 1meAI测肤旷视UI问题反馈, UI交互界面调整, 交互, **设
Cohesion: 0.22
Nodes (8): PSD 来源, 前端参数及限制, 后台校准留痕, 已确认并实现, 生产部署及只读验收, 皱纹(紧塑力)原文, 第四轮反馈修改与验收, 验证
### Community 41 - "showScreen"
Cohesion: 0.22
Nodes (11): loadAppFont(), loginAllowed(), loginBrowser(), loginSucceeded(), loginWechat(), openOverlay(), prepareScanArtwork(), renderAccountStatus() (+3 more)
### Community 41 - "test/capture-funnel.js"
Cohesion: 0.06
Nodes (36): boundary(), day(), format(), milliseconds(), range(), shifted(), BeijingTime, extras (+28 more)
### Community 42 - "layer_at"
Cohesion: 0.18
Nodes (11): list, bounds(), layer_at(), main(), save_image(), composite(), main(), sha256() (+3 more)
### Community 43 - "t"
Cohesion: 0.14
Nodes (14): bi(), Ef(), er(), ia(), Ie(), j(), Kt(), Mf() (+6 more)
### Community 44 - "start-local.sh"
Cohesion: 0.50
Nodes (3): HOST, PORT, start-local.sh script
### Community 45 - "dimension-cap.js"
Cohesion: 0.22
Nodes (8): assert, before, legacy, r, S, saved, UI, vm
### Community 46 - "zoom-fill.js"
Cohesion: 0.18
Nodes (10): assert, canvases, context, drawn, frame, fs, helper, img (+2 more)
### Community 47 - ".extractWeights"
Cohesion: 0.15
Nodes (14): createFaceDetectionNet(), createFaceRecognitionNet(), createMtcnn(), createSsdMobilenetv1(), createTinyFaceDetector(), createTinyYolov2(), extractFCParamsFactory(), extractorsFactory$2() (+6 more)
### Community 48 - "已修改"
Cohesion: 0.22
Nodes (8): 12 日第二批:明确项修改, 公网部署与复核(2026-10-12,北京时间), 准备捕捉提醒, 字重, 已修改, 拍照展示, 首页高清批注, 验证与状态
### Community 53 - "护理建议(全量文本,按需求摘抄)"
Cohesion: 0.10
Nodes (19): 5\.1 轮廓(紧塑力), 5\.2 皱纹(紧塑力), 5\.3 肤色(透亮感), 5\.4 平滑度(透亮感), 5\.5 水分(透亮感), 5\.6 泛红(稳韧力), 5\.7 痘痘(稳韧力), 6\.1 轮廓 (+11 more)
Nodes (20): 2026-10-15 本次确认范围, 5\.1 轮廓(紧塑力), 5\.2 皱纹(紧塑力), 5\.3 肤色(透亮感), 5\.4 平滑度(透亮感), 5\.5 水分(透亮感), 5\.6 泛红(稳韧力), 5\.7 痘痘(稳韧力) (+12 more)
### Community 54 - "UI 对齐 10.09 设计稿:调研与实施计划"
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()
Cohesion: 0.35
Nodes (12): build(), count(), dimensionScore(), fill(), focusOf(), forDisplay(), internalLevel(), levelForScore() (+4 more)
### Community 57 - "regression.js"
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.11
Nodes (16): height(), extractBoundingBoxes(), FaceLandmark68NetBase(), FaceRecognitionNet(), getSizesForScale(), iou(), nonMaxSuppression(), nonMaxSuppression$1() (+8 more)
### Community 61 - "报告布局与跟手交互调整"
Cohesion: 0.33
@@ -336,14 +434,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 - "综合得分:三维度按 4:3:3 加权"
Cohesion: 0.40
Nodes (4): 已确认公式, 本地验证, 生效位置, 综合得分:三维度按 4:3:3 加权
### Community 65 - "微信登录接入与公网部署说明"
Cohesion: 0.29
Nodes (7): 公网服务器配置, 公网真机验收清单, 反向代理与后台保护, 已完成的链路, 微信登录接入与公网部署说明, 数据、迁移与备份, 本机开发环境限制(不代表线上状态)
### Community 66 - "capture-quality.js"
Cohesion: 0.60
Nodes (3): evaluate(), finite(), scoreThreshold()
### Community 68 - "analyze-timeout.js"
Cohesion: 0.20
Nodes (7): assert, end, fs, path, source, start, vm
@@ -352,20 +454,24 @@ Nodes (7): assert, end, fs, path, source, start, vm
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 74 - "water-copy.js"
Cohesion: 0.18
Nodes (9): assert, Copy, crossed, example, old, savedBefore, SS, UI (+1 more)
### Community 78 - ".toString"
Cohesion: 0.12
Nodes (11): FaceLandmarks5(), FaceMatch(), getCenterPoint(), la(), Point(), pr(), pt(), vt() (+3 more)
### Community 77 - "care-selection.js"
Cohesion: 0.22
Nodes (9): assert, check(), Copy, heavy, keys, sample(), SS, UI (+1 more)
### Community 78 - "drawFaceExpressions"
Cohesion: 0.22
Nodes (6): DrawBox(), drawDetections(), drawFaceExpressions(), DrawFaceLandmarks(), FaceExpressions(), isWithFaceExpressions()
### Community 79 - "登录反馈与加载优化(2026-10-12)"
Cohesion: 0.33
Cohesion: 0.25
Nodes (5): 体验变化, 公网部署后复测, 慢的原因与处理, 登录反馈与加载优化(2026-10-12), 验证
### Community 80 - "线上微信登录部署记录"
Cohesion: 0.33
Cohesion: 0.29
Nodes (6): 初次部署时的凭据查找记录(现已由用户提供), 初次部署的线上检查(历史结果), 后续更新:已配置真实凭据, 已执行, 用户确认加入白名单后的复测, 线上微信登录部署记录
### Community 81 - "wechat-production.mjs"
@@ -377,16 +483,16 @@ Cohesion: 0.40
Nodes (4): 参数对照, 拍摄约束放宽与标题更新, 标题, 验证与发布
### Community 83 - "10.09 新版 H5 实施与验收"
Cohesion: 0.29
Nodes (5): 10.09 新版 H5 实施与验收, 按确认项实现, 文档未定义部分的实施口径, 素材再生成, 验证命令
Cohesion: 0.22
Nodes (6): 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 - "相机翻转性能优化"
Cohesion: 0.40
Nodes (4): 仍需真机验证, 修改范围, 相机翻转性能优化, 验证
### Community 87 - "产品推荐规则:肤龄基准与边界调整"
Cohesion: 0.33
@@ -396,89 +502,293 @@ Nodes (6): 产品推荐规则:肤龄基准与边界调整, 决策顺序, 后
Cohesion: 0.22
Nodes (8): assets, design_scale, document_size, native_header_px, shape_metrics, source, source_sha256, text_styles
### Community 91 - "wi"
Cohesion: 0.28
Nodes (9): Ci(), Ei(), Fi(), Ii(), ki(), pe(), ri(), wi() (+1 more)
### Community 91 - "scan-wait-timeout.js"
Cohesion: 0.22
Nodes (6): assert, fs, helpers, source, submit, vm
### Community 92 - "客户反馈修改(2026-10-12)"
Cohesion: 0.33
Nodes (5): 公网部署与复测, 客户反馈修改(2026-10-12), 皱纹数据说明, 逐项处理, 验证
### Community 93 - ".runAndExtendWithFaceDetection"
Cohesion: 0.33
Nodes (5): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), detectAllFaces(), DetectSingleFaceTask()
### Community 93 - "vizCompose"
Cohesion: 0.17
Nodes (15): loadReportWrinkles(), renderContourSides(), renderReport(), renderViz(), renderWrinkleSides(), setDim(), vizCollectMarks(), vizCompose() (+7 more)
### Community 94 - "第三轮反馈:PSD 对齐、验收及上线记录"
Cohesion: 0.33
Nodes (5): 原稿与导出可追溯性, 已完成, 本地验证, 生产部署及只读验收, 第三轮反馈:PSD 对齐、验收及上线记录
### Community 96 - "production-loop.mjs"
### Community 95 - "i"
Cohesion: 0.07
Nodes (37): b(), ba(), bn(), ca(), cl(), da(), dn(), ee() (+29 more)
### Community 96 - "capture-funnel-browser.mjs"
Cohesion: 0.50
Nodes (3): artifacts, errors, samples
Nodes (3): child, h5, root
### Community 100 - "extractAllFacesAndComputeResults"
### Community 97 - "account-client.js"
Cohesion: 0.53
Nodes (8): browser(), clearGuestCache(), ensureGuest(), guest(), init(), logout(), rememberKind(), request()
### Community 98 - "extractFaceTensors"
Cohesion: 0.09
Nodes (12): ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithAge(), extendWithFaceDescriptor(), extendWithFaceExpressions(), extendWithGender(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult() (+4 more)
Nodes (13): bgrToRgbTensor(), Box(), DetectAllFaceLandmarksTask(), extendWithFaceDetection(), extendWithFaceLandmarks(), extractFaceTensors(), FaceLandmarks(), isTensor3D() (+5 more)
### Community 102 - "extractFaceTensors"
### Community 99 - "extractAllFacesAndComputeResults"
Cohesion: 0.10
Nodes (11): Box(), DetectAllFaceLandmarksTask(), extendWithFaceDetection(), extendWithFaceLandmarks(), extractFaceTensors(), FaceLandmarks(), isDimensions(), isTensor3D() (+3 more)
Nodes (11): ComputeAllFaceDescriptorsTask(), ComputeSingleFaceDescriptorTask(), extendWithAge(), extendWithFaceDescriptor(), extendWithFaceExpressions(), extendWithGender(), extractAllFacesAndComputeResults(), extractSingleFaceAndComputeResult() (+3 more)
### Community 107 - "基础信息记忆与肤质拖动"
### Community 100 - ".runAndExtendWithFaceDetection"
Cohesion: 0.33
Nodes (4): 回归命令, 基础信息记忆与肤质拖动, 肤质交互, 范围
Nodes (5): allFacesMtcnn(), allFacesSsdMobilenetv1(), allFacesTinyYolov2(), detectAllFaces(), DetectSingleFaceTask()
### Community 101 - "wechat-fixture.cjs"
Cohesion: 0.50
Nodes (3): crypto, fs, path
### Community 102 - ".toString"
Cohesion: 0.12
Nodes (11): FaceLandmarks5(), FaceMatch(), getCenterPoint(), la(), Point(), pr(), pt(), vt() (+3 more)
### Community 108 - "profile-interaction.mjs"
Cohesion: 0.40
Nodes (3): dragBy(), h5, touch()
### Community 109 - "submitPhotos"
Cohesion: 0.24
Nodes (12): bindBackdropClose(), closeLoginSuccess(), closeOverlay(), handleCamHidden(), leaveCamera(), photosByView(), popHistorySilently(), requestAnalyze() (+4 more)
Cohesion: 0.22
Nodes (14): beginCaptureJourney(), bindBackdropClose(), closeLoginSuccess(), closeOverlay(), handleCamHidden(), leaveCamera(), photosByView(), popHistorySilently() (+6 more)
### Community 110 - "doCapture"
### Community 110 - "resetDetectState"
Cohesion: 0.13
Nodes (27): afterCurrent(), armNudge(), busyWithFlow(), captureHaptic(), captureStageHint(), detectFrame(), detTick(), doCapture() (+19 more)
### Community 111 - "color-copy.js"
Cohesion: 0.25
Nodes (7): assert, descriptions, missing, SS, templates, UI, v
### Community 115 - "痘痘按三视图各类型最大数量计分"
Cohesion: 0.40
Nodes (4): 后台和历史, 痘痘按三视图各类型最大数量计分, 规则, 验证
### Community 116 - "capture-no-recheck.js"
Cohesion: 0.18
Nodes (10): assert, completed, ctx, Diagnostics, fileFunction, fs, normalized, path (+2 more)
### Community 117 - "display-grade.js"
Cohesion: 0.17
Nodes (22): armNudge(), busyWithFlow(), captureFromVideo(), captureHaptic(), captureStageHint(), checkStill(), doCapture(), dropHint() (+14 more)
Nodes (11): assert, Copy, display, keys, old, oldBytes, r, SS (+3 more)
### Community 111 - "i"
Cohesion: 0.12
Nodes (18): bn(), dn(), ee(), et(), fn(), i(), je(), ln() (+10 more)
### Community 120 - "disposeUnusedWeightTensors"
Cohesion: 0.10
Nodes (23): disposeUnusedWeightTensors(), extractorsFactory$3(), extractorsFactory$5(), extractorsFactory$7(), extractorsFactory$9(), extractParamsFromWeigthMap(), extractParamsFromWeigthMap$1(), extractParamsFromWeigthMap$2() (+15 more)
### Community 116 - "convLayer"
Cohesion: 0.14
Nodes (17): boxPredictionLayer(), convLayer(), convWithBatchNorm(), depthwiseConvLayer(), depthwiseSeparableConv$1(), fullyConnectedLayer(), getStridesForLayerIdx(), leaky() (+9 more)
### Community 117 - "startCamera"
Cohesion: 0.15
Nodes (17): applyMirror(), beginDetect(), detectFrame(), detTick(), effMirror(), enableFallback(), getCamStream(), handleCamVisible() (+9 more)
### Community 122 - "startCamera"
Cohesion: 0.19
Nodes (14): applyMirror(), beginDetect(), effMirror(), enableFallback(), getCamStream(), loadFaceApi(), loadModels(), onCamTrackEnded() (+6 more)
### Community 123 - "评分圈内居中、基础信息页固定与正脸语音修复"
Cohesion: 0.40
Nodes (4): 发布, 范围, 评分圈内居中、基础信息页固定与正脸语音修复, 验证
### Community 127 - "height"
### Community 124 - "admin-beijing-browser.mjs"
Cohesion: 0.33
Nodes (5): child, file, records, root, times
### Community 126 - "wrinkle-case-browser.mjs"
Cohesion: 0.22
Nodes (8): child, profile, projected, raw, require, root, SS, UI
### Community 128 - "后台北京时间统一"
Cohesion: 0.50
Nodes (3): 修改, 后台北京时间统一, 验证
### Community 129 - "报告底部 Figma 还原"
Cohesion: 0.50
Nodes (3): 报告底部 Figma 还原, 静态素材核对, 验证
### Community 130 - "wrinkle-overall.js"
Cohesion: 0.25
Nodes (6): assert, base, changed, onlyOverall, SS, UI
### Community 131 - "原始肌龄显示与鸿蒙滚轮兼容"
Cohesion: 0.50
Nodes (3): 原始肌龄显示与鸿蒙滚轮兼容, 回归, 范围
### Community 133 - "recommendation.js"
Cohesion: 0.33
Nodes (6): assert, check(), missing, raw, SS, UI
### Community 135 - "恢复321与色斑案例复测"
Cohesion: 0.50
Nodes (3): 恢复321与色斑案例复测, 拍摄提示音, 真实色斑案例
### Community 136 - "七项护理选三与关注优先"
Cohesion: 0.50
Nodes (3): 七项护理选三与关注优先, 真实案例回放, 验证
### Community 137 - "皱纹改用接口整体评分"
Cohesion: 0.50
Nodes (3): 案例复算, 皱纹改用接口整体评分, 验证
### Community 139 - "private-case-preview.mjs"
Cohesion: 0.29
Nodes (6): fixture, mime, photos, raw, root, server
### Community 141 - "实测描述与旧护理兼容:修复复测"
Cohesion: 0.22
Nodes (8): R20261015-103650-FVQA, R20261015-111214-JKNS, R20261015-144549-7CNL, 同一批10条真实案例复测, 实测描述与旧护理兼容:修复复测, 新版代表文案, 本次修改, 自动化回归
### Community 142 - "tone-dark-circle.js"
Cohesion: 0.33
Nodes (5): a, assert, near(), SS, UI
### Community 143 - "test/accounts.js"
Cohesion: 0.22
Nodes (9): assert, create, fs, http, os, path, ReportUI, SkinScore (+1 more)
### Community 144 - "肤色项加入黑眼圈20%,色素图30%叠加"
Cohesion: 0.40
Nodes (4): 图像, 用户确认的评分口径, 肤色项加入黑眼圈20%,色素图30%叠加, 验证
### Community 149 - "report-persistence.mjs"
Cohesion: 0.29
Nodes (6): child, dataRoot, fx, h5, ReportUI, require
### Community 150 - "R20261015-104945-ZC5V"
Cohesion: 0.29
Nodes (7): R20261015-104945-ZC5V, 水分:改善→优秀(新版十分制显示 9.4), 泛红:改善→优秀(新版十分制显示 9.3), 痘痘:良好→优秀(新版十分制显示 9.6), 皱纹:改善→优秀(新版十分制显示 9.0), 肤色:改善→优秀(新版十分制显示 9.4), 轮廓:良好→优秀(新版十分制显示 9.7)
### Community 151 - "wechat-browser.mjs"
Cohesion: 0.22
Nodes (6): createAccounts, h5, ReportUI, require, root, SkinScore
### Community 152 - "customer-feedback-fourth.mjs"
Cohesion: 0.29
Nodes (6): CaptureQuality, child, dataRoot, h5, ReportUI, require
### Community 155 - "h5/README.md"
Cohesion: 0.15
Nodes (9): 回归命令, 基础信息记忆与肤质拖动, 肤质交互, 范围, 发版范围, 游客不留存与弹窗护理间距调整, 验证, 真实案例回放(当前规则) (+1 more)
### Community 156 - "报告文案统一按十分制分档"
Cohesion: 0.29
Nodes (6): 历史数据, 原因与规则, 回归, 报告文案统一按十分制分档, 真实案例复核, 覆盖范围
### Community 158 - "R20261015-111214-JKNS"
Cohesion: 0.29
Nodes (7): R20261015-111214-JKNS, 平滑度:良好→优秀(新版十分制显示 9.7), 水分:改善→优秀(新版十分制显示 9.9), 痘痘:良好→优秀(新版十分制显示 9.9), 皱纹:改善→优秀(新版十分制显示 9.2), 肤色:改善→良好(新版十分制显示 8.8), 轮廓:优秀→优秀(新版十分制显示 9.7)
### Community 160 - "age"
Cohesion: 0.36
Nodes (4): age(), clean(), create(), key()
### Community 161 - "head-pose.js"
Cohesion: 0.33
Nodes (8): assertPoints(), check(), clamp(), dist(), estimate(), makeFrontal(), makeSide(), midpoint()
### Community 162 - "contour-eye-copy.js"
Cohesion: 0.20
Nodes (9): assert, base, before, display, expected, raw, S, saved (+1 more)
### Community 163 - "test/wrinkle-jobs.js"
Cohesion: 0.13
Nodes (17): crypto, fs, path, register(), remember(), run(), save(), unavailable() (+9 more)
### Community 167 - "线上最近10条成功案例:报告文案对照"
Cohesion: 0.33
Nodes (5): 10条总览, 代表性差异, 发布前建议, 线上最近10条成功案例:报告文案对照, 结论
### Community 168 - "R20261015-103650-FVQA"
Cohesion: 0.33
Nodes (6): R20261015-103650-FVQA, 水分:良好→优秀(新版十分制显示 9.9), 泛红:改善→良好(新版十分制显示 8.4), 痘痘:良好→优秀(新版十分制显示 9.7), 皱纹:改善→优秀(新版十分制显示 9.3), 肤色:良好→优秀(新版十分制显示 9.5)
### Community 169 - "R20261015-105002-HVEV"
Cohesion: 0.33
Nodes (6): R20261015-105002-HVEV, 平滑度:良好→优秀(新版十分制显示 9.5), 水分:良好→优秀(新版十分制显示 9.9), 痘痘:良好→优秀(新版十分制显示 9.9), 皱纹:改善→优秀(新版十分制显示 9.0), 肤色:良好→优秀(新版十分制显示 9.3)
### Community 170 - "R20261015-105142-4UTP"
Cohesion: 0.33
Nodes (6): R20261015-105142-4UTP, 平滑度:良好→优秀(新版十分制显示 9.5), 水分:良好→优秀(新版十分制显示 9.9), 痘痘:良好→优秀(新版十分制显示 9.6), 皱纹:改善→良好(新版十分制显示 8.9), 肤色:良好→优秀(新版十分制显示 9.3)
### Community 171 - "R20261015-144549-7CNL"
Cohesion: 0.33
Nodes (6): R20261015-144549-7CNL, 泛红:改善→优秀(新版十分制显示 9.3), 痘痘:良好→优秀(新版十分制显示 9.9), 皱纹:改善→优秀(新版十分制显示 9.3), 肤色:良好→优秀(新版十分制显示 9.6), 轮廓:改善→优秀(新版十分制显示 9.4)
### Community 173 - "R20261015-104209-V2J6"
Cohesion: 0.40
Nodes (5): R20261015-104209-V2J6, 水分:良好→优秀(新版十分制显示 9.9), 痘痘:良好→优秀(新版十分制显示 9.9), 皱纹:改善→优秀(新版十分制显示 9.1), 肤色:良好→优秀(新版十分制显示 9.4)
### Community 174 - "R20261015-104729-BLXO"
Cohesion: 0.40
Nodes (5): R20261015-104729-BLXO, 水分:良好→优秀(新版十分制显示 10.0), 痘痘:良好→优秀(新版十分制显示 9.9), 皱纹:改善→优秀(新版十分制显示 9.0), 肤色:良好→优秀(新版十分制显示 9.4)
### Community 175 - "R20261015-112350-ZLOF"
Cohesion: 0.40
Nodes (5): R20261015-112350-ZLOF, 水分:良好→优秀(新版十分制显示 9.9), 泛红:改善→良好(新版十分制显示 8.7), 痘痘:良好→优秀(新版十分制显示 9.7), 肤色:良好→优秀(新版十分制显示 9.4)
### Community 176 - "逐条页卡文案全文对照"
Cohesion: 0.40
Nodes (5): R20261015-134214-Y6L7, 痘痘:良好→优秀(新版十分制显示 9.9), 皱纹:改善→优秀(新版十分制显示 9.2), 轮廓:改善→优秀(新版十分制显示 9.4), 逐条页卡文案全文对照
### Community 177 - "report-copy-cases.js"
Cohesion: 0.40
Nodes (4): assert, cases, fs, UI
### Community 180 - "production-loop.mjs"
Cohesion: 0.50
Nodes (3): artifacts, errors, samples
### Community 181 - "save"
Cohesion: 0.50
Nodes (5): atomic(), reportSnapshot(), save(), storeReport(), updateWrinkleViews()
### Community 182 - "capture-diagnostics.js"
Cohesion: 0.38
Nodes (5): height(), FaceLandmark68NetBase(), getSizesForScale(), normalize$1(), rescaleAndNormalize()
Nodes (6): assess(), CaptureQuality, HeadPose, normalize(), scalar(), views
### Community 188 - "wi"
Cohesion: 0.28
Nodes (9): Ci(), Ei(), Fi(), Ii(), ki(), pe(), ri(), wi() (+1 more)
### Community 189 - "convLayer"
Cohesion: 0.36
Nodes (8): convLayer(), fullyConnectedLayer(), ONet(), PNet(), prelu(), qh(), RNet(), sharedLayer()
### Community 191 - "extractConvParamsFactory"
Cohesion: 0.24
Nodes (9): extractConvParamsFactory(), extractorsFactory(), extractorsFactory$1(), extractorsFactory$6(), extractorsFactory$8(), extractParams(), extractParams$6(), extractParamsTiny() (+1 more)
### Community 192 - "convLayer$1"
Cohesion: 0.47
Nodes (6): conv$1(), convDown(), convLayer$1(), convNoRelu(), residual(), residualDown()
### Community 193 - ".forwardInput"
Cohesion: 0.47
Nodes (3): convWithBatchNorm(), depthwiseSeparableConv$1(), leaky()
### Community 194 - "wrinkle-async-browser.mjs"
Cohesion: 0.40
Nodes (4): child, dir, h5, require
## Knowledge Gaps
- **320 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+315 more)
- **653 isolated node(s):** `source`, `document_size`, `pages`, `assets`, `layers` (+648 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.
- **45 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 `v()` connect `n` to `report-ui.js`, `face-api.js`, `app.js`, `C`?**
- **Why does `height()` connect `.forwardInput` to `app.js`, `customer-feedback.js`, `extractFaceTensors`, `showScreen`, `scan-placeholder.mjs`, `profile-interaction.mjs`, `extractFaces`?**
_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._
- **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?**
_`r()` has 27 INFERRED edges - model-reasoned connections that need verification._
- **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._
- **Why does `r()` connect `n` to `face-api.js`, `.toString`, `test/capture-funnel.js`, `t`, `server.js`, `normalize`, `C`, `i`?**
_High betweenness centrality (0.035) - this node is a cross-community bridge._
- **Why does `n()` connect `n` to `face-api.js`, `skin-score.js`, `t`, `server.js`, `acne-three-view.js`, `C`, `i`?**
_High betweenness centrality (0.027) - this node is a cross-community bridge._
- **Are the 26 inferred relationships involving `n()` (e.g. with `calcAcne()` and `newRecordId()`) actually correct?**
_`n()` has 26 INFERRED edges - model-reasoned connections that need verification._
- **Are the 28 inferred relationships involving `r()` (e.g. with `ingest()` and `handleExport()`) actually correct?**
_`r()` has 28 INFERRED edges - model-reasoned connections that need verification._
- **Are the 10 inferred relationships involving `i()` (e.g. with `report-persistence.mjs` and `da()`) actually correct?**
_`i()` has 10 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._
_653 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+15407 -3073
View File
File diff suppressed because it is too large Load Diff
+477 -82
View File
@@ -40,23 +40,23 @@
"semantic_hash": ""
},
"h5/js/app.js": {
"mtime": 1791823225.0391843,
"ast_hash": "4787d84f22ac0257204e8ddbdd421140",
"mtime": 1792148228.1725585,
"ast_hash": "606796c7bf51f17bdadf83ee9bc76b30",
"semantic_hash": ""
},
"h5/js/head-pose.js": {
"mtime": 1791819697.652488,
"ast_hash": "303298be5be4af0e25b609a83795a810",
"mtime": 1792070106.4565594,
"ast_hash": "8d8256c7ee28c725f4e5b4a429455bac",
"semantic_hash": ""
},
"h5/js/skin-score.js": {
"mtime": 1791735501.357809,
"ast_hash": "783b6c9abbe2bf1832e8eafe5aead7e3",
"mtime": 1792148550.126307,
"ast_hash": "5fc0f09032bf3ece46312d5b060c91ee",
"semantic_hash": ""
},
"h5/server.js": {
"mtime": 1791800754.2112646,
"ast_hash": "9983e6c978b3357e31bbfce59202dcde",
"mtime": 1792148550.122564,
"ast_hash": "6e8f1c90c7673d52691cfc11d7aac613",
"semantic_hash": ""
},
"h5/test/build_fixtures.py": {
@@ -80,8 +80,8 @@
"semantic_hash": ""
},
"h5/test/regression.js": {
"mtime": 1791735501.3654072,
"ast_hash": "4a712e295c115b6051726cd028e67be2",
"mtime": 1792066212.160884,
"ast_hash": "4fd58d98a1cc12a7a19745092f1e15ba",
"semantic_hash": ""
},
"h5/vendor/faceapi/face-api.js": {
@@ -135,13 +135,13 @@
"semantic_hash": ""
},
"h5/README.md": {
"mtime": 1791811100.15898,
"ast_hash": "4b8b63aa8184ead90d868aff34e9a6e3",
"mtime": 1792046412.3080864,
"ast_hash": "5e60028c676088d6e62d373ff94f20c7",
"semantic_hash": ""
},
"h5/index.html": {
"mtime": 1791821148.115547,
"ast_hash": "770a726605078c6c0eebd95304e8e954",
"mtime": 1792140157.1269789,
"ast_hash": "a6c0d49e3c0f23f436446aa4f0a4a06c",
"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": {
@@ -815,8 +815,8 @@
"semantic_hash": ""
},
"h5/js/admin.js": {
"mtime": 1791806985.2188702,
"ast_hash": "d9d5781dd607497f2803404be4e23e7a",
"mtime": 1792148279.018301,
"ast_hash": "a20c65d76c3f65e89d9ecb685d700cae",
"semantic_hash": ""
},
"h5/js/region-data.js": {
@@ -830,8 +830,8 @@
"semantic_hash": ""
},
"h5/vendor/mobile-select/mobile-select.iife.min.js": {
"mtime": 1791559485.790093,
"ast_hash": "52578c3b95933f96493d3b879d353421",
"mtime": 1791878239.1849728,
"ast_hash": "9aeccc95862e05a14439c9cc906af5e5",
"semantic_hash": ""
},
"tools/export_psd_assets.py": {
@@ -860,13 +860,13 @@
"semantic_hash": ""
},
"h5/admin.html": {
"mtime": 1791735369.9312572,
"ast_hash": "ddfe588d49452e3973e21b080e61dbd7",
"mtime": 1792136351.592967,
"ast_hash": "8b6c17ed20217424aafa2f9383a5195e",
"semantic_hash": ""
},
"h5/vendor/mobile-select/README.md": {
"mtime": 1791559485.790093,
"ast_hash": "fe3287899b96f598c60f9bc030a1368e",
"mtime": 1791878239.2009728,
"ast_hash": "f8b834ff5056a3402914e3c99d4807ff",
"semantic_hash": ""
},
"design/ui-20261009/assets-overview.jpg": {
@@ -1140,8 +1140,8 @@
"semantic_hash": ""
},
"h5/assets/img/ui-20261009/icon-switch-camera.png": {
"mtime": 1791642110.915571,
"ast_hash": "6197c8000c21156ffecdf77f9c5ed5ba",
"mtime": 1791884685.3970492,
"ast_hash": "a2805c01722e185dfca450a1eed8c70d",
"semantic_hash": ""
},
"h5/assets/img/ui-20261009/icon-wechat.png": {
@@ -1230,8 +1230,8 @@
"semantic_hash": ""
},
"h5/js/account-client.js": {
"mtime": 1791774062.6429472,
"ast_hash": "3197b952f29f9e45176d4300082adaae",
"mtime": 1791878732.5729809,
"ast_hash": "6e1c148f77744345313e2a7ecf1ea756",
"semantic_hash": ""
},
"h5/js/camera-geometry.js": {
@@ -1240,33 +1240,33 @@
"semantic_hash": ""
},
"h5/js/report-copy.js": {
"mtime": 1791730702.7819743,
"ast_hash": "eedcef7cdd1a3f00fe7cb5dabcafb95c",
"mtime": 1792119867.9142396,
"ast_hash": "4d8562a761d5e80b83627df39e3d2d6e",
"semantic_hash": ""
},
"h5/js/report-ui.js": {
"mtime": 1791814981.3709843,
"ast_hash": "aac3430a5e0c0a386c10e32b7060f2f7",
"mtime": 1792148470.2383204,
"ast_hash": "dc51361171b0126b2f21da3fbe3e6b8c",
"semantic_hash": ""
},
"h5/lib/accounts.js": {
"mtime": 1791806985.2188702,
"ast_hash": "a8116f973f1e01dcbfbe2e1e26c7c826",
"mtime": 1792148228.1725585,
"ast_hash": "ad0f3c61ada00a0d6b0dc3ed3d2bf43f",
"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": 1792148394.0279312,
"ast_hash": "765ec67ba0ad43b480d57b3fc847fa6e",
"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": {
@@ -1300,8 +1300,8 @@
"semantic_hash": ""
},
"reference/AI\u6d4b\u80a4\u62a5\u544a\u9875\u8bdd\u672f\u4fee\u6539.md": {
"mtime": 1791730674.7447748,
"ast_hash": "7e6f8ef4ae4a1c010f3344ccdc088cf6",
"mtime": 1792119867.8840585,
"ast_hash": "8042fb30daafa9942cf46e27a19a709d",
"semantic_hash": ""
},
"design/ui-20261009/audit-current/01-splash.png": {
@@ -1530,8 +1530,8 @@
"semantic_hash": ""
},
"h5/test/wechat-browser.mjs": {
"mtime": 1791800811.939266,
"ast_hash": "616790aae9cf9b41d30ee8b8da979cb0",
"mtime": 1791879046.0249834,
"ast_hash": "af64c3a7fcd5c9338f9b86a584f987b6",
"semantic_hash": ""
},
"docs/2026-10-11\u63d0\u4ea4\u524dReview.md": {
@@ -1540,13 +1540,13 @@
"semantic_hash": ""
},
"h5/test/analyze-timeout.js": {
"mtime": 1791800811.939266,
"ast_hash": "07b7c2248ae3fd4112046828ce522318",
"mtime": 1792137936.2202272,
"ast_hash": "e87c7f42ce92ee6464af3463aead3ada",
"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": {
@@ -1565,13 +1565,13 @@
"semantic_hash": ""
},
"h5/test/recommendation.js": {
"mtime": 1791735559.0619977,
"ast_hash": "589f7046d94485dfc1df20a6129640e0",
"mtime": 1792148550.126307,
"ast_hash": "fb89d42b850cbbf54cd6cb189f48b65c",
"semantic_hash": ""
},
"docs/\u4ea7\u54c1\u63a8\u8350\u89c4\u5219-\u80a4\u9f84\u7248.md": {
"mtime": 1791735707.6717124,
"ast_hash": "69c1278c6c8eea5866982940e20e421c",
"mtime": 1792148470.2383204,
"ast_hash": "7b448209ee4f3be3f4a65fc9d86eabff",
"semantic_hash": ""
},
"docs/\u5fae\u4fe1\u767b\u5f55\u8054\u8c03-2026-10-12.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": {
@@ -1620,8 +1620,8 @@
"semantic_hash": ""
},
"h5/test/customer-feedback.js": {
"mtime": 1791806985.2188702,
"ast_hash": "b0897ac30075bc2a350af40ef6660733",
"mtime": 1792065226.843781,
"ast_hash": "80941e33bb834ed1b120ac83a90e8dd8",
"semantic_hash": ""
},
"h5/test/customer-feedback.mjs": {
@@ -1675,8 +1675,8 @@
"semantic_hash": ""
},
"h5/test/customer-feedback-second.js": {
"mtime": 1791819755.8747528,
"ast_hash": "03ea3689f3c86e78a1f5bd46966f4145",
"mtime": 1792137543.797617,
"ast_hash": "b0643aea1a92733a259d53b7342aad4c",
"semantic_hash": ""
},
"h5/test/customer-feedback-second.mjs": {
@@ -1695,8 +1695,8 @@
"semantic_hash": ""
},
"h5/test/customer-feedback-third.mjs": {
"mtime": 1791815038.0630124,
"ast_hash": "32560625a79f7c8a9d7b74de7337729c",
"mtime": 1791997644.8472245,
"ast_hash": "58089e992930098527cf51a3f45ace98",
"semantic_hash": ""
},
"tools/export_third_feedback_assets.py": {
@@ -1805,28 +1805,28 @@
"semantic_hash": ""
},
"h5/js/capture-quality.js": {
"mtime": 1791809137.1269257,
"ast_hash": "ec66579ee0cde06a3fa045d612da69c1",
"mtime": 1792137441.2704568,
"ast_hash": "8b4570b827eed27570a4bff3c0e7e25b",
"semantic_hash": ""
},
"h5/lib/capture-diagnostics.js": {
"mtime": 1791800706.5472584,
"ast_hash": "7fec6b17bde84b1df8ef30d026c0af66",
"mtime": 1792137441.2704568,
"ast_hash": "2f2433117ddaa0d6367954456a99f9bb",
"semantic_hash": ""
},
"h5/test/capture-quality-browser.mjs": {
"mtime": 1791819803.979456,
"ast_hash": "a4164c6ce0209e18a0b22a93745c90cf",
"mtime": 1792137627.2183216,
"ast_hash": "a7aaaa81d7b9a7c56b02751f9695bb1f",
"semantic_hash": ""
},
"h5/test/customer-feedback-fourth.js": {
"mtime": 1791819755.8706622,
"ast_hash": "92a53685400808b24315f85ed851566d",
"mtime": 1792137441.2704568,
"ast_hash": "6e162ac194ebca6611ed2c8692ab953d",
"semantic_hash": ""
},
"h5/test/customer-feedback-fourth.mjs": {
"mtime": 1791809137.130926,
"ast_hash": "a07ebd722efaf2d8b6e382f566a5f649",
"mtime": 1792064944.0438669,
"ast_hash": "79406f61ffdece2d34ab19d139aefaf4",
"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": {
@@ -1930,8 +1930,8 @@
"semantic_hash": ""
},
"h5/test/scan-placeholder.mjs": {
"mtime": 1791815038.0590124,
"ast_hash": "bb8566ee5f3ad2a6bf95aafafb7145ba",
"mtime": 1791885786.4050858,
"ast_hash": "98f0327cec6167ceb0a435240f763e4c",
"semantic_hash": ""
},
"tools/optimize_delivery_images.py": {
@@ -1973,5 +1973,400 @@
"mtime": 1791821522.1000154,
"ast_hash": "517105aaf52427102a325e0450142446",
"semantic_hash": ""
},
"h5/test/guest-privacy.mjs": {
"mtime": 1792063312.584469,
"ast_hash": "9d77ff05f907aa39b79e114e7432959d",
"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": 1791876185.0449448,
"ast_hash": "905d6f5ae894843f99e66a15ad3d8b33",
"semantic_hash": ""
},
"h5/test/harmony-picker.mjs": {
"mtime": 1791878418.4889727,
"ast_hash": "299b3a47e9212d7e758fc89cfffc39f8",
"semantic_hash": ""
},
"h5/test/raw-skin-age.js": {
"mtime": 1791992633.351766,
"ast_hash": "df1c2fc506e3976e14365b7c8c01958b",
"semantic_hash": ""
},
"docs/\u539f\u59cb\u808c\u9f84\u663e\u793a\u4e0e\u9e3f\u8499\u6eda\u8f6e\u517c\u5bb9-2026-10-13.md": {
"mtime": 1791879146.5129797,
"ast_hash": "99e4254133761034e9057465d4edeb6f",
"semantic_hash": ""
},
"docs/\u5fae\u4fe1\u9000\u51fa\u767b\u5f55-2026-10-13.md": {
"mtime": 1791879146.5129797,
"ast_hash": "e0fe3daeaa1eeeacf18ff18d8eee69e4",
"semantic_hash": ""
},
"h5/test/camera-flip-browser.mjs": {
"mtime": 1791881142.0210207,
"ast_hash": "b64cb9f61c398ce1aa12f59bc27cb481",
"semantic_hash": ""
},
"docs/\u76f8\u673a\u7ffb\u8f6c\u6027\u80fd\u4f18\u5316-2026-10-13.md": {
"mtime": 1791885450.2930646,
"ast_hash": "5d45e9c3fdde5ef718721caf5dbc88a7",
"semantic_hash": ""
},
"h5/test/splash-brand-browser.mjs": {
"mtime": 1791901607.7048087,
"ast_hash": "1ae44d528f227ea038a27d13fca81c75",
"semantic_hash": ""
},
"docs/\u62cd\u6444\u89d2\u5ea6\u4e0d\u5408\u683c\u9707\u52a8\u63d0\u9192-2026-10-13.md": {
"mtime": 1791885450.2930646,
"ast_hash": "fcbbc5db7241f97579efaf14eaa5135e",
"semantic_hash": ""
},
"h5/test/weighted-total.js": {
"mtime": 1792066095.1604717,
"ast_hash": "3e07e35851719cd8c61571ecb06852a6",
"semantic_hash": ""
},
"docs/\u7efc\u5408\u5f97\u5206\u4e09\u7ef4\u5ea6\u52a0\u6743-2026-10-14.md": {
"mtime": 1791959087.1679206,
"ast_hash": "f1d733b0970ac8756c01b11cf0e3fb1f",
"semantic_hash": ""
},
"h5/js/beijing-time.js": {
"mtime": 1791961186.2461162,
"ast_hash": "57be1d3fac9f980da30b144dce9fc467",
"semantic_hash": ""
},
"h5/test/admin-beijing-browser.mjs": {
"mtime": 1791961274.587497,
"ast_hash": "73d57935a0f54160ed123d9bc931e1fe",
"semantic_hash": ""
},
"h5/test/beijing-time.js": {
"mtime": 1791961274.587497,
"ast_hash": "6a99016695257e9200a3770aeb5395e2",
"semantic_hash": ""
},
"docs/\u540e\u53f0\u5317\u4eac\u65f6\u95f4-2026-10-14.md": {
"mtime": 1791961354.4159153,
"ast_hash": "947edc7a913eb62e48c935b8c255a293",
"semantic_hash": ""
},
"h5/test/wrinkle-case-browser.mjs": {
"mtime": 1791993447.323378,
"ast_hash": "f345329b4f7ec49f1b160abc846c1566",
"semantic_hash": ""
},
"h5/test/wrinkle-overall.js": {
"mtime": 1792066095.1604717,
"ast_hash": "213fbac73cb20131edb507c46b9766e9",
"semantic_hash": ""
},
"docs/\u76b1\u7eb9\u6539\u7528\u63a5\u53e3\u6574\u4f53\u8bc4\u5206-2026-10-14.md": {
"mtime": 1791980169.9924085,
"ast_hash": "d7e46261e97d5c1cb26266e62906740e",
"semantic_hash": ""
},
"h5/test/acne-three-view.js": {
"mtime": 1792047898.477616,
"ast_hash": "4481d834dfa5a74b0bea44285846cfcc",
"semantic_hash": ""
},
"docs/\u75d8\u75d8\u4e09\u89c6\u56fe\u5206\u7c7b\u6700\u5927\u6570\u91cf\u8ba1\u5206-2026-10-14.md": {
"mtime": 1791987357.9701402,
"ast_hash": "b185279405eba19190641896d6bc20d8",
"semantic_hash": ""
},
"h5/test/tone-dark-circle.js": {
"mtime": 1792046293.4017394,
"ast_hash": "2c783245bf43ef7173210418a76de600",
"semantic_hash": ""
},
"h5/test/tone-overlay-browser.mjs": {
"mtime": 1791993770.7910676,
"ast_hash": "6a75358301b737e260a5fd90e1184ced",
"semantic_hash": ""
},
"docs/\u80a4\u8272\u9ed1\u773c\u570820\u4e0e\u8272\u7d20\u56fe30-2026-10-14.md": {
"mtime": 1791991792.9998333,
"ast_hash": "43555335e06c1265799452de60f7ad8b",
"semantic_hash": ""
},
"h5/test/countdown-capture-browser.mjs": {
"mtime": 1792137441.2704568,
"ast_hash": "7ed7c448052132231301648fe78ca3b1",
"semantic_hash": ""
},
"h5/test/pigmentation-case-browser.mjs": {
"mtime": 1791993770.7950265,
"ast_hash": "07be5d3aa98e1efbd912d22c5909f2e2",
"semantic_hash": ""
},
"docs/\u6062\u590d321\u4e0e\u8272\u6591\u6848\u4f8b\u590d\u6d4b-2026-10-14.md": {
"mtime": 1791992536.840095,
"ast_hash": "ea1c0026e386003822802018b7fa9403",
"semantic_hash": ""
},
"docs/\u5c55\u793a\u808c\u9f84\u9650\u5236\u6b63\u8d1f5\u5c81-2026-10-14.md": {
"mtime": 1791992669.0724173,
"ast_hash": "5f8dee6cadcf4e782ce904c1ca1a7d31",
"semantic_hash": ""
},
"h5/test/color-copy.js": {
"mtime": 1792052385.2846854,
"ast_hash": "51d4c544f23d7c9235689f1f863befba",
"semantic_hash": ""
},
"docs/\u80a4\u8272\u6587\u6848\u4e0e\u5b9e\u9645\u9ed1\u773c\u5708\u7b49\u7ea7-2026-10-14.md": {
"mtime": 1791993479.4296343,
"ast_hash": "d883b001f777509857a96f02b872a6de",
"semantic_hash": ""
},
"h5/test/report-bottom-figma.mjs": {
"mtime": 1792065167.0812862,
"ast_hash": "7f998e93116d4e9e5cee58f5f8c7f357",
"semantic_hash": ""
},
"docs/\u62a5\u544a\u5e95\u90e8Figma\u8fd8\u539f-2026-10-15.md": {
"mtime": 1791999287.71825,
"ast_hash": "b72dae0ed84a076c0d8f99dcebe9bbeb",
"semantic_hash": ""
},
"h5/assets/img/ui-20261015/product-25.png": {
"mtime": 1791997501.3216276,
"ast_hash": "0a1542dbb3c5feb3267fafb1df3123f7",
"semantic_hash": ""
},
"h5/assets/img/ui-20261015/product-35.png": {
"mtime": 1791997504.752126,
"ast_hash": "ff34af3a983eefe9ec93802a525b4ed5",
"semantic_hash": ""
},
"h5/assets/img/ui-20261015/product-50.png": {
"mtime": 1791997508.2898638,
"ast_hash": "9e0d71ba4397d9dbbec27b1ad48fbe9a",
"semantic_hash": ""
},
"h5/assets/img/ui-20261015/product-badge.png": {
"mtime": 1791997415.2784162,
"ast_hash": "4ec7b754fe30aac6248e7f8f990ac078",
"semantic_hash": ""
},
"h5/contour-preview.html": {
"mtime": 1792045886.5890932,
"ast_hash": "a98beeb64d55d5b41969f2312afbe63e",
"semantic_hash": ""
},
"h5/assets/mock/contour-demo/front.jpg": {
"mtime": 1792033394.649536,
"ast_hash": "3b3ba2bbe11175c76f25ea63730106bd",
"semantic_hash": ""
},
"h5/assets/mock/contour-demo/left.jpg": {
"mtime": 1792033394.6534245,
"ast_hash": "af8c5642cfa8b4a4af1f48e4bb02f602",
"semantic_hash": ""
},
"h5/assets/mock/contour-demo/right.jpg": {
"mtime": 1792033394.6534245,
"ast_hash": "65e145520cf438df78b985ddddebe6fd",
"semantic_hash": ""
},
"h5/test/private-case-preview.mjs": {
"mtime": 1792058682.390061,
"ast_hash": "0085c0f9d7ed9105a918cc0253d6d238",
"semantic_hash": ""
},
"h5/test/water-copy.js": {
"mtime": 1792064498.2456841,
"ast_hash": "7e235bde0f51e74b4c911ecb157ed5ed",
"semantic_hash": ""
},
"docs/\u6c34\u5206\u6587\u6848\u72ec\u7acb\u5206\u6863-2026-10-15.md": {
"mtime": 1792043836.616439,
"ast_hash": "66bc24a457dced194579fc9b926f0a26",
"semantic_hash": ""
},
"h5/test/care-selection.js": {
"mtime": 1792047749.1986089,
"ast_hash": "82fe6a6b3a04f9520648b7d6c6424f3d",
"semantic_hash": ""
},
"docs/\u4e03\u9879\u62a4\u7406\u9009\u4e09\u4e0e\u5173\u6ce8\u4f18\u5148-2026-10-15.md": {
"mtime": 1792044443.0636487,
"ast_hash": "baf56556b9963af9bc77943fcca94989",
"semantic_hash": ""
},
"h5/test/vita-independent.js": {
"mtime": 1792064570.7705622,
"ast_hash": "72885089afbd4351c14fb47d97c650ae",
"semantic_hash": ""
},
"docs/\u900f\u4eae\u611f\u6539\u4e3a\u4e09\u9879\u72ec\u7acb\u6700\u4f4e\u5206-2026-10-15.md": {
"mtime": 1792046412.3041933,
"ast_hash": "48d0853b95e0b36ce79e3f2e3519826f",
"semantic_hash": ""
},
"h5/test/display-grade.js": {
"mtime": 1792148470.2383204,
"ast_hash": "ea3627208bb0545b926085a7daacc668",
"semantic_hash": ""
},
"docs/\u62a5\u544a\u6587\u6848\u6309\u5341\u5206\u5236\u5206\u6863-2026-10-15.md": {
"mtime": 1792052437.0090034,
"ast_hash": "39e6d98a95ee55d832e601851f03e90c",
"semantic_hash": ""
},
"h5/test/factual-copy.js": {
"mtime": 1792052385.3735242,
"ast_hash": "04a7bb0d5c58e7919eb9ef0626f47954",
"semantic_hash": ""
},
"h5/test/report-copy-cases.js": {
"mtime": 1792052385.2846854,
"ast_hash": "840047c867b82ed203dae3493147f613",
"semantic_hash": ""
},
"docs/\u62a5\u544a\u6587\u6848\u5b9e\u6d4b\u63cf\u8ff0\u4e0e\u65e7\u62a4\u7406\u517c\u5bb9\u590d\u6d4b-2026-10-15.md": {
"mtime": 1792052437.0051305,
"ast_hash": "2b5709c0f527e9cb3187bed2e6a12864",
"semantic_hash": ""
},
"docs/\u7ebf\u4e0a10\u4f8b\u62a5\u544a\u6587\u6848\u5bf9\u7167-2026-10-15.md": {
"mtime": 1792048486.081423,
"ast_hash": "0f935d92e8059deaadab2d86f5f2f333",
"semantic_hash": ""
},
"h5/test/fixtures/report-copy-before-two-fixes.cjs": {
"mtime": 1792052385.3271735,
"ast_hash": "ac8f1225149a88902b2b35476e1258f5",
"semantic_hash": ""
},
"docs/\u6587\u6848\u6700\u7ec8\u8303\u56f4-\u4ec5\u76b1\u7eb9\u4f18\u79c0\u4e0e\u75d8\u75d8\u68c0\u51fa-2026-10-15.md": {
"mtime": 1792055645.4742913,
"ast_hash": "cf11232413c36c4a299d092d37bb1804",
"semantic_hash": ""
},
"h5/test/contour-sides.mjs": {
"mtime": 1792066380.5569,
"ast_hash": "daccbed8eecef1089f061af539ae63c8",
"semantic_hash": ""
},
"h5/lib/wrinkle-views.js": {
"mtime": 1792058493.3015623,
"ast_hash": "5066fa726b330eb7e9658c5b741ab0d8",
"semantic_hash": ""
},
"h5/test/wrinkle-three-views.mjs": {
"mtime": 1792066380.5569,
"ast_hash": "39946726ecf37cb72652584f1d71811d",
"semantic_hash": ""
},
"h5/test/wrinkle-views.js": {
"mtime": 1792058682.390061,
"ast_hash": "0a35c79c3613c5198d977cf22d99fdbc",
"semantic_hash": ""
},
"docs/\u76b1\u7eb9\u4e09\u89c6\u56fe\u6807\u6ce8\u8bf4\u660e-2026-10-15.md": {
"mtime": 1792068382.5214024,
"ast_hash": "ca11237b7151b01ca87bc10923a2527b",
"semantic_hash": ""
},
"h5/test/dimension-cap.js": {
"mtime": 1792066181.2678514,
"ast_hash": "a00ea81951025f34b39ca9f3b4248251",
"semantic_hash": ""
},
"h5/test/contour-eye-copy.js": {
"mtime": 1792119867.8840585,
"ast_hash": "085d40c6e51ae1ef8d65f41cd414ad1f",
"semantic_hash": ""
},
"h5/test/zoom-fill.js": {
"mtime": 1792068382.5214024,
"ast_hash": "eab466eedfa8a5f70ef71ca995e1be04",
"semantic_hash": ""
},
"h5/lib/capture-funnel.js": {
"mtime": 1792136431.3330622,
"ast_hash": "11c141449642273af76eb2827dcad349",
"semantic_hash": ""
},
"h5/test/capture-funnel-browser.mjs": {
"mtime": 1792136490.2513402,
"ast_hash": "e58be6c0acb89ed299db7abf526e9451",
"semantic_hash": ""
},
"h5/test/capture-funnel.js": {
"mtime": 1792136431.3368118,
"ast_hash": "8d14b4df58066e0aff67f24873925d00",
"semantic_hash": ""
},
"docs/\u62cd\u7167\u62a5\u544a\u8f6c\u5316\u7edf\u8ba1-2026-10-16.md": {
"mtime": 1792136431.3368118,
"ast_hash": "a6263a5842743851ebb245afd63d587e",
"semantic_hash": ""
},
"docs/\u62cd\u6444\u5149\u7ebf\u4ec5\u63d0\u793a-2026-10-16.md": {
"mtime": 1792137024.177549,
"ast_hash": "16047b931716f23d0477bf0ca9435e09",
"semantic_hash": ""
},
"h5/test/capture-no-recheck.js": {
"mtime": 1792137543.797617,
"ast_hash": "7c496103ec30686842f1d48a0155a0ba",
"semantic_hash": ""
},
"docs/\u53d6\u6d88\u62cd\u540e\u6821\u9a8c-2026-10-16.md": {
"mtime": 1792137543.797617,
"ast_hash": "736cc241f069b7b95b4735c3fb099c3b",
"semantic_hash": ""
},
"h5/test/scan-wait-timeout.js": {
"mtime": 1792137936.2202272,
"ast_hash": "96d24336d28d6a6e4491062d3c7d2501",
"semantic_hash": ""
},
"docs/\u626b\u63cf\u7b49\u5f8530\u79d2\u515c\u5e95-2026-10-16.md": {
"mtime": 1792138004.0861843,
"ast_hash": "578ea3c34745606239eda2476662d546",
"semantic_hash": ""
},
"h5/lib/wrinkle-jobs.js": {
"mtime": 1792139119.6377332,
"ast_hash": "635306bfa84bf6d8181f18742e260a44",
"semantic_hash": ""
},
"h5/test/wrinkle-async-browser.mjs": {
"mtime": 1792140157.130621,
"ast_hash": "8df41ff28959b6fb450f3629d6b1f826",
"semantic_hash": ""
},
"h5/test/wrinkle-jobs.js": {
"mtime": 1792139246.8972948,
"ast_hash": "6bc66a7cd09973e8bc99ed1d1b56414d",
"semantic_hash": ""
},
"docs/\u4fa7\u8138\u76b1\u7eb9\u6d6e\u5c42\u5f02\u6b65\u52a0\u8f7d-2026-10-16.md": {
"mtime": 1792140269.8649895,
"ast_hash": "8b08cc58ff8e2b9341ea7afa2ce71e59",
"semantic_hash": ""
},
"h5/test/display-age-recommendation.js": {
"mtime": 1792148364.4246356,
"ast_hash": "73c329016fafb4073c6c6418f9ac071a",
"semantic_hash": ""
},
"docs/\u4ea7\u54c1\u63a8\u8350\u6539\u7528\u5c55\u793a\u808c\u9f84-2026-10-16.md": {
"mtime": 1792148470.2383204,
"ast_hash": "505317dc4f33f2a9ce53d31e2de3851f",
"semantic_hash": ""
}
}
+13 -4
View File
@@ -64,7 +64,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
- **取景蒙版**:内联 SVG(`#cam-mask`)按步骤切换轮廓——正脸步为正脸剪影(含耳部),侧脸步为对应方向的侧脸剪影;**轮廓路径由 Figma 拍照示意图(`design/assets/images/*-拍照示意.png` 的 alpha 镂空区)逐像素描摹生成**,坐标为 Figma 375×812 整帧,viewBox 只取 y 86–812;框外 85% 黑,轮廓线 2.5px 半透明白。检测运行时轮廓内有自上而下的扫描带(`#cam-clip` 由 app.js 复制轮廓生成);未检测到人脸时轮廓呼吸动画;条件合格时轮廓变绿。
- **预览铺满**:`#cam-video` 保持现有 `object-fit:cover` 铺满与镜像策略,不改取景视觉。
- **动态提示**:根据真机难拍反馈适度放宽(参数版本 `2026-10-12-fourth-v2`),单人、检测置信度≥0.4、人脸框高占原始画面 30%~68%、水平中心偏离≤16% 屏宽、完整入镜且距原始帧边缘≥1%。正脸 |yaw|≤15°/|pitch|≤18°,侧脸转头幅度 30°~70°/|pitch|≤20°,两者 |roll|≤15°;脸部平均亮度 65~235。参数集中在 `js/capture-quality.js` 与 `js/head-pose.js`,均为经验值,不等价于服务端质量分。
- **合格即拍**:连续稳定至少 450ms 且至少 2 个合格检测帧;容许 yaw/pitch 漂移≤8°、中心移动≤原帧 5.5%、框高占比变化≤6.5 个百分点。漏检、超限漂移或帧间隔超过 2 秒均重置稳定累计;白屏抓拍后在本地复验实际 JPEG,合格才推进。**无倒计时、无手动快门**(已有降级模式除外)。
- **合格后倒计时拍摄**:连续稳定至少 450ms 且至少 2 个合格检测帧;容许 yaw/pitch 漂移≤8°、中心移动≤原帧 5.5%、框高占比变化≤6.5 个百分点。漏检、超限漂移或帧间隔超过 2 秒均重置稳定累计;每张照片播放原有 `countdown-321.mp3`,等音频完整结束后再白屏抓拍,本地复验实际 JPEG,合格才推进。倒计时期间锁定快门及翻转,退出或隐藏页面取消未完成拍摄。网页内自动/降级手动抓拍均适用;系统相机降级的原生快门由操作系统控制。音频不支持、未解锁或加载失败时沿用无声抓拍,避免阻塞流程。
- **两张之间停顿**:正脸、右脸拍完后中央弹出停顿卡约 2s(「正脸拍摄完成 / 下一张:右脸出镜,请向左转头」),停顿期间不检测,结束后再切到下一步并播下一步引导,让用户对下一张有心理预期。
- **实时检测**:face-api 从本地 `vendor/faceapi/` 加载(`tinyFaceDetector` + `faceLandmark68Net`),进入拍照页开始加载(状态提示「检测引擎加载中…」)。检测循环 ~150ms 一帧,特征点取自原始帧(预览 CSS 镜像不影响),个别前置帧已镜像的机型由侧脸自校准翻转 `sideYawFlip`;连续漏检 ≥3 帧才报「未检测到人脸」。
- **镜像策略**:前置镜头预览镜像(照镜子习惯);后置预览不镜像,侧脸轮廓随之翻转(`.cam-mask.flip`)。**抓帧一律不镜像**,三张照片都是摄像头原始视角。
@@ -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 两条「竖排标题 + 左图右文」;点检测图全屏放大,再点或按返回键回到报告。
@@ -87,12 +89,14 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
1. 三视图送检:POST `/api/skin-analyze`,请求 `{ front_image, left_image, right_image, profile? }`(base64 可带 dataURL 前缀;正脸=photos[0],左=photos[2],右=photos[1];`profile` 为可选档案 `{real_age,gender,region,daily_skin_type}`,后台记录用)。
2. 服务端转发 Face++ **专业版 `skinanalyze_pro`**(三视图必须走 `image_file` 文件形式),请求 `return_side_results=jawline_info` 取左右下颌线角度,`return_maps`/`return_marks` 取叠加图与标注;响应 `{ ok:true, result, face_rectangle, side:{ left, right } }`,失败 `{ ok:false, code, message }`(message 已是用户可读文案)。`result.face_maps` 为对象,含报告页所需图层的完整 data URL(`red_area`/`brown_area`/`texture_enhanced_lines`/`texture_enhanced_pores`/`texture_enhanced_blackheads`,由 server.js 的 `REPORT_MAPS` 常量控制);`marks` 坐标(`acne`/`brown_spot`/`mole`/`closed_comedones`/`acne_pustule`/`acne_nodule`/`wrinkle_mark`/`melanin_mark`/`dark_circle_mark`)随 `result` 原样透传,与上传图片同一像素空间。
3. 评分由 `js/skin-score.js` 引擎完成,公式/锁值/话术/产品推荐以 `docs/三维度评分与话术生成需求文档_V2.3.docx` 为最高契约(V2.3 口径,含 §8.5 护理建议五段):
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹(12 分区);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);分组按实际年龄;
- 6 分类:轮廓 = min(眼袋映射分, 下颌线角度分);皱纹直接采用 `score_info.wrinkle_score` 整体分(不反向、不按分区严重度限分;分区仅作说明/可视化,缺失整体分则不回退旧规则);光滑(毛孔/黑头/纹理);肤色水润(色素×水润);敏感(泛红面积取 `red_spot.red_spot_area`/红区分);痘痘(分级映射×丘疹 `acne.count` 计数扣分);
- 三维度木桶 min:紧塑力(轮廓+皱纹)、活力感(光滑+肤色水润)、稳定力(敏感+痘痘);
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);肤龄钳制到实际年龄 ±3 并留痕;三视图版 18 条话术;
- 痘痘计数:闭口、丘疹、脓包、结节、痘印分别取正/左/右三视图最大数量(不相加),权重1/2/4/6/1.5、合计扣分封顶50;整体 `acne_score` 仍取正脸。报告文案使用相同计分数量;缺失不当0,后台详情显示三视图及最终取值、扣分。
- 肤色项:色素扣斑分80%+`dark_circle_score`黑眼圈评分20%,与水分项各占50%得到肤色水润P;缺项按有效权重归一。肤色页在原照片上以30%强度叠加色素图(缺图时用真实色素坐标),黄褐斑/雀斑显示检出状态,没有对应坐标不画伪造定位框。
- S = 7 + 3×P/100;档位:良好(P≥90)/改善(80–90)/预警(<80);生物可视年龄限制在用户实际年龄±5岁:`max(实际年龄−5, min(实际年龄+5, 原始肌龄))`,如实际25、接口40展示30;原始肌龄和推荐规则不变。缺少实际年龄展示原始肌龄,缺少肌龄显示`--`;三视图版 18 条话术;
- 精华推荐三重判据(均分档/最差项约束/年龄组档)取最强档,triggers 记录命中判据。
4. 前端 `SkinScore.buildViewModel(data.result, data.side, { realAge, skinType })` → viewModel 直渲报告页:
- 头部:综合评分(`vm.total`,缺失回退三维度 S 均分×10)、肤龄 `vm.skinAgeDisplay` + 钳制标记 `vm.ageClampNote`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
- 头部:综合得分通过 `SkinScore.weightedTotal(vm.dims)` 计算,紧塑力×4+透亮感×3+稳韧力×3,使用维度S分数(两位小数)加权、四舍五入取整数,不使用接口 `total_score`;任一维度缺失显示 `--`。后台列表、详情、CSV采用相同公式,历史报告按已保存维度计算展示,不改写原文件;肤龄读取 `SkinScore.reportSkinAge(vm, result)`,原值缺失显示 `--`;三维度 S(1 位小数)+ 档位配色(良好绿/改善琥珀/预警红);
- 三个 Tab 面板各展示所辖两分类:分类名、S + 档位徽章、话术正文(`vm.cats.*.text`);P 值不直接展示,`P=null` 的分类显示"本次未检出有效数据";
- 报告底部:降级标注(`vm.notes` 中与侧脸/下颌线/补拍相关的条目,去掉接口质量码后展示)、护理建议五段(`vm.careAdvice`)、精华推荐卡(`vm.essence.name` + 命中判据;推荐正文已在护理建议第 4 段,卡内不重复);
- 近期优先关注 = 最差维度(`vm.focus`);6 分类各配检测可视化图:轮廓=正脸实拍、皱纹=正脸压暗+`texture_enhanced_lines` 皱纹增强线(线图层几乎全透明,直挂白底不可读,故与压暗沉脸合成)、光滑=正脸+色斑/痣/痤疮标记叠加、肤色=`brown_area` 色沉图、敏感=`red_area` 红区图、痘痘=正脸+闭口粉刺/痤疮标记叠加(marks 按原像素坐标叠加到正脸实拍)。
@@ -108,6 +112,7 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
- **数据落库**:每次有效调用(正脸照片校验通过起)生成 `record_id`,照片写 `data/photos/<record_id>/`,记录(档案/照片地址/拍摄与上传状态/检测状态与失败原因/评分/文案/精华/耗时/raw 文件名/重试关系)追加到 `data/records.jsonl`。C 端响应结构不变。
- **记录列表**:多条件筛选(时间区间/检测状态/失败原因/年龄/产品组别/维度+档位/记录ID),行点击开详情抽屉(照片大图与下载、状态、六分类与三维度评分表、文案全文、护理建议、精华与触发判据、计算备注、接口原始 JSON 折叠查看、手动重试)。
- **时间口径**:后台列表/异常/详情显示、纯日期筛选、今日及每日统计、CSV时间统一为北京时间(UTC+8),由 `js/beijing-time.js` 共享处理,不依赖服务器或浏览器时区。原始记录及JSON接口时间仍为UTC ISO;完整ISO筛选保留显式时区对应的时间点。
- **失败与异常**:失败原因分布条 + 失败列表(每条可一键重试:用原照片重新检测,生成新记录并挂版本链)。
- **统计概览**:今日/累计/成功率/平均耗时卡片,三维度档位分布,产品组别分布(纯 CSS 饼图),近 14 天每日检测量柱状图,按日期区间导出 CSV(UTF-8 BOM,不含 raw_json)。
- **演示数据**:`node h5/data/seed.js` 生成约 10 条演示记录(覆盖重写 records.jsonl,照片复制自 `reference/facepp-skinanalyze/examples/images/` 真实三视图)。
@@ -129,6 +134,10 @@ node server.js # 默认 8080,可用 PORT=xxx 修改
本机浏览器只缓存随机身份凭据,报告保存在服务器;微信需公网 HTTPS、具备网页授权权限的服务号及相应环境变量。
详见 [新版实施与验收](../docs/10.09新版H5实施与验收.md) 和 [微信登录接入与公网部署说明](../docs/微信登录接入与公网部署说明.md)。下方旧功能说明以这两份更新文档为准。
2026-10-15 护理展示更新:`ReportUI.build` 从七项独立小维度分中选最低三项,再将属于当前优先关注大维度的入选项前置;同分按关注优先、文档顺序确定。肤色、水分分析及护理各自独立分档,不再共用肤色水润合成分。新版护理全文以 `reference/AI测肤报告页话术修改.md` 为准;不改变原三维度/综合得分/产品推荐,不批量改写历史presentation。详见 [七项护理选三与关注优先](../docs/七项护理选三与关注优先-2026-10-15.md)。
随后用户确认取消合成分参与维度计分:透亮感改为独立肤色、水分、平滑度三项P的最低有效值,不再平均肤色和水分。新分数用于总分、优先关注及原有边界推荐判断。后台新记录展示七项独立分数,历史记录不重算;旧tone字段仅为兼容。详见 [新版透亮感公式](../docs/透亮感改为三项独立最低分-2026-10-15.md)。
## 部署
需要 Node ≥ 18 运行 `server.js`(负责静态与 API 中转),**不能用纯静态托管**(密钥不能进前端)。
+19 -6
View File
@@ -17,10 +17,16 @@
</header>
<main>
<div class="filters channel-filter">
<label>渠道
<select id="f-channel"><option value="">全部渠道</option><option value="h5">H5</option><option value="taobao">淘宝小程序</option></select>
</label>
<span class="hint" id="channel-summary">正在读取渠道数据…</span>
</div>
<!-- ─── 记录列表(文档 4.1/4.2) ─── -->
<section id="tab-records" class="panel on">
<div class="filters">
<label>创建时间 <input type="date" id="f-from"> ~ <input type="date" id="f-to"></label>
<label>创建时间(北京时间) <input type="date" id="f-from"> ~ <input type="date" id="f-to"></label>
<label>检测状态
<select id="f-status">
<option value="">全部</option>
@@ -71,8 +77,8 @@
<table id="tbl-records">
<thead>
<tr>
<th>照片</th><th>记录ID</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
<th>检测状态</th><th>总分</th><th>肌龄(原始 / 展示)</th><th>三维度评分 / 档位</th><th>推荐精华</th><th>耗时</th><th>创建时间</th>
<th>照片</th><th>记录ID / 来源</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
<th>检测状态</th><th title="紧塑力×4+透亮感×3+稳韧力×3,四舍五入取整数">综合得分</th><th>肌龄(原始 / 展示)</th><th>三维度评分 / 档位</th><th>推荐精华</th><th>耗时</th><th>创建时间</th>
</tr>
</thead>
<tbody></tbody>
@@ -92,7 +98,7 @@
<table id="tbl-fails">
<thead>
<tr>
<th>记录ID</th><th>失败环节</th><th>失败码</th><th>失败信息</th><th>年龄</th><th>拍摄状态</th><th>创建时间</th><th>操作</th>
<th>记录ID / 来源</th><th>失败环节</th><th>失败码</th><th>失败信息</th><th>年龄</th><th>拍摄状态</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
<tbody></tbody>
@@ -103,6 +109,12 @@
<!-- ─── 统计概览(文档 4.5) ─── -->
<section id="tab-stats" class="panel">
<div class="cards" id="stat-cards"></div>
<div class="block"><h3>全部渠道检测概览</h3><div id="channel-stats"></div><p class="hint">此表展示全部渠道对照,上方指标按当前渠道筛选。按检测记录统计,不是跨渠道去重人数;游客临时报告不进入已保存检测记录统计。</p></div>
<div class="block">
<h3>拍照 → 报告页转化(上线后新流程)</h3>
<div id="capture-funnel"></div>
<p class="hint">按每次点击开始拍照的流程去重,不是人数;进入成功仅在本次报告渲染并切屏后上报,历史报告浏览与演示不计入。今日按开始拍照的北京时间归属。客户端埋点可能受断网、关闭页面影响,未完成不等于检测失败。</p>
</div>
<div class="grid2">
<div class="block">
<h3>三维度档位分布</h3>
@@ -114,7 +126,7 @@
</div>
</div>
<div class="block">
<h3>近 14 天每日检测量</h3>
<h3>近 14 天每日检测量(北京时间)</h3>
<div id="daily-chart" class="daily-chart"></div>
</div>
<div class="block">
@@ -123,7 +135,7 @@
<label>起始日期 <input type="date" id="exp-from"></label>
<label>结束日期 <input type="date" id="exp-to"></label>
<button type="button" class="btn primary" id="btn-export">导出 CSV</button>
<span class="hint">UTF-8 BOM,Excel 直接打开;不含接口原始 JSON</span>
<span class="hint">沿用当前渠道及记录筛选条件;导出日期可另选。北京时间,UTF-8 BOM,不含接口原始 JSON。</span>
</div>
</div>
</section>
@@ -151,6 +163,7 @@
<script src="js/report-copy.js"></script>
<script src="js/report-ui.js"></script>
<script src="js/beijing-time.js"></script>
<script src="js/admin.js"></script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

+129
View File
@@ -0,0 +1,129 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>轮廓与皱纹三视图渐变 · 测试预览</title>
<style>
* { box-sizing:border-box; }
html,body { margin:0; height:100%; background:#161616; font-family:system-ui,sans-serif; }
body { display:flex; flex-direction:column; }
header { flex:none; min-height:48px; background:#fff; display:flex; align-items:center; justify-content:center; gap:12px; padding:8px 12px; font-size:12px; }
header span { color:#666; }
button { border:0; border-radius:5px; background:#F5F078; color:#282828; padding:7px 12px; cursor:pointer; }
iframe { display:block; border:0; width:100%; flex:1; min-height:0; }
#error { display:none; color:#fff; padding:20px; text-align:center; }
</style>
</head>
<body>
<header><strong>轮廓/皱纹预览</strong><span>每3.8秒切换 · 渐变0.8秒</span><button id="toggle" disabled>暂停</button></header>
<p id="error" role="alert"></p>
<iframe id="report" title="测试报告:正脸、左脸、右脸自动渐变" src="./?mock=1&amp;screen=report"></iframe>
<script>
// Standalone local demo. Only alters this iframe's DOM; no analysis or save calls.
(function () {
var observer=null, paused=false, disposed=false, groups=[];
var iframe=document.getElementById('report'),toggle=document.getElementById('toggle');
var names=['正脸','左脸','右脸'];
// Repeating cycle: front → left → front → right → front → left …
var sequence=[0,1,0,2];
// Same face-centered crop formula as app.js vizFaceCrop. The demo's
// three sample photos share dimensions/framing; use their reference box.
function focusFace(img,referenceBox,referencePhoto,zoomFraction){
var width=img.naturalWidth,height=img.naturalHeight;
var sx=width/referencePhoto.naturalWidth,sy=height/referencePhoto.naturalHeight;
var rectangle=referenceBox&&{left:referenceBox.left*sx,top:referenceBox.top*sy,width:referenceBox.width*sx,height:referenceBox.height*sy};
if(!rectangle||rectangle.left<0||rectangle.top<0||rectangle.width<=0||rectangle.height<=0||rectangle.left+rectangle.width>width||rectangle.top+rectangle.height>height)return;
var ratio=215/278;
var ch=Math.min(Math.max(rectangle.height*1.55,rectangle.width*1.18/ratio),height,width/ratio),cw=ch*ratio;
if(cw<rectangle.width||ch<rectangle.height)return;
var left=Math.max(0,Math.min(width-cw,rectangle.left+rectangle.width/2-cw/2));
var top=Math.max(0,Math.min(height-ch,rectangle.top-rectangle.height*.3));
if(top+ch<rectangle.top+rectangle.height)top=rectangle.top+rectangle.height-ch;
if(zoomFraction===.5){
// Half of the added magnification, not half of the resulting scale.
// Start with a centered, contain-fit crop at the same frame ratio.
var baseHeight=Math.max(height,width/ratio),baseWidth=baseHeight*ratio;
var baseScale=1/baseWidth,fullScale=1/cw;
var halfWidth=1/(baseScale+(fullScale-baseScale)*.5),halfHeight=halfWidth/ratio;
var centerX=width*.5+(left+cw*.5-width*.5)*.5;
var centerY=height*.5+(top+ch*.5-height*.5)*.5;
cw=halfWidth;ch=halfHeight;left=centerX-cw*.5;top=centerY-ch*.5;
img.dataset.zoomFraction='0.5';
}
img.style.inset='auto';img.style.width=(width/cw*100)+'%';img.style.height=(height/ch*100)+'%';
img.style.left=(-left/cw*100)+'%';img.style.top=(-top/ch*100)+'%';
img.style.objectFit='fill';img.style.maxWidth='none';img.dataset.crop='face';
}
function stop(group){clearInterval(group.timer);group.timer=null;}
function next(group){
var previous=group.layers[group.current];
previous.style.zIndex='1';
group.step=(group.step+1)%sequence.length;
group.current=sequence[group.step];
var incoming=group.layers[group.current];
incoming.style.zIndex='2';
incoming.classList.add('active');
// Keep the old image fully opaque underneath the incoming fade. This
// yields a real cross-dissolve without flashing the white background.
clearTimeout(group.fadeTimer);
group.fadeTimer=setTimeout(function(){previous.classList.remove('active');previous.style.zIndex='0';},850);
group.caption.textContent=names[group.current]+' · '+(group.current+1)+' / 3';
}
function sync(){groups.forEach(function(group){stop(group);if(!disposed&&!paused&&group.inView&&!document.hidden&&group.ready)group.timer=setInterval(function(){next(group);},3800);});}
toggle.onclick=function(){paused=!paused;toggle.textContent=paused?'继续播放':'暂停';toggle.setAttribute('aria-pressed',String(paused));sync();};
document.addEventListener('visibilitychange',sync);
window.addEventListener('pagehide',function(){disposed=true;groups.forEach(function(group){stop(group);clearTimeout(group.fadeTimer);group.layers.forEach(function(img,i){img.classList.toggle('active',i===group.current);img.style.zIndex=i===group.current?'2':'0';});});});
window.addEventListener('pageshow',function(){disposed=false;sync();});
iframe.onload=async function(){
groups.forEach(function(group){stop(group);clearTimeout(group.fadeTimer);});if(observer)observer.disconnect();groups=[];
try{
var doc=iframe.contentDocument,card=doc.querySelector('.card-detail');
var referencePhoto=doc.createElement('img');
await new Promise(function(resolve,reject){referencePhoto.onload=resolve;referencePhoto.onerror=reject;referencePhoto.src=doc.defaultView.MOCK_FIXTURE.photos[0];});
// Preserve the real front-view wrinkle composite rendered by the
// report (including its original dimming and detection overlay).
var wrinkleImage=doc.getElementById('ditem-img2');
await new Promise(function(resolve,reject){
var started=Date.now(),poll=setInterval(function(){
if(wrinkleImage&&!wrinkleImage.hidden&&wrinkleImage.complete&&wrinkleImage.naturalWidth){clearInterval(poll);resolve();}
else if(Date.now()-started>8000){clearInterval(poll);reject(new Error('皱纹浮层未加载'));}
},50);
});
var style=doc.createElement('style');
style.textContent='.contour-demo-frame .ditem-img:not(.demo-face),.contour-demo-frame .viz-missing{display:none!important}.contour-demo-frame .demo-face{position:absolute;inset:0;display:block;opacity:0;transition:opacity .8s linear!important;pointer-events:none;object-fit:cover}.contour-demo-frame .demo-face.active{opacity:1;pointer-events:auto}.contour-demo-caption{text-align:center;color:#707070;font-size:11px;line-height:20px;margin:-4px 0 12px}';
doc.head.appendChild(style);
var sources=['front','left','right'];
var ready=[];
card.querySelectorAll('.viz-frame').forEach(function(frame){
frame.classList.add('contour-demo-frame');
var isWrinkles=!!frame.querySelector('#ditem-img2');
var group={frame:frame,layers:[],current:0,step:0,inView:false,ready:false,timer:null,fadeTimer:null};
group.caption=doc.createElement('p');group.caption.className='contour-demo-caption';group.caption.textContent='正脸 · 1 / 3';frame.after(group.caption);
groups.push(group);
sources.forEach(function(source,i){ready.push(new Promise(function(resolve,reject){
var img=doc.createElement('img');img.className='ditem-img demo-face'+(i===0?' active':'');img.alt=names[i]+'拍摄照片';
var useWrinkleOverlay=isWrinkles&&i===0;
img.onload=function(){
if(isWrinkles&&!useWrinkleOverlay)focusFace(img,doc.defaultView.MOCK_FIXTURE.face_rectangle,referencePhoto);
if(!isWrinkles)focusFace(img,doc.defaultView.MOCK_FIXTURE.face_rectangle,referencePhoto,.5);
resolve();
};img.onerror=function(){reject(new Error('示例照片加载失败'));};
img.dataset.wrinkleOverlay=useWrinkleOverlay?'original-front':'none';
frame.appendChild(img);group.layers.push(img);
img.src=useWrinkleOverlay?wrinkleImage.src:(doc.defaultView.MOCK_FIXTURE.photos[i]||'assets/mock/contour-demo/'+source+'.jpg');
}));});
});
await Promise.all(ready);if(disposed)return;
toggle.disabled=false;
card.previousElementSibling.scrollIntoView({block:'start'});
groups.forEach(function(group){group.ready=true;});
observer=new IntersectionObserver(function(entries){entries.forEach(function(entry){var group=groups.find(function(g){return g.frame===entry.target;});group.inView=entry.isIntersecting&&entry.intersectionRatio>=.2;});sync();},{threshold:.2});
groups.forEach(function(group){observer.observe(group.frame);});
sync();
}catch(e){var error=document.getElementById('error');error.style.display='block';error.textContent='预览未就绪,请刷新重试。';}
};
})();
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
/* Loaded only by the rednote instance: persistence disclosure needs natural height. */
#p-privacy .popup {
width: min(308px, calc(100vw - 24px));
height: auto;
min-height: 0;
max-height: calc(100vh - 24px);
overflow-y: auto;
padding: 36px 32px 32px;
top: 0;
}
#p-privacy .popup-title,
#p-privacy .popup-body,
#p-privacy .privacy-row,
#p-privacy .popup-btn {
position: static;
flex-shrink: 0;
}
#p-privacy .popup-title { margin: 0 0 16px; }
#p-privacy .popup-body { width: auto; margin: 0 0 18px; }
#p-privacy .privacy-row { margin: 0; }
#p-privacy .popup-btn { margin: 26px auto 0; }
+5
View File
@@ -220,6 +220,11 @@ button { font-family: inherit; border: 0; background: none; cursor: pointer; col
#cam-video.mirror { transform: scaleX(-1); } /* 前置镜头镜像预览 */
#s-cam.cam-live #cam-video { display: block; }
#s-cam.cam-live .cam-bg { display: none; }
#cam-frozen { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:none; z-index:0; pointer-events:none; }
#cam-frozen.mirror { transform:scaleX(-1); }
#s-cam.cam-switching #cam-frozen { display:block; }
#s-cam.cam-switching .cam-bg { display:none; }
.cam-flip:disabled { opacity:.5; }
/* 纵向定位按 WebView 口径:Figma 帧 812 高扣掉顶部 86px 微信导航栏 = 726 高视口 */
.cam-step {
position: absolute; left: 0; right: 0; top: 7.1%;
+1 -1
View File
@@ -95,7 +95,7 @@
}
.guide-description { font-size: 12px; line-height: 1.6; margin-bottom: calc(35 * var(--u)); }
.cam-feature-lines { fill: none; stroke: #fff; stroke-width: 2.5; stroke-dasharray: 5 5; filter: drop-shadow(0 1px 1px #000); }
#s-cam:not(.cam-live) .cam-feature-lines { stroke: #111; filter: none; }
#s-cam:not(.cam-live):not(.cam-switching) .cam-feature-lines { stroke: #111; filter: none; }
.font-credit { display: block; text-align: center; color: #888; font-size: 10px; margin: 24px auto 0; }
.account-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 8px; font-size: 12px; }
.account-status span::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: #b7bac0; }
+59 -1
View File
@@ -1,6 +1,11 @@
/* Third-round PSD, 2026-10-12. Measurements include text transforms.
Native 248px WeChat header is excluded. Fixed text sizes/line breaks are
intentional; flexible photo backgrounds and safe areas accommodate phones. */
.account-logout { font-size: 11px; opacity: .8; white-space: nowrap; }
.cam-countdown { position:absolute; left:50%; top:45%; transform:translate(-50%,-50%); z-index:4; pointer-events:none; color:#fff; font:600 88px/1 Arial,sans-serif; text-shadow:0 2px 16px rgba(0,0,0,.5); }
.cam-countdown-tick { display:block; animation:camera-countdown-tick .6s ease-out both; }
@keyframes camera-countdown-tick { from { opacity:.3; transform:scale(1.25); } to { opacity:1; transform:scale(1); } }
@media (prefers-reduced-motion:reduce) { .cam-countdown-tick { animation:none; } }
.splash-slogan {
left: calc(50% - 1.041667px); right: auto;
width: 249.305556px; height: 75.347222px;
@@ -8,6 +13,15 @@
top: max(6.838833%, calc(env(safe-area-inset-top) + 48px));
}
#s-splash .splash-slogan { top: max(6.838833%, calc(env(safe-area-inset-top) + 48px)); }
/* Keep the PSD Chinese line; compensate the lowercase brand's smaller x-height. */
.splash-slogan-updated .splash-slogan-copy { width:100%; height:100%; clip-path:inset(68% 0 0); }
.splash-slogan-brand, .splash-slogan-english { position:absolute; top:0; left:50%; transform:translateX(-50%); font-family:Arial,sans-serif; font-size:23px; font-weight:400; line-height:1; white-space:nowrap; }
.splash-slogan-brand { top:-4px; font-size:28px; }
.splash-slogan-english { top:26.736111px; }
#s-splash [data-action="open-privacy"],
#s-splash [data-action="open-privacy"]:focus,
#s-splash [data-action="open-privacy"]:active { border:0; outline:none; box-shadow:none; -webkit-appearance:none; appearance:none; }
#s-splash [data-action="open-privacy"]:focus-visible { filter:brightness(.94); }
.splash-bottom { bottom: max(5.882353%, env(safe-area-inset-bottom)); gap: calc(7.291667 * var(--u)); padding-bottom: 0; }
.brand-row { margin-bottom: calc(10.416667 * var(--u)); gap: 0; }
.brand-lockup { width: 179.513889px; height: 20.486111px; }
@@ -72,12 +86,42 @@
.prod-scroll { flex: 0 0 51.219512%; }
.prod-scroll img { width: 100%; height: 100%; aspect-ratio: auto; object-fit: fill; }
/* Figma 1:65, 1080px canvas. One algorithm-selected product, not a catalogue. */
/* User-confirmed: both lines centered; use the same font size/line height
as the stability heading rather than scaling text on wide previews. */
.prod-title { width:calc(206.597222 * var(--u)); max-width:100%; text-align:center; margin:calc(18 * var(--u)) auto; padding:0; transform:translateX(calc(.347222 * var(--u))); font-size:22.106597px; line-height:28.738542px; font-weight:700; white-space:nowrap; }
.report-care-products { width:calc(340.625 * var(--u)); margin-left:auto; margin-right:auto; padding:calc(21.180556 * var(--u)) calc(24.652778 * var(--u)) calc(7.986111 * var(--u)); }
.report-care-products .card-heading,
.product-section-heading { font-size:13.888889px; line-height:19.444444px; font-weight:700; text-align:left; }
.report-care-products .card-heading { margin-bottom:calc(5.208333 * var(--u)); }
.report-care-products .care-item { font-size:11.574074px; line-height:14.467593px; margin-bottom:calc(14.467593 * var(--u)); }
.report-care-products .care-item:last-child { margin-bottom:0; }
.product-section-heading { position:relative; margin:calc(24.0625 * var(--u)) 0 calc(5.555556 * var(--u)); }
.report-care-products .product-section-heading::before { content:""; position:absolute; left:0; right:0; top:calc(-12.03125 * var(--u)); border-top:1px solid #DDD; }
.report-care-products .card-product { width:auto; margin:0 calc(-18.402778 * var(--u)); border-radius:calc(3.472222 * var(--u)); background:#F6F6F6; }
.report-care-products .prod-card { height:auto; aspect-ratio:945 / 511; }
.report-care-products .prod-info { flex-basis:49.100529%; }
.report-care-products .prod-scroll { flex-basis:50.899471%; }
.report-care-products #product-title { left:8.571429%; top:29.158513%; font-size:14.467593px; line-height:18.062790px; font-weight:700; white-space:nowrap; }
.report-care-products .prod-name { left:8.677249%; top:41.095890%; font-size:12.013166px; line-height:15.070408px; font-weight:700; color:#989898; white-space:nowrap; }
.report-care-products .prod-badge { left:8.783069%; top:56.751468%; width:10.687831%; }
.report-care-products + .report-disclaimer { margin:calc(12.152778 * var(--u)) calc(5.208333 * var(--u)) calc(20 * var(--u)); font-size:7.233681px; line-height:10.127431px; }
/* Equal whitespace above/below all three headings; keep the original total gap. */
.dimension-title { margin:calc(18 * var(--u)) 0; }
.dimension-stability-title { font-size:22.106597px; line-height:28.738542px; }
.stability-card .dtab-tx { font-size:11.055903px; }
.stability-card .ditem-para { font-size:11.574074px; line-height:14.467593px; }
/* Fourth feedback adds a fast one-second round trip to original PSD pixels.
The stage/mask share one contain transform, including short/landscape screens. */
#p-analyzing { background: #000; overflow: hidden; }
.scan-stage { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(100vw, 480px, calc(100dvh * 1080 / 2091)); height: min(100dvh, calc(min(100vw, 480px) * 2091 / 1080)); }
@supports not (height: 100dvh) { .scan-stage { width: min(100vw, 480px, calc(100vh * 1080 / 2091)); height: min(100vh, calc(min(100vw, 480px) * 2091 / 1080)); } }
.scan-artwork { width: 100%; height: 100%; object-fit: contain; }
/* Cover the old flip icon baked into the PSD raster (843,1846–921,1924).
Share the camera's replacement asset; decorative only, never a control. */
.scan-flip-icon { position:absolute; left:77.314815%; top:87.900526%; width:8.888889%; height:4.591105%; background:#000; display:flex; align-items:center; justify-content:center; pointer-events:none; }
.scan-flip-icon img { width:83.333333%; height:83.333333%; object-fit:contain; }
.scan-face { position: absolute; inset: 0; -webkit-mask-size: 100% 100%; mask-size: 100% 100%; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; overflow: hidden; }
.scan-beam { position: absolute; left: 0; right: 0; top: 20%; height: 18%; background: linear-gradient(to bottom, #F5F078 0%, #F5F078 35%, rgba(245,240,120,0) 100%); }
#p-analyzing:not(.scan-artwork-ready) .scan-stage { visibility: hidden; }
@@ -120,7 +164,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:not(.report-care-products) .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.
@@ -159,6 +203,20 @@
.detail-panels.dragging { transition: none; will-change: transform; }
.detail-panels .dpanel { display: block; flex: 0 0 100%; min-width: 0; padding-top: calc(10 * var(--u)); animation: none; }
.detail-panels .ditem-img { -webkit-user-drag: none; }
/* Keep the existing front-photo scale/top edge; clip only its bottom 10%.
The image source stays intact for the existing full-image zoom overlay. */
.contour-front-frame { height: calc(250.2 * var(--u)); margin-bottom: calc(7.56 * var(--u)); }
.contour-front-frame .ditem-img { height: calc(278 * var(--u)); }
.contour-sides { width: calc(215 * var(--u)); margin: 0 auto calc(12 * var(--u)); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: calc(6.3 * var(--u)); }
.contour-sides[hidden], .contour-sides figure[hidden] { display: none; }
.contour-sides figure { margin: 0; min-width: 0; }
.contour-sides figure[data-view="right"] { grid-column: 2; }
.contour-side-photo, .wrinkle-side-photo { display: block; position: relative; width: 100%; height: calc(92.25 * var(--u)); padding: 0; border: 0; border-radius: calc(5 * var(--u)); overflow: hidden; background: #f6f6f6; cursor: zoom-in; }
.contour-side-photo img, .wrinkle-side-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; transform: translateY(-25%) scale(2.8); transform-origin: center; -webkit-user-drag: none; }
.contour-side-photo:focus-visible, .wrinkle-side-photo:focus-visible { outline: 2px solid #333; outline-offset: 3px; }
.wrinkle-layer-note { width:calc(215 * var(--u)); margin:0 auto calc(12 * var(--u)); color:#888; font-size:10px; line-height:1.4; }
.wrinkle-loading { position:absolute; inset:0; z-index:1; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,.4); color:#fff; text-align:center; font-size:calc(10 * var(--u)); line-height:1.6; pointer-events:none; }
.wrinkle-loading[hidden] { display:none; }
@media (prefers-reduced-motion: reduce) {
.detail-panels, .detail-viewport { transition-duration: .01ms; }
}
+40 -24
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><button type="button" class="account-logout" data-action="logout-wechat" hidden>退出登录</button></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,8 +42,12 @@
</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>
<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="account-status account-status-home"><span data-auth-status></span><button type="button" class="account-logout" data-action="logout-wechat" hidden>退出登录</button></div>
<div class="splash-slogan splash-slogan-updated" role="img" aria-label="1'me,FROM THE INSIDE OUT;以我为始 自有表现">
<img class="splash-slogan-copy" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="">
<span class="splash-slogan-brand" aria-hidden="true">1'me,</span>
<span class="splash-slogan-english" aria-hidden="true">FROM THE INSIDE OUT</span>
</div>
<div class="splash-bottom">
<div class="brand-row">
<img src="assets/img/ui-20261012/brand-lockup.png" width="517" height="59" loading="lazy" alt="1'me 肌肤状态检测" class="brand-lockup">
@@ -140,6 +144,7 @@
</svg>
<p class="cam-status" id="cam-status">检测引擎加载中…</p>
<p class="cam-hint" id="cam-hint"></p>
<div class="cam-countdown" id="cam-countdown" aria-hidden="true" hidden></div>
<p class="cam-nudge" id="cam-nudge">识别不到?请正对镜头,并到光线充足处</p>
<div class="cam-next" id="cam-next" aria-live="polite">
<i class="cam-next-ok"></i>
@@ -159,7 +164,7 @@
<img src="assets/img/ui-20261009/icon-shutter.png" alt="快门">
</button>
<button class="cam-side cam-flip" data-action="flip" aria-label="切换">
<img src="assets/img/ui-20261009/icon-switch-camera.png" alt="切换">
<img src="assets/img/ui-20261009/icon-switch-camera.png?v=20261013" alt="切换">
</button>
</div>
<div class="cam-fill" id="cam-fill"></div>
@@ -206,11 +211,20 @@
<div class="detail-tabs"><button class="dtab active" data-tab="0"><span class="dtab-tx">轮廓</span></button><button class="dtab" data-tab="1"><span class="dtab-tx">皱纹</span></button></div>
<div class="detail-viewport"><div class="detail-panels">
<div class="dpanel active" data-panel="0">
<div class="ditem"><div class="viz-frame"><img id="ditem-img1" class="ditem-img" alt="轮廓检测图" hidden><span class="viz-missing">本次未获取轮廓检测图</span></div>
<div class="ditem"><div class="viz-frame contour-front-frame"><img id="ditem-img1" class="ditem-img" alt="轮廓检测图" hidden><span class="viz-missing">本次未获取轮廓检测图</span></div>
<div class="contour-sides" id="contour-sides" hidden>
<figure data-view="left" hidden><button type="button" class="contour-side-photo" aria-label="查看左脸完整大图"><img id="contour-left" alt="左脸拍摄照片"></button></figure>
<figure data-view="right" hidden><button type="button" class="contour-side-photo" aria-label="查看右脸完整大图"><img id="contour-right" alt="右脸拍摄照片"></button></figure>
</div>
<div class="ditem-text"><p class="ditem-para" id="ditem-para1">检测完成后显示分析结果。</p></div></div>
</div>
<div class="dpanel" data-panel="1">
<div class="ditem"><div class="viz-frame"><img id="ditem-img2" class="ditem-img" alt="皱纹检测图" hidden><span class="viz-missing">本次未获取皱纹检测图</span></div>
<div class="ditem"><div class="viz-frame contour-front-frame"><img id="ditem-img2" class="ditem-img" alt="皱纹检测图" hidden><span class="viz-missing">本次未获取皱纹检测图</span></div>
<div class="contour-sides wrinkle-sides" id="wrinkle-sides" hidden>
<figure data-view="left" hidden><button type="button" class="wrinkle-side-photo" aria-label="查看左脸皱纹检测大图"><img id="wrinkle-left" alt="左脸皱纹检测照片"><span class="wrinkle-loading" role="status" aria-live="polite" hidden><span>皱纹标注<br>加载中…</span></span></button></figure>
<figure data-view="right" hidden><button type="button" class="wrinkle-side-photo" aria-label="查看右脸皱纹检测大图"><img id="wrinkle-right" alt="右脸皱纹检测照片"><span class="wrinkle-loading" role="status" aria-live="polite" hidden><span>皱纹标注<br>加载中…</span></span></button></figure>
</div>
<p class="wrinkle-layer-note" id="wrinkle-layer-note" hidden>部分侧脸未获取皱纹标注,未标注的侧脸仅展示原图。</p>
<div class="ditem-text"><p class="ditem-para" id="ditem-para2">检测完成后显示分析结果。</p></div></div>
</div>
</div></div>
@@ -234,8 +248,8 @@
</div>
</div></div>
</div>
<h2 class="dimension-title">稳韧力</h2>
<div class="card card-detail">
<h2 class="dimension-title dimension-stability-title">稳韧力</h2>
<div class="card card-detail stability-card">
<div class="detail-tabs"><button class="dtab active" data-tab="0"><span class="dtab-tx">泛红</span></button><button class="dtab" data-tab="1"><span class="dtab-tx">痘痘</span></button></div>
<div class="detail-viewport"><div class="detail-panels">
<div class="dpanel active" data-panel="0">
@@ -250,33 +264,28 @@
</div>
<p class="vm-notes" id="vm-notes" style="display:none"></p>
<p class="report-disclaimer">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。<br>*本次检测仅代表当前肌肤状态参考。</p>
<div class="card card-care">
<h2 class="card-heading">护理建议</h2>
<h2 class="prod-title">感受高光时刻<br>从日常的好状态开始</h2>
<div class="card card-care report-care-products">
<h2 class="card-heading">日常护理</h2>
<div id="care-list">
<p class="care-item">检测完成后显示分析结果。</p>
</div>
<p class="care-summary">好状态,是内外同调的长期节奏。</p>
</div>
<!-- 产品推荐大标题独立于白卡之外,直接落在灰底上(Figma 08) -->
<h2 class="prod-title">感受高光时刻<br>从日常的好状态开始</h2>
<h2 class="product-section-heading">推荐产品</h2>
<p id="product-unavailable" class="care-item" hidden>本次未获取有效肌龄,暂无法推荐产品,建议重新检测。</p>
<div class="card card-product">
<div class="card-product">
<div class="prod-card">
<div class="prod-info">
<h3 id="product-title"></h3>
<p class="prod-name" id="product-name"></p>
<img class="prod-badge" src="assets/img/ui-20261009/product-badge.png" alt="1’me">
<img class="prod-badge" src="assets/img/ui-20261015/product-badge.png" alt="1’me">
</div>
<div class="prod-scroll" id="prod-scroll">
<img id="product-image" alt="算法推荐精华">
</div>
</div>
</div>
</div>
<p class="report-disclaimer">*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。<br>*本次检测仅代表当前肌肤状态参考。<br>*仅根据您最近的测肤记录推荐。</p>
</div>
</section>
@@ -291,7 +300,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>
@@ -300,7 +309,13 @@
<!-- ═══════════ 弹窗:拍照引导 ═══════════ -->
<!-- 背景只保留启动页底图 + Slogan(不叠档案表单),对齐设计稿 04 -->
<div class="overlay overlay-guide" id="p-photoguide">
<div class="guide-bg"><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>
<div class="guide-bg">
<div class="splash-slogan splash-slogan-updated" role="img" aria-label="1'me,FROM THE INSIDE OUT;以我为始 自有表现">
<img class="splash-slogan-copy" src="assets/img/ui-20261012/splash-slogan.png" width="718" height="217" loading="lazy" alt="">
<span class="splash-slogan-brand" aria-hidden="true">1'me,</span>
<span class="splash-slogan-english" aria-hidden="true">FROM THE INSIDE OUT</span>
</div>
</div>
<div class="popup">
<button class="popup-close" data-action="close-photoguide" aria-label="关闭">
<img src="assets/img/ui-20261009/icon-close.png" alt="关闭">
@@ -457,6 +472,7 @@
<div class="scan-stage" aria-hidden="true">
<img id="scan-artwork" class="scan-artwork" data-src="assets/img/ui-20261012/scan-base.webp" width="1080" height="2091" alt="" draggable="false">
<div id="scan-face" class="scan-face" data-mask="assets/img/ui-20261012/scan-face-mask.png"><i class="scan-beam"></i></div>
<span class="scan-flip-icon"><img src="assets/img/ui-20261009/icon-switch-camera.png?v=20261013" width="80" height="80" alt="" draggable="false"></span>
</div>
<p class="scan-status" role="status">检测中…</p>
<button class="scan-cancel" type="button" data-action="cancel-analysis">取消</button>

Some files were not shown because too many files have changed in this diff Show More