Compare commits

..
11 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
185 changed files with 24364 additions and 1187 deletions
+27 -3
View File
@@ -1,22 +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]/
+4
View File
@@ -4,6 +4,10 @@
- `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 路由。
+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`。
@@ -1,4 +1,4 @@
# 1meAI测肤旷视UI问题反馈
# 1'meAI测肤旷视UI问题反馈
## UI交互界面调整
+10 -3
View File
@@ -17,6 +17,12 @@
</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">
@@ -71,7 +77,7 @@
<table id="tbl-records">
<thead>
<tr>
<th>照片</th><th>记录ID</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>
@@ -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,7 @@
<!-- ─── 统计概览(文档 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>
@@ -128,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>
+64 -22
View File
@@ -49,6 +49,27 @@ function api(url, opts) {
});
});
}
function channelName(channel) { return ({ h5: 'H5', taobao: '淘宝小程序', rednote: '小红书' })[channel] || channel || 'H5'; }
function selectedChannelQuery(url) {
var channel = $('#f-channel').value;
return channel ? url + (url.indexOf('?') < 0 ? '?' : '&') + 'channel=' + encodeURIComponent(channel) : url;
}
function sourceQuery(source) { return source && source !== 'local' ? '?source=' + encodeURIComponent(source) : ''; }
function renderSources(data) {
var sources = data.sources || [];
$('#channel-summary').textContent = sources.length > 1
? '已汇总 H5 与淘宝数据;外部渠道记录只读,重试请进入原服务后台。'
: '当前读取本服务的数据;尚未接入其他服务的数据源。';
}
function renderChannelStats(data) {
renderSources(data);
var rows = data.by_channel || {}, html = '<table><thead><tr><th>渠道</th><th>累计检测</th><th>今日检测</th><th>成功</th><th>失败</th></tr></thead><tbody>';
Object.keys(rows).forEach(function (key) {
var row = rows[key];
html += '<tr><td>' + esc(channelName(key)) + '</td><td>' + row.total + '</td><td>' + row.today_total + '</td><td>' + row.success_total + '</td><td>' + row.fail_total + '</td></tr>';
});
$('#channel-stats').innerHTML = html + '</tbody></table>';
}
/* ─── 页签切换 ─── */
var TABS = { records: 1, fails: 1, stats: 1 };
@@ -116,6 +137,7 @@ function collectFilters() {
add('from', $('#f-from').value);
add('to', $('#f-to').value);
add('detect_status', $('#f-status').value);
add('channel', $('#f-channel').value);
add('fail_code', $('#f-failcode').value);
add('age_min', $('#f-agemin').value);
add('age_max', $('#f-agemax').value);
@@ -130,6 +152,7 @@ function collectFilters() {
function loadRecords() {
api('/api/admin/records?' + collectFilters()).then(function (data) {
renderSources(data);
pageState.total = data.total;
var tbody = $('#tbl-records tbody');
tbody.innerHTML = '';
@@ -145,7 +168,7 @@ function loadRecords() {
: '<span class="sub">无</span>';
tr.innerHTML =
'<td>' + thumb + '</td>' +
'<td class="mono">' + esc(it.record_id) + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
'<td class="mono">' + esc(it.record_id) + '<div class="sub">' + esc(channelName(it.channel)) + (it.readonly ? ' · 只读' : '') + '</div>' + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
'<td>' + profileHtml(it) + '</td>' +
'<td>' + esc(it.shoot_status) + captureQualityHtml(it) + '</td>' +
'<td>' + esc(it.upload_status) + '</td>' +
@@ -159,7 +182,7 @@ function loadRecords() {
'<td class="mono">' + fmtTime(it.created_at) + '</td>';
tr.addEventListener('click', function (e) {
if (e.target && e.target.className === 'thumb') { showLightbox(e.target.src); return; }
openDetail(it.record_id);
openDetail(it.record_id, it.source);
});
tbody.appendChild(tr);
})(data.items[i]);
@@ -175,6 +198,7 @@ function loadRecords() {
function renderCaptureFunnel(st) {
var funnel = st.capture_funnel;
var panel = $('#capture-funnel');
if (panel) panel.parentNode.hidden = !funnel;
if (funnel && panel) {
var labels = [['started','开始拍照'],['front','正面拍完'],['right','右脸拍完'],['left','三张拍完'],['submitted','提交检测'],['response','收到成功结果'],['report','进入报告页']];
var html = '<table><thead><tr><th>阶段</th><th>累计流程</th><th>今日流程</th></tr></thead><tbody>';
@@ -190,7 +214,8 @@ function renderCaptureFunnel(st) {
}
}
function loadFails() {
api('/api/admin/stats').then(function (st) {
api(selectedChannelQuery('/api/admin/stats')).then(function (st) {
renderSources(st);
var box = $('#fail-dist');
box.innerHTML = '';
if (!st.fail_dist.length) box.appendChild(el('div', 'pie-empty', '暂无失败记录'));
@@ -205,7 +230,7 @@ function loadFails() {
}
}).catch(function (e) { toast('加载失败:' + e.message); });
api('/api/admin/records?detect_status=失败&page_size=100').then(function (data) {
api(selectedChannelQuery('/api/admin/records?detect_status=失败&page_size=100')).then(function (data) {
var tbody = $('#tbl-fails tbody');
tbody.innerHTML = '';
if (!data.items.length) {
@@ -215,17 +240,17 @@ function loadFails() {
(function (it) {
var tr = el('tr');
tr.innerHTML =
'<td class="mono">' + esc(it.record_id) + '</td>' +
'<td class="mono">' + esc(it.record_id) + '<div class="sub">' + esc(channelName(it.channel)) + '</div></td>' +
'<td>' + esc(it.fail_stage || '-') + '</td>' +
'<td><span class="st st-fail">' + esc(it.fail_code || '-') + '</span></td>' +
'<td style="white-space:normal;max-width:340px">' + esc(it.fail_msg || '-') + '</td>' +
'<td>' + (it.real_age != null ? it.real_age + '岁' : '-') + '</td>' +
'<td>' + esc(it.shoot_status) + '</td>' +
'<td class="mono">' + fmtTime(it.created_at) + '</td>' +
'<td><button type="button" class="btn small primary">重试</button></td>';
'<td>' + (it.readonly ? '<span class="sub">只读,请到原服务后台重试</span>' : '<button type="button" class="btn small primary">重试</button>') + '</td>';
tr.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id); return; }
openDetail(it.record_id);
if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id, it.source); return; }
openDetail(it.record_id, it.source);
});
tbody.appendChild(tr);
})(data.items[i]);
@@ -233,10 +258,10 @@ function loadFails() {
}).catch(function (e) { toast('加载失败:' + e.message); });
}
function doRetry(id) {
function doRetry(id, source) {
if (!confirm('将使用原照片重新检测并生成新记录,确认重试 ' + id + ' ?')) return;
toast('重试中,请稍候(最长约 1 分钟)…');
api('/api/admin/records/' + encodeURIComponent(id) + '/retry', { method: 'POST' }).then(function (data) {
api('/api/admin/records/' + encodeURIComponent(id) + '/retry' + sourceQuery(source), { method: 'POST' }).then(function (data) {
toast('重试完成,新记录 ' + data.record.record_id + '(' + data.record.detect_status + ')');
var active = $('.panel.on').id;
if (active === 'tab-records') loadRecords();
@@ -247,7 +272,8 @@ function doRetry(id) {
/* ═══════════ 统计概览 ═══════════ */
var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026'];
function loadStats() {
api('/api/admin/stats').then(function (st) {
api(selectedChannelQuery('/api/admin/stats')).then(function (st) {
renderChannelStats(st);
renderCaptureFunnel(st);
var cards = [
['今日检测', st.today_total],
@@ -332,18 +358,23 @@ function loadStats() {
/* ═══════════ 记录详情(抽屉) ═══════════ */
var currentDetailId = null;
var currentDetailSource = 'local';
var detailSeq = 0;
function openDetail(id) {
function openDetail(id, source) {
var seq = ++detailSeq;
currentDetailId = id;
currentDetailSource = source || 'local';
$('#d-title').textContent = '记录详情 ' + id;
$('#btn-retry').disabled = true;
$('#d-body').innerHTML = '<div class="sub">加载中…</div>';
$('#drawer').classList.remove('hidden');
$('#drawer-mask').classList.remove('hidden');
api('/api/admin/records/' + encodeURIComponent(id)).then(renderDetail)
.catch(function (e) { $('#d-body').innerHTML = '<div class="sub">加载失败:' + esc(e.message) + '</div>'; });
api('/api/admin/records/' + encodeURIComponent(id) + sourceQuery(currentDetailSource)).then(function (data) { if (seq === detailSeq) renderDetail(data); })
.catch(function (e) { if (seq === detailSeq) $('#d-body').innerHTML = '<div class="sub">加载失败:' + esc(e.message) + '</div>'; });
}
function closeDetail() {
detailSeq++;
$('#drawer').classList.add('hidden');
$('#drawer-mask').classList.add('hidden');
currentDetailId = null;
@@ -355,7 +386,8 @@ function dSec(title, innerHtml) {
function renderDetail(data) {
var r = data.record;
$('#btn-retry').disabled = !(data.photos && data.photos.front);
$('#btn-retry').disabled = data.readonly || !(data.photos && data.photos.front);
$('#btn-retry').title = data.readonly ? '只读汇总记录,请在原服务后台重试' : '';
var h = '';
/* 三张照片 */
@@ -375,6 +407,7 @@ function renderDetail(data) {
/* 状态行 */
h += dSec('状态', '<div class="d-kv">' +
'<span>渠道 <b>' + esc(channelName(r.channel)) + (data.readonly ? '(只读汇总)' : '') + '</b></span>' +
'<span>检测状态 <b>' + esc(r.detect_status) + '</b></span>' +
'<span>拍摄状态 <b>' + esc(r.shoot_status) + '</b></span>' +
'<span>上传状态 <b>' + esc(r.upload_status) + '</b></span>' +
@@ -553,7 +586,7 @@ function renderDetail(data) {
if (data.raw_json) {
var rawFile = data.raw_file || (window.JuziEntry && window.JuziEntry.mode === 'rednote' ? r.raw_file : null);
var rawDl = rawFile
? ' <a href="' + adminURL('/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw') + '" download>下载完整原始 JSON</a>'
? ' <a href="' + adminURL('/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw' + sourceQuery(data.source)) + '" download>下载完整原始 JSON</a>'
: '';
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (rawFile || '') + ')' + rawDl + '</summary><pre>' +
esc(JSON.stringify(data.raw_json, null, 1)) + '</pre></details>');
@@ -572,7 +605,7 @@ function renderDetail(data) {
/* 版本链跳转 */
var links = body.querySelectorAll('.d-versions a');
for (var v = 0; v < links.length; v++) {
links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id')); });
links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id'), data.source); });
}
}
@@ -584,6 +617,14 @@ function showLightbox(src) {
/* ═══════════ 事件绑定与初始化 ═══════════ */
function bind() {
if (window.JuziEntry && window.JuziEntry.mode === 'rednote') {
$('#f-channel').innerHTML = '<option value="">全部渠道</option><option value="rednote">小红书</option>';
}
$('#f-channel').addEventListener('change', function () {
pageState.page = 1;
var active = $('.panel.on').id.replace('tab-', '');
switchTab(active);
});
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].addEventListener('click', function () { switchTab(this.dataset.tab); });
@@ -599,13 +640,14 @@ function bind() {
$('#btn-next').addEventListener('click', function () { pageState.page++; loadRecords(); });
$('#btn-close').addEventListener('click', closeDetail);
$('#drawer-mask').addEventListener('click', closeDetail);
$('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId); });
$('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId, currentDetailSource); });
$('#lightbox').addEventListener('click', function () { this.classList.add('hidden'); });
$('#btn-export').addEventListener('click', function () {
var p = [];
if ($('#exp-from').value) p.push('from=' + $('#exp-from').value);
if ($('#exp-to').value) p.push('to=' + $('#exp-to').value);
var url = adminURL('/api/admin/export' + (p.length ? '?' + p.join('&') : ''));
var p = new URLSearchParams(collectFilters());
p.delete('page'); p.delete('page_size');
if ($('#exp-from').value) p.set('from', $('#exp-from').value);
if ($('#exp-to').value) p.set('to', $('#exp-to').value);
var url = adminURL('/api/admin/export?' + p.toString());
var a = el('a');
a.href = url;
a.download = '';
+16 -1
View File
@@ -33,6 +33,7 @@ module.exports = function createAccounts(root, env = process.env) {
if (base) { base.pathname = prefix; base.search = ''; base.hash = ''; }
const secure = !!(base && base.protocol === 'https:');
const guestSessions = new Map(); // No guest identities/sessions are written to disk.
const taobaoGuests = new Map(); // Separate, temporary Bearer sessions; never written to db.
const appId = env.WECHAT_APP_ID || '';
const secret = env.WECHAT_APP_SECRET || '';
const available = !rednote && !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
@@ -226,13 +227,27 @@ module.exports = function createAccounts(root, env = process.env) {
save();
return { token, expires_in: 30 * 86400, user: publicUser(user) };
}
function loginTaobaoGuest() {
for (const [key, value] of taobaoGuests) if (value.expires <= Date.now()) taobaoGuests.delete(key);
if (taobaoGuests.size >= 1000) return null;
const token = random(), user = { id: crypto.randomUUID(), kind: 'taobao_guest' };
taobaoGuests.set(hash(token), { user, expires: Date.now() + 8 * 3600000 });
return { token, expires_in: 8 * 3600, user: publicUser(user) };
}
function taobaoSession(req) {
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
const key = match && hash(match[1]), guest = key && taobaoGuests.get(key);
if (guest) {
if (guest.expires > Date.now()) return guest.user;
taobaoGuests.delete(key);
return null;
}
const row = match && db.sessions[hash(match[1])];
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'taobao') : null;
}
function taobaoLogout(req) {
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
if (match && taobaoGuests.delete(hash(match[1]))) return;
if (match && taobaoSession(req)) { delete db.sessions[hash(match[1])]; save(); }
}
function reports(owner) {
@@ -247,5 +262,5 @@ module.exports = function createAccounts(root, env = process.env) {
return snapshot;
}
return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available,
loginTaobao, taobaoSession, taobaoLogout, reports, ownedReport };
loginTaobao, loginTaobaoGuest, taobaoSession, taobaoLogout, reports, ownedReport };
};
+72
View File
@@ -0,0 +1,72 @@
'use strict';
// Administrative reads only. Detection/authentication continue to write solely
// to the process's own DATA_ROOT. Source paths never come from an HTTP request.
const fs = require('node:fs');
const path = require('node:path');
module.exports = function ({ localRoot, localChannel = 'h5', taobaoRoot, publicRoot }) {
const sources = new Map([['local', { id: 'local', root: path.resolve(localRoot), channel: localChannel, readonly: false }]]);
const inside = (file, root) => file === root || file.startsWith(root + path.sep);
if (taobaoRoot) {
if (!path.isAbsolute(taobaoRoot)) throw Error('JUZI_ADMIN_TAOBAO_DATA_ROOT must be absolute');
const root = fs.realpathSync(taobaoRoot);
const primary = fs.realpathSync(localRoot);
if (inside(root, primary) || inside(primary, root)) throw Error('Administrative data sources must be separate directories');
if (publicRoot && inside(root, fs.realpathSync(publicRoot))) throw Error('Administrative external data must be outside the public application directory');
sources.set('taobao', { id: 'taobao', root, channel: 'taobao', readonly: true });
}
function source(id = 'local') { return sources.get(id) || null; }
function records(id) {
const selected = id ? [source(id)].filter(Boolean) : [...sources.values()];
return selected.flatMap(s => {
let text;
const target = file(s.id, 'records.jsonl');
if (!target) return [];
try { text = fs.readFileSync(target, 'utf8'); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
return text.split('\n').flatMap(line => {
let rec; try { rec = JSON.parse(line); } catch (_) { return []; }
if (!rec || typeof rec.record_id !== 'string' || !/^[\w-]+$/.test(rec.record_id)) return [];
return [{ ...rec, channel: rec.channel || s.channel, admin_source: s.id,
admin_key: s.id + ':' + rec.record_id, admin_readonly: s.readonly }];
});
});
}
function find(id, sourceId = 'local') { return records(sourceId).find(r => r.record_id === id) || null; }
function file(sourceId, ...parts) {
const s = source(sourceId);
if (!s || parts.some(p => typeof p !== 'string' || !p || p !== path.basename(p) || p === '.' || p === '..')) return null;
const target = path.resolve(s.root, ...parts);
if (!inside(target, s.root)) return null;
try { return inside(fs.realpathSync(target), fs.realpathSync(s.root)) ? target : null; }
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
}
function presentation(rec) {
const target = file(rec.admin_source, 'accounts', 'reports', rec.owner_id, rec.record_id + '.json');
if (!target) return null;
try { return JSON.parse(fs.readFileSync(target, 'utf8')).presentation || null; }
catch (_) { return null; }
}
function funnel(now, channel, localFunnel) {
const stats = [...sources.values()].filter(s => !channel || s.channel === channel).map(s => {
if (s.id === 'local') return localFunnel.stats(now);
return require('./capture-funnel')(path.join(s.root, 'capture-funnel.jsonl')).stats(now);
});
function sum(scope) {
const rows = stats.map(st => st[scope]);
const counts = {};
for (const st of rows) for (const [stage, n] of Object.entries(st.counts)) counts[stage] = (counts[stage] || 0) + n;
const reports = counts.report || 0;
return { counts, rate: counts.started ? Math.round(reports / counts.started * 1000) / 10 : 0,
response_without_report: rows.reduce((n, r) => n + r.response_without_report, 0),
unfinished: rows.reduce((n, r) => n + r.unfinished, 0),
avg_ms: reports ? Math.round(rows.reduce((n, r) => n + (r.avg_ms || 0) * r.counts.report, 0) / reports) : null };
}
// Keep the event schema even when a selected channel has no telemetry yet.
if (!stats.length) return null;
const starts = stats.map(st => st.since).filter(Number.isFinite);
return { since: starts.length ? Math.min(...starts) : null, total: sum('total'), today: sum('today') };
}
return { source, records, find, file, presentation, funnel,
descriptors: () => [...sources.values()].map(({ id, channel, readonly }) => ({ id, channel, readonly })) };
};
+20 -8
View File
@@ -3,29 +3,41 @@ const crypto = require('node:crypto');
// Cloud gateway query parameters + the exact, unread JSON body. Algorithm:
// https://developer.alibaba.com/docs/doc.htm?articleId=121746&docType=1&treeId=832
//
// 注意:真实网关请求不带 sign_method 参数(2026-11-01 从 nginx access.log 抓包确认:
// 参数只有 app_key/open_id/sign/env/request_id/... 没有 sign_method)。缺省时网关用的是
// md5(secret + content + secret),也就是 sign_method=md5 的形式,而不是 md5(content + secret)。
// 之前按「空方法 = md5(content+secret)」实现,导致所有请求都验签失败。
function signature(params, body, secret) {
const method = params.sign_method || '';
if (!['', 'md5', 'hmac', 'hmac_md5', 'hmac_sha256'].includes(method)) return null;
const method = params.sign_method || 'md5';
if (!['md5', 'hmac', 'hmac_md5', 'hmac_sha256'].includes(method)) return null;
const content = Object.keys(params).filter(key => key !== 'sign' && params[key] !== '').sort()
.map(key => key + params[key]).join('') + body;
if (method === 'hmac' || method === 'hmac_md5' || method === 'hmac_sha256') {
return crypto.createHmac(method === 'hmac_sha256' ? 'sha256' : 'md5', secret).update(content).digest('hex').toUpperCase();
}
return crypto.createHash('md5').update((method === 'md5' ? secret : '') + content + secret).digest('hex').toUpperCase();
return crypto.createHash('md5').update(secret + content + secret).digest('hex').toUpperCase();
}
function verify(url, body, env = process.env) {
// Guest requests still require the real signed app gateway, without userInfo
// authorization or an open_id. Body fields never establish an identity.
function verifyGateway(url, body, env = process.env) {
if (!env.TAOBAO_APP_KEY || !env.TAOBAO_APP_SECRET) return null;
const params = Object.create(null);
for (const [key, value] of new URL(url, 'http://localhost').searchParams) {
if (key in params) return null; // Never ambiguously verify duplicate parameters.
params[key] = value;
}
if (params.app_key !== env.TAOBAO_APP_KEY || !/^[A-Za-z0-9_-]{4,256}$/.test(params.open_id || '') ||
if (params.app_key !== env.TAOBAO_APP_KEY ||
!/^[A-Fa-f0-9]{32,64}$/.test(params.sign || '')) return null;
const expected = signature(params, body, env.TAOBAO_APP_SECRET);
if (!expected || expected.length !== params.sign.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(params.sign.toUpperCase()))) return null;
// Login challenge is single-use and signed inside the body, preventing replay.
return { providerKey: crypto.createHash('sha256').update(params.app_key + ':' + params.open_id).digest('hex') };
return { appKey: params.app_key, openId: params.open_id };
}
module.exports = { signature, verify };
function verify(url, body, env = process.env) {
const gateway = verifyGateway(url, body, env);
if (!gateway || !/^[A-Za-z0-9_-]{4,256}$/.test(gateway.openId || '')) return null;
// Login challenge is single-use and signed inside the body, preventing replay.
return { providerKey: crypto.createHash('sha256').update(gateway.appKey + ':' + gateway.openId).digest('hex') };
}
module.exports = { signature, verify, verifyGateway };
+26 -9
View File
@@ -11,6 +11,7 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn
try {
const raw = (await readBody(req, pathname === '/api/taobao/photo' ? 70 * 1024 : 4096)).toString('utf8');
const body = JSON.parse(raw);
if (!body || typeof body !== 'object' || Array.isArray(body)) return reply(400, { ok: false, message: '请求数据异常' });
if (pathname === '/api/taobao/challenge') {
const challenge = taobaoSessions.challenge(req.socket.remoteAddress);
return reply(challenge ? 200 : 429, challenge ? { ok: true, challenge } : { ok: false, message: '操作频繁,请稍后重试' });
@@ -21,29 +22,45 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn
if (!identity || !taobaoSessions.consume(body.challenge)) return reply(401, { ok: false, message: '淘宝身份校验失败,请重新授权登录' });
return reply(200, { ok: true, ...accounts.loginTaobao(identity.providerKey) });
}
if (pathname === '/api/taobao/guest-session') {
if (body.consent !== true) return reply(400, { ok: false, message: '请先阅读并同意隐私协议' });
if (!TaobaoAuth.verifyGateway(req.url, raw) || !taobaoSessions.consume(body.challenge)) {
return reply(401, { ok: false, message: '游客会话校验失败,请重新开始' });
}
const guest = accounts.loginTaobaoGuest();
return reply(guest ? 200 : 429, guest ? { ok: true, ...guest } : { ok: false, message: '操作频繁,请稍后再试' });
}
const owner = accounts.taobaoSession(req);
if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新登录' });
if (pathname === '/api/taobao/me') return reply(200, { ok: true, user: { id: owner.id, kind: owner.kind } });
if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新开始' });
if (pathname === '/api/taobao/me') {
// A cached Bearer must not restore the previous Taobao account after
// the host app switches accounts. Guests require a signed app gateway
// but intentionally have no dependency on userInfo/open_id authorization.
const identity = owner.kind === 'taobao_guest' ? null : TaobaoAuth.verify(req.url, raw);
const matches = owner.kind === 'taobao_guest' ? !!TaobaoAuth.verifyGateway(req.url, raw)
: !!identity && identity.providerKey === owner.provider_key;
return reply(matches ? 200 : 401, matches ? { ok: true, user: { id: owner.id, kind: owner.kind } }
: { ok: false, message: '淘宝账号状态已变化,请重新进入' });
}
if (pathname === '/api/taobao/photo') {
const { status, ...data } = taobaoSessions.upload(req, body);
return reply(status, { ok: status === 200, ...data });
}
if (pathname === '/api/taobao/skin-analyze') {
const photos = taobaoSessions.takePhotos(req, body.photo_refs);
if (!photos) return reply(409, { ok: false, message: '照片上传未完成或检测进行中,请重试' });
// Start once and poll: cloud gateway requests have a maximum 10s timeout.
const job = taobaoSessions.start(req, () => new Promise(resolve => {
const attempt = taobaoSessions.beginAnalysis(req, body, photos => new Promise(resolve => {
const response = { setHeader() {}, writeHead() {}, end(text, cb) { resolve(JSON.parse(text)); if (cb) cb(); } };
handleAnalyze(req, response, true, { ...body, ...photos }, owner).catch(() => resolve({ ok: false, message: '检测服务暂不可用,请重试' }));
}), true);
if (!job) { taobaoSessions.finish(req); return reply(429, { ok: false, message: '服务繁忙,请稍后再试' }); }
return reply(202, { ok: true, job });
}));
return reply(attempt.status, { ok: attempt.status === 202,
...(attempt.job ? { job: attempt.job } : { message: attempt.message }) });
}
if (pathname === '/api/taobao/analysis') {
const result = taobaoSessions.result(req, body.job);
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: '检测任务已失效,请查看历史报告或重新检测' });
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: owner.kind === 'taobao_guest' ? '本次检测任务已失效,请重新开始' : '检测任务已失效,请查看历史报告或重新检测' });
}
if (pathname === '/api/taobao/reports') {
if (owner.kind === 'taobao_guest') return reply(body.id ? 404 : 200, body.id ? { ok: false, message: '游客模式不保存历史报告' } : { ok: true, total: 0, items: [] });
if (!body.id) return reply(200, { ok: true, total: accounts.reports(owner.id).length, items: accounts.reports(owner.id) });
const report = accounts.ownedReport(owner.id, body.id);
return reply(report ? 200 : 404, report ? { ok: true, report } : { ok: false, message: '报告不存在' });
+73 -13
View File
@@ -3,8 +3,11 @@ const crypto = require('node:crypto');
// Short-lived transfers/jobs/challenges. Identity/history live in accounts.js.
module.exports = function ({ session, now = Date.now } = {}) {
const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map();
const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map(), submissions = new Map();
const lifetime = 5 * 60000, maxChunk = 64 * 1024, maxPhoto = 4 * 1024 * 1024;
// Keep only small idempotency tombstones after result expiry. Never silently
// charge again when a client retries an old request_id in the same session.
const submissionLifetime = 30 * 86400000;
let bytes = 0;
function clearPhotos(row) {
for (const photo of Object.values(row.photos)) bytes -= photo.bytes;
@@ -15,6 +18,12 @@ module.exports = function ({ session, now = Date.now } = {}) {
for (const [id, until] of challenges) if (until <= now()) challenges.delete(id);
for (const [id, row] of rates) if (row.until <= now()) rates.delete(id);
for (const [id, job] of jobs) if (job.expires <= now() && job.status !== 'running') jobs.delete(id);
for (const [id, value] of submissions) if (value.expires <= now()) submissions.delete(id);
}
function scope(req, owner) {
const authorization = req.headers && req.headers.authorization;
return typeof authorization === 'string' && authorization
? crypto.createHash('sha256').update(authorization).digest('hex') : owner.id;
}
function challenge(address) {
sweep();
@@ -35,8 +44,9 @@ module.exports = function ({ session, now = Date.now } = {}) {
sweep();
const owner = session(req);
if (!owner) return null;
if (!transfers.has(owner.id) && create && transfers.size < 1000) transfers.set(owner.id, { photos: {}, active: false, expires: now() + lifetime });
return transfers.get(owner.id) || null;
const key = scope(req, owner);
if (!transfers.has(key) && create && transfers.size < 1000) transfers.set(key, { photos: {}, active: false, expires: now() + lifetime });
return transfers.get(key) || null;
}
function upload(req, body) {
const current = row(req, true);
@@ -64,35 +74,85 @@ module.exports = function ({ session, now = Date.now } = {}) {
current.expires = now() + lifetime;
return { status: 200, complete: photo.chunks.length === total, photo_ref: id };
}
function takePhotos(req, refs) {
const current = row(req);
function availablePhotos(current, refs) {
if (!current || current.active) return null;
if (!refs || !['front', 'left', 'right'].every(view => {
const photo = current.photos[view];
return photo && photo.id === refs[view] && photo.chunks.length === photo.total;
})) return null;
const photos = Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')]));
return Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')]));
}
function takePhotos(req, refs) {
const current = row(req), photos = availablePhotos(current, refs);
if (!photos) return null;
clearPhotos(current); current.active = true;
return photos;
}
function finish(req, ownerId) { const current = ownerId ? transfers.get(ownerId) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } }
function revoke(req) { const current = row(req); if (current) { clearPhotos(current); current.expires = 0; sweep(); } }
function start(req, run, releasePhotos = false) {
function finish(req, key) { const current = key ? transfers.get(key) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } }
function revoke(req) {
const owner = session(req);
if (!owner) return;
const key = scope(req, owner), current = transfers.get(key);
if (current) { clearPhotos(current); transfers.delete(key); }
for (const [id, job] of jobs) if (job.scope === key) { job.result = null; jobs.delete(id); }
for (const [id, value] of submissions) if (value.scope === key) submissions.delete(id);
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
return value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) : value;
}
function submission(req, requestId, body) {
sweep();
const owner = session(req);
if (!owner) return { status: 401, message: '检测会话已失效,请重新开始' };
if (requestId === undefined) return { status: 200 }; // Older clients remain supported.
if (typeof requestId !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(requestId)) {
return { status: 400, message: '检测请求标识异常,请重新开始' };
}
const key = scope(req, owner) + ':' + requestId;
const fingerprint = crypto.createHash('sha256').update(JSON.stringify(stable({ photo_refs: body.photo_refs, profile: body.profile }))).digest('hex');
const previous = submissions.get(key);
if (previous) {
if (previous.fingerprint !== fingerprint) return { status: 409, message: '检测请求已用于其他照片或档案,请重新开始' };
if (!jobs.has(previous.job)) return { status: 410, message: '本次检测任务已失效,请重新开始新的检测' };
return { status: 202, job: previous.job };
}
if (submissions.size >= 10000) return { status: 429, message: '服务繁忙,请稍后再试' };
return { status: 200, key, fingerprint };
}
function start(req, run, releasePhotos = false, attempt) {
sweep();
const owner = session(req);
if (!owner || jobs.size >= 1000) return null;
const id = crypto.randomBytes(24).toString('hex');
const job = { owner: owner.id, status: 'running', expires: now() + lifetime, result: null };
const key = scope(req, owner);
const job = { owner: owner.id, scope: key, status: 'running', expires: now() + lifetime, result: null };
jobs.set(id, job);
if (attempt && attempt.key) submissions.set(attempt.key, { job: id, scope: key, fingerprint: attempt.fingerprint, expires: now() + submissionLifetime });
Promise.resolve().then(run).then(result => { job.result = result; }, () => { job.result = { ok: false, message: '检测服务暂不可用,请重试' }; })
.finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, owner.id); });
.finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, key); });
return id;
}
function beginAnalysis(req, body, run) {
const attempt = submission(req, body.request_id, body);
if (attempt.status !== 200) return attempt;
const current = row(req), photos = availablePhotos(current, body.photo_refs);
if (!photos) return { status: 409, message: '照片上传未完成或检测进行中,请重试' };
// Reserve the job and its idempotency key BEFORE consuming uploads. There
// are no awaits here; the paid callback is queued for the next microtask.
// A full job queue leaves the complete uploads available for a later retry.
const job = start(req, () => run(photos), true, attempt);
if (!job) return { status: 429, message: '服务繁忙,请稍后再试' };
clearPhotos(current); current.active = true;
return { status: 202, job };
}
function result(req, id) {
sweep();
const job = jobs.get(id), owner = session(req);
return job && owner && job.owner === owner.id ? { status: job.status, payload: job.result } : null;
return job && owner && job.owner === owner.id && job.scope === scope(req, owner)
? { status: job.status, payload: job.result } : null;
}
const timer = setInterval(sweep, 60000); timer.unref();
return { challenge, consume, upload, takePhotos, finish, revoke, start, result };
return { challenge, consume, upload, takePhotos, finish, revoke, start, beginAnalysis, submission, result };
};
+62 -38
View File
@@ -33,6 +33,8 @@ const CaptureDiagnostics = require('./lib/capture-diagnostics.js');
const WrinkleViews = require('./lib/wrinkle-views.js');
const captureFunnel = require('./lib/capture-funnel.js')(path.join(DATA_ROOT, 'capture-funnel.jsonl'));
const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
const adminSources = require('./lib/admin-sources')({ localRoot: DATA_ROOT, localChannel: entry.channel,
taobaoRoot: entry.rednote ? null : process.env.JUZI_ADMIN_TAOBAO_DATA_ROOT, publicRoot: ROOT });
const wrinkleJobs = require('./lib/wrinkle-jobs.js')({
dir:path.join(DATA_ROOT,'wrinkle-jobs'),
collect:async context=>WrinkleViews.forReport(await WrinkleViews.collect(context.primary,context.photos,{
@@ -93,7 +95,8 @@ const MIME = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8'
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8',
'.mp3': 'audio/mpeg', '.aac': 'audio/aac'
};
function serveStatic(req, res, pathname) {
let p;
@@ -578,9 +581,10 @@ function sendJson(res, status, obj) {
function photoUrls(rec) {
const urls = {};
const prefix = entry.rednote ? '/rednote' : '';
if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front`;
if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left`;
if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right`;
const source = rec.admin_source && rec.admin_source !== 'local' ? '?source=' + encodeURIComponent(rec.admin_source) : '';
if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front${source}`;
if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left${source}`;
if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right${source}`;
return urls;
}
@@ -603,6 +607,8 @@ function listItem(rec) {
created_at: rec.created_at,
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
channel: rec.channel || 'h5',
source: rec.admin_source || 'local', admin_key: rec.admin_key || 'local:' + rec.record_id,
readonly: !!rec.admin_readonly,
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
capture_quality: rec.capture_quality || null,
capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null,
@@ -648,7 +654,7 @@ function matchQuery(rec, q) {
return true;
}
function handleRecordsQuery(req, res, url) {
function adminQuery(url) {
const sp = url.searchParams;
const q = {
from: sp.get('from') || null,
@@ -665,21 +671,32 @@ function handleRecordsQuery(req, res, url) {
age_max: sp.has('age_max') && sp.get('age_max') !== '' ? Number(sp.get('age_max')) : null
};
if ((q.age_min != null && !Number.isFinite(q.age_min)) || (q.age_max != null && !Number.isFinite(q.age_max))) {
return sendJson(res, 400, { ok: false, message: 'age_min/age_max 必须是数字' });
throw Error('age_min/age_max 必须是数字');
}
try { q.timeRange = BeijingTime.range(q.from, q.to); }
if (q.channel && !['h5', 'taobao', 'rednote'].includes(q.channel)) throw Error('渠道参数无效');
q.timeRange = BeijingTime.range(q.from, q.to);
return q;
}
function adminRecordOrder(a, b) {
const delta = BeijingTime.milliseconds(b.created_at) - BeijingTime.milliseconds(a.created_at);
return (Number.isFinite(delta) ? delta : 0) || String(a.admin_key).localeCompare(String(b.admin_key));
}
function handleRecordsQuery(req, res, url) {
let q;
try { q = adminQuery(url); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const sp = url.searchParams;
const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20));
const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序
const filtered = adminSources.records().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort(adminRecordOrder); // 跨数据源统一排序;同时间用来源+ID稳定分页
const total = filtered.length;
const items = filtered.slice((page - 1) * pageSize, page * pageSize).map(listItem);
sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items });
sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items, sources: adminSources.descriptors() });
}
function handleRecordDetail(req, res, id) {
const rec = displayRecommendationRecord(findRecord(id));
function handleRecordDetail(req, res, id, source = 'local') {
const rec = displayRecommendationRecord(adminSources.find(id, source));
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
// 原始 JSON 全文:读文件失败给 null。
// face_maps 是整幅 base64 叠加图(单文件可达数 MB),详情页直接渲染会卡死浏览器:
@@ -687,7 +704,7 @@ function handleRecordDetail(req, res, id) {
let rawJson = null;
if (rec.raw_file) {
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
try { rawJson = JSON.parse(fs.readFileSync(path.join(DATA_DIR, fname), 'utf8')); }
try { rawJson = JSON.parse(fs.readFileSync(adminSources.file(source, 'results', fname), 'utf8')); }
catch (e) { rawJson = null; }
}
if (rawJson && rawJson.result && rawJson.result.face_maps) {
@@ -699,11 +716,11 @@ function handleRecordDetail(req, res, id) {
}
}
// 版本链:它 retry_of 指向的(父)+ retry_of 指向它的(子)
const all = readAllRecords();
const all = adminSources.records(source);
const parent = rec.retry_of ? (all.find(r => r.record_id === rec.retry_of) || null) : null;
const children = all.filter(r => r.retry_of === rec.record_id)
.map(r => ({ record_id: r.record_id, detect_status: r.detect_status, created_at: r.created_at }));
let presentation = rec.presentation || accounts.reportPresentation(rec.owner_id, rec.record_id);
.map(r => ({ record_id: r.record_id, source, detect_status: r.detect_status, created_at: r.created_at }));
let presentation = rec.presentation || (source === 'local' ? accounts.reportPresentation(rec.owner_id, rec.record_id) : adminSources.presentation(rec));
if (presentation && (presentation.gradeRule !== ReportUI.gradeRule || presentation.copyRule !== ReportUI.copyRule) && rawJson && rawJson.result) {
// Recover text variables from raw data, but retain the saved scores,
// recommendation and care order. No database/snapshot mutation.
@@ -719,23 +736,26 @@ function handleRecordDetail(req, res, id) {
if(presentation && rec.recommendation && rec.recommendation.ageBasis==='display')presentation=Object.assign({},presentation,{product:ReportUI.products.find(p=>p.tier===rec.recommendation.tier)||null});
sendJson(res, 200, {
ok: true,
source, readonly: !!rec.admin_readonly,
raw_file: rec.raw_file || null,
record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec),
dims: displayScoreLevels(rec.dims,true), sub_scores: displayScoreLevels(rec.sub_scores), grade_rule: ReportUI.gradeRule, presentation }),
raw_json: rawJson,
photos: photoUrls(rec),
versions: {
parent: parent ? { record_id: parent.record_id, detect_status: parent.detect_status, created_at: parent.created_at } : null,
parent: parent ? { record_id: parent.record_id, source, detect_status: parent.detect_status, created_at: parent.created_at } : null,
children
}
});
}
/* 完整原始 JSON 下载(face_maps base64 未裁剪,动辄数 MB,仅供复核下载,不做页面内展示) */
function handleRawDownload(req, res, id) {
const rec = findRecord(id);
function handleRawDownload(req, res, id, source = 'local') {
const rec = adminSources.find(id, source);
if (!rec || !rec.raw_file) return sendJson(res, 404, { ok: false, message: '记录或原始 JSON 不存在' });
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
const file = path.join(DATA_DIR, fname);
const file = adminSources.file(source, 'results', fname);
if (!file) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' });
fs.readFile(file, (err, data) => {
if (err) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' });
res.writeHead(200, {
@@ -746,14 +766,15 @@ function handleRawDownload(req, res, id) {
});
}
function handlePhoto(req, res, id, which) {
const safe = String(id || '').replace(/[^\w-]/g, '');
const file = path.join(PHOTOS_DIR, safe, which + '.jpg');
function handlePhoto(req, res, id, which, source = 'local') {
const rec = adminSources.find(id, source);
const file = rec && adminSources.file(source, 'photos', id, which + '.jpg');
if (!file) { res.writeHead(404); return res.end('Not Found'); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'Content-Disposition': `inline; filename="${safe}-${which}.jpg"`
'Content-Disposition': `inline; filename="${id}-${which}.jpg"`
});
res.end(data);
});
@@ -761,8 +782,9 @@ function handlePhoto(req, res, id, which) {
function handleStats(req, res, url) {
const channel = url.searchParams.get('channel') || null; // 传 channel 时以下指标仅统计该渠道
if (channel && !['h5', 'taobao', 'rednote'].includes(channel)) return sendJson(res, 400, { ok: false, message: '渠道参数无效' });
const now = Date.now(), today = BeijingTime.day(now);
const allRaw = readAllRecords().map(displayRecommendationRecord);
const allRaw = adminSources.records().map(displayRecommendationRecord);
// 按渠道拆分概览(始终基于全量,历史无 channel 字段的记录按 'h5' 计)
const by_channel = {};
for (const r of allRaw) {
@@ -822,7 +844,8 @@ function handleStats(req, res, url) {
fail_total: failed.length,
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
avg_cost_ms: avgCost,
capture_funnel: captureFunnel.stats(now),
capture_funnel: adminSources.funnel(now, channel, captureFunnel),
sources: adminSources.descriptors(),
by_channel,
fail_dist,
dim_dist,
@@ -883,15 +906,11 @@ function csvCell(v) {
}
function handleExport(req, res, url) {
const q = {
from: url.searchParams.get('from') || null,
to: url.searchParams.get('to') || null,
channel: url.searchParams.get('channel') || null
};
try { q.timeRange = BeijingTime.range(q.from, q.to); }
let q;
try { q = adminQuery(url); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
const filtered = adminSources.records().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort(adminRecordOrder);
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? SkinScore.capDimensionScore(r.dims[d].S) : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelForScore(r.dims[d].S) : null;
@@ -899,6 +918,8 @@ function handleExport(req, res, url) {
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)',
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本', '渠道'];
const includeSources = adminSources.descriptors().length > 1;
if (includeSources) header.push('数据来源', '记录唯一键');
const lines = [header.join(',')];
for (const r of filtered) {
lines.push([
@@ -915,7 +936,7 @@ function handleExport(req, res, url) {
r.capture_quality ? r.capture_quality.status : null,
r.capture_diagnostics ? r.capture_diagnostics.version : null,
r.channel || 'h5'
].map(csvCell).join(','));
].concat(includeSources ? [r.admin_source, r.admin_key] : []).map(csvCell).join(','));
}
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
const toTag = (q.to || 'all').replace(/[^\d-]/g, '');
@@ -929,14 +950,17 @@ function handleExport(req, res, url) {
async function handleAdmin(req, res, pathname, url) {
try {
const source = url.searchParams.get('source') || 'local';
if (!adminSources.source(source)) return sendJson(res, 404, { ok: false, message: '数据来源不存在' });
const retryM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/retry$/);
if (req.method === 'POST' && retryM && source !== 'local') return sendJson(res, 409, { ok: false, message: '该渠道为只读汇总,请在原服务后台重试' });
if (req.method === 'POST' && retryM) return await handleRetry(req, res, retryM[1]);
const rawM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/raw$/);
if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1]);
if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1], source);
const detailM = pathname.match(/^\/api\/admin\/records\/([\w-]+)$/);
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1]);
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1], source);
const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/);
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]);
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2], source);
if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url);
if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res, url);
if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url);
+49
View File
@@ -0,0 +1,49 @@
'use strict';
// 淘宝云网关签名:真实网关请求不带 sign_method,缺省等价于 md5(secret+content+secret)。
// 2026-11-01 用 nginx access.log 抓到的真实请求离线复算确认(原先按 md5(content+secret) 实现,
// 导致真机登录一直 401「淘宝身份校验失败」)。
const assert = require('node:assert/strict');
const TaobaoAuth = require('../lib/taobao-auth');
const body = '{"consent":true,"challenge":"nonce"}';
const secret = 'secret';
const base = { app_key: 'app', env: 'online', open_id: 'open-account' };
// 1) 显式 sign_method=md5
assert.equal(
TaobaoAuth.signature({ ...base, sign_method: 'md5' }, body, secret),
'E673310E672C9EB7E918857C6C64D247');
// 2) 没有 sign_method(真实网关的形态)
assert.equal(
TaobaoAuth.signature({ ...base }, body, secret),
'DD2B95886A5E8F61D6111A5659E4FBD5');
// 3) 旧的错误实现 md5(content+secret) 必须不再被采用
assert.notEqual(
TaobaoAuth.signature({ ...base }, body, secret),
'E3EB6341161C9ED8CAE8E161F19D1729');
// 4) 不认识的 sign_method 仍然拒绝
assert.equal(TaobaoAuth.signature({ ...base, sign_method: 'none' }, body, secret), null);
// 5) verify 缺凭据直接返回 null
assert.equal(TaobaoAuth.verify('/api/taobao/session', body, {}), null);
// 6) verify 正例:按缺省 md5 形态算出的 sign 必须通过,并返回 providerKey
const digest = TaobaoAuth.signature({ app_key: 'app', open_id: 'open-account' }, body, secret);
assert.equal(digest, 'F3C8634FF590F83DBE269783A9038ECA');
const identity = TaobaoAuth.verify(
`/api/taobao/session?app_key=app&open_id=open-account&sign=${digest}`,
body,
{ TAOBAO_APP_KEY: 'app', TAOBAO_APP_SECRET: secret });
assert.equal(identity.providerKey,
require('node:crypto').createHash('sha256').update('app:open-account').digest('hex'));
// 7) verify 反例:body 被改动后签名失效
assert.equal(TaobaoAuth.verify(
`/api/taobao/session?app_key=app&open_id=open-account&sign=${digest}`,
body + ' ',
{ TAOBAO_APP_KEY: 'app', TAOBAO_APP_SECRET: secret }), null);
console.log('PASS Taobao gateway signature: default md5 form, explicit md5, unknown method, verify positive/negative');
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=1me rednote H5 - isolated browser identities
Description=1'me rednote H5 - isolated browser identities
After=network.target
[Service]
@@ -0,0 +1,16 @@
# ===== 旷视 Face++ 凭据(skinanalyze_pro 专业版)=====
#
# ⚠️ 2026-11-02 测试验证阶段,为方便切换开发机,本文件已随仓库提交。
# 约束:
# 1) 仅限内网私有仓库 git.xiangsilian.com/xsl/juzi;不要 fork 到公开仓库;
# 2) 正式上线前必须:重置该 key、把本文件从 git 历史中清除(git filter-repo)、
# 改回「环境变量 / 保密字典」注入;
# 3) 不要复制到聊天、文档、工单等任何外部渠道。
#
# 用法:set -a; source reference/facepp-skinanalyze/credentials.env; set +a
# ---- 旷视 Face++(巨子测肤 skinanalyze_pro 测试接口)----
# 试用key(cAa5...)已弃用: 旷视规则试用key不能调专业版。当前为正式key(EcZ...)
FACEPP_API_KEY=EcZiqYhA8eQSiRgXVem8mijgzPfSC2qp
FACEPP_API_SECRET=p62QRWv9tQitZ1tvXmEBV8AgmaS00lO8
FACEPP_SKINANALYZE_URL=https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro
@@ -0,0 +1 @@
[{"weights":[{"name":"dense0/conv0/filters","shape":[3,3,3,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008194216092427571,"min":-0.9423348506291708}},{"name":"dense0/conv0/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006839508168837603,"min":-0.8412595047670252}},{"name":"dense0/conv1/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009194007106855804,"min":-1.2779669878529567}},{"name":"dense0/conv1/pointwise_filter","shape":[1,1,32,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0036026100317637128,"min":-0.3170296827952067}},{"name":"dense0/conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.000740380117706224,"min":-0.06367269012273527}},{"name":"dense0/conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":1,"min":0}},{"name":"dense0/conv2/pointwise_filter","shape":[1,1,32,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":1,"min":0}},{"name":"dense0/conv2/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0037702228508743585,"min":-0.6220867703942692}},{"name":"dense1/conv0/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0033707996209462483,"min":-0.421349952618281}},{"name":"dense1/conv0/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.014611541991140328,"min":-1.8556658328748217}},{"name":"dense1/conv0/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002832523046755323,"min":-0.30307996600281956}},{"name":"dense1/conv1/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006593170586754294,"min":-0.6329443763284123}},{"name":"dense1/conv1/pointwise_filter","shape":[1,1,64,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.012215249211180444,"min":-1.6001976466646382}},{"name":"dense1/conv1/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002384825547536214,"min":-0.3028728445370992}},{"name":"dense1/conv2/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005859645441466687,"min":-0.7617539073906693}},{"name":"dense1/conv2/pointwise_filter","shape":[1,1,64,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013121426806730382,"min":-1.7845140457153321}},{"name":"dense1/conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0032247188044529336,"min":-0.46435950784122243}},{"name":"dense2/conv0/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002659512618008782,"min":-0.32977956463308894}},{"name":"dense2/conv0/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015499923743453681,"min":-1.9839902391620712}},{"name":"dense2/conv0/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0032450980999890497,"min":-0.522460794098237}},{"name":"dense2/conv1/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005911862382701799,"min":-0.792189559282041}},{"name":"dense2/conv1/pointwise_filter","shape":[1,1,128,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021025861478319356,"min":-2.2077154552235325}},{"name":"dense2/conv1/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.00349616945958605,"min":-0.46149436866535865}},{"name":"dense2/conv2/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008104994250278847,"min":-1.013124281284856}},{"name":"dense2/conv2/pointwise_filter","shape":[1,1,128,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.029337059282789044,"min":-3.5791212325002633}},{"name":"dense2/conv2/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0038808938334969913,"min":-0.4230174278511721}},{"name":"fc/weights","shape":[128,136],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.014016061670639936,"min":-1.8921683255363912}},{"name":"fc/bias","shape":[136],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0029505149698724935,"min":0.088760145008564}}],"paths":["face_landmark_68_tiny_model-shard1"]}]
+14
View File
@@ -0,0 +1,14 @@
[
{
"file": "face_landmark_68_tiny_model-weights_manifest.json",
"source": "https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_landmark_68_tiny_model-weights_manifest.json",
"bytes": 4441,
"sha256": "3c63b8984302c187b218d9ef5aa149ed8c2c7fa3fe54db078614692bc48d153c"
},
{
"file": "face_landmark_68_tiny_model-shard1",
"source": "https://raw.githubusercontent.com/justadudewhohacks/face-api.js/master/weights/face_landmark_68_tiny_model-shard1",
"bytes": 77224,
"sha256": "b98e9f2f7da76f8a6dda9741a36ed485b224b889d552de2b2c1bb16217f67bfc"
}
]
+36 -7
View File
@@ -1,6 +1,34 @@
## 2026-11-03 正式包开发入口清理
正式路由仅保留登录、主页、档案、拍照、报告和历史六页。`pages/mvp`、`pages/camtest`、`pages/camnative` 已从 app.json 移除,并同时加入编译和上传排除项;开发测试源码保留供离线回归,不随正式包注册或上传。登录和主页的开发入口、诊断弹层、对应处理方法与样式已清理。主拍照页 `?debug=local-face` 还必须显式启用 cameraDebug,当前配置为 false。
“重新开启相机”是实际失败后的恢复入口,不是测试按钮:只在真实错误/权限提示下显示,登录校验、初次启动、预览、拍摄完成过渡和报告处理中隐藏;点击重试时先清除旧错误,失败再恢复提示。没有增加自动重试,也没有改变相机方向、角度、识别后端、照片顺序、亮度门限或 1.5 秒新鲜度限制。
# 淘宝小程序开发说明
当前完成本地开发闭环:授权登录 → 基础档案 → 正/右/左三步手动拍摄或相册选图 → 服务端检测 → 七项报告 → 最近5份历史报告。真实淘宝授权、云网关、相机和图片组件仍需在关联 AppKey 的淘宝 IDE 与真机中验收。
用户已确认自动三连拍在当前手机通过,正式业务配置已恢复 `photoInputMode: 'camera'`,使用与测试页相同的识别和自动拍照逻辑;原相册入口保留。UI 和交互保持原样,不新增上传面板。若需独立排查选图上传,仍可临时设为 `'upload'` 跳过实时识别。淘宝专用 Node 已备份并更新新版游客及幂等接口,云服务访问异常及实际手机三图报告仍需验收。
正式入口:登录/游客 → 开始检测 → 原隐私弹窗 → 档案 → 拍照引导的“开始” → 自动三连拍 → 上传及报告。首次使用时衔接平台相机授权,无需先进入开发测试页。游客和正式账号的多页面集成回归均已覆盖,测试服务响应不代表真实云网关已通过。
页面按 H5 还原:报告检测图区域支持左右跟手滑动切换页卡,标签和高度同步;点检测图可放大,再点关闭;档案肤质卡支持跟手横滑、箭头和圆点联动。正常页面隐藏开发诊断入口,主页保留“开始检测/我的报告”,报告页不附加额外按钮。专项诊断时可在开发环境临时启用 `cameraDebug:true` 显示测试入口。
当前适配:独立登录首屏(淘宝账号/游客)→ 基础档案 → 正/右/左三步拍摄或相册选图 → 检测等待 → 七项报告 → 对应商品。正式淘宝账号保留最近 5 份历史;游客会话、档案和报告仅本次内存使用。商品 I/II/III 统一打开 `1090510453077`,推荐规则沿用 H5。
**首版要求实时识别抓拍。** 当前默认采用普通 `camera` 前置取景及连续帧,在本机运行人脸定位和轻量 68 点模型,复用 H5 姿态及稳定性判断,倒计时后用原生 `takePhoto` 获取 JPEG。用户已验证普通相机默认前置、帧数增加、可以拍照出图;手机已识别人脸并通过正脸姿态,但优化后的 CPU 单帧仍约 4.25 秒。当前候选改用原生 WebGL 加速,需通过数值自测、预热后再处理真实帧;手机兼容性、速度和自动三连拍仍待验收。AR 路线保留为显式开发对照,其默认自拍修正尚未通过真机验证。诊断页面保留在开发源码中,已移出正式路由及上传包。诊断不登录、不上传、不产生检测费用。
本轮实施状态、验收和部署顺序见 [适配交接](../docs/TAOBAO-ADAPTATION-2026-11-02.md)。
开发版“相机测试(开发版)”现直接进入 `CAM-NATIVE-20261102-04`,点顶部“开始自动三连拍测试”可进入 `LOCAL-FACE-20261102-09`。该页复用正式取景、实时识别、正/右/左自动拍摄及 JPEG 压缩,最后只展示本页临时照片,不要求账号或档案、不上传照片、不创建检测任务。卡住时点“复制识别诊断”,只复制模型阶段、耗时、帧尺寸、计数、安全错误代码及固定合成输入的图形自测指标,不复制图像或人脸坐标。测试页不播放语音;应用自身已有的静态资源加载及账号恢复不受这个页内模式控制。
手机在旧版 `LOCAL-FACE-20261102-01` 已识别 1 脸并通过姿态,CPU 优化版 `02` 从约 10.7 秒降至 4.25 秒,仍因结果过期停止,尚未完成自动抓拍。版本 `03` 明确请求原生 WebGL1,并按淘宝官方文档开启 `window.enableSkia:"true"`;手机矩阵自测执行 4 次绘制后报 `MATMUL_FAILED`。版本 `04` 依次验证 `default`、`packed_byte`(禁用浮点下载)和 `unpacked_byte`(再禁用打包 shader),真机三档均失败。最后一档记录 5 次绘制、1 次字节读回,矩阵校验和 0/预期 2786、244 项不匹配、最大误差 22,呈现全零特征;这些聚合指标尚不能单独证明全部元素为零或确定根因,仍未处理真实相机帧。
版本 `05` 按[淘宝官方说明](https://developer.alibaba.com/docs/doc.htm?articleId=121761&docType=1&treeId=829)关闭 `readPixels` 的宿主额外翻转,并增加 2×2 原生图形检查。手机清屏读回通过,非对称 shader 有 3 项不匹配,预期校验和 2244/实际 2040;实际值与此前清屏校验和相同。这与绘制未覆盖的情况一致,也可能来自坐标、缓冲区上传或绘制状态,尚不能唯一确定根因,仍未处理真实相机帧。
版本 `06` 显式规范化 GL 绘制状态,每次绘制前清黑,先分别验证恒色 `drawArrays`/索引绘制,再验证原非对称 shader 和字节/浮点纹理。原生兼容候选按 `standard` → `buffer_copy`(将视图字节紧密拷贝为标准 `bufferData` ArrayBuffer)→ `framebuffer_y`(仅补偿已绑定 FBO 的 viewport/scissor Y 坐标)→ `combined` 自测;仅 `VALUE_MISMATCH` 允许换路,其他错误停止。四路使用同一上下文,每路结束清理自己的检查对象;TF 三档选择最多创建 3 个上下文。每条可用路径都须通过全部原生检查及后续严格矩阵/卷积自测、真实模型预热,不能按机型硬开,也不放宽 1.5 秒源帧新鲜度或回退 CPU。诊断保留原始失败阶段/代码、最终 `nativeStrategy`、固定合成的四像素及缓冲区字节数,仅初始化时缓存;真实人脸结果不能覆盖。手机实际选路、识别速度和三连拍仍待验收。
当前候选 `07` 针对真机 `06` 的 `nativeInitialStage=constant_indexed`:标准路径普通恒色绘制已通过,索引绘制失败;最后的坐标兼容黑屏不代表原始根因。原生索引调用前先重新绑定索引缓冲。若仍然是普通绘制通过而索引数值失败,则验证等价的四顶点 `TRIANGLE_STRIP` 路径;通过非对称图案、纹理、原矩阵/卷积和模型预热后才使用。仅把自有固定索引 `[0,1,2,2,1,3]`、`TRIANGLES/6/UNSIGNED_SHORT/offset0` 转换,未知签名、缓冲未确认或部分修改时明确拒绝。诊断新增 `drawStrategy=quad_strip`、实际绘制计数和 `constantQuad`;此时 `constantIndexed=not_run`,不宣称原生索引已通过。原生候选最多 8 次、仍使用同一上下文,手机效果与速度待验收。
此入口仅在 `environment: 'development'`、`captureMode: 'native-face'`、`nativeCamera: true` 时开放,允许当前 cloud 配置;`debug=local-face` 参数不能在生产环境绕过登录。原来的 `debug=camera` 手动验收仍要求显式 `cameraDebug` 开关及本机直连,不变更其范围。
## 本地启动
@@ -20,7 +48,7 @@ npm run dev:backend
代理使用固定的本地测试身份并模拟淘宝网关签名;Face++ 返回仓库内的固定样本。模拟逻辑仅在开发启动脚本中,正式服务器不会接受客户端自报 `open_id`,也没有免验签的开发登录后门。本地 H5 的微信入口不启用;微信 `/state/` 授权由独立浏览器回归验证。
`config.js` 当前是本地配置:`transport: 'direct'`、端口18180。直连传输只允许 localhost/127.0.0.1;不要把它当成淘宝正式网络方案。IDE 中导入本目录并关联自己的 AppKey。桌面模拟器是否允许访问回环地址需以实际 IDE 为准;手机上的127.0.0.1指向手机自身。
`config.js` 默认是 `cloud`/`tower`/`test`,使用聚石塔云网关。仅做本机样本联调时手动切换到 `transport: 'direct'`、`baseUrl: 'http://127.0.0.1:18180'`;结束后恢复云配置。代理为正式和游客会话签名,不绕过后端校验。手机上的 127.0.0.1 指向手机自身。
## 文件职责
@@ -30,14 +58,15 @@ npm run dev:backend
依次检查:首次授权、拒绝后相册仍可使用、授权设置恢复、前后镜头、三张方向、切后台恢复、压缩最长边≤1280。真实系统权限和相机画面仍需淘宝IDE/手机验收,自动测试仅验证代码流程。
- `pages/splash`:协议同意、淘宝授权登录、退出和历史入口。
- `pages/login`:淘宝/游客登录、协议、登录恢复,以及仅开发版显示的 AR 测试入口。
- `pages/splash`:品牌首页、开始检测、历史、退出/结束游客体验。
- `pages/profile`:账号隔离的基础信息缓存、出生年月和省市滚轮、肤质选择。
- `pages/camera`:手动三步拍摄/预览确认;离页取消、重复点击保护;单画布顺序压缩成最长边1280的 JPEG。
- `pages/camera`:普通前置相机本地实时识别、正/右/左三步引导、H5 轮廓及定位线、自动倒计时和原生 JPEG 拍摄;保留 AR 开发对照、完成卡与扫描等待页。相册路径拍后直接推进;单画布顺序压缩最长边 1280 的 JPEG;暂停后复用原分析任务,不重新收费。
- `pages/report`:服务端返回的当前评分/话术、七项分析、护理选三、精华、真实图层和坐标标注;侧脸图层异步加载。
- `pages/history`:本人最近5份完整报告。
- `utils/transport.js`:本机代理或淘宝云 SDK 传输,保留反代子路径配置。
- `utils/api.js`:账号凭据、64KiB照片分片、检测任务提交与轮询、历史报告。
- `vendor/`:从 H5 同步的评分、报告呈现、文案及哈希清单,不手工修改。
- `vendor/`:从 H5 同步的评分、报告呈现、拍照规则及哈希清单;`native-face/` 为从 H5 原始模型生成的小程序 CPU 副本,含来源记录。分别使用共享同步脚本和 `node scripts/build-native-face.cjs` 重建,不手工改生成文件。
- `scripts/`、`test/`、`docs/`:开发与回归文件,IDE 配置排除上传。
H5 规则变更后运行 `npm run sync:shared`;`npm run check:shared` 会检查逐字节一致。在线报告优先使用服务端返回的 `vm/presentation`,原始检测分数和展示肌龄分别保存。
@@ -93,7 +122,7 @@ npm run test:h5
- 真实 AppKey/cloudAppId、签名参数和授权权限包联调。
- 真实网关传输报告大图时的返回体限制与内存占用;已减少小程序 `setData` 中重复照片和非当前Tab图层。
- Android/iOS相机权限、前置相片方向、cover-view覆盖、系统相机降级、后台恢复、原生canvas及大图读取。
- UI真机对齐。当前相机使用椭圆框和左右转头提示,尚未迁移H5精确取景轮廓与实时姿态自动拍摄。
- 商品跳转尚需真实商品ID与平台能力验收;当前报告展示推荐产品,没有伪造商品链接。
- UI真机对齐。H5 取景轮廓及实时姿态自动拍摄已接入本地代码,仍需在手机验证定位、左右转头提示、识别耗时和完整三连拍。
- 商品目标已确认:`1090510453077`(1'me以我自在有势挺括双仓精华液)。仍需验证平台能力、游客/新报告/历史报告跳转及返回状态。
以上是待真实平台验收的范围,不影响已通过的本地接口与H5回归结论。
+74 -39
View File
@@ -1,83 +1,118 @@
/* ═════ 全局样式(对齐 H5 设计变量:--yellow #F5F078 / --ink #282828 / --bg #EFEFEF) ═════ */
/* ═════ 全局样式 —— 对齐 H5 h5/css/style.css 的 1'me 设计变量 ═════
单位换算:H5 的 calc(N * var(--u))(375 设计宽基准)→ 2N rpx(750rpx = 屏宽)。
H5 里固定 px 的字号保持 px 不变。
注意:淘宝小程序 acss 不支持 @font-face,字体退化为系统字体栈。 */
page {
background: #101823;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: #EFEFEF;
color: #282828;
font-size: 28rpx;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
/* ─── 主按钮(品牌黄) ─── */
/* Center against the full screen, independently of native left/right buttons. */
.brand-header { position:fixed; left:0; top:0; width:100%; box-sizing:border-box; background:#FFFFFF; color:#282828; z-index:70; pointer-events:none; }
.brand-header-line { position:relative; width:100%; display:flex; align-items:center; justify-content:center; }
.brand-header-title { display:block; width:calc(100% - 152px); text-align:center; font-size:15px; font-weight:500; line-height:22px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* ─── 主按钮:长方形 + 小倒角(H5 设计稿 181×37,圆角 5) ─── */
.btn-primary {
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
border-radius: 48rpx;
width: 362rpx;
height: 74rpx;
background: #F5F078;
border-radius: 10rpx;
color: #282828;
font-size: 32rpx;
font-weight: 600;
font-size: 14.5px;
font-weight: 500;
letter-spacing: 1rpx;
border: none;
padding: 0;
line-height: 1;
}
.btn-primary::after { border: none; }
.btn-primary.btn-disabled { opacity: 0.5; }
.btn-primary.btn-disabled { opacity: .5; }
/* ─── 白色卡片(报告页通用) ─── */
.card {
background: #FFFFFF;
border-radius: 24rpx;
margin: 24rpx 32rpx 0;
padding: 32rpx;
/* ─── 折线箭头(H5 .chev) ─── */
.chev {
width: 9px;
height: 9px;
border: solid #2C2C2C;
border-width: 0 2px 2px 0;
display: inline-block;
padding: 2px;
box-sizing: border-box;
}
.card-heading {
font-size: 32rpx;
font-weight: 600;
margin-bottom: 24rpx;
.chev.left { transform: rotate(135deg); }
.chev.right { transform: rotate(-45deg); }
.chev.down { transform: rotate(45deg); }
/* ─── 圆形返回按钮(H5 .nav-back) ─── */
.nav-back {
width: 27px;
height: 27px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #E5E5E5;
border-radius: 50%;
background: #fff;
box-sizing: border-box;
}
/* ─── 弹窗遮罩 ─── */
/* ─── 弹窗(H5 隐私协议/政策层) ─── */
.overlay {
position: fixed;
left: 0; top: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, .6);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.popup {
width: 600rpx;
background: #FFFFFF;
border-radius: 24rpx;
padding: 48rpx 40rpx 40rpx;
box-sizing: border-box;
position: relative;
width: 640rpx;
max-height: 78vh;
background: #FFFFFF;
border-radius: 14rpx;
padding: 44rpx 40rpx 36rpx;
box-sizing: border-box;
}
.popup-scroll { max-height: 52vh; }
.popup-title {
font-size: 34rpx;
font-weight: 600;
font-size: 19px;
font-weight: 700;
text-align: center;
line-height: 1.5;
line-height: 1.4;
color: #282828;
margin-bottom: 24rpx;
}
.popup-body {
font-size: 26rpx;
font-size: 13px;
color: #4A4A4A;
line-height: 1.7;
margin-bottom: 32rpx;
line-height: 1.75;
}
.popup-close {
position: absolute;
top: 24rpx;
right: 24rpx;
width: 48rpx;
height: 48rpx;
line-height: 48rpx;
top: 16rpx;
right: 16rpx;
width: 56rpx;
height: 56rpx;
line-height: 56rpx;
text-align: center;
font-size: 36rpx;
font-size: 18px;
color: #868686;
background: none;
padding: 0;
}
.popup-close::after { border: none; }
/* ─── 卡片(H5 .card) ─── */
.card {
background: #FFFFFF;
border-radius: 14rpx;
box-sizing: border-box;
}
+21 -2
View File
@@ -10,11 +10,30 @@ App({
payload: null
},
onShareAppMessage: function () {
return require('./utils/share').message();
},
onLaunch: function () {
// 预取语音清单(失败静默,不影响主流程)
require('./utils/voice.js').init();
require('./utils/api.js').resetForLaunch();
// The login screen needs no native audio player. voice.play() creates its
// context lazily when spoken guidance is actually needed.
},
onShow: function () {
var api = require('./utils/api.js');
if (api.hasSession() && !api.isGuest()) api.restore().catch(function () {});
},
onHide: function () {
require('./utils/api.js').suspendSession();
require('./utils/voice.js').stop(true);
},
// 真机上看不到 console:开发版里把未捕获异常弹出到屏幕上,方便截图定位。
onError: function (error) {
try {
var config = require('./config');
if (config.environment === 'production') return;
var text = String((error && (error.message || error.stack)) || error);
my.showToast({ content: 'JS错误: ' + text.slice(0, 120), type: 'none', duration: 8000 });
} catch (_) {}
}
});
+8 -2
View File
@@ -1,5 +1,6 @@
{
"pages": [
"pages/login/login",
"pages/splash/splash",
"pages/profile/profile",
"pages/camera/camera",
@@ -7,11 +8,16 @@
"pages/history/history"
],
"window": {
"defaultTitle": "1'me在线肌肤测试",
"defaultTitle": "​",
"enableSkia": "true",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#101823",
"pullRefresh": false,
"allowsBounceVertical": "NO"
"allowsBounceVertical": "NO",
"navigationBarForceEnable": true,
"transparentTitle": "always",
"navigationBarTextStyle": "black",
"gestureBack": "NO"
},
"permission": {
"scope.camera": {
+1 -1
View File
@@ -1,5 +1,5 @@
# 小程序内置资源
这里只放确实需要随包内置的资源。当前UI图片、推荐产品图与语音均通过 `config.js` 的 `assetBase` 加载,复用共享后端的现有素材;没有修改H5资源。
这里只放确实需要随包内置的资源。`startup/` 内置登录首屏的三张 H5 原图(约 129 KiB),避免首屏等待公网图片;图片内容、尺寸与 H5 一致。拍照遮罩等资源也随包内置。其余 UI 图片、推荐产品图与语音通过 `config.js` 的 `assetBase` 加载,复用共享后端素材;没有修改 H5 资源。
正式淘宝端需要确认资源域名权限。开发与平台验收事项见 [../README.md](../README.md)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+8
View File
@@ -0,0 +1,8 @@
<template name="brand-header">
<!-- Native transparent navigation keeps its back/menu/close controls. -->
<view class="brand-header" style="height:{{brandHeader.height}}px;padding-top:{{brandHeader.top}}px;">
<view class="brand-header-line" style="height:{{brandHeader.bar}}px;">
<text class="brand-header-title" a:if="{{brandHeader.showTitle}}">{{brandHeader.title}}</text>
</view>
</view>
</template>
+44 -8
View File
@@ -1,13 +1,49 @@
// Public configuration only. AppSecret and Face++ keys stay on the server.
//
// Cloud test for 聚石塔 SAE:
// cloudType 'tower' = 聚石塔云应用(不向网关传 domain)
// cloudType 'empty' = 空应用/外部服务(传 domain,baseUrl 必须 HTTPS)
// To go back to local direct:
// transport: 'direct', baseUrl: 'http://127.0.0.1:18180', mvpLocal: true
module.exports = {
environment: 'development',
cameraDebug: true, // Offline camera acceptance only; disabled outside local development.
transport: 'direct', // Local development. Use 'cloud' with real gateway configuration.
baseUrl: 'http://127.0.0.1:18180',
assetBase: 'http://127.0.0.1:18180/assets/',
cloudAppId: '',
cloudEnv: 'online', // External/empty cloud applications use online
basePath: '', // E.g. '/state' behind an existing reverse proxy
cameraDebug: false,
transport: 'cloud',
baseUrl: 'https://miniappcloud.taobao.com', // 'tower' 模式不参与请求;仅占位
// 真机可通过公网 HTTPS 加载的静态资源(启动页底图、肤质卡、语音、商品图)。
// 127.0.0.1 在手机上指向手机自己,会全部加载失败。
assetBase: 'https://1me.xajuzi.com/state/assets/',
cloudType: 'tower',
cloudAppId: '', // 聚石塔默认云应用可留空;多个云应用时填关联页的 cloudAppId
cloudEnv: 'test',
basePath: '',
// 当前手机已验证普通 camera 默认前置、连续帧和原生 JPEG;在本地跑
// H5 人脸模型并自动拍摄。当前用户手机已确认自动三连拍可完成。
captureMode: 'native-face',
// 正式入口使用已验证的自动三连拍;原相册入口保留。
// 如需单独排查选图上传,可临时设为 'upload' 跳过实时识别。
photoInputMode: 'camera',
// 连续识别帧使用平台支持的小尺寸,减少跨桥数据量;正式拍照仍为 high JPEG。
nativeFrameSize: 'small',
// 轻量 68 点模型;160 输入保留本地等比双脸用例的多人识别能力。
nativeFaceInputSize: 160,
nativeFaceMaxSide: 320,
nativeFaceLandmarkModel: 'tiny',
// CPU 路线真机仍约4.25s;使用经数值自测的原生WebGL,失败明确停止。
nativeFaceBackend: 'webgl',
// 仅作为没有格式标记的紧密四通道帧的默认值;显式未知格式仍报错。
nativePixelFormat: 'RGBA',
// AR 仅保留作开发对照。此值来自单次切换反馈,首次默认自拍未验收
// 通过;不把该请求值视为已确认的实际镜头方向。
arFrontPosition: 'back',
// 未标注格式且长度精确匹配四通道时使用的应用默认值;平台格式优先。
// 用户已选择直接采用默认照片颜色,开发页颜色校验不作为使用门槛。
arPixelFormat: 'RGBA',
nativeCamera: true,
mvpLocal: false,
mvpBaseUrl: 'http://127.0.0.1:8080',
mock: false,
productItemIds: {} // Optional real Taobao item IDs keyed by tier 1/2/3
// 当前三个推荐档位均进入用户确认的同一款天猫商品;评分及推荐文案仍按 H5。
productItemId: '1090510453077',
productItemIds: { 1: '1090510453077', 2: '1090510453077', 3: '1090510453077' }
};
+3
View File
@@ -0,0 +1,3 @@
https://miniapp.open.taobao.com/docV3.htm?docId=119114&docType=1&tag=dev
https://open.taobao.com/doc.htm?docId=121164&docType=1
https://miniapp.open.taobao.com/docV3.htm?spm=a219a.13922336.0.0.6e99286cfVnRmI&docId=118541&docType=1&tag=dev
+24 -3
View File
@@ -3,11 +3,32 @@
"compileType": "mini",
"miniprogramRoot": "./",
"appid": "",
"exclude": ["scripts/**", "test/**", "docs/**", "README.md", "package-lock.json"],
"uploadExclude": ["scripts/**", "test/**", "docs/**", "README.md", "package-lock.json"],
"exclude": [
"graphify-out/**",
"scripts/**",
"test/**",
"docs/**",
"README.md",
"package-lock.json",
"pages/mvp/**",
"pages/camtest/**",
"pages/camnative/**"
],
"uploadExclude": [
"graphify-out/**",
"scripts/**",
"test/**",
"docs/**",
"README.md",
"package-lock.json",
"pages/mvp/**",
"pages/camtest/**",
"pages/camnative/**"
],
"enableAppxNg": true,
"component2": true,
"transpile": {
"es6": true
}
},
"miniappCanary": false
}
+219 -110
View File
@@ -1,183 +1,292 @@
/* ═════ 拍照页样式 ═════ */
/* ═════ 05-07 拍照页 ═════
对齐 H5 h5/css/style.css 的 #s-cam 静态态:
.cam-step top 7.1%,21.5px/700,letter-spacing 1u,text-shadow
.cam-guide bottom 22.4%,14.5px,分隔线 15u 高
.cam-controls bottom 5.2%,高 62u,内边距 56u,快门 62u
单位换算:calc(N * var(--u)) → 2N rpx */
.cam {
position: relative;
width: 100%;
height: 100vh;
background: #101823;
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
overflow: hidden;
background: #000;
}
.cam-device {
width: 100%;
height: 100vh;
/* H5 静态态:白底 + 85% 黑蒙版 ≈ #262626 */
.cam-bg {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: #fff;
}
/* ─── 步骤文案 ─── */
/* ─── 顶部步骤文案 ─── */
.cam-step {
position: absolute;
top: 80rpx;
left: 0;
right: 0;
left: 0; right: 0;
top: 7.1%;
text-align: center;
color: #fff;
font-size: 21.5px;
font-weight: 700;
line-height: 1.5;
letter-spacing: 2rpx;
text-shadow: 0 1px 10px rgba(0, 0, 0, .4);
z-index: 2;
}
.cam-step-no {
color: #FFFFFF;
font-size: 48rpx;
font-weight: 600;
}
.cam-step-tip {
color: #FFFFFF;
font-size: 40rpx;
font-weight: 600;
margin-top: 8rpx;
.cam-step-tip { font-size: 21.5px; }
.cam-step-no { font-size: 21.5px; }
/* ─── 真机取景相机(铺满,不能有子节点) ─── */
.cam-device {
position: absolute;
left: 0; top: 0;
width: 100%;
height: 100%;
z-index: 0;
}
/* ─── 取景蒙版(椭圆线框) ─── */
/* H5 原始轮廓生成的透明 PNG;额外边缘遮罩保持不同比例视口的暗框。 */
.cam-mask {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
pointer-events: none;
}
.cam-oval {
width: 560rpx;
height: 720rpx;
border: 4rpx solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-sizing: border-box;
}
/* 侧脸步轮廓偏移提示(骨架版:向左/右微移示意朝向) */
.mask-1 { transform: translateX(-60rpx); } /* 左脸出镜:耳在画面左 */
.mask-2 { transform: translateX(60rpx); } /* 右脸出镜:耳在画面右 */
/* ─── 底部引导要点 ─── */
/* ─── 底部引导条 ─── */
.cam-guide {
position: absolute;
bottom: 260rpx;
left: 0;
right: 0;
left: 0; right: 0;
bottom: 22.4%;
display: flex;
justify-content: center;
align-items: center;
z-index: 2;
}
.cam-guide-item {
color: #FFFFFF;
font-size: 26rpx;
padding: 0 24rpx;
border-right: 2rpx solid rgba(255, 255, 255, 0.4);
color: #fff;
font-size: 14.5px;
font-weight: 400;
}
.cam-guide-sep {
width: 0;
height: 30rpx;
border-left: 1px solid rgba(255, 255, 255, .7);
margin: 0 52rpx;
}
.cam-guide-item:last-child { border-right: none; }
/* ─── 控制条 ─── */
/* ─── 控制条:相册 / 快门 / 翻转 ─── */
.cam-controls {
position: absolute;
bottom: 80rpx;
left: 0;
right: 0;
left: 0; right: 0;
bottom: 5.2%;
height: 124rpx;
display: flex;
align-items: center;
justify-content: space-around;
justify-content: space-between;
padding: 0 112rpx;
box-sizing: border-box;
margin-bottom: env(safe-area-inset-bottom);
z-index: 2;
}
.cam-side {
width: 120rpx;
height: 88rpx;
line-height: 88rpx;
text-align: center;
color: #FFFFFF;
font-size: 28rpx;
}
.cam-shutter {
width: 144rpx;
height: 144rpx;
border: 6rpx solid #FFFFFF;
border-radius: 50%;
width: 60rpx;
height: 124rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14.5px;
}
.cam-shutter {
width: 124rpx;
height: 124rpx;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.cam-shutter-dot {
width: 108rpx;
height: 108rpx;
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: #F5F078;
background: #fff;
}
/* ─── 停顿卡 ─── */
.cam-done {
.cam-side image { width:60rpx; height:60rpx; }
.cam-side-switch { position:relative; }
.cam-switch-label { position:absolute; top:calc(50% + 34rpx); left:50%; transform:translateX(-50%); white-space:nowrap; font-size:10px; line-height:1.3; }
.cam-verifying { position:absolute; top:0; right:0; bottom:0; left:0; z-index:100; display:flex; flex-direction:column; align-items:center; justify-content:center; background:#101823; color:#fff; font-size:14px; gap:32rpx; }
.cam-shutter-auto { visibility:hidden; }
.cam-live-hint { position:absolute; bottom:27%; left:24rpx; right:24rpx; text-align:center; color:#fff; font-size:17px; font-weight:500; z-index:3; }
.cam-live-hint.good { color:#34C759; }
.cam-shutter image { width:124rpx; height:124rpx; }
.cam-mask-image { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); }
.cam-mask-image.mirror { transform:translate(-50%,-50%) scaleX(-1); }
.cam-live-scan { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); overflow:hidden; -webkit-mask-size:100% 100%; mask-size:100% 100%; }
.cam-live-scan.mirror { transform:translate(-50%,-50%) scaleX(-1); }
.scan-front { -webkit-mask-image:url('../../assets/camera/hole-front.png'); mask-image:url('../../assets/camera/hole-front.png'); }
.scan-right { -webkit-mask-image:url('../../assets/camera/hole-right.png'); mask-image:url('../../assets/camera/hole-right.png'); }
.scan-left { -webkit-mask-image:url('../../assets/camera/hole-left.png'); mask-image:url('../../assets/camera/hole-left.png'); }
.cam-live-band { position:absolute; top:10%; left:0; right:0; height:12%; background:linear-gradient(to bottom,rgba(245,240,120,0),rgba(245,240,120,.32),rgba(245,240,120,.95)); animation:camera-scan 2.2s ease-in-out infinite; }
@keyframes camera-scan { from {transform:translateY(0);} to {transform:translateY(440%);} }
.cam-mask-edge { position:absolute; background:rgba(0,0,0,.85); }
.edge-top { top:0; left:0; right:0; } .edge-bottom { bottom:0; left:0; right:0; }
.edge-left { left:0; top:0; bottom:0; } .edge-right { right:0; top:0; bottom:0; }
.cam-next { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:460rpx; box-sizing:content-box; padding:44rpx 32rpx 40rpx; border-radius:24rpx; background:rgba(0,0,0,.72); color:#fff; text-align:center; z-index:5; }
.cam-next-ok { margin:0 auto 24rpx; border-radius:50%; width:80rpx; height:80rpx; line-height:80rpx; background:#34C759; font-size:26px; }
.cam-next-done { font-size:17px; font-weight:500; }
.cam-next-tip { margin-top:16rpx; white-space:pre-line; font-size:14px; line-height:1.5; }
.cam-countdown { position:absolute; top:45%; left:50%; transform:translate(-50%,-50%); color:#fff; font:600 88px/1 Arial,sans-serif; z-index:8; }
.cam-fill { position:absolute; top:0; left:0; right:0; bottom:0; background:#fff; z-index:9; }
.cam-enable { position:absolute; bottom:16%; left:50%; transform:translateX(-50%); padding:16rpx 32rpx; border:1px solid #fff; border-radius:8rpx; color:#fff; z-index:4; }
.cam-settings { position:absolute; top:24rpx; left:24rpx; color:#fff; font-size:12px; z-index:4; }
.compress-canvas { position:fixed; left:200vw; top:0; pointer-events:none; }
/* ─── 提示/错误 ─── */
.cam-hint {
position: absolute;
top: 45%;
left: 60rpx;
right: 60rpx;
background: rgba(0, 0, 0, 0.65);
color: #FFFFFF;
font-size: 28rpx;
left: 64rpx;
right: 64rpx;
bottom: 34%;
text-align: center;
border-radius: 16rpx;
padding: 24rpx;
color: rgba(255, 255, 255, .9);
font-size: 13px;
line-height: 1.6;
z-index: 3;
}
/* ─── 拍后预览 ─── */
.cam-preview {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: #000000;
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
}
.cam-preview-img {
width: 100%;
height: 78%;
height: 68%;
}
.cam-preview-btns {
position: absolute;
bottom: 80rpx;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
gap: 40rpx;
margin-top: 40rpx;
}
.cam-preview-btn {
width: 280rpx;
height: 88rpx;
line-height: 88rpx;
width: 220rpx;
height: 72rpx;
line-height: 72rpx;
text-align: center;
border-radius: 44rpx;
border: 2rpx solid #FFFFFF;
color: #FFFFFF;
font-size: 30rpx;
border-radius: 10rpx;
border: 1px solid rgba(255, 255, 255, .6);
color: #fff;
font-size: 14.5px;
}
.cam-preview-btn.ok {
background: #F5F078;
border-color: #F5F078;
color: #282828;
font-weight: 600;
}
/* ─── 分析中 ─── */
.cam-analyzing {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.75);
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 20;
}
.cam-analyzing-text {
color: #FFFFFF;
font-size: 30rpx;
color: #fff;
font-size: 17px;
letter-spacing: 1rpx;
padding:0 40rpx;
text-align:center;
z-index:2;
}
.cam-cancel {
position:absolute; bottom:20rpx; left:0; right:0; text-align:center;
color: rgba(255, 255, 255, .7);
font-size: 13px;
text-decoration: underline;
}
.scan-stage { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }
.scan-artwork { width:100%; height:100%; }
.scan-face { position:absolute; left:0; top:0; right:0; bottom:0; -webkit-mask-image:url('../../assets/camera/scan-face-mask.png'); mask-image:url('../../assets/camera/scan-face-mask.png'); -webkit-mask-size:100% 100%; mask-size:100% 100%; 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%); animation:report-scan 1s ease-in-out infinite; }
@keyframes report-scan { 0%,100% {transform:translateY(0);} 50% {transform:translateY(166%);} }
.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; }
.scan-flip-icon image { width:83.333333%; height:83.333333%; }
.analysis-stage { position:absolute; bottom:100rpx; left:32rpx; right:32rpx; text-align:center; color:#bbb; font-size:12px; }
.analysis-retry,.analysis-retake { color:#fff; font-size:14px; padding:20rpx 36rpx; margin-top:24rpx; border:1px solid #777; border-radius:10rpx; z-index:2; }
/* ─── 隐藏画布(压缩降级用) ─── */
.compress-canvas {
position: fixed;
left: -9999rpx;
top: -9999rpx;
width: 640px;
height: 640px;
/* ─── 权限诊断(开发版) ─── */
.cam-diag-entry {
position: absolute;
top: 88rpx;
right: 32rpx;
z-index: 30;
padding: 12rpx 20rpx;
border-radius: 12rpx;
background: rgba(0, 0, 0, .55);
color: #fff;
font-size: 12px;
}
.cam-analyzing { flex-direction: column; }
.cam-cancel { margin-top: 48rpx; color: #fff; font-size: 26rpx; padding: 24rpx; }
.camera-permission { padding: 56rpx 40rpx; color: #fff; line-height: 1.8; }
.camera-permission button { margin-top: 24rpx; }
.debug-banner { padding: 24rpx; background: #F5F078; color: #282828; font-size: 24rpx; }
.debug-results { padding: 32rpx; background: #101823; color: white; line-height: 1.8; }
.debug-title { font-size: 32rpx; font-weight: 600; }
.debug-result { margin: 24rpx 0; }
.debug-result image { display: block; width: 100%; height: 320rpx; }
.debug-results button { margin-top: 24rpx; }
.cam-diag {
position: absolute;
left: 32rpx; right: 32rpx;
top: 160rpx; bottom: 160rpx;
z-index: 40;
background: rgba(6, 16, 26, .96);
border: 1rpx solid rgba(120, 200, 255, .35);
border-radius: 20rpx;
padding: 20rpx;
display: flex;
flex-direction: column;
}
.cam-diag-scroll { flex: 1; }
.cam-diag-text {
color: #9fd0ff;
font-size: 11px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
.cam-diag-close {
margin-top: 16rpx;
text-align: center;
background: #F5F078;
color: #282828;
border-radius: 12rpx;
padding: 14rpx 0;
font-size: 13px;
}
.cam-probe-toolbar { position:fixed; z-index:50; left:24rpx; right:24rpx; padding:14rpx 18rpx; border-radius:12rpx; background:rgba(12,39,56,.94); color:#fff; }
.cam-probe-version { margin-bottom:10rpx; font-size:22rpx; line-height:30rpx; }
.cam-probe-actions { display:flex; gap:14rpx; }
.cam-probe-actions button { flex:1; margin:0; padding:0 8rpx; font-size:25rpx; line-height:64rpx; background:#F5F078; color:#142632; }
/* ─── 调试模式结果 ─── */
.cam-debug {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: #fff;
padding: 88rpx 40rpx 40rpx;
box-sizing: border-box;
overflow-y: auto;
z-index: 25;
}
.cam-debug-title { font-size: 17px; font-weight: 600; margin-bottom: 16rpx; }
.cam-debug-note { font-size: 12px; color: #707070; line-height: 1.6; margin-bottom: 24rpx; }
.cam-debug-item { margin-bottom: 32rpx; }
.cam-debug-img { width: 100%; height: 320rpx; background: #f2f2f2; }
.cam-debug-meta { font-size: 12px; color: #4A4A4A; margin-top: 12rpx; }
.cam-debug-btn { width: 100%; margin-top: 8rpx; }
.cam-debug-exit { margin-top: 24rpx; text-align: center; font-size: 13px; color: #707070; text-decoration: underline; }
+109 -67
View File
@@ -1,77 +1,119 @@
<!-- ═════ 05-07 拍照页 ═════
覆盖在 camera 原生组件上的一切必须用 cover-view / cover-image -->
<view class="cam">
<view a:if="{{debugMode && !cameraEnabled}}" class="debug-banner">相机验收 · 免登录 · 照片只在本页处理,不上传</view>
<view a:if="{{debugComplete}}" class="debug-results">
<view class="debug-title">三步拍摄与 JPEG 压缩已完成</view>
<view>请核对实际照片方向是否为正脸、右脸、左脸;本页不判断姿态是否合格。</view>
<view class="debug-result" a:for="{{debugPhotos}}" a:key="key">
<image src="{{item.photo}}" mode="aspectFit" />
<view>{{item.title}} · JPEG {{item.width}}×{{item.height}} · 约{{item.kilobytes}}KB</view>
</view>
<button class="btn-primary" onTap="onDebugRestart">重新验收</button>
<button onTap="onCancel">退出并清空本页照片</button>
<import src="../../components/brand-header.axml" />
<template is="brand-header" data="{{brandHeader: brandHeader}}" />
<!-- H5 三步取景。普通前置帧在本地识别人脸并自动抓拍,AR 为显式可选分支。
所有覆盖层为兄弟节点。 -->
<view class="cam" style="top:{{brandHeader.height}}px;">
<view class="cam-verifying" a:if="{{identityPending}}">
<view>{{identityError || '正在确认登录状态…'}}</view>
<view a:if="{{identityError}}" onTap="onRetryIdentity">重试</view>
</view>
<view a:if="{{!cameraEnabled && !debugComplete}}" class="camera-permission">
<view>{{stepText}} · {{stepTip}}</view>
<view>{{permissionMessage}}</view>
<button class="btn-primary" onTap="onEnableCamera">开启相机</button>
<button onTap="onOpenSettings">授权设置</button>
<button onTap="onSystemCamera">使用系统相机</button>
<button onTap="onAlbum">从相册选择</button>
<view a:if="{{previewUrl}}">
<image src="{{previewUrl}}" mode="aspectFit" style="width:100%;height:400rpx;" />
<button onTap="onRetake">重新选择</button><button class="btn-primary" onTap="onConfirm">使用照片</button>
</view>
<view a:if="{{analyzing}}">{{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}}</view>
<!-- ①真机取景:相机铺满,不加任何子节点 -->
<ar-camera a:if="{{cameraEnabled && useAR}}" id="skin-ar-camera-{{cameraSession}}" class="cam-device" data-camera-session="{{cameraSession}}"
devicePosition="{{arDevicePosition}}" mode="faceDetection2D" useCapturedImage="{{true}}"
onInit="onARInit" onARFrame="onARFrame" onError="onCameraError" onStop="onCameraStop" />
<camera a:if="{{cameraEnabled && !useAR}}" id="{{nativeCameraId}}" class="cam-device" data-camera-session="{{cameraSession}}"
mode="normal" device-position="{{devicePosition}}" frame-size="{{nativeFrameSize}}" flash="off"
onError="onCameraError" onStop="onCameraStop" />
<!-- ②系统相机兜底:H5 相机静态态(白底 + 85% 黑蒙版 ≈ #262626) -->
<view class="cam-bg" a:if="{{!cameraEnabled}}" />
<!-- PNGs are rasterized from the exact H5 SVG paths, including feature lines. -->
<view class="cam-mask">
<view class="cam-mask-edge edge-top" style="height:{{maskInsetY}}px" />
<view class="cam-mask-edge edge-bottom" style="height:{{maskInsetY}}px" />
<view class="cam-mask-edge edge-left" style="width:{{maskInsetX}}px" />
<view class="cam-mask-edge edge-right" style="width:{{maskInsetX}}px" />
<image class="cam-mask-image {{cameraEnabled && devicePosition === 'back' && maskStep ? 'mirror' : ''}}" style="{{maskStageStyle}}" src="../../assets/camera/mask-{{maskName}}{{cameraEnabled ? '-live' : ''}}.png" mode="scaleToFill" />
<view a:if="{{cameraEnabled && autoCapture && !doneTip && !analyzing}}" class="cam-live-scan scan-{{maskName}} {{devicePosition === 'back' && maskStep ? 'mirror' : ''}}" style="{{maskStageStyle}}"><view class="cam-live-band" /></view>
</view>
<camera a:if="{{cameraEnabled}}" id="skin-camera" class="cam-device" device-position="{{devicePosition}}"
flash="off" onError="onCameraError" onStop="onCameraStop">
<!-- 顶部步骤文案 -->
<cover-view class="cam-step">
<cover-view a:if="{{debugMode}}">开发调试 · 不上传照片</cover-view>
<cover-view class="cam-step-no">{{stepText}}</cover-view>
<cover-view class="cam-step-tip">{{stepTip}}</cover-view>
</cover-view>
<!-- 取景蒙版:人脸轮廓(骨架版用椭圆线框,朝向提示随步切换;精确轮廓 TODO:换 cover-image 轮廓图) -->
<cover-view class="cam-mask">
<cover-view class="cam-oval mask-{{maskStep}}"></cover-view>
</cover-view>
<view class="cam-next" a:if="{{doneTip && !analyzing}}">
<view class="cam-next-ok">✓</view>
<view class="cam-next-done">{{doneTip}}</view>
<view class="cam-next-tip">{{nextTip}}</view>
</view>
<view class="cam-countdown" a:if="{{countdown}}">{{countdown}}</view>
<view class="cam-fill" a:if="{{fillLight}}" />
<!-- 底部引导要点 -->
<cover-view class="cam-guide">
<cover-view class="cam-guide-item" a:for="{{guides}}" a:key="*this">{{item}}</cover-view>
</cover-view>
<view class="cam-step">
<view class="cam-step-no">{{stepText}}</view>
<view class="cam-step-tip">{{stepTip}}</view>
</view>
<!-- 控制条:相册 / 快门 / 翻转 -->
<cover-view class="cam-controls">
<cover-view class="cam-side" onTap="onAlbum">相册</cover-view>
<cover-view class="cam-shutter" onTap="onShutter">
<cover-view class="cam-shutter-dot"></cover-view>
</cover-view>
<cover-view class="cam-side" onTap="onFlip">翻转</cover-view>
</cover-view>
<view class="cam-guide">
<block a:for="{{guides}}" a:key="*this">
<view a:if="{{index > 0}}" class="cam-guide-sep" />
<text class="cam-guide-item">{{item}}</text>
</block>
</view>
<!-- 本步完成停顿卡 -->
<cover-view class="cam-done" a:if="{{doneTip}}">{{doneTip}}</cover-view>
<!-- 拍后预览:确认使用 / 重拍 -->
<view class="cam-preview" a:if="{{previewUrl}}" catchTap="noop">
<image class="cam-preview-img" src="{{previewUrl}}" mode="aspectFit" />
<view class="cam-preview-btns">
<view class="cam-preview-btn" onTap="onRetake">重拍</view>
<view class="cam-preview-btn ok" onTap="onConfirm">使用照片</view>
</view>
</view>
<!-- 拍后预览:确认使用 / 重拍 -->
<cover-view class="cam-preview" a:if="{{previewUrl}}" catchTap="noop">
<cover-image class="cam-preview-img" src="{{previewUrl}}"></cover-image>
<cover-view class="cam-preview-btns">
<cover-view class="cam-preview-btn" onTap="onRetake">重拍</cover-view>
<cover-view class="cam-preview-btn ok" onTap="onConfirm">使用照片</cover-view>
</cover-view>
</cover-view>
<!-- 分析中 -->
<view class="cam-analyzing" a:if="{{analyzing || analysisError}}">
<view class="scan-stage" style="{{scanStageStyle}}" a:if="{{!debugMode && !analysisError}}">
<image class="scan-artwork" src="../../assets/camera/scan-base.png" mode="scaleToFill" onError="onScanError" />
<view class="scan-face"><view class="scan-beam" /></view>
<view class="scan-flip-icon"><image src="../../assets/camera/icon-switch-camera.png" mode="aspectFit" /></view>
</view>
<view class="cam-analyzing-text" a:if="{{debugMode || analysisError || scanError}}">{{analysisError || (debugMode ? '正在验证本地图片压缩…' : '检测中…')}}</view>
<view class="analysis-stage" a:if="{{!analysisError}}">{{analysisStage}}</view>
<view class="analysis-retry" a:if="{{analysisError}}" onTap="onRetryAnalysis">重试查看本次检测</view>
<view class="analysis-retake" a:if="{{analysisError}}" onTap="onRestart">重新拍摄</view>
<view class="cam-cancel" onTap="onCancel">{{debugMode ? '退出验收' : '取消'}}</view>
</view>
<!-- 分析中 -->
<cover-view class="cam-analyzing" a:if="{{analyzing}}">
<cover-view class="cam-analyzing-text">{{debugMode ? '正在验证本地图片压缩…' : '正在生成您的肌肤报告…'}}</cover-view>
<cover-view class="cam-cancel" onTap="onCancel">{{debugMode ? '退出验收' : '返回,稍后查看历史报告'}}</cover-view>
</cover-view>
</camera>
<!-- 底部控制条:相册 / 快门 / 翻转 -->
<view class="cam-controls" a:if="{{!previewUrl && !analyzing && !analysisError && !doneTip}}">
<view class="cam-side" onTap="onAlbum"><image src="../../assets/camera/icon-album.png" mode="aspectFit" /></view>
<view class="cam-shutter {{autoCapture && cameraEnabled ? 'cam-shutter-auto' : ''}}" onTap="onShutter">
<image src="../../assets/camera/icon-shutter.png" mode="aspectFit" />
</view>
<view class="cam-side cam-side-switch" onTap="onFlip"><image src="../../assets/camera/icon-switch-camera.png" mode="aspectFit" /><text class="cam-switch-label">切换镜头</text></view>
</view>
<!-- 压缩降级用的隐藏画布(canvas 重绘压缩,不可见) -->
<canvas id="compress-canvas" class="compress-canvas" width="{{compressWidth}}" height="{{compressHeight}}" style="width:{{compressWidth}}px;height:{{compressHeight}}px;" />
<!-- 提示/错误文案 -->
<view class="cam-hint" a:if="{{permissionMessage && !previewUrl && !analyzing}}">{{permissionMessage}}</view>
<view class="cam-live-hint {{liveGood ? 'good' : ''}}" a:if="{{cameraEnabled && autoCapture && !doneTip && !analyzing}}">{{liveHint}}</view>
<view class="cam-enable" a:if="{{cameraSupported && !cameraEnabled && !identityPending && !previewUrl && !doneTip && !debugComplete && !analyzing && !analysisError && (camError || permissionMessage)}}" onTap="onEnableCamera">重新开启相机</view>
<view class="cam-settings" a:if="{{permissionMessage && !analyzing}}" onTap="onOpenSettings">授权设置</view>
<!-- Must remain mounted and laid out; display:none/hidden causes empty exports on phones. -->
<canvas id="compress-canvas" canvas-id="compress-canvas" width="{{compressWidth}}" height="{{compressHeight}}" class="compress-canvas" style="width:{{compressWidth}}px;height:{{compressHeight}}px" />
<!-- 开发版:权限诊断 -->
<view class="cam-probe-toolbar" a:if="{{localFaceProbe}}" style="top:{{probeToolbarTop}}px;">
<view class="cam-probe-version">自动三连拍验收 · {{captureProbeVersion}}</view>
<view class="cam-probe-actions">
<button size="mini" onTap="onCopyCaptureDiagnostics">复制识别诊断</button>
<button size="mini" onTap="onCancel">退出验收</button>
</view>
</view>
<view class="cam-diag-entry" a:if="{{showDiagnose && !localFaceProbe}}" onTap="onDiagnose">权限诊断</view>
<view class="cam-diag" a:if="{{diagText}}" style="{{localFaceProbe ? 'top:' + (probeToolbarTop + 96) + 'px;' : ''}}" catchTap="noop">
<scroll-view class="cam-diag-scroll" scroll-y>
<text class="cam-diag-text" selectable="{{true}}">{{diagText}}</text>
</scroll-view>
<view class="cam-diag-close" onTap="onCloseDiagnose">关闭</view>
</view>
<!-- 调试模式结果页 -->
<view class="cam-debug" a:if="{{debugComplete}}" style="{{localFaceProbe ? 'padding-top:' + (probeToolbarTop + 96) + 'px;' : ''}}">
<view class="cam-debug-title">三步拍摄与 JPEG 压缩已完成</view>
<view class="cam-debug-note">{{localFaceProbe ? '三张照片已通过实时识别自动拍摄;请核对正脸、右脸、左脸顺序和实际方向。照片只在本页临时预览,不上传、不生成报告。' : '请核对实际照片方向是否为正脸、右脸、左脸;本页不判断姿态是否合格。'}}</view>
<view class="cam-debug-item" a:for="{{debugPhotos}}" a:key="key">
<image class="cam-debug-img" src="{{item.photo}}" mode="aspectFit" />
<view class="cam-debug-meta">{{item.title}} · JPEG {{item.width}}×{{item.height}} · 约{{item.kilobytes}}KB</view>
</view>
<button class="btn-primary cam-debug-btn" onTap="onDebugRestart">重新验收</button>
<view class="cam-debug-exit" onTap="onCancel">退出并清空本页照片</view>
</view>
</view>
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,6 +1,10 @@
{
"defaultTitle": "1'me在线肌肤测试",
"titleBarColor": "#101823",
"defaultTitle": "​",
"titleBarColor": "#FFFFFF",
"backgroundColor": "#101823",
"disableScroll": true
"disableScroll": true,
"navigationBarTextStyle": "black",
"navigationBarForceEnable": true,
"transparentTitle": "always",
"gestureBack": "NO"
}
+21
View File
@@ -0,0 +1,21 @@
page { background:#f1f4f7; color:#16212c; }
.native-toolbar { position:fixed; top:112px; left:0; right:0; z-index:1000; height:144rpx; padding:12rpx 24rpx; box-sizing:border-box; background:#fff; border-bottom:1px solid #ced9e2; }
.native-toolbar-auto { height:228rpx; }
.native-auto-entry { margin:0 0 12rpx; padding:0 12rpx; height:68rpx; line-height:64rpx; background:#126597; color:#fff; font-size:28rpx; font-weight:600; }
.native-toolbar-version { margin-bottom:10rpx; font-size:22rpx; line-height:30rpx; color:#466172; }
.native-toolbar-actions { display:flex; gap:14rpx; }
.native-toolbar-actions button { flex:1; margin:0; font-size:26rpx; line-height:68rpx; }
.native-toolbar-actions .native-copy { background:#126597; color:#fff; font-weight:600; }
.native-test { padding:172rpx 24rpx 64rpx; padding-top:calc(112px + 172rpx); }
.native-title { font-size:36rpx; font-weight:600; }
.native-note { margin:14rpx 0; font-size:24rpx; line-height:1.65; color:#596774; }
.native-preview { height:560rpx; margin:24rpx 0; background:#161d25; border-radius:12rpx; overflow:hidden; }
.native-camera { display:block; width:100%; height:100%; }
.native-empty { padding:120rpx 28rpx; color:#d6e0e8; text-align:center; }
.native-actions { display:flex; flex-wrap:wrap; gap:14rpx; }
.native-actions button { margin:0 0 12rpx; font-size:25rpx; }
.native-card { margin-top:20rpx; padding:22rpx; border-radius:12rpx; background:#fff; font-size:25rpx; line-height:1.75; word-break:break-all; }
.native-status { margin:12rpx 0; color:#165b8c; font-weight:600; }
.native-meta { margin-top:12rpx; font-family:monospace; font-size:23rpx; }
.native-diagnostic-text { display:block; white-space:pre-wrap; font-family:monospace; font-size:23rpx; line-height:1.65; user-select:text; -webkit-user-select:text; }
.native-photo { display:block; width:100%; height:540rpx; margin:18rpx 0; background:#e6eaee; }
+53
View File
@@ -0,0 +1,53 @@
<view class="native-toolbar {{showAutoTest ? 'native-toolbar-auto' : ''}}" style="top:{{toolbarTop}}px;">
<view class="native-toolbar-version">普通相机 · {{probeVersion}}</view>
<button a:if="{{showAutoTest}}" class="native-auto-entry" onTap="onOpenAutoCapture" disabled="{{photoBusy}}">开始自动三连拍测试</button>
<view class="native-toolbar-actions">
<button class="native-copy" size="mini" type="primary" onTap="onCopyDiagnostics">复制诊断信息</button>
<button size="mini" onTap="onGenerateDiagnostics">生成可选文字</button>
</view>
</view>
<view class="native-test" style="padding-top:calc({{toolbarTop}}px + {{showAutoTest ? 256 : 172}}rpx);">
<view class="native-title">普通 camera · 独立诊断</view>
<view class="native-note">仅验证前后镜头、真实帧和手动拍照;不是正式三连拍替代。照片仅本页临时预览,不登录、不上传、不做检测。</view>
<view a:if="{{showAutoTest}}" class="native-note">上方“开始自动三连拍测试”将进入正式取景界面的本地验收,验证识别、转头提示、自动拍摄和压缩,不上传照片、不创建检测任务。</view>
<view class="native-card native-diagnostic">
<view class="native-status">{{copyStatus || '点击顶部按钮生成当前脱敏诊断'}}</view>
<view class="native-note">生成或复制时会刷新实时计数。请求镜头不代表实际镜头;内容不含照片路径、像素、人脸坐标、账号或原始错误文字。</view>
<text a:if="{{diagnosticText}}" class="native-diagnostic-text" selectable="{{true}}">{{diagnosticText}}</text>
<view a:else class="native-note">当前尚未生成。即使没有剪贴板 API,也可点击“生成可选文字”后长按文字选择复制。</view>
</view>
<view class="native-preview">
<camera a:if="{{cameraEnabled}}" id="native-camera-{{cameraSession}}" data-camera-session="{{cameraSession}}" class="native-camera"
mode="normal" device-position="{{devicePosition}}" frame-size="medium" flash="off"
onReady="onNativeReady" onError="onNativeError" onStop="onNativeStop" />
<view a:else class="native-empty">相机已卸载,可点重新打开</view>
</view>
<view class="native-actions">
<button size="mini" onTap="onFlip" disabled="{{photoBusy}}">切换到{{devicePosition === 'front' ? '后置' : '前置'}}</button>
<button size="mini" onTap="onRetryCamera" disabled="{{photoBusy}}">重新打开并监听</button>
<button size="mini" onTap="onStopFrames">停止帧监听</button>
<button size="mini" onTap="onTakePhoto" disabled="{{photoBusy || !cameraEnabled}}">{{photoBusy ? '拍照处理中' : '手动拍一张'}}</button>
</view>
<view class="native-card">
<view>请求镜头:{{devicePosition}} · 会话 {{cameraSession}}</view>
<view>{{cameraStatus}}</view>
<view>onReady 事件:{{nativeReady ? '收到' : '未收到'}}</view>
<view>{{contextStatus}}</view>
<view class="native-status">{{frameStatus}}</view>
<view>start:{{startStatus}}</view><view>stop:{{stopStatus}}</view>
<view>原始帧 {{frameCount}} · 回调 {{frameCallbacks}} · 非标准回调 {{invalidFrameCount}}</view>
<view a:if="{{firstFrameMs}}">首帧等待:{{firstFrameMs}}</view>
<view a:if="{{frameInfo}}" class="native-meta">{{frameInfo}}</view>
<view class="native-note">帧计数只检查正数尺寸和非空字节,不假定 RGBA,也不代表已经得到人脸关键点。</view>
</view>
<view class="native-card">
<view>{{photoStatus}}</view>
<block a:if="{{photoPath}}"><view>拍照时请求镜头:{{photoPosition}}</view>
<image class="native-photo" src="{{photoPath}}" mode="aspectFit" onError="onPreviewError" />
<button size="mini" onTap="onClearPhoto">清除本页预览</button>
</block>
</view>
<view class="native-card">
<button size="mini" onTap="onOpenARProbe" disabled="{{photoBusy}}">AR 相机对照(可选)</button>
</view>
</view>
+269
View File
@@ -0,0 +1,269 @@
var Diagnostics = require('../../utils/ar-camera').diagnostics;
var development = require('../../utils/development');
var PROBE_VERSION = 'CAM-NATIVE-20261102-04';
var KNOWN_FORMATS = ['RGBA', 'RGBA8888', 'RGB', 'RGB888', 'RGBX', 'BGRA', 'BGRA8888', 'BGR', 'BGR888',
'ARGB', 'ARGB8888', 'ABGR', 'ABGR8888', 'NV21', 'NV12', 'YUV420', 'YUV420P', 'YUV420SP', 'I420', 'YV12', 'JPEG', 'JPG', 'PNG'];
function message(error, fallback) {
var value = error && (error.errorMessage || error.errMsg || error.message);
return typeof value === 'string' ? value.slice(0, 160) : fallback;
}
function positive(value) { return typeof value === 'number' && isFinite(value) && value > 0; }
function frameMeta(frame) {
frame = frame || {};
var data = frame.data, bytes = data && data.byteLength;
var meta = { width: positive(frame.width) ? frame.width : 0, height: positive(frame.height) ? frame.height : 0,
dataType: Object.prototype.toString.call(data), byteLength: positive(bytes) ? bytes : 0 };
if (typeof frame.timestamp === 'number' && isFinite(frame.timestamp)) meta.timestamp = frame.timestamp;
if (typeof frame.pixelFormat === 'string') meta.pixelFormat = frame.pixelFormat.slice(0, 40);
if (typeof frame.format === 'string') meta.format = frame.format.slice(0, 40);
return meta;
}
Page({
data: {
probeVersion: PROBE_VERSION, toolbarTop: 112, showAutoTest: false, diagnosticText: '', copyStatus: '',
cameraEnabled: false, cameraSession: 0, devicePosition: 'front', nativeReady: false,
cameraStatus: '正在打开普通 camera', contextStatus: '尚未创建上下文',
frameStatus: '尚未监听', startStatus: '尚未调用', stopStatus: '尚未调用',
frameCallbacks: 0, frameCount: 0, invalidFrameCount: 0, firstFrameMs: '', frameInfo: '',
photoBusy: false, photoStatus: '仅在点击拍照时生成临时照片', photoPath: '', photoPosition: ''
},
onLoad: function () {
this.alive = true; this.visible = false; this.pageReady = false;
this.generation = 0; this.photoOperation = null;
this.diagnosticSystem = Diagnostics.system(my); this.copySequence = 0;
this.setData({ toolbarTop: Diagnostics.toolbarTop(my), showAutoTest: development.nativeFaceProbeEnabled() });
this.contextCapabilities = { takePhoto: false, onCameraFrame: false, listenerStart: false, listenerStop: false };
this.listenerResults = { start: { state: 'not_called', code: 'unavailable' }, stop: { state: 'not_called', code: 'unavailable' } };
this.photoAttempted = false; this.photoResultSuccess = false; this.photoReturnedPath = false;
},
onReady: function () { this.pageReady = true; this.mountCamera(); },
onShow: function () {
this.visible = true;
if (this.photoOperation && this.photoOperation.timedOut) this.setData({ photoBusy: true, photoStatus: '拍照10秒未返回;等待原操作结束,或退出此诊断页后重试' });
this.mountCamera();
},
onHide: function () {
this.visible = false; this.disposeCamera();
this.setData({ cameraEnabled: false, photoBusy: !!this.photoOperation, cameraStatus: '页面已隐藏,相机已卸载' });
},
onUnload: function () {
this.visible = false; this.alive = false; this.disposeCamera();
if (this.photoOperation) clearTimeout(this.photoOperation.timer);
this.photoOperation = null; this.data.photoPath = '';
},
current: function (generation) { return this.alive && this.visible && this.data.cameraEnabled && this.generation === generation; },
diagnosticReport: function () {
var caps = this.contextCapabilities || {}, results = this.listenerResults || {}, meta = this.lastDiagnosticFrame;
var lines = [PROBE_VERSION].concat(this.diagnosticSystem || []);
lines.push('requestedPosition=' + (this.data.devicePosition === 'front' || this.data.devicePosition === 'back' ? this.data.devicePosition : 'unknown'));
lines.push('actualPosition=not_verified; requestedPosition does not identify the actual lens');
lines.push('context.takePhoto=' + !!caps.takePhoto + '; context.onCameraFrame=' + !!caps.onCameraFrame);
lines.push('listener.start=' + !!caps.listenerStart + '; listener.stop=' + !!caps.listenerStop);
lines.push('nativeReady=' + !!this.data.nativeReady + '; cameraMounted=' + !!this.data.cameraEnabled);
lines.push('frameCallbacks=' + (this.frameCallbacks || 0) + '; rawFrames=' + (this.frameCount || 0) + '; invalidCallbacks=' + (this.invalidFrameCount || 0));
if (meta) lines.push('frame.width=' + meta.width + '; frame.height=' + meta.height + '; frame.byteLength=' + meta.byteLength + '; frame.pixelFormat=' + meta.pixelFormat + '; frame.format=' + meta.format);
['start', 'stop'].forEach(function (key) {
var result = results[key] || { state: 'not_called', code: 'unavailable' };
lines.push('listener.' + key + '.state=' + result.state + '; listener.' + key + '.code=' + result.code);
});
lines.push('photo.attempted=' + !!this.photoAttempted + '; photo.pending=' + !!this.photoOperation + '; photo.success=' + !!this.photoResultSuccess + '; photo.returnedPath=' + !!this.photoReturnedPath + '; photo.previewPresent=' + !!this.data.photoPath);
return lines.join('\n');
},
onGenerateDiagnostics: function () {
if (!this.alive) return;
this.setData({ diagnosticText: this.diagnosticReport(), copyStatus: '脱敏诊断已生成,可长按下方文字选择复制。' });
},
onCopyDiagnostics: function () {
if (!this.alive) return;
var that = this, sequence = ++this.copySequence, report = this.diagnosticReport();
this.setData({ diagnosticText: report, copyStatus: '正在复制;也可长按下方脱敏文字选择复制。' });
function status(value) { if (that.alive && that.visible && sequence === that.copySequence) that.setData({ copyStatus: value }); }
try {
if (typeof my.setClipboard !== 'function') { status('当前宿主缺少剪贴板 API,请长按下方脱敏文字选择复制。'); return; }
my.setClipboard({ text: report, success: function () { status('普通相机诊断已复制,仅含版本和相机状态。'); },
fail: function () { status('剪贴板复制失败,请长按下方脱敏文字选择复制。'); } });
} catch (_) { status('剪贴板复制失败,请长按下方脱敏文字选择复制。'); }
},
currentEvent: function (event) {
if (!this.alive || !this.visible || !this.data.cameraEnabled) return false;
var target = event && event.currentTarget, session = target && target.dataset && target.dataset.cameraSession;
var match = target && typeof target.id === 'string' && /^native-camera-(\d+)$/.exec(target.id);
if (session === undefined && match) session = match[1];
return session === undefined || Number(session) === this.data.cameraSession;
},
mountCamera: function () {
if (!this.alive || !this.visible || !this.pageReady || this.data.cameraEnabled) return;
var that = this, generation = ++this.generation, session = this.data.cameraSession + 1;
this.frameCallbacks = 0; this.frameCount = 0; this.invalidFrameCount = 0; this.lastFramePaint = 0;
this.lastDiagnosticFrame = null;
this.contextCapabilities = { takePhoto: false, onCameraFrame: false, listenerStart: false, listenerStop: false };
this.listenerResults = { start: { state: 'not_called', code: 'unavailable' }, stop: { state: 'not_called', code: 'unavailable' } };
this.setData({ cameraEnabled: true, cameraSession: session, nativeReady: false, cameraStatus: '已请求' + (this.data.devicePosition === 'front' ? '前置' : '后置') + ',请观察真实取景',
contextStatus: '正在创建上下文', frameStatus: '尚未监听', startStatus: '尚未调用', stopStatus: '尚未调用',
frameCallbacks: 0, frameCount: 0, invalidFrameCount: 0, firstFrameMs: '', frameInfo: '', photoBusy: !!this.photoOperation }, function () {
if (!that.current(generation) || session !== that.data.cameraSession) return;
if (typeof my.createCameraContext !== 'function') { that.setData({ contextStatus: '缺少 my.createCameraContext', frameStatus: '无法监听' }); return; }
try {
that.cameraContext = my.createCameraContext('native-camera-' + session);
if (!that.cameraContext) throw new Error('未返回 CameraContext');
var methods = Diagnostics.methods(that.cameraContext);
that.contextCapabilities.takePhoto = methods.indexOf('takePhoto') >= 0;
that.contextCapabilities.onCameraFrame = methods.indexOf('onCameraFrame') >= 0;
that.setData({ contextStatus: 'takePhoto: ' + (typeof that.cameraContext.takePhoto === 'function') + ';onCameraFrame: ' + (typeof that.cameraContext.onCameraFrame === 'function') });
that.startFrames(generation);
} catch (error) { if (that.current(generation)) that.setData({ contextStatus: message(error, '创建相机上下文失败'), frameStatus: '无法监听' }); }
});
},
onNativeReady: function (event) { if (this.currentEvent(event)) this.setData({ nativeReady: true }); },
startFrames: function (generation) {
if (!this.current(generation)) return;
var that = this, context = this.cameraContext;
if (!context || typeof context.onCameraFrame !== 'function') { this.listenerResults.start = { state: 'missing_method', code: 'unavailable' }; this.setData({ frameStatus: '当前上下文缺少 onCameraFrame,不能取得实时帧' }); return; }
this.listenerResults.start = { state: 'pending', code: 'unavailable' };
this.framesActive = true; this.framesStartedAt = Date.now();
this.setData({ frameStatus: '等待真实帧;start 成功也不代表收到帧', startStatus: '正在调用' });
this.frameTimer3 = setTimeout(function () {
if (that.current(generation) && that.framesActive && !that.frameCount) that.setData({ frameStatus: '3秒未收到可用原始帧,继续等到10秒(回调 ' + that.frameCallbacks + ' 次)' });
}, 3000);
this.frameTimer10 = setTimeout(function () {
if (!that.current(generation) || !that.framesActive || that.frameCount) return;
that.setData({ frameStatus: '10秒仍未收到可用原始帧,本轮已停止;不能判定宿主支持', frameCallbacks: that.frameCallbacks, invalidFrameCount: that.invalidFrameCount });
that.stopFrames(false);
}, 10000);
try {
this.frameListener = context.onCameraFrame(function (frame) { that.receiveFrame(generation, frame); });
var methods = Diagnostics.methods(this.frameListener);
this.contextCapabilities.listenerStart = methods.indexOf('start') >= 0; this.contextCapabilities.listenerStop = methods.indexOf('stop') >= 0;
if (!this.frameListener || typeof this.frameListener.start !== 'function' || typeof this.frameListener.stop !== 'function') throw new Error('未返回具备 start/stop 的 CameraFrameListener');
this.frameListener.start({
success: function () { if (that.current(generation) && that.framesActive) { that.listenerResults.start = { state: 'success', code: 0 }; that.setData({ startStatus: '调用成功(需另看真实帧计数)' }); } },
fail: function (error) {
if (!that.current(generation) || !that.framesActive) return;
that.listenerResults.start = { state: 'failure', code: diagnosticCode(error) };
that.setData({ startStatus: message(error, '监听启动失败'), frameStatus: '监听启动失败' }); that.stopFrames(false);
}
});
} catch (error) {
if (!this.current(generation)) return;
this.listenerResults.start = { state: 'throw', code: diagnosticCode(error) };
this.setData({ startStatus: message(error, '监听接口调用失败'), frameStatus: '监听接口调用失败' }); this.stopFrames(false);
}
},
receiveFrame: function (generation, frame) {
if (!this.current(generation) || !this.framesActive) return;
this.frameCallbacks++;
var meta = frameMeta(frame), valid = meta.width > 0 && meta.height > 0 && meta.byteLength > 0;
this.lastDiagnosticFrame = { width: meta.width, height: meta.height, byteLength: meta.byteLength,
pixelFormat: diagnosticFormat(meta.pixelFormat), format: diagnosticFormat(meta.format) };
var firstValid = valid && !this.frameCount;
if (valid) {
this.frameCount++;
if (this.frameCount === 1) {
this.clearFrameTimers();
this.setData({ firstFrameMs: Math.max(0, Date.now() - this.framesStartedAt) + 'ms' });
}
} else this.invalidFrameCount++;
// Keep no frame/pixel buffer; publish scalar metadata at most ~3 times/s.
if (this.frameCallbacks === 1 || firstValid || Date.now() - this.lastFramePaint >= 350) {
this.lastFramePaint = Date.now();
this.setData({ frameCallbacks: this.frameCallbacks, frameCount: this.frameCount, invalidFrameCount: this.invalidFrameCount,
frameInfo: JSON.stringify(meta), frameStatus: this.frameCount ? '已收到原始帧;像素格式和人脸识别能力仍需另验' : '收到回调,但尺寸或原始字节缺失,未计为可用帧' });
}
},
clearFrameTimers: function () { clearTimeout(this.frameTimer3); clearTimeout(this.frameTimer10); this.frameTimer3 = null; this.frameTimer10 = null; },
stopFrames: function (showStatus) {
this.framesActive = false; this.clearFrameTimers();
var listener = this.frameListener; this.frameListener = null;
var that = this, generation = this.generation;
if (showStatus && this.current(generation)) this.setData({ frameStatus: '已主动停止监听', frameCallbacks: this.frameCallbacks, frameCount: this.frameCount, invalidFrameCount: this.invalidFrameCount });
if (!listener || typeof listener.stop !== 'function') return;
this.listenerResults.stop = { state: 'pending', code: 'unavailable' };
if (this.current(generation)) this.setData({ stopStatus: '正在调用' });
try {
listener.stop({ success: function () { if (that.current(generation)) { that.listenerResults.stop = { state: 'success', code: 0 }; that.setData({ stopStatus: '调用成功' }); } },
fail: function (error) { if (that.current(generation)) { that.listenerResults.stop = { state: 'failure', code: diagnosticCode(error) }; that.setData({ stopStatus: message(error, '停止监听失败') }); } } });
} catch (error) { if (this.current(generation)) { this.listenerResults.stop = { state: 'throw', code: diagnosticCode(error) }; this.setData({ stopStatus: message(error, '停止监听异常') }); } }
},
onStopFrames: function () { this.stopFrames(true); },
disposeCamera: function () {
this.generation++; this.stopFrames(false); this.cameraContext = null;
},
remount: function (position) {
if (!this.alive || !this.visible) return;
if (this.photoOperation) { this.setData({ photoStatus: '上次拍照尚未结束,请等待;若持续无响应,请退出此诊断页再进入' }); return; }
this.disposeCamera();
var that = this;
this.setData({ cameraEnabled: false, devicePosition: position }, function () { that.mountCamera(); });
},
onFlip: function () { this.remount(this.data.devicePosition === 'front' ? 'back' : 'front'); },
onRetryCamera: function () { this.remount(this.data.devicePosition); },
onNativeError: function (event) {
if (!this.currentEvent(event)) return;
this.disposeCamera();
this.setData({ cameraEnabled: false, cameraStatus: message(event && event.detail, '相机组件报错,请检查系统相机权限'), frameStatus: '相机异常,监听已停止' });
},
onNativeStop: function (event) {
if (!this.currentEvent(event)) return;
this.disposeCamera(); this.setData({ cameraEnabled: false, cameraStatus: '相机组件已异常停止,可点重新打开', frameStatus: '监听已停止' });
},
onTakePhoto: function () {
if (!this.alive || !this.visible || !this.data.cameraEnabled || this.photoOperation) return;
var context = this.cameraContext, that = this;
if (!context || typeof context.takePhoto !== 'function') { this.setData({ photoStatus: '当前上下文缺少 takePhoto' }); return; }
var operation = { generation: this.generation, position: this.data.devicePosition, done: false };
this.photoAttempted = true; this.photoResultSuccess = false; this.photoReturnedPath = false;
this.photoOperation = operation;
this.setData({ photoBusy: true, photoStatus: '正在拍照;仅在本页预览' });
operation.timer = setTimeout(function () {
// A timeout cannot cancel native takePhoto. Keep the lock until its callback
// or page destruction, so a late native export cannot overlap a retry.
if (operation.done || that.photoOperation !== operation) return;
operation.timedOut = true;
if (that.alive && that.visible) that.setData({ photoStatus: '拍照10秒未返回;等待原操作结束,或退出此诊断页后重试' });
}, 10000);
try {
context.takePhoto({ quality: 'high', success: function (result) { that.finishPhoto(operation, 'success', result); },
fail: function (error) { that.finishPhoto(operation, 'fail', error); },
complete: function () { that.finishPhoto(operation, 'complete'); } });
} catch (error) { this.finishPhoto(operation, 'fail', error); }
},
finishPhoto: function (operation, kind, result) {
if (operation.done || this.photoOperation !== operation) return;
operation.done = true; clearTimeout(operation.timer); this.photoOperation = null;
if (!this.alive || !this.visible) return;
this.setData({ photoBusy: false });
if (!this.current(operation.generation)) return;
var file = result && (result.tempImagePath || result.tempFilePath || result.apFilePath);
this.photoReturnedPath = typeof file === 'string' && !!file;
this.photoResultSuccess = kind === 'success' && this.photoReturnedPath;
if (kind === 'success' && typeof file === 'string' && file) this.setData({ photoPath: file, photoPosition: operation.position, photoStatus: '已取得临时照片,请观察照片是否来自所请求镜头' });
else this.setData({ photoStatus: message(result, kind === 'success' ? '拍照成功回调未返回可预览的临时路径' : '拍照未成功返回照片') });
},
onOpenAutoCapture: function () {
if (!development.nativeFaceProbeEnabled() || this.photoOperation || this.data.photoBusy) return;
my.redirectTo({ url: '/pages/camera/camera?debug=local-face' });
},
onOpenARProbe: function () {
if (this.photoOperation || this.data.photoBusy) return;
my.redirectTo({ url: '/pages/camtest/camtest' });
},
onClearPhoto: function () { this.setData({ photoPath: '', photoPosition: '', photoStatus: '本页照片预览已清除' }); },
onPreviewError: function () { if (this.alive && this.visible) this.setData({ photoStatus: '已收到拍照路径,但本页图片预览加载失败' }); }
});
function diagnosticFormat(value) {
return typeof value !== 'string' ? 'not_reported' : (KNOWN_FORMATS.indexOf(value.toUpperCase()) >= 0 ? value.toUpperCase() : 'unrecognized');
}
function diagnosticCode(error) {
if (!error || typeof error !== 'object') return 'unavailable';
var keys = ['error', 'errorCode', 'code'];
for (var index = 0; index < keys.length; index++) {
var descriptor;
try { descriptor = Object.getOwnPropertyDescriptor(error, keys[index]); } catch (_) { continue; }
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) continue;
var value = descriptor.value;
if (typeof value === 'number' && isFinite(value) && Math.floor(value) === value && Math.abs(value) <= 999999999) return value;
if (typeof value === 'string' && /^-?\d{1,9}$/.test(value)) return Number(value);
}
return 'unavailable';
}
+5
View File
@@ -0,0 +1,5 @@
{
"defaultTitle": "普通相机诊断",
"pullRefresh": false,
"gestureBack": "NO"
}
+40
View File
@@ -0,0 +1,40 @@
.wrap { position: absolute; left: 0; top: 0; width:100vw; height:100vh; padding-top:calc(112px + 240rpx); box-sizing:border-box; background: #000; overflow-y: auto; overflow-x:hidden; }
.cam { position: fixed; left: 0; top: 0; width: 100%; height: 100%; }
.diag-toolbar { position: fixed; z-index: 20; left: 0; top: 112px; width: 100%; box-sizing: border-box; padding: 12rpx 24rpx; background: #ffe56c; color: #211800; box-shadow: 0 6rpx 18rpx rgba(0,0,0,.4); }
.toolbar-version { font-size: 11px; line-height: 1.5; margin-bottom: 8rpx; }
.native-probe-entry { margin:0 0 12rpx; padding:0 12rpx; height:72rpx; line-height:68rpx; background:#126597; color:#fff; font-size:14px; font-weight:700; }
.toolbar-actions { display:flex; }
.copy-diagnostics, .export-photo { flex:1; width:0; height:72rpx; line-height:68rpx; box-sizing:border-box; margin:0; padding:0 8rpx; background:#ffda00; color:#211800; border:2rpx solid #705500; font-size:14px; font-weight:700; }
.export-photo { margin-left:12rpx; background:#fff7d3; }
.export-photo[disabled] { background:#ece3ac; color:#817c61; border-color:#b6ac78; }
.copy-status { margin-top: 8rpx; font-size: 11px; line-height: 1.5; }
.copy-fallback { margin-top: 8rpx; padding: 12rpx; background: #fffbe9; border-radius: 8rpx; }
.copy-help { margin-bottom: 8rpx; font-size: 10px; line-height: 1.5; }
.copy-text-scroll { height: 240rpx; }
.copy-text { font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; user-select: text; }
.probe { position: relative; z-index: 2; width:702rpx; max-width:calc(100vw - 48rpx); box-sizing:border-box; margin: 24rpx; padding: 24rpx; border-radius: 12rpx; background: rgba(0, 0, 0, .76); color: #fff; }
.title { font-size: 18px; font-weight: 600; margin-bottom: 16rpx; }
.version { font-size: 12px; color: #f5f078; margin-top: 8rpx; }
.diagnostics { font-size: 10px; line-height: 1.6; margin-top: 12rpx; color: #c7e0f5; word-break: break-all; }
.diagnostics text { white-space: pre-wrap; }
.note { font-size: 11px; line-height: 1.6; color: #ddd; margin-top: 12rpx; word-break: break-all; }
.facing { margin-top: 16rpx; color: #f5f078; font-size: 13px; line-height: 1.6; }
.actions { display: flex; flex-wrap: wrap; margin: 20rpx 0; }
.actions button { font-size: 12px; margin: 0 12rpx 12rpx 0; padding: 0 16rpx; max-width:100%; }
.calibration { margin: 16rpx 0; padding: 12rpx; border: 1rpx solid #777; border-radius: 8rpx; }
.calibration-optional { margin-top: 12rpx; padding: 0 12rpx; font-size: 11px; }
.calibration-confirmed { margin-top: 12rpx; font-size: 12px; line-height: 1.6; color: #bceaa7; }
.calibration-grid { display: flex; flex-wrap: wrap; margin-top: 16rpx; }
.calibration-card { width: calc(50% - 12rpx); margin: 0 12rpx 16rpx 0; box-sizing: border-box; background: #161616; }
.calibration-format { padding: 8rpx; font-size: 12px; text-align: center; color: #f5f078; }
.calibration-image { width: 100%; height: 320rpx; background: #222; }
.calibration-card button { padding: 0 6rpx; margin: 8rpx 0 0; font-size: 11px; }
.targets { display: flex; flex-wrap: wrap; }
.target { margin: 0 12rpx 12rpx 0; padding: 12rpx; font-size: 11px; border: 1rpx solid #777; border-radius: 8rpx; }
.target.active { border-color: #f5f078; color: #f5f078; }
.status { font-size: 14px; line-height: 1.6; }
.error { margin-top: 16rpx; padding: 12rpx; border: 1rpx solid #e79384; color: #ffd1c7; font-size: 11px; line-height: 1.6; word-break: break-all; }
.fields { max-height: 240rpx; margin-top: 16rpx; }
.fields text { font-size: 10px; line-height: 1.6; white-space: pre-wrap; color: #c7e0f5; }
.snapshot { margin-top: 20rpx; width: 100%; height: 440rpx; background: #111; }
.compress-canvas { position: fixed; left: -10000px; top: -10000px; pointer-events: none; }
+64
View File
@@ -0,0 +1,64 @@
<view class="wrap" style="padding-top:calc({{toolbarTop}}px + 240rpx);">
<!-- Uses the documented Taobao component, only after camera permission. -->
<ar-camera a:if="{{running}}" id="test-ar-camera-{{cameraSession}}" data-session="{{cameraSession}}" class="cam" mode="faceDetection2D" useCapturedImage="{{true}}" devicePosition="{{devicePosition}}"
onInit="onARInit" onARFrame="onARFrame" onStop="onARStop" onError="onError" />
<view class="diag-toolbar" style="top:{{toolbarTop}}px;">
<view class="toolbar-version">AR 相机诊断 · {{probeVersion}}</view>
<button class="native-probe-entry" onTap="onOpenNativeProbe" disabled="{{busy || exporting}}">进入普通前置相机测试</button>
<view class="toolbar-actions">
<button class="copy-diagnostics" onTap="onCopyDiagnostics">复制诊断信息</button>
<button class="export-photo" onTap="onSnapshot" disabled="{{!running || busy || exporting}}">导出照片</button>
</view>
<view class="copy-status" a:if="{{copyStatus}}">{{copyStatus}}</view>
<view class="copy-fallback" a:if="{{diagnosticText}}">
<view class="copy-help">长按文字可选择复制;再次点击上方按钮可更新。仅含安全诊断,不含照片或账号信息。</view>
<scroll-view class="copy-text-scroll" scroll-y><text class="copy-text" selectable="{{true}}">{{diagnosticText}}</text></scroll-view>
</view>
</view>
<view class="probe">
<view class="title">AR 相机真机验收</view>
<view class="version">诊断版本 {{probeVersion}}</view>
<view class="note">只检测本机实时帧与照片导出;不登录账号、不上传、不生成报告。</view>
<view class="diagnostics"><text>{{environmentText}}</text></view>
<view class="note">{{capabilityText}}</view>
<view class="facing">请求{{devicePosition === 'front' ? '前置' : '后置'}}镜头 · 请核对实际画面</view>
<view class="note">切换将重新挂载相机;方向属性是否生效需在手机核验。原始人脸坐标未作镜像,请分别向左右转头并核对提示。</view>
<view class="actions">
<button onTap="onStart" disabled="{{running || busy}}">开启 AR 相机</button>
<button onTap="onFlip" disabled="{{!running}}">切换镜头</button>
<button onTap="onStop" disabled="{{!running}}">停止</button>
</view>
<view class="calibration">
<view class="note">高级可选:校验照片颜色。可直接使用顶部“导出照片”。</view>
<button class="calibration-optional" onTap="onCalibrate" disabled="{{!running || busy || exporting}}">高级:校验照片颜色</button>
<view class="note">此功能只在本机生成同一帧的候选预览,不上传、不保存,可根据实际肤色、衣物和背景选择。</view>
<view class="calibration-confirmed" a:if="{{calibrationConfirmed}}">本次运行已确认格式:{{calibrationConfirmed}}</view>
<view class="note" a:if="{{calibrationText}}">{{calibrationText}}</view>
<view class="calibration-grid" a:if="{{calibrationItems.length}}">
<view class="calibration-card" a:for="{{calibrationItems}}" a:key="format">
<view class="calibration-format">{{item.format}}</view>
<image class="calibration-image" src="{{item.url}}" mode="aspectFit" />
<button data-format="{{item.format}}" data-batch="{{item.batchId}}" onTap="onConfirmCalibration" disabled="{{!calibrationReady}}">这张颜色正常</button>
</view>
</view>
</view>
<view class="note">只读取 context 公开方法的属性描述符,不执行 getter 或未知方法。复制内容不包含照片、人脸坐标或账号信息。</view>
<view class="diagnostics"><text>Context 方法:{{contextText}}</text></view>
<view class="diagnostics"><text>{{eventText}}</text></view>
<view class="targets">
<view class="target {{target === 'front' ? 'active' : ''}}" data-target="front" onTap="onTarget">正脸</view>
<view class="target {{target === 'right' ? 'active' : ''}}" data-target="right" onTap="onTarget">右脸(向左转头)</view>
<view class="target {{target === 'left' ? 'active' : ''}}" data-target="left" onTap="onTarget">左脸(向右转头)</view>
</view>
<view class="status">{{status}}</view>
<view class="note">{{frameText}} · 回调 {{frameCount}} 次 · 标准帧 {{standardFrameCount}} 次</view>
<view class="note" a:if="{{poseText}}">{{poseText}}</view>
<view class="error" a:if="{{errorText}}">{{errorText}}</view>
<scroll-view class="fields" scroll-y a:if="{{fieldText}}"><text>{{fieldText}}</text></scroll-view>
<view class="note">收到人脸 anchor 不代表能够导出照片。未知帧格式将明确报错;请核对预览是否为本次本人、左右方向和肤色是否正常。</view>
<image class="snapshot" a:if="{{snapshotUrl}}" src="{{snapshotUrl}}" mode="aspectFit" />
<view class="note" a:if="{{snapshotText}}">{{snapshotText}}</view>
</view>
<!-- Keep this canvas mounted and laid out for actual device exports. -->
<canvas id="compress-canvas" canvas-id="compress-canvas" width="{{compressWidth}}" height="{{compressHeight}}" class="compress-canvas" style="width:{{compressWidth}}px;height:{{compressHeight}}px" />
</view>
+317
View File
@@ -0,0 +1,317 @@
// Standalone device probe. No account API, upload, server call or saved photo.
var AR = require('../../utils/ar-camera');
var image = require('../../utils/image');
var permissions = require('../../utils/permissions');
var config = require('../../config');
var PROBE_VERSION = 'AR-DIAG-20261102-05';
var CALIBRATION_FORMATS = ['RGBA', 'BGRA', 'ARGB', 'ABGR'];
function confirmedPixelFormat() {
try {
var format = image.confirmedFramePixelFormat();
return CALIBRATION_FORMATS.indexOf(format) >= 0 ? format : '';
} catch (_) { return ''; }
}
Page({
data: { running: false, busy: false, exporting: false, devicePosition: config.arFrontPosition === 'back' ? 'back' : 'front', cameraSession: 0, status: '请点击开启,验证淘宝真实 AR 帧与照片导出。',
probeVersion: PROBE_VERSION, toolbarTop: 112, environmentText: '', capabilityText: '', contextText: '等待 onInit 后读取公开方法名', eventText: '尚未收到原生事件', copyStatus: '', diagnosticText: '',
target: 'front', frameCount: 0, standardFrameCount: 0, frameText: '尚未收到帧事件',
calibrationItems: [], calibrationReady: false, calibrationText: '', calibrationConfirmed: '',
poseText: '', fieldText: '', errorText: '', snapshotUrl: '', snapshotText: '', compressWidth: 1, compressHeight: 1 },
onLoad: function () {
this.alive = true; this.visible = true; this.sequence = 0;
var capability = AR.capability(my);
this.diagnosticSystem = AR.diagnostics.system(my); this.contextMethodNames = []; this.nativeEventStats = {};
this.diagnosticCapability = 'ar-camera=' + capability.componentSignal + '; createArCameraContext=' + capability.contextFactory;
this.setData({ toolbarTop: AR.diagnostics.toolbarTop(my), calibrationConfirmed: confirmedPixelFormat(), environmentText: this.diagnosticSystem.join('\n') || '系统未返回可读取的设备/宿主版本;请手动补充淘宝版本。',
capabilityText: 'ar-camera 声明:' + capability.componentSignal + ';上下文工厂:' + (capability.contextFactory ? '存在' : '不存在') + '。声明结果不代表真机已支持。' });
},
onShow: function () { this.visible = true; },
flushFrameDiagnostic: function () {
var pending = this.pendingFrameDiagnostic;
if (!pending) return;
this.pendingFrameDiagnostic = null;
var record = this.nativeEventStats[pending.key], summary = AR.diagnostics.event(pending.event);
record.type = summary.type || '(未提供 type)'; record.metadata = summary.metadata;
this.lastDiagnosticMetadata = summary.metadata;
},
nativeEventView: function () {
var that = this;
return { eventText: Object.keys(this.nativeEventStats).map(function (item) {
var value = that.nativeEventStats[item];
return item + ' · type=' + value.type + ' · 次数 ' + value.count;
}).join('\n'), fieldText: (this.lastDiagnosticMetadata || []).join('\n') || '本事件未返回白名单元信息;不会输出未知字段值或人脸坐标。' };
},
recordNativeEvent: function (name, event, deferFrame) {
var key = name + ':request=' + this.data.devicePosition;
var record = this.nativeEventStats[key] || { count: 0, type: '(未提供 type)', metadata: [] };
record.count++;
this.nativeEventStats[key] = record;
// Count every frame in JS, but do not scan descriptors or cross the UI
// bridge until the 400ms refresh. Only the latest frame is retained.
if (deferFrame) { this.pendingFrameDiagnostic = { key: key, event: event }; return; }
this.flushFrameDiagnostic();
var summary = AR.diagnostics.event(event);
record.type = summary.type || '(未提供 type)'; record.metadata = summary.metadata;
this.lastDiagnosticMetadata = summary.metadata;
this.setData(this.nativeEventView());
return summary;
},
diagnosticReport: function () {
// A user-requested copy gets up-to-date counters and last-frame metadata
// without introducing another UI refresh on the high-rate native stream.
this.flushFrameDiagnostic();
var that = this, lines = [PROBE_VERSION].concat(this.diagnosticSystem || []);
lines.push(this.diagnosticCapability || 'capability=unavailable');
lines.push('requestedPosition=' + this.data.devicePosition + '; actualPosition=需核对实际画面');
lines.push('context.methods=' + ((this.contextMethodNames || []).join(', ') || '(none observed)'));
lines.push('pixelFormat.default=' + (CALIBRATION_FORMATS.indexOf(config.arPixelFormat) >= 0 ? config.arPixelFormat : '(none)') + '; defaultIsUserConfirmed=false');
lines.push('pixelFormat.confirmed=' + (confirmedPixelFormat() || '(unconfirmed)'));
lines.push('currentSession.frameEvents=' + (this.frames || 0) + '; parsableFrames=' + (this.standardFrames || 0));
Object.keys(this.nativeEventStats || {}).forEach(function (key) {
var record = that.nativeEventStats[key];
lines.push(key + '; type=' + record.type + '; count=' + record.count);
record.metadata.forEach(function (item) { lines.push(' ' + item); });
});
return lines.join('\n');
},
onCopyDiagnostics: function () {
if (!this.alive) return;
// Always reveal the same privacy-filtered text first, so clipboard failure
// or an absent API still leaves a native selectable-text fallback.
var report = this.diagnosticReport();
this.setData({ diagnosticText: report, copyStatus: '安全诊断已生成;下方文字可长按复制。' });
if (typeof my.setClipboard !== 'function') { this.setData({ copyStatus: '当前淘宝未提供剪贴板 API,请长按下方诊断文字复制。' }); return; }
var that = this;
try {
my.setClipboard({ text: report, success: function () { if (that.alive) that.setData({ copyStatus: '诊断信息已复制。也可长按下方文字复制。' }); },
fail: function () { if (that.alive) that.setData({ copyStatus: '自动复制失败,请长按下方诊断文字复制。' }); } });
} catch (_) { this.setData({ copyStatus: '自动复制失败,请长按下方诊断文字复制。' }); }
},
onOpenNativeProbe: function () {
if (this.data.busy || this.data.exporting) return;
my.redirectTo({ url: '/pages/camnative/camnative' });
},
currentCalibration: function (batch) {
return !!(batch && this.calibrationBatch === batch && batch.sequence === this.sequence && this.alive && this.visible && this.data.running);
},
invalidateCalibration: function () {
if (this.calibrationBatch) this.calibrationBatch.frame = null;
this.calibrationBatch = null;
if (this.alive) this.setData({ calibrationItems: [], calibrationReady: false, calibrationText: '' });
},
onCalibrate: function () {
if (!this.alive || !this.visible || !this.data.running) return Promise.resolve();
if (this.exportInFlight || this.data.busy) {
this.setData({ errorText: '上一批照片仍在导出,请稍候再校验颜色。' }); return Promise.resolve();
}
if (!this.lastFrame) { this.setData({ errorText: '尚未收到真实相机帧,无法校验照片颜色。' }); return Promise.resolve(); }
var frozen, formats;
try {
// Freeze once: every preview and the eventual explicit confirmation refer
// to the exact same image, not a later native/reused camera buffer.
frozen = image.freezeCalibrationFrame(this.lastFrame);
formats = image.frameCalibrationFormats(frozen).filter(function (format) { return CALIBRATION_FORMATS.indexOf(format) >= 0; });
} catch (error) { this.setData({ errorText: error.message || '当前帧无法进行本地颜色校验。' }); return Promise.resolve(); }
if (!formats.length) {
this.setData({ errorText: '当前帧不是可校验的无格式四通道图像,请等待新帧或直接导出照片。' }); return Promise.resolve();
}
this.invalidateCalibration();
var that = this, operation = {}, batch = { sequence: this.sequence, id: this.sequence + '-' + ((this.calibrationSerial || 0) + 1),
frame: frozen, formats: formats, items: [], settled: false };
this.calibrationSerial = (this.calibrationSerial || 0) + 1;
this.calibrationBatch = batch; this.exportInFlight = operation;
this.setData({ busy: true, exporting: true, errorText: '', snapshotUrl: '', snapshotText: '',
calibrationText: '正在按顺序生成本地颜色预览;全部完成后,请选择颜色正常的一张。' });
return formats.reduce(function (chain, format) {
return chain.then(function () {
if (!that.currentCalibration(batch)) return;
return image.captureFrameToBase64(batch.frame, that, { pixelFormat: format, maxSide: 480 }).then(function (url) {
if (!that.currentCalibration(batch)) return;
batch.items.push({ format: format, url: url, batchId: batch.id });
that.setData({ calibrationItems: batch.items.slice(), calibrationText: '已生成 ' + batch.items.length + '/' + batch.formats.length + ' 张,完成后可确认。' });
});
});
}, Promise.resolve()).then(function () {
batch.settled = true;
if (that.currentCalibration(batch)) that.setData({ calibrationReady: true, calibrationText: '请比较肤色和背景,点击颜色正常照片下方的按钮;尚未确认任何新格式。' });
}).catch(function (error) {
batch.settled = true;
if (that.currentCalibration(batch)) {
that.invalidateCalibration();
that.setData({ errorText: error.message || '颜色预览导出失败,请重试。', calibrationText: '本批颜色预览导出失败,未确认新格式,请重试。' });
}
}).then(function () {
if (!that.currentCalibration(batch)) batch.frame = null;
if (that.exportInFlight !== operation) return;
that.exportInFlight = null;
if (that.alive) {
var patch = { exporting: false };
if (batch.sequence === that.sequence) patch.busy = false;
that.setData(patch);
}
});
},
onConfirmCalibration: function (event) {
var batch = this.calibrationBatch, dataset = event && event.currentTarget && event.currentTarget.dataset || {}, format = dataset.format;
if (!this.currentCalibration(batch) || !batch.settled || String(dataset.batch) !== batch.id || batch.formats.indexOf(format) < 0
|| !batch.items.some(function (item) { return item.format === format; })) return;
try {
var confirmed = image.confirmFramePixelFormat(batch.frame, format);
if (confirmed !== format) throw new Error('未能确认相机格式,请重新校验。');
this.invalidateCalibration();
this.setData({ calibrationConfirmed: confirmed, calibrationText: '本次相机格式已确认:' + confirmed + ',已记录本次颜色选择。', errorText: '' });
} catch (error) { this.setData({ errorText: error.message || '格式确认失败,请重新校验。' }); }
},
acceptsCameraEvent: function (event) {
if (!this.alive || !this.visible || !this.data.running) return false;
var target = event && (event.currentTarget || event.target), dataset = target && target.dataset;
// Native callbacks from an unmounted camera must not enter a new session.
// Events without a marker remain compatible with older host callbacks.
if (dataset && dataset.session !== undefined && String(dataset.session) !== String(this.data.cameraSession)) return false;
var id = target && target.id;
return typeof id !== 'string' || id.indexOf('test-ar-camera-') !== 0 || id === 'test-ar-camera-' + this.data.cameraSession;
},
mountCamera: function (sequence) {
if (!this.alive || !this.visible || sequence !== this.sequence) return;
var that = this;
clearTimeout(this.frameTimer);
this.frames = 0; this.standardFrames = 0; this.lastFrame = null; this.context = null; this.lastUpdate = 0; this.frameUiShown = false; this.pendingFrameDiagnostic = null; this.contextMethodNames = [];
this.setData({ running: true, busy: false, cameraSession: sequence, frameCount: 0, standardFrameCount: 0, frameText: '尚未收到帧事件', contextText: '等待 onInit 后读取公开方法名',
status: '已请求' + (this.data.devicePosition === 'front' ? '前置' : '后置') + '镜头,等待真实帧;请核对实际画面…' });
this.frameTimer = setTimeout(function () {
if (that.alive && that.visible && sequence === that.sequence && !that.frames) that.setData({ status: '10秒内未收到帧事件(onARFrame)。请复制诊断,不能视为能力已通过。' });
}, 10000);
},
onStart: function () {
if (this.data.running || this.data.busy) return Promise.resolve();
var that = this, sequence = ++this.sequence;
this.setData({ busy: true, errorText: '', snapshotUrl: '', snapshotText: '', frameCount: 0, poseText: '', fieldText: '' });
// Camera permission only. Neither Taobao userInfo nor album is needed.
return permissions.ensure('scope.camera').then(function () {
if (!that.alive || !that.visible || sequence !== that.sequence) return;
that.mountCamera(sequence);
}).catch(function (error) {
if (that.alive && sequence === that.sequence) that.setData({ errorText: error.message || '相机授权失败' });
}).then(function () { if (that.alive && sequence === that.sequence) that.setData({ busy: false }); });
},
onARInit: function (event) {
if (!this.acceptsCameraEvent(event)) return;
this.recordNativeEvent('onInit', event);
try {
this.context = AR.context(my, 'test-ar-camera-' + this.data.cameraSession);
// Enumerate descriptors only. This diagnostic never probes unknown methods
// or executes property getters/automatic context actions.
this.contextMethodNames = AR.diagnostics.methods(this.context);
this.setData({ contextText: this.contextMethodNames.join(', ') || '未发现可读取的公开方法;不代表原生能力不存在。', status: 'AR 初始化完成,等待实时人脸数据…' });
} catch (error) { this.setData({ errorText: error.message || 'AR 上下文初始化失败' }); }
},
onARFrame: function (event) {
if (!this.acceptsCameraEvent(event)) return;
this.frames = (this.frames || 0) + 1; this.lastFrame = event && event.detail || event;
this.recordNativeEvent('onARFrame', event, true);
clearTimeout(this.frameTimer);
// Facing selects hardware, not the native landmark coordinate convention.
// Do not reverse yaw solely because a front camera was requested.
var verdict;
try { verdict = AR.evaluate(event, this.data.target, false); } catch (_) { verdict = { frameReceived: false }; }
if (verdict.frameReceived) this.standardFrames = (this.standardFrames || 0) + 1;
var now = Date.now();
if (this.frameUiShown && now - this.lastUpdate < 400) return;
this.lastUpdate = now; this.frameUiShown = true;
this.flushFrameDiagnostic();
var patch = this.nativeEventView();
patch.frameCount = this.frames; patch.standardFrameCount = this.standardFrames || 0;
if (!verdict.frameReceived) {
patch.status = '已收到 onARFrame 回调,但格式不符合 width/height/anchors;请复制诊断。';
patch.frameText = '已收到原生回调,尚无可解析的标准帧'; patch.poseText = '';
this.setData(patch);
return;
}
var observation = verdict.observation;
patch.status = verdict.ok ? '实时人脸判断通过;仍需点击导出照片验证原始图像。' : verdict.hint;
patch.frameText = verdict.width + '×' + verdict.height + ' · 人脸 ' + verdict.faceCount + ' · 已收到真实帧';
patch.poseText = observation ? '几何姿态 yaw ' + observation.yaw.toFixed(1) + '° / pitch ' + observation.pitch.toFixed(1) + '° / roll ' + observation.roll.toFixed(1) + '°' : '';
this.setData(patch);
},
onTarget: function (event) {
var target = event.currentTarget.dataset.target;
if (['front', 'left', 'right'].indexOf(target) >= 0) this.setData({ target: target });
},
onFlip: function () {
if (!this.alive || !this.visible || !this.data.running) return Promise.resolve();
var that = this, sequence = ++this.sequence;
clearTimeout(this.frameTimer);
this.flushFrameDiagnostic();
this.invalidateCalibration();
this.lastFrame = null; this.context = null; this.frames = 0; this.lastUpdate = 0;
// devicePosition is shared-component compatibility evidence. Its actual
// effect in the Taobao host must be checked on a phone; no context method
// or successful callback is treated as proof that hardware changed.
return new Promise(function (resolve) {
that.setData({ running: false, busy: true, devicePosition: that.data.devicePosition === 'front' ? 'back' : 'front',
cameraSession: sequence, frameCount: 0, frameText: '尚未收到真实帧', poseText: '', fieldText: '', errorText: '',
snapshotUrl: '', snapshotText: '', status: '正在重新挂载相机;切换效果以实际画面为准…' }, function () {
that.mountCamera(sequence);
resolve();
});
});
},
onSnapshot: function () {
// Image decoding/draw callbacks may still use the shared canvas after a
// camera session changes. Release this lock only when that export settles.
if (this.exportInFlight) {
this.setData({ errorText: '上一帧照片仍在导出,请稍候再试。' });
return Promise.resolve();
}
if (!this.lastFrame || this.data.busy) {
if (!this.lastFrame) this.setData({ errorText: '尚未收到真实相机帧,无法验证照片导出。' });
return Promise.resolve();
}
var that = this, sequence = this.sequence, operation = {};
this.exportInFlight = operation;
this.setData({ busy: true, exporting: true, errorText: '', snapshotUrl: '', snapshotText: '' });
var work;
try { work = image.captureFrameToBase64(this.lastFrame, this, { defaultPixelFormat: config.arPixelFormat }); } catch (error) { work = Promise.reject(error); }
return Promise.resolve(work).then(function (value) {
if (!that.alive || !that.visible || sequence !== that.sequence) return;
that.setData({ snapshotUrl: value, snapshotText: 'JPEG ' + that.data.compressWidth + '×' + that.data.compressHeight + ',仅本页预览,未上传、未保存到相册。', status: '已导出真实帧照片,请核对方向和颜色。' });
}).catch(function (error) {
if (that.alive && that.visible && sequence === that.sequence) that.setData({ errorText: error.message || '照片导出失败,请记录帧字段与实际错误。' });
}).then(function () {
if (that.exportInFlight !== operation) return;
that.exportInFlight = null;
if (that.alive) {
var patch = { exporting: false };
if (sequence === that.sequence) patch.busy = false;
that.setData(patch);
}
});
},
onARStop: function (event) {
if (!this.acceptsCameraEvent(event)) return;
this.recordNativeEvent('onStop', event);
this.invalidateCalibration();
this.sequence++;
this.setData({ status: 'AR相机已停止。请重新开启后验证。', running: false, busy: false });
this.lastFrame = null; this.context = null; clearTimeout(this.frameTimer);
},
onError: function (event) {
if (!this.acceptsCameraEvent(event)) return;
var summary = this.recordNativeEvent('onError', event);
this.invalidateCalibration();
this.sequence++;
this.lastFrame = null; this.context = null; clearTimeout(this.frameTimer);
this.setData({ running: false, busy: false, status: 'AR 相机未通过真机验证。', errorText: '收到 AR 错误事件。' + (summary.metadata.join(';') || '未返回可安全展示的错误码,请复制诊断。') });
},
onStop: function () {
this.flushFrameDiagnostic();
this.invalidateCalibration();
this.sequence++; this.lastFrame = null; this.context = null; clearTimeout(this.frameTimer);
if (this.alive) this.setData({ running: false, busy: false, status: '已停止,帧缓存已清空。' });
},
onHide: function () {
this.visible = false; this.onStop();
if (this.alive) this.setData({ snapshotUrl: '', snapshotText: '', poseText: '', fieldText: '' });
},
onUnload: function () { this.onHide(); this.alive = false; }
});
+6
View File
@@ -0,0 +1,6 @@
{
"defaultTitle": "AR 相机真机验收",
"titleBarColor": "#101823",
"backgroundColor": "#000000",
"gestureBack": "NO"
}

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