Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e34eeb1df | ||
|
|
873b45d3a5 | ||
|
|
774376fa16 | ||
|
|
381da60ae4 | ||
|
|
8bc6251899 | ||
|
|
2eb0d8e807 | ||
|
|
43a5373233 | ||
|
|
fed52930cc | ||
|
|
f5c416dd9d | ||
|
|
65ba3cc7a5 | ||
|
|
ac0b7ca42b |
@@ -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,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 路由。
|
||||
|
||||
@@ -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 实例。
|
||||
@@ -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 <token>。</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
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
实际淘宝 IDE 模拟器运行截图(非真机截图)。login-centered.png 从完整 IDE 窗口截图中裁取模拟器屏幕,未修改页面内容。
|
||||
|
After Width: | Height: | Size: 95 KiB |
|
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 后补齐真实几何。
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,4 @@
|
||||
2 张淘宝 IDE 模拟器实际运行截图,未使用 H5 投影、Mock 报告或重绘界面。仅裁去 IDE 外框,未修改界面内容。
|
||||
01:登录页。02:未勾选协议点击游客体验后,真实显示“请先阅读并同意用户协议和隐私政策”。
|
||||
格式 PNG,单张 239×423,均小于 4MB,满足用户提供的最大尺寸 2160×3840。
|
||||
后续主页与档案运行截图需要先手动完成模拟器中的淘宝登录验证。
|
||||
|
After Width: | Height: | Size: 201 KiB |
@@ -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
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 节“不要做什么”。
|
||||
@@ -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`——那是唯一还悬着的技术问题,也决定拍照页最终形态。
|
||||
@@ -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)。主分支可快进到适配分支,不需覆盖已有提交或改写历史。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 保留为可选对照,其默认自拍和图像导出仍未验收。
|
||||
- 手机上的新游客接口和原系统相机报告故障最终复验(本地回归已覆盖,线上尚未更新)。
|
||||
- 固定商品真机跳转、真实网关大响应及后台跨源上线配置。
|
||||
- 照片保留/清理周期、数据库/对象存储迁移;本轮没有自动删除旧数据。
|
||||
@@ -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交互界面调整
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 })) };
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: '报告不存在' });
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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"]}]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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回归结论。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
# 小程序内置资源
|
||||
|
||||
这里只放确实需要随包内置的资源。当前UI图片、推荐产品图与语音均通过 `config.js` 的 `assetBase` 加载,复用共享后端的现有素材;没有修改H5资源。
|
||||
这里只放确实需要随包内置的资源。`startup/` 内置登录首屏的三张 H5 原图(约 129 KiB),避免首屏等待公网图片;图片内容、尺寸与 H5 一致。拍照遮罩等资源也随包内置。其余 UI 图片、推荐产品图与语音通过 `config.js` 的 `assetBase` 加载,复用共享后端素材;没有修改 H5 资源。
|
||||
|
||||
正式淘宝端需要确认资源域名权限。开发与平台验收事项见 [../README.md](../README.md)。
|
||||
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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>
|
||||
@@ -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' }
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"defaultTitle": "普通相机诊断",
|
||||
"pullRefresh": false,
|
||||
"gestureBack": "NO"
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"defaultTitle": "AR 相机真机验收",
|
||||
"titleBarColor": "#101823",
|
||||
"backgroundColor": "#000000",
|
||||
"gestureBack": "NO"
|
||||
}
|
||||